diff --git a/.env.dev b/.env.dev index e27084a69d420605153126965060d66171b3a7f3..d42cdad02b312920fe355ab34dfcddd228c34fd3 100644 --- a/.env.dev +++ b/.env.dev @@ -1,4 +1,3 @@ -BACKEND_URL=http://localhost:6001 API_AUTHENTICATION_REQUIRED=True CACHALOT_ENABLED=False RAVEN_ENABLED=false diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index cde12894ac4000b3944e2d7e6099c30104849c83..0fa450c46c763c54153855a1733a1e92690933b3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,6 +20,7 @@ test_api: paths: - "$PIP_CACHE_DIR" variables: + DJANGO_ALLOWED_HOSTS: "localhost" DATABASE_URL: "postgresql://postgres@postgres/postgres" before_script: diff --git a/CHANGELOG b/CHANGELOG index c3aac8eacdf0c925e6978830b1e375df4d9c8f8a..3e1ddbb3067d95bd13f7d4926f07d2784eda5a49 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -5,6 +5,8 @@ Changelog 0.3.5 (Unreleased) ------------------ +- Front: ambiant colors in player based on current track cover (#59) +- Front: simplified front dev setup thanks to webpack proxy (#59) - Front: added some unittests for the store (#55) - Front: fixed broken login redirection when 401 - Front: Removed autoplay on page reload diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 9e17267bb98cfd06ec4cadaa08370641126275c8..6d02cbbc1038999cc7a3de46448369aa00e030bf 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -23,6 +23,10 @@ try: env.read_env(ROOT_DIR.file('.env')) except FileNotFoundError: pass + +ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS') + + # APP CONFIGURATION # ------------------------------------------------------------------------------ DJANGO_APPS = ( diff --git a/api/config/settings/production.py b/api/config/settings/production.py index e009833050ead06ea7d62089f4c8182b3bbd172c..df15d325f22d8d78616c937a3142b4a11b34ded8 100644 --- a/api/config/settings/production.py +++ b/api/config/settings/production.py @@ -54,7 +54,6 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') # ------------------------------------------------------------------------------ # Hosts/domain names that are valid for this site # See https://docs.djangoproject.com/en/1.6/ref/settings/#allowed-hosts -ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS') CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS # END SITE CONFIGURATION diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 9f96dad0e300a64ae0d03eaf36cca177f8f89589..506893a4d23ae1026f5f26a159eeb841443dde96 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -31,10 +31,7 @@ class TrackFileSerializer(serializers.ModelSerializer): fields = ('id', 'path', 'duration', 'source', 'filename', 'track') def get_path(self, o): - request = self.context.get('request') url = o.path - if request: - url = request.build_absolute_uri(url) return url diff --git a/api/test.yml b/api/test.yml index c59ce45bbbbaf2357a2b50782bc004f51b584cce..e892dfb178221ccd5ba8ed83558f3a6436b20dad 100644 --- a/api/test.yml +++ b/api/test.yml @@ -10,6 +10,7 @@ services: volumes: - .:/app environment: + - "DJANGO_ALLOWED_HOSTS=localhost" - "DATABASE_URL=postgresql://postgres@postgres/postgres" postgres: image: postgres diff --git a/dev.yml b/dev.yml index 971e38b62ecbea864abc7d21db25547b06ba3084..e3cd50da7ab29b8de22783ed686313dc79dac6cd 100644 --- a/dev.yml +++ b/dev.yml @@ -49,13 +49,11 @@ services: - ./api:/app - ./data/music:/music environment: - - "DJANGO_ALLOWED_HOSTS=localhost" + - "DJANGO_ALLOWED_HOSTS=localhost,nginx" - "DJANGO_SETTINGS_MODULE=config.settings.local" - "DJANGO_SECRET_KEY=dev" - "DATABASE_URL=postgresql://postgres@postgres/postgres" - "CACHE_URL=redis://redis:6379/0" - ports: - - "12081:12081" links: - postgres - redis diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev index 48436173bcb5ce236bc6a5cce4455467e3794039..1b749c30a24006e2e954044bc50ec03a94d1adb6 100644 --- a/docker/nginx/conf.dev +++ b/docker/nginx/conf.dev @@ -40,8 +40,8 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host:$server_port; - proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Host localhost:8080; + proxy_set_header X-Forwarded-Port 8080; proxy_redirect off; proxy_pass http://api:12081/; } diff --git a/front/config/index.js b/front/config/index.js index a312c7b26c9f137ee8b57c89cc3f969da4a07ab4..7ce6e26e1c63ad12fc681a5317158856d30a99c8 100644 --- a/front/config/index.js +++ b/front/config/index.js @@ -28,7 +28,20 @@ module.exports = { autoOpenBrowser: true, assetsSubDirectory: 'static', assetsPublicPath: '/', - proxyTable: {}, + proxyTable: { + '/api': { + target: 'http://nginx:6001', + changeOrigin: true, + }, + '/media': { + target: 'http://nginx:6001', + changeOrigin: true, + }, + '/staticfiles': { + target: 'http://nginx:6001', + changeOrigin: true, + } + }, // CSS Sourcemaps off by default because relative paths are "buggy" // with this option, according to the CSS-Loader README // (https://github.com/webpack/css-loader#sourcemaps) diff --git a/front/config/prod.env.js b/front/config/prod.env.js index fe0e80b8f7c6db0a9b7bae510a4129820bb4a62f..decfe36154adc59fbf4a432cecac77119bbcdbf7 100644 --- a/front/config/prod.env.js +++ b/front/config/prod.env.js @@ -1,4 +1,4 @@ module.exports = { NODE_ENV: '"production"', - BACKEND_URL: '"' + (process.env.BACKEND_URL || '/') + '"' + BACKEND_URL: '"/"' } diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 6aaf04c42a0dd2ed0e7803f924e8adca719fe302..86ec578194df2d3b5d3dddc28fd4dd085884e5d1 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -6,8 +6,7 @@ <i class="logo bordered inverted orange big icon"> <logo class="logo"></logo> </i> - </router-link> - <span + </router-link><span slot="after" @click="isCollapsed = !isCollapsed" :class="['ui', 'basic', 'big', {'inverted': isCollapsed}, 'orange', 'icon', 'collapse', 'button']"> @@ -88,9 +87,7 @@ </div> </div> </div> - <div class="ui inverted segment player-wrapper"> - <player></player> - </div> + <player></player> </div> </template> @@ -151,6 +148,7 @@ export default { $sidebar-color: #1B1C1D; .sidebar { + background: $sidebar-color; @include media(">tablet") { display:flex; flex-direction:column; @@ -212,11 +210,6 @@ $sidebar-color: #1B1C1D; flex: 1; } -.player-wrapper { - border-top: 1px solid rgba(255, 255, 255, 0.1) !important; - background-color: rgb(46, 46, 46) !important; -} - .logo { cursor: pointer; display: inline-block; @@ -224,9 +217,6 @@ $sidebar-color: #1B1C1D; .ui.search { display: block; - > a { - margin-right: 1.5rem; - } .collapse.button { margin-right: 0.5rem; margin-top: 0.5rem; diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index 9388c2682914f5d3b0623c01eccee202c61d58e3..e44a92d4fe09c054ae2ffae43c780371769f270c 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -1,145 +1,147 @@ <template> - <div class="player"> - <audio-track - ref="currentAudio" - v-if="currentTrack" - :key="(currentIndex, currentTrack.id)" - :is-current="true" - :start-time="$store.state.player.currentTime" - :autoplay="$store.state.player.playing" - :track="currentTrack"> - </audio-track> + <div class="ui inverted segment player-wrapper" :style="style"> + <div class="player"> + <audio-track + ref="currentAudio" + v-if="currentTrack" + :key="(currentIndex, currentTrack.id)" + :is-current="true" + :start-time="$store.state.player.currentTime" + :autoplay="$store.state.player.playing" + :track="currentTrack"> + </audio-track> - <div v-if="currentTrack" class="track-area ui unstackable items"> - <div class="ui inverted item"> - <div class="ui tiny image"> - <img v-if="currentTrack.album.cover" :src="Track.getCover(currentTrack)"> - <img v-else src="../../assets/audio/default-cover.png"> - </div> - <div class="middle aligned content"> - <router-link class="small header discrete link track" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"> - {{ currentTrack.title }} - </router-link> - <div class="meta"> - <router-link class="artist" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}"> - {{ currentTrack.artist.name }} - </router-link> / - <router-link class="album" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}"> - {{ currentTrack.album.title }} - </router-link> + <div v-if="currentTrack" class="track-area ui unstackable items"> + <div class="ui inverted item"> + <div class="ui tiny image"> + <img ref="cover" @load="updateBackground" v-if="currentTrack.album.cover" :src="Track.getCover(currentTrack)"> + <img v-else src="../../assets/audio/default-cover.png"> </div> - <div class="description"> - <track-favorite-icon :track="currentTrack"></track-favorite-icon> + <div class="middle aligned content"> + <router-link class="small header discrete link track" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"> + {{ currentTrack.title }} + </router-link> + <div class="meta"> + <router-link class="artist" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}"> + {{ currentTrack.artist.name }} + </router-link> / + <router-link class="album" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}"> + {{ currentTrack.album.title }} + </router-link> + </div> + <div class="description"> + <track-favorite-icon :track="currentTrack"></track-favorite-icon> + </div> </div> </div> </div> - </div> - <div class="progress-area" v-if="currentTrack"> - <div class="ui grid"> - <div class="left floated four wide column"> - <p class="timer start" @click="updateProgress(0)">{{currentTimeFormatted}}</p> - </div> + <div class="progress-area" v-if="currentTrack"> + <div class="ui grid"> + <div class="left floated four wide column"> + <p class="timer start" @click="updateProgress(0)">{{currentTimeFormatted}}</p> + </div> - <div class="right floated four wide column"> - <p class="timer total">{{durationFormatted}}</p> + <div class="right floated four wide column"> + <p class="timer total">{{durationFormatted}}</p> + </div> + </div> + <div ref="progress" class="ui small orange inverted progress" @click="touchProgress"> + <div class="bar" :data-percent="progress" :style="{ 'width': progress + '%' }"></div> </div> </div> - <div ref="progress" class="ui small orange inverted progress" @click="touchProgress"> - <div class="bar" :data-percent="progress" :style="{ 'width': progress + '%' }"></div> - </div> - </div> - <div class="two wide column controls ui grid"> - <div - @click="previous" - title="Previous track" - class="two wide column control" - :disabled="!hasPrevious"> - <i :class="['ui', {'disabled': !hasPrevious}, 'step', 'backward', 'big', 'icon']" ></i> - </div> - <div - v-if="!playing" - @click="togglePlay" - title="Play track" - class="two wide column control"> - <i :class="['ui', 'play', {'disabled': !currentTrack}, 'big', 'icon']"></i> - </div> - <div - v-else - @click="togglePlay" - title="Pause track" - class="two wide column control"> - <i :class="['ui', 'pause', {'disabled': !currentTrack}, 'big', 'icon']"></i> - </div> - <div - @click="next" - title="Next track" - class="two wide column control" - :disabled="!hasNext"> - <i :class="['ui', {'disabled': !hasNext}, 'step', 'forward', 'big', 'icon']" ></i> - </div> - <div class="two wide column control volume-control"> - <i title="Unmute" @click="$store.commit('player/volume', 1)" v-if="volume === 0" class="volume off secondary icon"></i> - <i title="Mute" @click="$store.commit('player/volume', 0)" v-else-if="volume < 0.5" class="volume down secondary icon"></i> - <i title="Mute" @click="$store.commit('player/volume', 0)" v-else class="volume up secondary icon"></i> - <input type="range" step="0.05" min="0" max="1" v-model="sliderVolume" /> - </div> - <div class="two wide column control looping"> - <i - title="Looping disabled. Click to switch to single-track looping." - v-if="looping === 0" - @click="$store.commit('player/looping', 1)" - :disabled="!currentTrack" - :class="['ui', {'disabled': !currentTrack}, 'step', 'repeat', 'secondary', 'icon']"></i> - <i - title="Looping on a single track. Click to switch to whole queue looping." - v-if="looping === 1" - @click="$store.commit('player/looping', 2)" - :disabled="!currentTrack" - class="repeat secondary icon"> - <span class="ui circular tiny orange label">1</span> - </i> - <i - title="Looping on whole queue. Click to disable looping." - v-if="looping === 2" - @click="$store.commit('player/looping', 0)" - :disabled="!currentTrack" - class="repeat orange secondary icon"> - </i> - </div> - <div - @click="shuffle()" - :disabled="queue.tracks.length === 0" - title="Shuffle your queue" - class="two wide column control"> - <i :class="['ui', 'random', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" ></i> - </div> - <div class="one wide column"></div> - <div - @click="clean()" - :disabled="queue.tracks.length === 0" - title="Clear your queue" - class="two wide column control"> - <i :class="['ui', 'trash', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" ></i> + <div class="two wide column controls ui grid"> + <div + @click="previous" + title="Previous track" + class="two wide column control" + :disabled="!hasPrevious"> + <i :class="['ui', {'disabled': !hasPrevious}, 'step', 'backward', 'big', 'icon']" ></i> + </div> + <div + v-if="!playing" + @click="togglePlay" + title="Play track" + class="two wide column control"> + <i :class="['ui', 'play', {'disabled': !currentTrack}, 'big', 'icon']"></i> + </div> + <div + v-else + @click="togglePlay" + title="Pause track" + class="two wide column control"> + <i :class="['ui', 'pause', {'disabled': !currentTrack}, 'big', 'icon']"></i> + </div> + <div + @click="next" + title="Next track" + class="two wide column control" + :disabled="!hasNext"> + <i :class="['ui', {'disabled': !hasNext}, 'step', 'forward', 'big', 'icon']" ></i> + </div> + <div class="two wide column control volume-control"> + <i title="Unmute" @click="$store.commit('player/volume', 1)" v-if="volume === 0" class="volume off secondary icon"></i> + <i title="Mute" @click="$store.commit('player/volume', 0)" v-else-if="volume < 0.5" class="volume down secondary icon"></i> + <i title="Mute" @click="$store.commit('player/volume', 0)" v-else class="volume up secondary icon"></i> + <input type="range" step="0.05" min="0" max="1" v-model="sliderVolume" /> + </div> + <div class="two wide column control looping"> + <i + title="Looping disabled. Click to switch to single-track looping." + v-if="looping === 0" + @click="$store.commit('player/looping', 1)" + :disabled="!currentTrack" + :class="['ui', {'disabled': !currentTrack}, 'step', 'repeat', 'secondary', 'icon']"></i> + <i + title="Looping on a single track. Click to switch to whole queue looping." + v-if="looping === 1" + @click="$store.commit('player/looping', 2)" + :disabled="!currentTrack" + class="repeat secondary icon"> + <span class="ui circular tiny orange label">1</span> + </i> + <i + title="Looping on whole queue. Click to disable looping." + v-if="looping === 2" + @click="$store.commit('player/looping', 0)" + :disabled="!currentTrack" + class="repeat orange secondary icon"> + </i> + </div> + <div + @click="shuffle()" + :disabled="queue.tracks.length === 0" + title="Shuffle your queue" + class="two wide column control"> + <i :class="['ui', 'random', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" ></i> + </div> + <div class="one wide column"></div> + <div + @click="clean()" + :disabled="queue.tracks.length === 0" + title="Clear your queue" + class="two wide column control"> + <i :class="['ui', 'trash', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" ></i> + </div> </div> + <GlobalEvents + @keydown.space.prevent.exact="togglePlay" + @keydown.ctrl.left.prevent.exact="previous" + @keydown.ctrl.right.prevent.exact="next" + @keydown.ctrl.down.prevent.exact="$store.commit('player/incrementVolume', -0.1)" + @keydown.ctrl.up.prevent.exact="$store.commit('player/incrementVolume', 0.1)" + @keydown.f.prevent.exact="$store.dispatch('favorites/toggle', currentTrack.id)" + @keydown.l.prevent.exact="$store.commit('player/toggleLooping')" + @keydown.s.prevent.exact="shuffle" + /> </div> - <GlobalEvents - @keydown.space.prevent.exact="togglePlay" - @keydown.ctrl.left.prevent.exact="previous" - @keydown.ctrl.right.prevent.exact="next" - @keydown.ctrl.down.prevent.exact="$store.commit('player/incrementVolume', -0.1)" - @keydown.ctrl.up.prevent.exact="$store.commit('player/incrementVolume', 0.1)" - @keydown.f.prevent.exact="$store.dispatch('favorites/toggle', currentTrack.id)" - @keydown.l.prevent.exact="$store.commit('player/toggleLooping')" - @keydown.s.prevent.exact="shuffle" - /> - </div> </template> <script> import {mapState, mapGetters, mapActions} from 'vuex' import GlobalEvents from '@/components/utils/global-events' +import ColorThief from '@/vendor/color-thief' import Track from '@/audio/track' import AudioTrack from '@/components/audio/Track' @@ -153,9 +155,12 @@ export default { AudioTrack }, data () { + let defaultAmbiantColors = [[46, 46, 46], [46, 46, 46], [46, 46, 46], [46, 46, 46]] return { sliderVolume: this.volume, - Track: Track + Track: Track, + defaultAmbiantColors: defaultAmbiantColors, + ambiantColors: defaultAmbiantColors } }, mounted () { @@ -177,6 +182,14 @@ export default { let target = this.$refs.progress time = e.layerX / target.offsetWidth * this.duration this.$refs.currentAudio.setCurrentTime(time) + }, + updateBackground () { + if (!this.currentTrack.album.cover) { + this.ambiantColors = this.defaultAmbiantColors + return + } + let image = this.$refs.cover + this.ambiantColors = ColorThief.prototype.getPalette(image, 4).slice(0, 4) } }, computed: { @@ -195,9 +208,34 @@ export default { durationFormatted: 'player/durationFormatted', currentTimeFormatted: 'player/currentTimeFormatted', progress: 'player/progress' - }) + }), + style: function () { + let style = { + 'background': this.ambiantGradiant + } + return style + }, + ambiantGradiant: function () { + let indexConf = [ + {orientation: 330, percent: 100, opacity: 0.7}, + {orientation: 240, percent: 90, opacity: 0.7}, + {orientation: 150, percent: 80, opacity: 0.7}, + {orientation: 60, percent: 70, opacity: 0.7} + ] + let gradients = this.ambiantColors.map((e, i) => { + let [r, g, b] = e + let conf = indexConf[i] + return `linear-gradient(${conf.orientation}deg, rgba(${r}, ${g}, ${b}, ${conf.opacity}) 10%, rgba(255, 255, 255, 0) ${conf.percent}%)` + }).join(', ') + return gradients + } }, watch: { + currentTrack (newValue) { + if (!newValue) { + this.ambiantColors = this.defaultAmbiantColors + } + }, volume (newValue) { this.sliderVolume = newValue }, diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue index 56a8e77be1f1719b66c1c72cc3d1e07d45a71853..988ff0a7d7ccb78707e3b66c3474e380e6838e72 100644 --- a/front/src/components/audio/SearchBar.vue +++ b/front/src/components/audio/SearchBar.vue @@ -1,7 +1,6 @@ <template> <div class="ui fluid category search"> - <slot></slot> - <div class="ui icon input"> + <slot></slot><div class="ui icon input"> <input class="prompt" placeholder="Search for artists, albums, tracks..." type="text"> <i class="search icon"></i> </div> diff --git a/front/src/config.js b/front/src/config.js index b0ceb789226223a80c51356e7598ba81fda52819..47d9d7b8b2cdc83ded7330e2400c48d4b72b8e2e 100644 --- a/front/src/config.js +++ b/front/src/config.js @@ -1,12 +1,6 @@ class Config { constructor () { this.BACKEND_URL = process.env.BACKEND_URL - if (this.BACKEND_URL === '/') { - this.BACKEND_URL = window.location.protocol + '//' + window.location.hostname + ':' + window.location.port - } - if (this.BACKEND_URL.slice(-1) !== '/') { - this.BACKEND_URL += '/' - } this.API_URL = this.BACKEND_URL + 'api/v1/' } } diff --git a/front/src/vendor/color-thief.js b/front/src/vendor/color-thief.js new file mode 100644 index 0000000000000000000000000000000000000000..0acb7c13ae9e396fca00c5b9cc8040267e156c16 --- /dev/null +++ b/front/src/vendor/color-thief.js @@ -0,0 +1,660 @@ +/* eslint-disable */ +/* + * Color Thief v2.0 + * by Lokesh Dhakar - http://www.lokeshdhakar.com + * + * Thanks + * ------ + * Nick Rabinowitz - For creating quantize.js. + * John Schulz - For clean up and optimization. @JFSIII + * Nathan Spady - For adding drag and drop support to the demo page. + * + * License + * ------- + * Copyright 2011, 2015 Lokesh Dhakar + * Released under the MIT license + * https://raw.githubusercontent.com/lokesh/color-thief/master/LICENSE + * + * @license + */ + + +/* + CanvasImage Class + Class that wraps the html image element and canvas. + It also simplifies some of the canvas context manipulation + with a set of helper functions. +*/ +var CanvasImage = function (image) { + this.canvas = document.createElement('canvas'); + this.context = this.canvas.getContext('2d'); + + document.body.appendChild(this.canvas); + + this.width = this.canvas.width = image.width; + this.height = this.canvas.height = image.height; + + this.context.drawImage(image, 0, 0, this.width, this.height); +}; + +CanvasImage.prototype.clear = function () { + this.context.clearRect(0, 0, this.width, this.height); +}; + +CanvasImage.prototype.update = function (imageData) { + this.context.putImageData(imageData, 0, 0); +}; + +CanvasImage.prototype.getPixelCount = function () { + return this.width * this.height; +}; + +CanvasImage.prototype.getImageData = function () { + return this.context.getImageData(0, 0, this.width, this.height); +}; + +CanvasImage.prototype.removeCanvas = function () { + this.canvas.parentNode.removeChild(this.canvas); +}; + + +var ColorThief = function () {}; + +/* + * getColor(sourceImage[, quality]) + * returns {r: num, g: num, b: num} + * + * Use the median cut algorithm provided by quantize.js to cluster similar + * colors and return the base color from the largest cluster. + * + * Quality is an optional argument. It needs to be an integer. 1 is the highest quality settings. + * 10 is the default. There is a trade-off between quality and speed. The bigger the number, the + * faster a color will be returned but the greater the likelihood that it will not be the visually + * most dominant color. + * + * */ +ColorThief.prototype.getColor = function(sourceImage, quality) { + var palette = this.getPalette(sourceImage, 5, quality); + var dominantColor = palette[0]; + return dominantColor; +}; + + +/* + * getPalette(sourceImage[, colorCount, quality]) + * returns array[ {r: num, g: num, b: num}, {r: num, g: num, b: num}, ...] + * + * Use the median cut algorithm provided by quantize.js to cluster similar colors. + * + * colorCount determines the size of the palette; the number of colors returned. If not set, it + * defaults to 10. + * + * BUGGY: Function does not always return the requested amount of colors. It can be +/- 2. + * + * quality is an optional argument. It needs to be an integer. 1 is the highest quality settings. + * 10 is the default. There is a trade-off between quality and speed. The bigger the number, the + * faster the palette generation but the greater the likelihood that colors will be missed. + * + * + */ +ColorThief.prototype.getPalette = function(sourceImage, colorCount, quality) { + + if (typeof colorCount === 'undefined' || colorCount < 2 || colorCount > 256) { + colorCount = 10; + } + if (typeof quality === 'undefined' || quality < 1) { + quality = 10; + } + + // Create custom CanvasImage object + var image = new CanvasImage(sourceImage); + var imageData = image.getImageData(); + var pixels = imageData.data; + var pixelCount = image.getPixelCount(); + + // Store the RGB values in an array format suitable for quantize function + var pixelArray = []; + for (var i = 0, offset, r, g, b, a; i < pixelCount; i = i + quality) { + offset = i * 4; + r = pixels[offset + 0]; + g = pixels[offset + 1]; + b = pixels[offset + 2]; + a = pixels[offset + 3]; + // If pixel is mostly opaque and not white + if (a >= 125) { + if (!(r > 250 && g > 250 && b > 250)) { + pixelArray.push([r, g, b]); + } + } + } + + // Send array to quantize function which clusters values + // using median cut algorithm + var cmap = MMCQ.quantize(pixelArray, colorCount); + var palette = cmap? cmap.palette() : null; + + // Clean up + image.removeCanvas(); + + return palette; +}; + +ColorThief.prototype.getColorFromUrl = function(imageUrl, callback, quality) { + sourceImage = document.createElement("img"); + var thief = this; + sourceImage.addEventListener('load' , function(){ + var palette = thief.getPalette(sourceImage, 5, quality); + var dominantColor = palette[0]; + callback(dominantColor, imageUrl); + }); + sourceImage.src = imageUrl +}; + + +ColorThief.prototype.getImageData = function(imageUrl, callback) { + xhr = new XMLHttpRequest(); + xhr.open('GET', imageUrl, true); + xhr.responseType = 'arraybuffer' + xhr.onload = function(e) { + if (this.status == 200) { + uInt8Array = new Uint8Array(this.response) + i = uInt8Array.length + binaryString = new Array(i); + for (var i = 0; i < uInt8Array.length; i++){ + binaryString[i] = String.fromCharCode(uInt8Array[i]) + } + data = binaryString.join('') + base64 = window.btoa(data) + callback ("data:image/png;base64,"+base64) + } + } + xhr.send(); +}; + +ColorThief.prototype.getColorAsync = function(imageUrl, callback, quality) { + var thief = this; + this.getImageData(imageUrl, function(imageData){ + sourceImage = document.createElement("img"); + sourceImage.addEventListener('load' , function(){ + var palette = thief.getPalette(sourceImage, 5, quality); + var dominantColor = palette[0]; + callback(dominantColor, this); + }); + sourceImage.src = imageData; + }); +}; + + + +/*! + * quantize.js Copyright 2008 Nick Rabinowitz. + * Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php + * @license + */ + +// fill out a couple protovis dependencies +/*! + * Block below copied from Protovis: http://mbostock.github.com/protovis/ + * Copyright 2010 Stanford Visualization Group + * Licensed under the BSD License: http://www.opensource.org/licenses/bsd-license.php + * @license + */ +if (!pv) { + var pv = { + map: function(array, f) { + var o = {}; + return f ? array.map(function(d, i) { o.index = i; return f.call(o, d); }) : array.slice(); + }, + naturalOrder: function(a, b) { + return (a < b) ? -1 : ((a > b) ? 1 : 0); + }, + sum: function(array, f) { + var o = {}; + return array.reduce(f ? function(p, d, i) { o.index = i; return p + f.call(o, d); } : function(p, d) { return p + d; }, 0); + }, + max: function(array, f) { + return Math.max.apply(null, f ? pv.map(array, f) : array); + } + }; +} + + + +/** + * Basic Javascript port of the MMCQ (modified median cut quantization) + * algorithm from the Leptonica library (http://www.leptonica.com/). + * Returns a color map you can use to map original pixels to the reduced + * palette. Still a work in progress. + * + * @author Nick Rabinowitz + * @example + +// array of pixels as [R,G,B] arrays +var myPixels = [[190,197,190], [202,204,200], [207,214,210], [211,214,211], [205,207,207] + // etc + ]; +var maxColors = 4; + +var cmap = MMCQ.quantize(myPixels, maxColors); +var newPalette = cmap.palette(); +var newPixels = myPixels.map(function(p) { + return cmap.map(p); +}); + + */ +var MMCQ = (function() { + // private constants + var sigbits = 5, + rshift = 8 - sigbits, + maxIterations = 1000, + fractByPopulations = 0.75; + + // get reduced-space color index for a pixel + function getColorIndex(r, g, b) { + return (r << (2 * sigbits)) + (g << sigbits) + b; + } + + // Simple priority queue + function PQueue(comparator) { + var contents = [], + sorted = false; + + function sort() { + contents.sort(comparator); + sorted = true; + } + + return { + push: function(o) { + contents.push(o); + sorted = false; + }, + peek: function(index) { + if (!sorted) sort(); + if (index===undefined) index = contents.length - 1; + return contents[index]; + }, + pop: function() { + if (!sorted) sort(); + return contents.pop(); + }, + size: function() { + return contents.length; + }, + map: function(f) { + return contents.map(f); + }, + debug: function() { + if (!sorted) sort(); + return contents; + } + }; + } + + // 3d color space box + function VBox(r1, r2, g1, g2, b1, b2, histo) { + var vbox = this; + vbox.r1 = r1; + vbox.r2 = r2; + vbox.g1 = g1; + vbox.g2 = g2; + vbox.b1 = b1; + vbox.b2 = b2; + vbox.histo = histo; + } + VBox.prototype = { + volume: function(force) { + var vbox = this; + if (!vbox._volume || force) { + vbox._volume = ((vbox.r2 - vbox.r1 + 1) * (vbox.g2 - vbox.g1 + 1) * (vbox.b2 - vbox.b1 + 1)); + } + return vbox._volume; + }, + count: function(force) { + var vbox = this, + histo = vbox.histo; + if (!vbox._count_set || force) { + var npix = 0, + index, i, j, k; + for (i = vbox.r1; i <= vbox.r2; i++) { + for (j = vbox.g1; j <= vbox.g2; j++) { + for (k = vbox.b1; k <= vbox.b2; k++) { + index = getColorIndex(i,j,k); + npix += (histo[index] || 0); + } + } + } + vbox._count = npix; + vbox._count_set = true; + } + return vbox._count; + }, + copy: function() { + var vbox = this; + return new VBox(vbox.r1, vbox.r2, vbox.g1, vbox.g2, vbox.b1, vbox.b2, vbox.histo); + }, + avg: function(force) { + var vbox = this, + histo = vbox.histo; + if (!vbox._avg || force) { + var ntot = 0, + mult = 1 << (8 - sigbits), + rsum = 0, + gsum = 0, + bsum = 0, + hval, + i, j, k, histoindex; + for (i = vbox.r1; i <= vbox.r2; i++) { + for (j = vbox.g1; j <= vbox.g2; j++) { + for (k = vbox.b1; k <= vbox.b2; k++) { + histoindex = getColorIndex(i,j,k); + hval = histo[histoindex] || 0; + ntot += hval; + rsum += (hval * (i + 0.5) * mult); + gsum += (hval * (j + 0.5) * mult); + bsum += (hval * (k + 0.5) * mult); + } + } + } + if (ntot) { + vbox._avg = [~~(rsum/ntot), ~~(gsum/ntot), ~~(bsum/ntot)]; + } else { +// console.log('empty box'); + vbox._avg = [ + ~~(mult * (vbox.r1 + vbox.r2 + 1) / 2), + ~~(mult * (vbox.g1 + vbox.g2 + 1) / 2), + ~~(mult * (vbox.b1 + vbox.b2 + 1) / 2) + ]; + } + } + return vbox._avg; + }, + contains: function(pixel) { + var vbox = this, + rval = pixel[0] >> rshift; + gval = pixel[1] >> rshift; + bval = pixel[2] >> rshift; + return (rval >= vbox.r1 && rval <= vbox.r2 && + gval >= vbox.g1 && gval <= vbox.g2 && + bval >= vbox.b1 && bval <= vbox.b2); + } + }; + + // Color map + function CMap() { + this.vboxes = new PQueue(function(a,b) { + return pv.naturalOrder( + a.vbox.count()*a.vbox.volume(), + b.vbox.count()*b.vbox.volume() + ); + }); + } + CMap.prototype = { + push: function(vbox) { + this.vboxes.push({ + vbox: vbox, + color: vbox.avg() + }); + }, + palette: function() { + return this.vboxes.map(function(vb) { return vb.color; }); + }, + size: function() { + return this.vboxes.size(); + }, + map: function(color) { + var vboxes = this.vboxes; + for (var i=0; i<vboxes.size(); i++) { + if (vboxes.peek(i).vbox.contains(color)) { + return vboxes.peek(i).color; + } + } + return this.nearest(color); + }, + nearest: function(color) { + var vboxes = this.vboxes, + d1, d2, pColor; + for (var i=0; i<vboxes.size(); i++) { + d2 = Math.sqrt( + Math.pow(color[0] - vboxes.peek(i).color[0], 2) + + Math.pow(color[1] - vboxes.peek(i).color[1], 2) + + Math.pow(color[2] - vboxes.peek(i).color[2], 2) + ); + if (d2 < d1 || d1 === undefined) { + d1 = d2; + pColor = vboxes.peek(i).color; + } + } + return pColor; + }, + forcebw: function() { + // XXX: won't work yet + var vboxes = this.vboxes; + vboxes.sort(function(a,b) { return pv.naturalOrder(pv.sum(a.color), pv.sum(b.color));}); + + // force darkest color to black if everything < 5 + var lowest = vboxes[0].color; + if (lowest[0] < 5 && lowest[1] < 5 && lowest[2] < 5) + vboxes[0].color = [0,0,0]; + + // force lightest color to white if everything > 251 + var idx = vboxes.length-1, + highest = vboxes[idx].color; + if (highest[0] > 251 && highest[1] > 251 && highest[2] > 251) + vboxes[idx].color = [255,255,255]; + } + }; + + // histo (1-d array, giving the number of pixels in + // each quantized region of color space), or null on error + function getHisto(pixels) { + var histosize = 1 << (3 * sigbits), + histo = new Array(histosize), + index, rval, gval, bval; + pixels.forEach(function(pixel) { + rval = pixel[0] >> rshift; + gval = pixel[1] >> rshift; + bval = pixel[2] >> rshift; + index = getColorIndex(rval, gval, bval); + histo[index] = (histo[index] || 0) + 1; + }); + return histo; + } + + function vboxFromPixels(pixels, histo) { + var rmin=1000000, rmax=0, + gmin=1000000, gmax=0, + bmin=1000000, bmax=0, + rval, gval, bval; + // find min/max + pixels.forEach(function(pixel) { + rval = pixel[0] >> rshift; + gval = pixel[1] >> rshift; + bval = pixel[2] >> rshift; + if (rval < rmin) rmin = rval; + else if (rval > rmax) rmax = rval; + if (gval < gmin) gmin = gval; + else if (gval > gmax) gmax = gval; + if (bval < bmin) bmin = bval; + else if (bval > bmax) bmax = bval; + }); + return new VBox(rmin, rmax, gmin, gmax, bmin, bmax, histo); + } + + function medianCutApply(histo, vbox) { + if (!vbox.count()) return; + + var rw = vbox.r2 - vbox.r1 + 1, + gw = vbox.g2 - vbox.g1 + 1, + bw = vbox.b2 - vbox.b1 + 1, + maxw = pv.max([rw, gw, bw]); + // only one pixel, no split + if (vbox.count() == 1) { + return [vbox.copy()]; + } + /* Find the partial sum arrays along the selected axis. */ + var total = 0, + partialsum = [], + lookaheadsum = [], + i, j, k, sum, index; + if (maxw == rw) { + for (i = vbox.r1; i <= vbox.r2; i++) { + sum = 0; + for (j = vbox.g1; j <= vbox.g2; j++) { + for (k = vbox.b1; k <= vbox.b2; k++) { + index = getColorIndex(i,j,k); + sum += (histo[index] || 0); + } + } + total += sum; + partialsum[i] = total; + } + } + else if (maxw == gw) { + for (i = vbox.g1; i <= vbox.g2; i++) { + sum = 0; + for (j = vbox.r1; j <= vbox.r2; j++) { + for (k = vbox.b1; k <= vbox.b2; k++) { + index = getColorIndex(j,i,k); + sum += (histo[index] || 0); + } + } + total += sum; + partialsum[i] = total; + } + } + else { /* maxw == bw */ + for (i = vbox.b1; i <= vbox.b2; i++) { + sum = 0; + for (j = vbox.r1; j <= vbox.r2; j++) { + for (k = vbox.g1; k <= vbox.g2; k++) { + index = getColorIndex(j,k,i); + sum += (histo[index] || 0); + } + } + total += sum; + partialsum[i] = total; + } + } + partialsum.forEach(function(d,i) { + lookaheadsum[i] = total-d; + }); + function doCut(color) { + var dim1 = color + '1', + dim2 = color + '2', + left, right, vbox1, vbox2, d2, count2=0; + for (i = vbox[dim1]; i <= vbox[dim2]; i++) { + if (partialsum[i] > total / 2) { + vbox1 = vbox.copy(); + vbox2 = vbox.copy(); + left = i - vbox[dim1]; + right = vbox[dim2] - i; + if (left <= right) + d2 = Math.min(vbox[dim2] - 1, ~~(i + right / 2)); + else d2 = Math.max(vbox[dim1], ~~(i - 1 - left / 2)); + // avoid 0-count boxes + while (!partialsum[d2]) d2++; + count2 = lookaheadsum[d2]; + while (!count2 && partialsum[d2-1]) count2 = lookaheadsum[--d2]; + // set dimensions + vbox1[dim2] = d2; + vbox2[dim1] = vbox1[dim2] + 1; +// console.log('vbox counts:', vbox.count(), vbox1.count(), vbox2.count()); + return [vbox1, vbox2]; + } + } + + } + // determine the cut planes + return maxw == rw ? doCut('r') : + maxw == gw ? doCut('g') : + doCut('b'); + } + + function quantize(pixels, maxcolors) { + // short-circuit + if (!pixels.length || maxcolors < 2 || maxcolors > 256) { +// console.log('wrong number of maxcolors'); + return false; + } + + // XXX: check color content and convert to grayscale if insufficient + + var histo = getHisto(pixels), + histosize = 1 << (3 * sigbits); + + // check that we aren't below maxcolors already + var nColors = 0; + histo.forEach(function() { nColors++; }); + if (nColors <= maxcolors) { + // XXX: generate the new colors from the histo and return + } + + // get the beginning vbox from the colors + var vbox = vboxFromPixels(pixels, histo), + pq = new PQueue(function(a,b) { return pv.naturalOrder(a.count(), b.count()); }); + pq.push(vbox); + + // inner function to do the iteration + function iter(lh, target) { + var ncolors = 1, + niters = 0, + vbox; + while (niters < maxIterations) { + vbox = lh.pop(); + if (!vbox.count()) { /* just put it back */ + lh.push(vbox); + niters++; + continue; + } + // do the cut + var vboxes = medianCutApply(histo, vbox), + vbox1 = vboxes[0], + vbox2 = vboxes[1]; + + if (!vbox1) { +// console.log("vbox1 not defined; shouldn't happen!"); + return; + } + lh.push(vbox1); + if (vbox2) { /* vbox2 can be null */ + lh.push(vbox2); + ncolors++; + } + if (ncolors >= target) return; + if (niters++ > maxIterations) { +// console.log("infinite loop; perhaps too few pixels!"); + return; + } + } + } + + // first set of colors, sorted by population + iter(pq, fractByPopulations * maxcolors); + + // Re-sort by the product of pixel occupancy times the size in color space. + var pq2 = new PQueue(function(a,b) { + return pv.naturalOrder(a.count()*a.volume(), b.count()*b.volume()); + }); + while (pq.size()) { + pq2.push(pq.pop()); + } + + // next set - generate the median cuts using the (npix * vol) sorting. + iter(pq2, maxcolors - pq2.size()); + + // calculate the actual colors + var cmap = new CMap(); + while (pq2.size()) { + cmap.push(pq2.pop()); + } + + return cmap; + } + + return { + quantize: quantize + }; +})(); + +export default ColorThief