Now use vuex to manage state for player/queue/radios

This commit is contained in:
Eliot Berriot 2017-12-23 16:41:19 +01:00
parent 254996453f
commit df94ae37bf
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
16 changed files with 563 additions and 694 deletions

View File

@ -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

View File

@ -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

View File

@ -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>
<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})
}
}
}

View File

@ -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')
}
}
}

View File

@ -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)
}
}
}

View File

@ -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 () {

View File

@ -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>

View File

@ -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
}

View File

@ -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
}
}
}

View File

@ -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]
}
}
}

View File

@ -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 }
})

View File

@ -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

16
front/src/store/index.js Normal file
View File

@ -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
}
})

91
front/src/store/player.js Normal file
View File

@ -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)
}
}
}

153
front/src/store/queue.js Normal file
View File

@ -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})
}
}
}

78
front/src/store/radios.js Normal file
View File

@ -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')
})
}
}
}