diff --git a/api/requirements/base.txt b/api/requirements/base.txt index aee1222591882c83062e190a2a7c8b36a10dc7d7..7e56b6cfda24f977288de6ed3b91a5df42f1fde0 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -49,7 +49,7 @@ mutagen>=1.39,<1.40 # Until this is merged #django-taggit>=0.22,<0.23 -git+https://github.com/jdufresne/django-taggit.git@e8f7f216f04c9781bebc84363ab24d575f948ede +git+https://github.com/alex/django-taggit.git@95776ac66948ed7ba7c12e35c1170551e3be66a5 # Until this is merged git+https://github.com/EliotBerriot/PyMemoize.git@django # Until this is merged diff --git a/dev.yml b/dev.yml index c71298cfc8932c1b7874a906b582fe85a59ee4bd..44e38e32671073cbeb4d3c9d697a216c2b99b541 100644 --- a/dev.yml +++ b/dev.yml @@ -13,7 +13,6 @@ services: - "8080:8080" volumes: - './front:/app' - - /app/node_modules postgres: env_file: .env.dev diff --git a/front/package.json b/front/package.json index bad90430f4144be99d4b17ac11e82073a6705ff0..5bec01602d4beed1e1443af3fd690bdf86e06e00 100644 --- a/front/package.json +++ b/front/package.json @@ -22,7 +22,8 @@ "vue-lazyload": "^1.1.4", "vue-resource": "^1.3.4", "vue-router": "^2.3.1", - "vuedraggable": "^2.14.1" + "vuedraggable": "^2.14.1", + "vuex": "^3.0.1" }, "devDependencies": { "autoprefixer": "^6.7.2", diff --git a/front/src/audio/index.js b/front/src/audio/index.js deleted file mode 100644 index 4896b83b0f895c8bca9269aebf9141d16ab53087..0000000000000000000000000000000000000000 --- a/front/src/audio/index.js +++ /dev/null @@ -1,184 +0,0 @@ -import logger from '@/logging' -import time from '@/utils/time' - -const Cov = { - on (el, type, func) { - el.addEventListener(type, func) - }, - off (el, type, func) { - el.removeEventListener(type, func) - } -} - -class Audio { - constructor (src, options = {}) { - let preload = true - if (options.preload !== undefined && options.preload === false) { - preload = false - } - this.tmp = { - src: src, - options: options - } - this.onEnded = function (e) { - logger.default.info('track ended') - } - if (options.onEnded) { - this.onEnded = options.onEnded - } - this.onError = options.onError - - this.state = { - preload: preload, - startLoad: false, - failed: false, - try: 3, - tried: 0, - playing: false, - paused: false, - playbackRate: 1.0, - progress: 0, - currentTime: 0, - volume: 0.5, - duration: 0, - loaded: '0', - durationTimerFormat: '00:00', - currentTimeFormat: '00:00', - lastTimeFormat: '00:00' - } - if (options.volume !== undefined) { - this.state.volume = options.volume - } - this.hook = { - playState: [], - loadState: [] - } - if (preload) { - this.init(src, options) - } - } - - init (src, options = {}) { - if (!src) throw Error('src must be required') - this.state.startLoad = true - if (this.state.tried >= this.state.try) { - this.state.failed = true - logger.default.error('Cannot fetch audio', src) - if (this.onError) { - this.onError(src) - } - return - } - this.$Audio = new window.Audio(src) - Cov.on(this.$Audio, 'error', () => { - this.state.tried++ - this.init(src, options) - }) - if (options.autoplay) { - this.play() - } - if (options.rate) { - this.$Audio.playbackRate = options.rate - } - if (options.loop) { - this.$Audio.loop = true - } - if (options.volume) { - this.setVolume(options.volume) - } - this.loadState() - } - - loadState () { - if (this.$Audio.readyState >= 2) { - Cov.on(this.$Audio, 'progress', this.updateLoadState.bind(this)) - } else { - Cov.on(this.$Audio, 'loadeddata', () => { - this.loadState() - }) - } - } - - updateLoadState (e) { - if (!this.$Audio) return - this.hook.loadState.forEach(func => { - func(this.state) - }) - this.state.duration = Math.round(this.$Audio.duration * 100) / 100 - this.state.loaded = Math.round(10000 * this.$Audio.buffered.end(0) / this.$Audio.duration) / 100 - this.state.durationTimerFormat = time.parse(this.state.duration) - } - - updatePlayState (e) { - this.state.currentTime = Math.round(this.$Audio.currentTime * 100) / 100 - this.state.duration = Math.round(this.$Audio.duration * 100) / 100 - this.state.progress = Math.round(10000 * this.state.currentTime / this.state.duration) / 100 - - this.state.durationTimerFormat = time.parse(this.state.duration) - this.state.currentTimeFormat = time.parse(this.state.currentTime) - this.state.lastTimeFormat = time.parse(this.state.duration - this.state.currentTime) - - this.hook.playState.forEach(func => { - func(this.state) - }) - } - - updateHook (type, func) { - if (!(type in this.hook)) throw Error('updateHook: type should be playState or loadState') - this.hook[type].push(func) - } - - play () { - if (this.state.startLoad) { - if (!this.state.playing && this.$Audio.readyState >= 2) { - logger.default.info('Playing track') - this.$Audio.play() - this.state.paused = false - this.state.playing = true - Cov.on(this.$Audio, 'timeupdate', this.updatePlayState.bind(this)) - Cov.on(this.$Audio, 'ended', this.onEnded) - } else { - Cov.on(this.$Audio, 'loadeddata', () => { - this.play() - }) - } - } else { - this.init(this.tmp.src, this.tmp.options) - Cov.on(this.$Audio, 'loadeddata', () => { - this.play() - }) - } - } - - destroyed () { - this.$Audio.pause() - Cov.off(this.$Audio, 'timeupdate', this.updatePlayState) - Cov.off(this.$Audio, 'progress', this.updateLoadState) - Cov.off(this.$Audio, 'ended', this.onEnded) - this.$Audio.remove() - } - - pause () { - logger.default.info('Pausing track') - this.$Audio.pause() - this.state.paused = true - this.state.playing = false - this.$Audio.removeEventListener('timeupdate', this.updatePlayState) - } - - setVolume (number) { - if (number > -0.01 && number <= 1) { - this.state.volume = Math.round(number * 100) / 100 - this.$Audio.volume = this.state.volume - } - } - - setTime (time) { - if (time < 0 && time > this.state.duration) { - return false - } - this.$Audio.currentTime = time - } -} - -export default Audio diff --git a/front/src/audio/queue.js b/front/src/audio/queue.js deleted file mode 100644 index 4273fb9a630dd098458b532cd85f5bdb96399aba..0000000000000000000000000000000000000000 --- a/front/src/audio/queue.js +++ /dev/null @@ -1,332 +0,0 @@ -import Vue from 'vue' -import _ from 'lodash' - -import logger from '@/logging' -import cache from '@/cache' -import config from '@/config' -import Audio from '@/audio' -import backend from '@/audio/backend' -import radios from '@/radios' -import url from '@/utils/url' -import auth from '@/auth' - -class Queue { - constructor (options = {}) { - logger.default.info('Instanciating queue') - this.previousQueue = cache.get('queue') - this.tracks = [] - this.currentIndex = -1 - this.currentTrack = null - this.ended = true - this.state = { - looping: 0, // 0 -> no, 1 -> on track, 2 -> on queue - volume: cache.get('volume', 0.5) - } - this.audio = { - state: { - startLoad: false, - failed: false, - try: 3, - tried: 0, - playing: false, - paused: false, - playbackRate: 1.0, - progress: 0, - currentTime: 0, - duration: 0, - volume: this.state.volume, - loaded: '0', - durationTimerFormat: '00:00', - currentTimeFormat: '00:00', - lastTimeFormat: '00:00' - } - } - } - - cache () { - let cached = { - tracks: this.tracks.map(track => { - // we keep only valuable fields to make the cache lighter and avoid - // cyclic value serialization errors - let artist = { - id: track.artist.id, - mbid: track.artist.mbid, - name: track.artist.name - } - return { - id: track.id, - title: track.title, - mbid: track.mbid, - album: { - id: track.album.id, - title: track.album.title, - mbid: track.album.mbid, - cover: track.album.cover, - artist: artist - }, - artist: artist, - files: track.files - } - }), - currentIndex: this.currentIndex - } - cache.set('queue', cached) - } - - restore () { - let cached = cache.get('queue') - if (!cached) { - return false - } - logger.default.info('Restoring previous queue...') - this.tracks = cached.tracks - this.play(cached.currentIndex) - this.previousQueue = null - return true - } - removePrevious () { - this.previousQueue = undefined - cache.remove('queue') - } - setVolume (newValue) { - newValue = Math.min(newValue, 1) - newValue = Math.max(newValue, 0) - this.state.volume = newValue - if (this.audio.setVolume) { - this.audio.setVolume(newValue) - } else { - this.audio.state.volume = newValue - } - cache.set('volume', newValue) - } - incrementVolume (value) { - this.setVolume(this.state.volume + value) - } - reorder (oldIndex, newIndex) { - // called when the user uses drag / drop to reorder - // tracks in queue - if (oldIndex === this.currentIndex) { - this.currentIndex = newIndex - return - } - if (oldIndex < this.currentIndex && newIndex >= this.currentIndex) { - // item before was moved after - this.currentIndex -= 1 - } - if (oldIndex > this.currentIndex && newIndex <= this.currentIndex) { - // item after was moved before - this.currentIndex += 1 - } - } - - append (track, index, skipPlay) { - this.previousQueue = null - index = index || this.tracks.length - if (index > this.tracks.length - 1) { - // we simply push to the end - this.tracks.push(track) - } else { - // we insert the track at given position - this.tracks.splice(index, 0, track) - } - if (!skipPlay) { - this.resumeQueue() - } - this.cache() - } - - appendMany (tracks, index) { - logger.default.info('Appending many tracks to the queue', tracks.map(e => { return e.title })) - let self = this - if (this.tracks.length === 0) { - index = 0 - } else { - index = index || this.tracks.length - } - tracks.forEach((t) => { - self.append(t, index, true) - index += 1 - }) - this.resumeQueue() - } - - resumeQueue () { - if (this.ended | this.errored) { - this.next() - } - } - - populateFromRadio () { - if (!radios.running) { - return - } - var self = this - radios.fetch().then((response) => { - logger.default.info('Adding track to queue from radio') - self.append(response.data.track) - }, (response) => { - logger.default.error('Error while adding track to queue from radio') - }) - } - - clean () { - this.stop() - radios.stop() - this.tracks = [] - this.currentIndex = -1 - this.currentTrack = null - // so we replay automatically on next track append - this.ended = true - } - - cleanTrack (index) { - // are we removing current playin track - let current = index === this.currentIndex - if (current) { - this.stop() - } - if (index < this.currentIndex) { - this.currentIndex -= 1 - } - this.tracks.splice(index, 1) - if (current) { - // we play next track, which now have the same index - this.play(index) - } - if (this.currentIndex === this.tracks.length - 1) { - this.populateFromRadio() - } - } - - stop () { - if (this.audio.pause) { - this.audio.pause() - } - if (this.audio.destroyed) { - this.audio.destroyed() - } - } - play (index) { - let self = this - let currentIndex = index - let currentTrack = this.tracks[index] - - if (this.audio.destroyed) { - logger.default.debug('Destroying previous audio...', index - 1) - this.audio.destroyed() - } - - if (!currentTrack) { - return - } - - this.currentIndex = currentIndex - this.currentTrack = currentTrack - - this.ended = false - this.errored = false - let file = this.currentTrack.files[0] - if (!file) { - this.errored = true - return this.next() - } - let path = backend.absoluteUrl(file.path) - if (auth.user.authenticated) { - // we need to send the token directly in url - // so authentication can be checked by the backend - // because for audio files we cannot use the regular Authentication - // header - path = url.updateQueryString(path, 'jwt', auth.getAuthToken()) - } - - let audio = new Audio(path, { - preload: true, - autoplay: true, - rate: 1, - loop: false, - volume: this.state.volume, - onEnded: this.handleAudioEnded.bind(this), - onError: function (src) { - self.errored = true - self.next() - } - }) - this.audio = audio - audio.updateHook('playState', function (e) { - // in some situations, we may have a race condition, for example - // if the user spams the next / previous buttons, with multiple audios - // playing at the same time. To avoid that, we ensure the audio - // still matches de queue current audio - if (audio !== self.audio) { - logger.default.debug('Destroying duplicate audio') - audio.destroyed() - } - }) - if (this.currentIndex === this.tracks.length - 1) { - this.populateFromRadio() - } - this.cache() - } - - handleAudioEnded (e) { - this.recordListen(this.currentTrack) - if (this.state.looping === 1) { - // we loop on the same track - logger.default.info('Looping on the same track') - return this.play(this.currentIndex) - } - if (this.currentIndex < this.tracks.length - 1) { - logger.default.info('Audio track ended, playing next one') - return this.next() - } else { - logger.default.info('We reached the end of the queue') - if (this.state.looping === 2) { - logger.default.info('Going back to the beginning of the queue') - return this.play(0) - } else { - this.ended = true - } - } - } - - recordListen (track) { - let url = config.API_URL + 'history/listenings/' - let resource = Vue.resource(url) - resource.save({}, {'track': track.id}).then((response) => {}, (response) => { - logger.default.error('Could not record track in history') - }) - } - - previous () { - if (this.currentIndex > 0) { - this.play(this.currentIndex - 1) - } - } - - next () { - if (this.currentIndex < this.tracks.length - 1) { - logger.default.debug('Playing next track') - this.play(this.currentIndex + 1) - } - } - - toggleLooping () { - if (this.state.looping > 1) { - this.state.looping = 0 - } else { - this.state.looping += 1 - } - } - - shuffle () { - let tracks = this.tracks - let shuffled = _.shuffle(tracks) - this.clean() - this.appendMany(shuffled) - } - -} - -let queue = new Queue() - -export default queue diff --git a/front/src/auth/index.js b/front/src/auth/index.js deleted file mode 100644 index 80236942858440d517d2fe80b7bb43c71b3ea7c0..0000000000000000000000000000000000000000 --- a/front/src/auth/index.js +++ /dev/null @@ -1,99 +0,0 @@ -import logger from '@/logging' -import config from '@/config' -import cache from '@/cache' -import Vue from 'vue' - -import favoriteTracks from '@/favorites/tracks' - -// URL and endpoint constants -const LOGIN_URL = config.API_URL + 'token/' -const USER_PROFILE_URL = config.API_URL + 'users/users/me/' -// const SIGNUP_URL = API_URL + 'users/' - -let userData = { - authenticated: false, - username: '', - availablePermissions: {}, - profile: {} -} -let auth = { - - // Send a request to the login URL and save the returned JWT - login (context, creds, redirect, onError) { - return context.$http.post(LOGIN_URL, creds).then(response => { - logger.default.info('Successfully logged in as', creds.username) - cache.set('token', response.data.token) - cache.set('username', creds.username) - - this.user.authenticated = true - this.user.username = creds.username - this.connect() - // Redirect to a specified route - if (redirect) { - context.$router.push(redirect) - } - }, response => { - logger.default.error('Error while logging in', response.data) - if (onError) { - onError(response) - } - }) - }, - - // To log out, we just need to remove the token - logout () { - cache.clear() - this.user.authenticated = false - logger.default.info('Log out, goodbye!') - }, - - checkAuth () { - logger.default.info('Checking authentication...') - var jwt = this.getAuthToken() - var username = cache.get('username') - if (jwt) { - this.user.authenticated = true - this.user.username = username - logger.default.info('Logged back in as ' + username) - this.connect() - } else { - logger.default.info('Anonymous user') - this.user.authenticated = false - } - }, - - getAuthToken () { - return cache.get('token') - }, - - // The object to be passed as a header for authenticated requests - getAuthHeader () { - return 'JWT ' + this.getAuthToken() - }, - - fetchProfile () { - let resource = Vue.resource(USER_PROFILE_URL) - return resource.get({}).then((response) => { - logger.default.info('Successfully fetched user profile') - return response.data - }, (response) => { - logger.default.info('Error while fetching user profile') - }) - }, - connect () { - // called once user has logged in successfully / reauthenticated - // e.g. after a page refresh - let self = this - this.fetchProfile().then(data => { - Vue.set(self.user, 'profile', data) - Object.keys(data.permissions).forEach(function (key) { - // this makes it easier to check for permissions in templates - Vue.set(self.user.availablePermissions, key, data.permissions[String(key)].status) - }) - }) - favoriteTracks.fetch() - } -} - -Vue.set(auth, 'user', userData) -export default auth diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 68927a37b09a5e302a9dfb7ce9be44b9e7750b96..d6a2539224afb89412f5488cd1e7b02ade452cdb 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -28,8 +28,8 @@ <div class="tabs"> <div class="ui bottom attached active tab" data-tab="library"> <div class="ui inverted vertical fluid menu"> - <router-link class="item" v-if="auth.user.authenticated" :to="{name: 'profile', params: {username: auth.user.username}}"><i class="user icon"></i> Logged in as {{ auth.user.username }}</router-link> - <router-link class="item" v-if="auth.user.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> Logout</router-link> + <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><i class="user icon"></i> Logged in as {{ $store.state.auth.username }}</router-link> + <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> Logout</router-link> <router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link> <router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link> <router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link> @@ -51,7 +51,7 @@ <div class="ui bottom attached tab" data-tab="queue"> <table class="ui compact inverted very basic fixed single line table"> <draggable v-model="queue.tracks" element="tbody" @update="reorder"> - <tr @click="queue.play(index)" v-for="(track, index) in queue.tracks" :key="index" :class="[{'active': index === queue.currentIndex}]"> + <tr @click="$store.dispatch('queue/currentIndex', index)" v-for="(track, index) in queue.tracks" :key="index" :class="[{'active': index === queue.currentIndex}]"> <td class="right aligned">{{ index + 1}}</td> <td class="center aligned"> <img class="ui mini image" v-if="track.album.cover" :src="backend.absoluteUrl(track.album.cover)"> @@ -63,23 +63,23 @@ </td> <td> <template v-if="favoriteTracks.objects[track.id]"> - <i @click.stop="queue.cleanTrack(index)" class="pink heart icon"></i> - </template + <i class="pink heart icon"></i> + </template </td> <td> - <i @click.stop="queue.cleanTrack(index)" class="circular trash icon"></i> + <i @click.stop="cleanTrack(index)" class="circular trash icon"></i> </td> </tr> </draggable> </table> - <div v-if="radios.running" class="ui black message"> + <div v-if="$store.state.radios.running" class="ui black message"> <div class="content"> <div class="header"> <i class="feed icon"></i> You have a radio playing </div> <p>New tracks will be appended here automatically.</p> - <div @click="radios.stop()" class="ui basic inverted red button">Stop radio</div> + <div @click="$store.dispatch('radios/stop')" class="ui basic inverted red button">Stop radio</div> </div> </div> </div> @@ -87,24 +87,18 @@ <div class="ui inverted segment player-wrapper"> <player></player> </div> - <GlobalEvents - @keydown.r.stop="queue.restore" - /> </div> </template> <script> -import GlobalEvents from '@/components/utils/global-events' +import {mapState, mapActions} from 'vuex' import Player from '@/components/audio/Player' import favoriteTracks from '@/favorites/tracks' import Logo from '@/components/Logo' import SearchBar from '@/components/audio/SearchBar' -import auth from '@/auth' -import queue from '@/audio/queue' import backend from '@/audio/backend' import draggable from 'vuedraggable' -import radios from '@/radios' import $ from 'jquery' @@ -114,24 +108,28 @@ export default { Player, SearchBar, Logo, - draggable, - GlobalEvents + draggable }, data () { return { - auth: auth, backend: backend, - queue: queue, - radios, favoriteTracks } }, mounted () { $(this.$el).find('.menu .item').tab() }, + computed: { + ...mapState({ + queue: state => state.queue + }) + }, methods: { - reorder (e) { - this.queue.reorder(e.oldIndex, e.newIndex) + ...mapActions({ + cleanTrack: 'queue/cleanTrack' + }), + reorder: function (oldValue, newValue) { + this.$store.commit('queue/reorder', {oldValue, newValue}) } } } diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 240fa498032c05c2823f0aa3d53cf2e8a7991b0f..4767255ecae8b6bb27a872614f9a4e029146ded9 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -17,7 +17,6 @@ <script> import logger from '@/logging' -import queue from '@/audio/queue' import jQuery from 'jquery' export default { @@ -40,19 +39,19 @@ export default { methods: { add () { if (this.track) { - queue.append(this.track) + this.$store.dispatch('queue/append', {track: this.track}) } else { - queue.appendMany(this.tracks) + this.$store.dispatch('queue/appendMany', {tracks: this.tracks}) } }, addNext (next) { if (this.track) { - queue.append(this.track, queue.currentIndex + 1) + this.$store.dispatch('queue/append', {track: this.track, index: this.$store.state.queue.currentIndex + 1}) } else { - queue.appendMany(this.tracks, queue.currentIndex + 1) + this.$store.dispatch('queue/appendMany', {tracks: this.tracks, index: this.$store.state.queue.currentIndex + 1}) } if (next) { - queue.next() + this.$store.dispatch('queue/next') } } } diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index c862660ad60ed0960cc5b0e66a2380810cdef9b7..fec74b3dcb26411d6cfedf4125c32d4a810ce574 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -1,104 +1,112 @@ <template> <div class="player"> - <div v-if="queue.currentTrack" class="track-area ui items"> + <audio-track + ref="currentAudio" + v-if="currentTrack" + :key="(currentIndex, currentTrack.id)" + :is-current="true" + :track="currentTrack"> + </audio-track> + + <div v-if="currentTrack" class="track-area ui items"> <div class="ui inverted item"> <div class="ui tiny image"> - <img v-if="queue.currentTrack.album.cover" :src="Track.getCover(queue.currentTrack)"> + <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: queue.currentTrack.id }}"> - {{ queue.currentTrack.title }} + <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: queue.currentTrack.artist.id }}"> - {{ queue.currentTrack.artist.name }} + <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: queue.currentTrack.album.id }}"> - {{ queue.currentTrack.album.title }} + <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="queue.currentTrack"></track-favorite-icon> + <track-favorite-icon :track="currentTrack"></track-favorite-icon> </div> </div> </div> </div> - <div class="progress-area" v-if="queue.currentTrack"> + <div class="progress-area" v-if="currentTrack"> <div class="ui grid"> <div class="left floated four wide column"> - <p class="timer start" @click="queue.audio.setTime(0)">{{queue.audio.state.currentTimeFormat}}</p> + <p class="timer start" @click="updateProgress(0)">{{currentTimeFormatted}}</p> </div> <div class="right floated four wide column"> - <p class="timer total">{{queue.audio.state.durationTimerFormat}}</p> + <p class="timer total">{{durationFormatted}}</p> </div> </div> <div ref="progress" class="ui small orange inverted progress" @click="touchProgress"> - <div class="bar" :data-percent="queue.audio.state.progress" :style="{ 'width': queue.audio.state.progress + '%' }"></div> + <div class="bar" :data-percent="progress" :style="{ 'width': progress + '%' }"></div> </div> </div> <div class="two wide column controls ui grid"> <div - @click="queue.previous()" + @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="!queue.audio.state.playing" - @click="pauseOrPlay" + v-if="!playing" + @click="togglePlay" title="Play track" class="two wide column control"> - <i :class="['ui', 'play', {'disabled': !queue.currentTrack}, 'big', 'icon']"></i> + <i :class="['ui', 'play', {'disabled': !currentTrack}, 'big', 'icon']"></i> </div> <div v-else - @click="pauseOrPlay" + @click="togglePlay" title="Pause track" class="two wide column control"> - <i :class="['ui', 'pause', {'disabled': !queue.currentTrack}, 'big', 'icon']"></i> + <i :class="['ui', 'pause', {'disabled': !currentTrack}, 'big', 'icon']"></i> </div> <div - @click="queue.next()" + @click="next" title="Next track" class="two wide column control" :disabled="!hasNext"> - <i :class="['ui', {'disabled': !hasPrevious}, 'step', 'forward', 'big', 'icon']" ></i> + <i :class="['ui', {'disabled': !hasNext}, 'step', 'forward', 'big', 'icon']" ></i> </div> <div class="two wide column control volume-control"> - <i title="Unmute" @click="queue.setVolume(1)" v-if="currentVolume === 0" class="volume off secondary icon"></i> - <i title="Mute" @click="queue.setVolume(0)" v-else-if="currentVolume < 0.5" class="volume down secondary icon"></i> - <i title="Mute" @click="queue.setVolume(0)" v-else class="volume up secondary icon"></i> + <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="queue.state.looping === 0" - @click="queue.state.looping = 1" - :disabled="!queue.currentTrack" - :class="['ui', {'disabled': !queue.currentTrack}, 'step', 'repeat', 'secondary', 'icon']"></i> + 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="queue.state.looping === 1" - @click="queue.state.looping = 2" - :disabled="!queue.currentTrack" + 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="queue.state.looping === 2" - @click="queue.state.looping = 0" - :disabled="!queue.currentTrack" + v-if="looping === 2" + @click="$store.commit('player/looping', 0)" + :disabled="!currentTrack" class="repeat orange secondary icon"> </i> </div> <div - @click="queue.shuffle()" + @click="shuffle()" :disabled="queue.tracks.length === 0" title="Shuffle your queue" class="two wide column control"> @@ -106,7 +114,7 @@ </div> <div class="one wide column"></div> <div - @click="queue.clean()" + @click="clean()" :disabled="queue.tracks.length === 0" title="Clear your queue" class="two wide column control"> @@ -114,79 +122,87 @@ </div> </div> <GlobalEvents - @keydown.space.prevent.exact="pauseOrPlay" - @keydown.ctrl.left.prevent.exact="queue.previous" - @keydown.ctrl.right.prevent.exact="queue.next" - @keydown.ctrl.down.prevent.exact="queue.incrementVolume(-0.1)" - @keydown.ctrl.up.prevent.exact="queue.incrementVolume(0.1)" - @keydown.f.prevent.exact="favoriteTracks.toggle(queue.currentTrack.id)" - @keydown.l.prevent.exact="queue.toggleLooping" - @keydown.s.prevent.exact="queue.shuffle" + @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="favoriteTracks.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 favoriteTracks from '@/favorites/tracks' -import queue from '@/audio/queue' -import radios from '@/radios' import Track from '@/audio/track' +import AudioTrack from '@/components/audio/Track' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' export default { name: 'player', components: { TrackFavoriteIcon, - GlobalEvents + GlobalEvents, + AudioTrack }, data () { return { - sliderVolume: this.currentVolume, - queue: queue, + sliderVolume: this.volume, Track: Track, - favoriteTracks, - radios + favoriteTracks } }, mounted () { // we trigger the watcher explicitely it does not work otherwise - this.sliderVolume = this.currentVolume + this.sliderVolume = this.volume }, methods: { - pauseOrPlay () { - if (this.queue.audio.state.playing) { - this.queue.audio.pause() - } else { - this.queue.audio.play() - } - }, + ...mapActions({ + pause: 'player/pause', + togglePlay: 'player/togglePlay', + clean: 'queue/clean', + next: 'queue/next', + previous: 'queue/previous', + shuffle: 'queue/shuffle', + updateProgress: 'player/updateProgress' + }), touchProgress (e) { let time let target = this.$refs.progress - time = e.layerX / target.offsetWidth * this.queue.audio.state.duration - this.queue.audio.setTime(time) + time = e.layerX / target.offsetWidth * this.duration + this.$refs.currentAudio.setCurrentTime(time) } }, computed: { - hasPrevious () { - return this.queue.currentIndex > 0 - }, - hasNext () { - return this.queue.currentIndex < this.queue.tracks.length - 1 - }, - currentVolume () { - return this.queue.audio.state.volume - } + ...mapState({ + currentIndex: state => state.queue.currentIndex, + playing: state => state.player.playing, + volume: state => state.player.volume, + looping: state => state.player.looping, + duration: state => state.player.duration, + queue: state => state.queue + }), + ...mapGetters({ + currentTrack: 'queue/currentTrack', + hasNext: 'queue/hasNext', + hasPrevious: 'queue/hasPrevious', + durationFormatted: 'player/durationFormatted', + currentTimeFormatted: 'player/currentTimeFormatted', + progress: 'player/progress' + }) }, watch: { - currentVolume (newValue) { + volume (newValue) { this.sliderVolume = newValue }, sliderVolume (newValue) { - this.queue.setVolume(parseFloat(newValue)) + this.$store.commit('player/volume', newValue) } } } diff --git a/front/src/components/audio/Search.vue b/front/src/components/audio/Search.vue index 5c902e5e5abba8e1472feff9cfae3af269e1641e..2811c2b5c4f58ca71f8c5c55c239ffcf41c0e5d6 100644 --- a/front/src/components/audio/Search.vue +++ b/front/src/components/audio/Search.vue @@ -30,7 +30,6 @@ <script> import logger from '@/logging' -import queue from '@/audio/queue' import backend from '@/audio/backend' import AlbumCard from '@/components/audio/album/Card' import ArtistCard from '@/components/audio/artist/Card' @@ -54,8 +53,7 @@ export default { artists: [] }, backend: backend, - isLoading: false, - queue: queue + isLoading: false } }, mounted () { diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue index 2324c88392f258f95b7915cce04ea1b9166d5e80..386e24a74f677d40fc60d1b536c15793288a5454 100644 --- a/front/src/components/audio/SearchBar.vue +++ b/front/src/components/audio/SearchBar.vue @@ -12,7 +12,6 @@ <script> import jQuery from 'jquery' import config from '@/config' -import auth from '@/auth' import router from '@/router' const SEARCH_URL = config.API_URL + 'search?query={query}' @@ -27,7 +26,7 @@ export default { }, apiSettings: { beforeXHR: function (xhrObject) { - xhrObject.setRequestHeader('Authorization', auth.getAuthHeader()) + xhrObject.setRequestHeader('Authorization', this.$store.getters['auth/header']) return xhrObject }, onResponse: function (initialResponse) { diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue new file mode 100644 index 0000000000000000000000000000000000000000..f0e1f14fa70fbb52a85ba5d99ddc7ffdb4e11dbf --- /dev/null +++ b/front/src/components/audio/Track.vue @@ -0,0 +1,103 @@ +<template> + <audio + ref="audio" + :src="url" + @error="errored" + @progress="updateLoad" + @loadeddata="loaded" + @timeupdate="updateProgress" + @ended="ended" + preload> + + </audio> +</template> + +<script> +import {mapState} from 'vuex' +import backend from '@/audio/backend' +import url from '@/utils/url' + +// import logger from '@/logging' + +export default { + props: { + track: {type: Object}, + isCurrent: {type: Boolean, default: false} + }, + computed: { + ...mapState({ + playing: state => state.player.playing, + currentTime: state => state.player.currentTime, + duration: state => state.player.duration, + volume: state => state.player.volume, + looping: state => state.player.looping + }), + url: function () { + let file = this.track.files[0] + if (!file) { + this.$store.dispatch('player/trackErrored') + return null + } + let path = backend.absoluteUrl(file.path) + if (this.$store.state.auth.authenticated) { + // we need to send the token directly in url + // so authentication can be checked by the backend + // because for audio files we cannot use the regular Authentication + // header + path = url.updateQueryString(path, 'jwt', this.$store.state.auth.token) + } + return path + } + }, + methods: { + errored: function () { + this.$store.dispatch('player/trackErrored') + }, + updateLoad: function () { + + }, + loaded: function () { + this.$store.commit('player/duration', this.$refs.audio.duration) + if (this.isCurrent) { + this.$store.commit('player/playing', true) + this.$refs.audio.play() + } + }, + updateProgress: function () { + if (this.$refs.audio) { + this.$store.dispatch('player/updateProgress', this.$refs.audio.currentTime) + } + }, + ended: function () { + if (this.looping === 1) { + this.setCurrentTime(0) + this.$refs.audio.play() + } + this.$store.dispatch('player/trackEnded', this.track) + }, + setCurrentTime (t) { + if (t < 0 | t > this.duration) { + return + } + this.updateProgress(t) + this.$refs.audio.currentTime = t + } + }, + watch: { + playing: function (newValue) { + if (newValue === true) { + this.$refs.audio.play() + } else { + this.$refs.audio.pause() + } + }, + volume: function (newValue) { + this.$refs.audio.volume = newValue + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/components/audio/album/Card.vue b/front/src/components/audio/album/Card.vue index ce5e832e2794a4288a673460244434f30945615f..4c803b29cc704fbc7e05258bfe595ee9b6908d99 100644 --- a/front/src/components/audio/album/Card.vue +++ b/front/src/components/audio/album/Card.vue @@ -51,7 +51,6 @@ </template> <script> -import queue from '@/audio/queue' import backend from '@/audio/backend' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' import PlayButton from '@/components/audio/PlayButton' @@ -68,7 +67,6 @@ export default { data () { return { backend: backend, - queue: queue, initialTracks: 4, showAllTracks: false } diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue index efb98e382804ecb2623d4f96f5e6b9935559052b..8a591d3bd05e9542484a2b8bf8ef9b931363a837 100644 --- a/front/src/components/audio/track/Table.vue +++ b/front/src/components/audio/track/Table.vue @@ -58,9 +58,9 @@ Keep your PRIVATE_TOKEN secret as it gives access to your account. </div> <pre> -export PRIVATE_TOKEN="{{ auth.getAuthToken ()}}" +export PRIVATE_TOKEN="{{ $store.state.auth.token }}" <template v-for="track in tracks"><template v-if="track.files.length > 0"> -curl -G -o "{{ track.files[0].filename }}" <template v-if="auth.user.authenticated">--header "Authorization: JWT $PRIVATE_TOKEN"</template> "{{ backend.absoluteUrl(track.files[0].path) }}"</template></template> +curl -G -o "{{ track.files[0].filename }}" <template v-if="$store.state.auth.authenticated">--header "Authorization: JWT $PRIVATE_TOKEN"</template> "{{ backend.absoluteUrl(track.files[0].path) }}"</template></template> </pre> </div> </div> @@ -83,7 +83,6 @@ curl -G -o "{{ track.files[0].filename }}" <template v-if="auth.user.authenticat <script> import backend from '@/audio/backend' -import auth from '@/auth' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' import PlayButton from '@/components/audio/PlayButton' @@ -102,7 +101,6 @@ export default { data () { return { backend: backend, - auth: auth, showDownloadModal: false } } diff --git a/front/src/components/auth/Login.vue b/front/src/components/auth/Login.vue index 54e7b82e096433aacd18d02053e66c164e51ca60..99b439af8b3e25c821ddb1dbc0c86c6419961d2c 100644 --- a/front/src/components/auth/Login.vue +++ b/front/src/components/auth/Login.vue @@ -39,12 +39,11 @@ </template> <script> -import auth from '@/auth' export default { name: 'login', props: { - next: {type: String} + next: {type: String, default: '/'} }, data () { return { @@ -72,14 +71,17 @@ export default { } // We need to pass the component's this context // to properly make use of http in the auth service - auth.login(this, credentials, {path: this.next}, function (response) { - // error callback - if (response.status === 400) { - self.error = 'invalid_credentials' - } else { - self.error = 'unknown_error' + this.$store.dispatch('auth/login', { + credentials, + next: this.next, + onError: response => { + if (response.status === 400) { + self.error = 'invalid_credentials' + } else { + self.error = 'unknown_error' + } } - }).then((response) => { + }).then(e => { self.isLoading = false }) } diff --git a/front/src/components/auth/Logout.vue b/front/src/components/auth/Logout.vue index f4b2979bc05394dc04f7644e88700a08b13989d5..fbacca70338ed295dc284664d443c9b239b25da0 100644 --- a/front/src/components/auth/Logout.vue +++ b/front/src/components/auth/Logout.vue @@ -3,8 +3,8 @@ <div class="ui vertical stripe segment"> <div class="ui small text container"> <h2>Are you sure you want to log out?</h2> - <p>You are currently logged in as {{ auth.user.username }}</p> - <button class="ui button" @click="logout">Yes, log me out!</button> + <p>You are currently logged in as {{ $store.state.auth.username }}</p> + <button class="ui button" @click="$store.dispatch('auth/logout')">Yes, log me out!</button> </form> </div> </div> @@ -12,23 +12,8 @@ </template> <script> -import auth from '@/auth' - export default { - name: 'logout', - data () { - return { - // We need to initialize the component with any - // properties that will be used in it - auth: auth - } - }, - methods: { - logout () { - auth.logout() - this.$router.push({name: 'index'}) - } - } + name: 'logout' } </script> diff --git a/front/src/components/auth/Profile.vue b/front/src/components/auth/Profile.vue index 2aaae9e2df34d292f5cc66ae04dee5898bc9d335..607fa8ff2b84ffdde60ccbf3b514938dfeb5aba4 100644 --- a/front/src/components/auth/Profile.vue +++ b/front/src/components/auth/Profile.vue @@ -3,17 +3,17 @@ <div v-if="isLoading" class="ui vertical segment"> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> - <template v-if="profile"> + <template v-if="$store.state.auth.profile"> <div :class="['ui', 'head', 'vertical', 'center', 'aligned', 'stripe', 'segment']"> <h2 class="ui center aligned icon header"> <i class="circular inverted user green icon"></i> <div class="content"> - {{ profile.username }} + {{ $store.state.auth.profile.username }} <div class="sub header">Registered since {{ signupDate }}</div> </div> </h2> <div class="ui basic green label">this is you!</div> - <div v-if="profile.is_staff" class="ui yellow label"> + <div v-if="$store.state.auth.profile.is_staff" class="ui yellow label"> <i class="star icon"></i> Staff member </div> @@ -23,35 +23,21 @@ </template> <script> -import auth from '@/auth' -var dateFormat = require('dateformat') +const dateFormat = require('dateformat') export default { name: 'login', props: ['username'], - data () { - return { - profile: null - } - }, created () { - this.fetchProfile() - }, - methods: { - fetchProfile () { - let self = this - auth.fetchProfile().then(data => { - self.profile = data - }) - } + this.$store.dispatch('auth/fetchProfile') }, computed: { signupDate () { - let d = new Date(this.profile.date_joined) + let d = new Date(this.$store.state.auth.profile.date_joined) return dateFormat(d, 'longDate') }, isLoading () { - return !this.profile + return !this.$store.state.auth.profile } } } diff --git a/front/src/components/favorites/List.vue b/front/src/components/favorites/List.vue index 91efd72907e745244f5a74d7cdc3547e750611d2..aef4bea93c1a0a6bfdbd695098619f1d45d6af22 100644 --- a/front/src/components/favorites/List.vue +++ b/front/src/components/favorites/List.vue @@ -6,7 +6,7 @@ </div> <h2 v-if="results" class="ui center aligned icon header"> <i class="circular inverted heart pink icon"></i> - {{ favoriteTracks.count }} favorites + {{ $store.state.favorites.count }} favorites </h2> <radio-button type="favorites"></radio-button> </div> @@ -55,10 +55,8 @@ <script> import $ from 'jquery' -import Vue from 'vue' import logger from '@/logging' import config from '@/config' -import favoriteTracks from '@/favorites/tracks' import TrackTable from '@/components/audio/track/Table' import RadioButton from '@/components/radios/Button' import Pagination from '@/components/Pagination' @@ -80,7 +78,6 @@ export default { isLoading: false, nextLink: null, previousLink: null, - favoriteTracks, page: parseInt(this.defaultPage), paginateBy: parseInt(this.defaultPaginateBy || 25), orderingDirection: defaultOrdering.direction, @@ -122,10 +119,9 @@ export default { self.results = response.data self.nextLink = response.data.next self.previousLink = response.data.previous - Vue.set(favoriteTracks, 'count', response.data.count) - favoriteTracks.count = response.data.count + self.$store.commit('favorites/count', response.data.count) self.results.results.forEach((track) => { - Vue.set(favoriteTracks.objects, track.id, true) + self.$store.commit('favorites/track', {id: track.id, value: true}) }) logger.default.timeEnd('Loading user favorites') self.isLoading = false diff --git a/front/src/components/favorites/TrackFavoriteIcon.vue b/front/src/components/favorites/TrackFavoriteIcon.vue index 5e3e5b07e68bede9300a0b95fb68ac2927a4b936..5abc57a952446b6b9d34e366f09c8af7e617dcd0 100644 --- a/front/src/components/favorites/TrackFavoriteIcon.vue +++ b/front/src/components/favorites/TrackFavoriteIcon.vue @@ -1,5 +1,5 @@ <template> - <button @click="favoriteTracks.set(track.id, !isFavorite)" v-if="button" :class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'button']"> + <button @click="$store.dispatch('favorites/set', {id: track.id, value: !isFavorite})" v-if="button" :class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'button']"> <i class="heart icon"></i> <template v-if="isFavorite"> In favorites @@ -8,23 +8,23 @@ Add to favorites </template> </button> - <i v-else @click="favoriteTracks.set(track.id, !isFavorite)" :class="['favorite-icon', 'heart', {'pink': isFavorite}, {'favorited': isFavorite}, 'link', 'icon']" :title="title"></i> + <i v-else @click="$store.dispatch('favorites/set', {id: track.id, value: !isFavorite})" :class="['favorite-icon', 'heart', {'pink': isFavorite}, {'favorited': isFavorite}, 'link', 'icon']" :title="title"></i> </template> <script> -import favoriteTracks from '@/favorites/tracks' +import {mapState} from 'vuex' export default { props: { track: {type: Object}, button: {type: Boolean, default: false} }, - data () { - return { - favoriteTracks - } - }, computed: { + ...mapState({ + favorites: state => { + return state.favorites.tracks + } + }), title () { if (this.isFavorite) { return 'Remove from favorites' @@ -33,7 +33,7 @@ export default { } }, isFavorite () { - return favoriteTracks.objects[this.track.id] + return this.$store.getters['favorites/isFavorite'](this.track.id) } } diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue index e8b053b6d0124175fda02254e71a85a420401e5e..3e3de9c61044852d2ae1ba9cb46b5c32a512f3cb 100644 --- a/front/src/components/library/Library.vue +++ b/front/src/components/library/Library.vue @@ -4,8 +4,8 @@ <router-link class="ui item" to="/library" exact>Browse</router-link> <router-link class="ui item" to="/library/artists" exact>Artists</router-link> <div class="ui secondary right menu"> - <router-link v-if="auth.user.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>Import</router-link> - <router-link v-if="auth.user.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches</router-link> + <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>Import</router-link> + <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches</router-link> </div> </div> <router-view :key="$route.fullPath"></router-view> @@ -14,15 +14,8 @@ <script> -import auth from '@/auth' - export default { - name: 'library', - data: function () { - return { - auth - } - } + name: 'library' } </script> diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue index 36a76e822c2ac0218d8c23a1fb2cca8f21db4f29..48cd801c3d8a96fc34b4904febbacb0d6243d66f 100644 --- a/front/src/components/library/Track.vue +++ b/front/src/components/library/Track.vue @@ -61,7 +61,6 @@ <script> -import auth from '@/auth' import url from '@/utils/url' import logger from '@/logging' import backend from '@/audio/backend' @@ -124,8 +123,8 @@ export default { downloadUrl () { if (this.track.files.length > 0) { let u = backend.absoluteUrl(this.track.files[0].path) - if (auth.user.authenticated) { - u = url.updateQueryString(u, 'jwt', auth.getAuthToken()) + if (this.$store.state.auth.authenticated) { + u = url.updateQueryString(u, 'jwt', this.$store.state.auth.token) } return u } diff --git a/front/src/components/metadata/Search.vue b/front/src/components/metadata/Search.vue index 8a400cf7b0e5b488e794afb62a0ddab8d38ce59a..c3dc7433c5e268baa4e848bcb9a22125b555bd93 100644 --- a/front/src/components/metadata/Search.vue +++ b/front/src/components/metadata/Search.vue @@ -23,7 +23,6 @@ <script> import jQuery from 'jquery' import config from '@/config' -import auth from '@/auth' export default { props: { @@ -66,7 +65,7 @@ export default { }, apiSettings: { beforeXHR: function (xhrObject, s) { - xhrObject.setRequestHeader('Authorization', auth.getAuthHeader()) + xhrObject.setRequestHeader('Authorization', this.$store.getters['auth/header']) return xhrObject }, onResponse: function (initialResponse) { diff --git a/front/src/components/radios/Button.vue b/front/src/components/radios/Button.vue index b334dce561183cb32a06ba8ce5b6955f202ef244..4bf4279890d05f39ac06a350164b9c2101747068 100644 --- a/front/src/components/radios/Button.vue +++ b/front/src/components/radios/Button.vue @@ -9,33 +9,28 @@ <script> -import radios from '@/radios' - export default { props: { type: {type: String, required: true}, objectId: {type: Number, default: null} }, - data () { - return { - radios - } - }, methods: { toggleRadio () { if (this.running) { - radios.stop() + this.$store.dispatch('radios/stop') } else { - radios.start(this.type, this.objectId) + this.$store.dispatch('radios/start', {type: this.type, objectId: this.objectId}) } } }, computed: { running () { - if (!radios.running) { + let state = this.$store.state.radios + let current = state.current + if (!state.running) { return false } else { - return radios.current.type === this.type & radios.current.objectId === this.objectId + return current.type === this.type & current.objectId === this.objectId } } } diff --git a/front/src/components/radios/Card.vue b/front/src/components/radios/Card.vue index 1e496324aa893518ca2728fcf6f9f19c168bc9c1..dc8a24ff3c2e31d901ee11fb27eceb976ca7e819 100644 --- a/front/src/components/radios/Card.vue +++ b/front/src/components/radios/Card.vue @@ -13,7 +13,6 @@ </template> <script> -import radios from '@/radios' import RadioButton from './Button' export default { @@ -25,7 +24,7 @@ export default { }, computed: { radio () { - return radios.types[this.type] + return this.$store.getters['radios/types'][this.type] } } } diff --git a/front/src/favorites/tracks.js b/front/src/favorites/tracks.js deleted file mode 100644 index 45d05c50d250f06466c3eb3bf610d9536329b28e..0000000000000000000000000000000000000000 --- a/front/src/favorites/tracks.js +++ /dev/null @@ -1,57 +0,0 @@ -import config from '@/config' -import logger from '@/logging' -import Vue from 'vue' - -const REMOVE_URL = config.API_URL + 'favorites/tracks/remove/' -const FAVORITES_URL = config.API_URL + 'favorites/tracks/' - -export default { - objects: {}, - count: 0, - set (id, newValue) { - let self = this - Vue.set(self.objects, id, newValue) - if (newValue) { - Vue.set(self, 'count', self.count + 1) - let resource = Vue.resource(FAVORITES_URL) - resource.save({}, {'track': id}).then((response) => { - logger.default.info('Successfully added track to favorites') - }, (response) => { - logger.default.info('Error while adding track to favorites') - Vue.set(self.objects, id, !newValue) - Vue.set(self, 'count', self.count - 1) - }) - } else { - Vue.set(self, 'count', self.count - 1) - let resource = Vue.resource(REMOVE_URL) - resource.delete({}, {'track': id}).then((response) => { - logger.default.info('Successfully removed track from favorites') - }, (response) => { - logger.default.info('Error while removing track from favorites') - Vue.set(self.objects, id, !newValue) - Vue.set(self, 'count', self.count + 1) - }) - } - }, - toggle (id) { - let isFavorite = this.objects[id] - this.set(id, !isFavorite) - }, - fetch (url) { - // will fetch favorites by batches from API to have them locally - var self = this - url = url || FAVORITES_URL - let resource = Vue.resource(url) - resource.get().then((response) => { - logger.default.info('Fetched a batch of ' + response.data.results.length + ' favorites') - Vue.set(self, 'count', response.data.count) - response.data.results.forEach(result => { - Vue.set(self.objects, result.track, true) - }) - if (response.data.next) { - self.fetch(response.data.next) - } - }) - } - -} diff --git a/front/src/main.js b/front/src/main.js index f153635121ececa77e909defc6defccc8d00c938..0c9230e8e8acc3059bf7bf030f53e74e5a69e204 100644 --- a/front/src/main.js +++ b/front/src/main.js @@ -9,8 +9,8 @@ import Vue from 'vue' import App from './App' import router from './router' import VueResource from 'vue-resource' -import auth from './auth' import VueLazyload from 'vue-lazyload' +import store from './store' window.$ = window.jQuery = require('jquery') @@ -25,8 +25,8 @@ Vue.config.productionTip = false Vue.http.interceptors.push(function (request, next) { // modify headers - if (auth.user.authenticated) { - request.headers.set('Authorization', auth.getAuthHeader()) + if (store.state.auth.authenticated) { + request.headers.set('Authorization', store.getters['auth/header']) } next(function (response) { // redirect to login form when we get unauthorized response from server @@ -37,11 +37,12 @@ Vue.http.interceptors.push(function (request, next) { }) }) -auth.checkAuth() +store.dispatch('auth/check') /* eslint-disable no-new */ new Vue({ el: '#app', router, + store, template: '<App/>', components: { App } }) diff --git a/front/src/radios/index.js b/front/src/radios/index.js deleted file mode 100644 index b468830863f1a443a987149128495413b7b5f000..0000000000000000000000000000000000000000 --- a/front/src/radios/index.js +++ /dev/null @@ -1,64 +0,0 @@ -import Vue from 'vue' -import config from '@/config' -import logger from '@/logging' -import queue from '@/audio/queue' - -const CREATE_RADIO_URL = config.API_URL + 'radios/sessions/' -const GET_TRACK_URL = config.API_URL + 'radios/tracks/' - -var radios = { - types: { - random: { - name: 'Random', - description: "Totally random picks, maybe you'll discover new things?" - }, - favorites: { - name: 'Favorites', - description: 'Play your favorites tunes in a never-ending happiness loop.' - }, - 'less-listened': { - name: 'Less listened', - description: "Listen to tracks you usually don't. It's time to restore some balance." - } - }, - start (type, objectId) { - this.current.type = type - this.current.objectId = objectId - this.running = true - let resource = Vue.resource(CREATE_RADIO_URL) - var self = this - var params = { - radio_type: type, - related_object_id: objectId - } - resource.save({}, params).then((response) => { - logger.default.info('Successfully started radio ', type) - self.current.session = response.data.id - queue.populateFromRadio() - }, (response) => { - logger.default.error('Error while starting radio', type) - }) - }, - stop () { - this.current.type = null - this.current.objectId = null - this.running = false - this.session = null - }, - fetch () { - let resource = Vue.resource(GET_TRACK_URL) - var self = this - var params = { - session: self.current.session - } - return resource.save({}, params) - } -} - -Vue.set(radios, 'running', false) -Vue.set(radios, 'current', {}) -Vue.set(radios.current, 'objectId', null) -Vue.set(radios.current, 'type', null) -Vue.set(radios.current, 'session', null) - -export default radios diff --git a/front/src/store/auth.js b/front/src/store/auth.js new file mode 100644 index 0000000000000000000000000000000000000000..d4b23adcb03401d75721310b22383460d64fe113 --- /dev/null +++ b/front/src/store/auth.js @@ -0,0 +1,100 @@ +import Vue from 'vue' +import config from '@/config' +import logger from '@/logging' +import cache from '@/cache' +import router from '@/router' +// import favoriteTracks from '@/favorites/tracks' + +const LOGIN_URL = config.API_URL + 'token/' +const USER_PROFILE_URL = config.API_URL + 'users/users/me/' + +export default { + namespaced: true, + state: { + authenticated: false, + username: '', + availablePermissions: {}, + profile: null, + token: '' + }, + getters: { + header: state => { + return 'JWT ' + state.token + } + }, + mutations: { + profile: (state, value) => { + state.profile = value + }, + authenticated: (state, value) => { + state.authenticated = value + }, + username: (state, value) => { + state.username = value + }, + token: (state, value) => { + state.token = value + }, + permission: (state, {key, status}) => { + state.availablePermissions[key] = status + } + }, + actions: { + // Send a request to the login URL and save the returned JWT + login ({commit, dispatch, state}, {next, credentials, onError}) { + let resource = Vue.resource(LOGIN_URL) + return resource.save({}, credentials).then(response => { + logger.default.info('Successfully logged in as', credentials.username) + commit('token', response.data.token) + cache.set('token', response.data.token) + commit('username', credentials.username) + cache.set('username', credentials.username) + commit('authenticated', true) + dispatch('fetchProfile') + // Redirect to a specified route + router.push(next) + }, response => { + logger.default.error('Error while logging in', response.data) + onError(response) + }) + }, + logout ({commit}) { + cache.clear() + commit('authenticated', false) + commit('profile', null) + logger.default.info('Log out, goodbye!') + router.push({name: 'index'}) + }, + check ({commit, dispatch, state}) { + logger.default.info('Checking authentication...') + var jwt = cache.get('token') + var username = cache.get('username') + if (jwt) { + commit('authenticated', true) + commit('username', username) + commit('token', jwt) + logger.default.info('Logged back in as ' + username) + dispatch('fetchProfile') + } else { + logger.default.info('Anonymous user') + commit('authenticated', false) + } + }, + fetchProfile ({commit, dispatch, state}) { + let resource = Vue.resource(USER_PROFILE_URL) + return resource.get({}).then((response) => { + logger.default.info('Successfully fetched user profile') + let data = response.data + commit('profile', data) + dispatch('favorites/fetch', null, {root: true}) + Object.keys(data.permissions).forEach(function (key) { + // this makes it easier to check for permissions in templates + commit('permission', {key, status: data.permissions[String(key)].status}) + }) + return response.data + }, (response) => { + logger.default.info('Error while fetching user profile') + }) + } + } +} diff --git a/front/src/store/favorites.js b/front/src/store/favorites.js new file mode 100644 index 0000000000000000000000000000000000000000..8bb4bb5afe6add992547b631a91c3683e00fbb6d --- /dev/null +++ b/front/src/store/favorites.js @@ -0,0 +1,78 @@ +import Vue from 'vue' +import config from '@/config' +import logger from '@/logging' + +const REMOVE_URL = config.API_URL + 'favorites/tracks/remove/' +const FAVORITES_URL = config.API_URL + 'favorites/tracks/' + +export default { + namespaced: true, + state: { + tracks: [], + count: 0 + }, + mutations: { + track: (state, {id, value}) => { + if (value) { + state.tracks.push(id) + } else { + let i = state.tracks.indexOf(id) + if (i > -1) { + state.tracks.splice(i, 1) + } + } + }, + count: (state, value) => { + state.count = value + } + }, + getters: { + isFavorite: (state) => (id) => { + return state.tracks.indexOf(id) > -1 + } + }, + actions: { + set ({commit, state}, {id, value}) { + commit('track', {id, value}) + if (value) { + commit('count', state.count + 1) + let resource = Vue.resource(FAVORITES_URL) + resource.save({}, {'track': id}).then((response) => { + logger.default.info('Successfully added track to favorites') + }, (response) => { + logger.default.info('Error while adding track to favorites') + commit('track', {id, value: !value}) + commit('count', state.count - 1) + }) + } else { + commit('count', state.count - 1) + let resource = Vue.resource(REMOVE_URL) + resource.delete({}, {'track': id}).then((response) => { + logger.default.info('Successfully removed track from favorites') + }, (response) => { + logger.default.info('Error while removing track from favorites') + commit('track', {id, value: !value}) + commit('count', state.count + 1) + }) + } + }, + toggle ({getters, dispatch}, id) { + dispatch('set', {id, value: getters['isFavorite'](id)}) + }, + fetch ({dispatch, state, commit}, url) { + // will fetch favorites by batches from API to have them locally + url = url || FAVORITES_URL + let resource = Vue.resource(url) + resource.get().then((response) => { + logger.default.info('Fetched a batch of ' + response.data.results.length + ' favorites') + response.data.results.forEach(result => { + commit('track', {id: result.track, value: true}) + }) + commit('count', state.tracks.length) + if (response.data.next) { + dispatch('fetch', response.data.next) + } + }) + } + } +} diff --git a/front/src/store/index.js b/front/src/store/index.js new file mode 100644 index 0000000000000000000000000000000000000000..99e466e510f0cad3de2abfdaad4847bd0083defd --- /dev/null +++ b/front/src/store/index.js @@ -0,0 +1,20 @@ +import Vue from 'vue' +import Vuex from 'vuex' + +import favorites from './favorites' +import auth from './auth' +import queue from './queue' +import radios from './radios' +import player from './player' + +Vue.use(Vuex) + +export default new Vuex.Store({ + modules: { + auth, + favorites, + queue, + radios, + player + } +}) diff --git a/front/src/store/player.js b/front/src/store/player.js new file mode 100644 index 0000000000000000000000000000000000000000..74b0b9f9ea72dcbc38d0c3cefc6fa90d1647fc49 --- /dev/null +++ b/front/src/store/player.js @@ -0,0 +1,91 @@ +import Vue from 'vue' +import config from '@/config' +import logger from '@/logging' +import time from '@/utils/time' + +export default { + namespaced: true, + state: { + playing: false, + volume: 0.5, + duration: 0, + currentTime: 0, + errored: false, + looping: 0 // 0 -> no, 1 -> on track, 2 -> on queue + }, + mutations: { + volume (state, value) { + value = parseFloat(value) + value = Math.min(value, 1) + value = Math.max(value, 0) + state.volume = value + }, + incrementVolume (state, value) { + value = parseFloat(state.volume + value) + value = Math.min(value, 1) + value = Math.max(value, 0) + state.volume = value + }, + duration (state, value) { + state.duration = value + }, + errored (state, value) { + state.errored = value + }, + currentTime (state, value) { + state.currentTime = value + }, + looping (state, value) { + state.looping = value + }, + playing (state, value) { + state.playing = value + }, + toggleLooping (state) { + if (state.looping > 1) { + state.looping = 0 + } else { + state.looping += 1 + } + } + }, + getters: { + durationFormatted: state => { + return time.parse(Math.round(state.duration)) + }, + currentTimeFormatted: state => { + return time.parse(Math.round(state.currentTime)) + }, + progress: state => { + return Math.round(state.currentTime / state.duration * 100) + } + }, + actions: { + incrementVolume (context, value) { + context.commit('volume', context.state.volume + value) + }, + stop (context) { + }, + togglePlay ({commit, state}) { + commit('playing', !state.playing) + }, + trackListened ({commit}, track) { + let url = config.API_URL + 'history/listenings/' + let resource = Vue.resource(url) + resource.save({}, {'track': track.id}).then((response) => {}, (response) => { + logger.default.error('Could not record track in history') + }) + }, + trackEnded ({dispatch}, track) { + dispatch('trackListened', track) + dispatch('queue/next', null, {root: true}) + }, + trackErrored ({commit, dispatch}) { + commit('errored', true) + dispatch('queue/next', null, {root: true}) + }, + updateProgress ({commit}, t) { + commit('currentTime', t) + } + } +} diff --git a/front/src/store/queue.js b/front/src/store/queue.js new file mode 100644 index 0000000000000000000000000000000000000000..3a0b7dd7979471f647dd5743602fb1b8fd5ea0bd --- /dev/null +++ b/front/src/store/queue.js @@ -0,0 +1,153 @@ +import logger from '@/logging' +import _ from 'lodash' + +export default { + namespaced: true, + state: { + tracks: [], + currentIndex: -1, + ended: true, + previousQueue: null + }, + mutations: { + currentIndex (state, value) { + state.currentIndex = value + }, + ended (state, value) { + state.ended = value + }, + splice (state, {start, size}) { + state.tracks.splice(start, size) + }, + tracks (state, value) { + state.tracks = value + }, + insert (state, {track, index}) { + state.tracks.splice(index, 0, track) + }, + reorder (state, {oldIndex, newIndex}) { + // called when the user uses drag / drop to reorder + // tracks in queue + if (oldIndex === state.currentIndex) { + state.currentIndex = newIndex + return + } + if (oldIndex < state.currentIndex && newIndex >= state.currentIndex) { + // item before was moved after + state.currentIndex -= 1 + } + if (oldIndex > state.currentIndex && newIndex <= state.currentIndex) { + // item after was moved before + state.currentIndex += 1 + } + } + + }, + getters: { + currentTrack: state => { + return state.tracks[state.currentIndex] + }, + hasNext: state => { + return state.currentIndex < state.tracks.length - 1 + }, + hasPrevious: state => { + return state.currentIndex > 0 + } + }, + actions: { + append (context, {track, index, skipPlay}) { + index = index || context.state.tracks.length + if (index > context.state.tracks.length - 1) { + // we simply push to the end + context.commit('insert', {track, index: context.state.tracks.length}) + } else { + // we insert the track at given position + context.commit('insert', {track, index}) + } + if (!skipPlay) { + context.dispatch('resume') + } + // this.cache() + }, + + appendMany (context, {tracks, index}) { + logger.default.info('Appending many tracks to the queue', tracks.map(e => { return e.title })) + if (context.state.tracks.length === 0) { + index = 0 + } else { + index = index || context.state.tracks.length + } + tracks.forEach((t) => { + context.dispatch('append', {track: t, index: index, skipPlay: true}) + index += 1 + }) + context.dispatch('resume') + }, + + cleanTrack ({state, dispatch, commit}, index) { + // are we removing current playin track + let current = index === state.currentIndex + if (current) { + dispatch('player/stop', null, {root: true}) + } + if (index < state.currentIndex) { + dispatch('currentIndex', state.currentIndex - 1) + } + commit('splice', {start: index, size: 1}) + if (current) { + // we play next track, which now have the same index + dispatch('currentIndex', index) + } + }, + + resume (context) { + if (context.state.ended | context.rootState.player.errored) { + context.dispatch('next') + } + }, + previous (context) { + if (context.state.currentIndex > 0) { + context.dispatch('currentIndex', context.state.currentIndex - 1) + } + }, + next ({state, dispatch, commit, rootState}) { + if (rootState.player.looping === 1) { + // we loop on the same track, this is handled directly on the track + // component, so we do nothing. + return logger.default.info('Looping on the same track') + } + if (rootState.player.looping === 2 && state.currentIndex >= state.tracks.length - 1) { + logger.default.info('Going back to the beginning of the queue') + return dispatch('currentIndex', 0) + } else { + if (state.currentIndex < state.tracks.length - 1) { + logger.default.debug('Playing next track') + return dispatch('currentIndex', state.currentIndex + 1) + } else { + commit('ended', true) + } + } + }, + currentIndex ({commit, state, rootState, dispatch}, index) { + commit('ended', false) + commit('player/errored', false, {root: true}) + commit('currentIndex', index) + if (state.tracks.length - index <= 2 && rootState.radios.running) { + dispatch('radios/populateQueue', null, {root: true}) + } + }, + clean ({dispatch, commit}) { + dispatch('player/stop', null, {root: true}) + // radios.stop() + commit('tracks', []) + dispatch('currentIndex', -1) + // so we replay automatically on next track append + commit('ended', true) + }, + shuffle ({dispatch, commit, state}) { + let shuffled = _.shuffle(state.tracks) + commit('tracks', []) + dispatch('appendMany', {tracks: shuffled}) + } + } +} diff --git a/front/src/store/radios.js b/front/src/store/radios.js new file mode 100644 index 0000000000000000000000000000000000000000..a9c429876a4ff974635de9f73062dd2597237209 --- /dev/null +++ b/front/src/store/radios.js @@ -0,0 +1,78 @@ +import Vue from 'vue' +import config from '@/config' +import logger from '@/logging' + +const CREATE_RADIO_URL = config.API_URL + 'radios/sessions/' +const GET_TRACK_URL = config.API_URL + 'radios/tracks/' + +export default { + namespaced: true, + state: { + current: null, + running: false + }, + getters: { + types: state => { + return { + random: { + name: 'Random', + description: "Totally random picks, maybe you'll discover new things?" + }, + favorites: { + name: 'Favorites', + description: 'Play your favorites tunes in a never-ending happiness loop.' + }, + 'less-listened': { + name: 'Less listened', + description: "Listen to tracks you usually don't. It's time to restore some balance." + } + } + } + }, + mutations: { + current: (state, value) => { + state.current = value + }, + running: (state, value) => { + state.running = value + } + }, + actions: { + start ({commit, dispatch}, {type, objectId}) { + let resource = Vue.resource(CREATE_RADIO_URL) + var params = { + radio_type: type, + related_object_id: objectId + } + resource.save({}, params).then((response) => { + logger.default.info('Successfully started radio ', type) + commit('current', {type, objectId, session: response.data.id}) + commit('running', true) + dispatch('populateQueue') + }, (response) => { + logger.default.error('Error while starting radio', type) + }) + }, + stop ({commit}) { + commit('current', null) + commit('running', false) + }, + populateQueue ({state, dispatch}) { + if (!state.running) { + return + } + let resource = Vue.resource(GET_TRACK_URL) + var params = { + session: state.current.session + } + let promise = resource.save({}, params) + promise.then((response) => { + logger.default.info('Adding track to queue from radio') + dispatch('queue/append', {track: response.data.track}, {root: true}) + }, (response) => { + logger.default.error('Error while adding track to queue from radio') + }) + } + } + +}