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/components/Sidebar.vue b/front/src/components/Sidebar.vue index 68927a37b09a5e302a9dfb7ce9be44b9e7750b96..9112c258863e2f8c088197ecafba3f1b61744559 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -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,19 @@ <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 +109,29 @@ 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/Track.vue b/front/src/components/audio/Track.vue new file mode 100644 index 0000000000000000000000000000000000000000..77d5ecc32701d7bcf6b52fce5984644a612ba46d --- /dev/null +++ b/front/src/components/audio/Track.vue @@ -0,0 +1,104 @@ +<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 auth from '@/auth' +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 (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()) + } + 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/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/main.js b/front/src/main.js index f153635121ececa77e909defc6defccc8d00c938..eb714976fa4ff46c943e66ca03c0e74496a09583 100644 --- a/front/src/main.js +++ b/front/src/main.js @@ -11,6 +11,7 @@ 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') @@ -42,6 +43,7 @@ auth.checkAuth() 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/index.js b/front/src/store/index.js new file mode 100644 index 0000000000000000000000000000000000000000..afd6e3f84cd6d53002359f7b496b427fa33540f1 --- /dev/null +++ b/front/src/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue' +import Vuex from 'vuex' + +import queue from './queue' +import radios from './radios' +import player from './player' + +Vue.use(Vuex) + +export default new Vuex.Store({ + modules: { + 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') + }) + } + } + +}