Initial commit that merge both the front end and the API in the same repository
63
front/src/App.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<template>
|
||||
<div id="app">
|
||||
<sidebar></sidebar>
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Sidebar from '@/components/Sidebar'
|
||||
|
||||
export default {
|
||||
name: 'app',
|
||||
components: { Sidebar }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
// we do the import here instead in main.js
|
||||
// as resolve order is not deterministric in webpack
|
||||
// and we end up with CSS rules not applied,
|
||||
// see https://github.com/webpack/webpack/issues/215
|
||||
@import 'semantic/semantic.css';
|
||||
|
||||
|
||||
#app {
|
||||
font-family: 'Avenir', Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.main.pusher {
|
||||
margin-left: 350px !important;
|
||||
transform: none !important;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.ui.stripe.segment {
|
||||
padding: 4em;
|
||||
}
|
||||
|
||||
.ui.small.text.container {
|
||||
max-width: 500px !important;
|
||||
}
|
||||
|
||||
.button.icon.tiny {
|
||||
padding: 0.5em !important;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
.logo {
|
||||
path {
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.discrete.link {
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
}
|
||||
|
||||
.floated.buttons .button ~ .dropdown {
|
||||
border-left: none;
|
||||
}
|
||||
</style>
|
||||
BIN
front/src/assets/audio/default-cover.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
front/src/assets/logo/favicon.ico
Normal file
|
After Width: | Height: | Size: 8 KiB |
BIN
front/src/assets/logo/logo-full.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
34
front/src/assets/logo/logo-with-text.svg
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<svg version="1.1" id="Calque_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 283.5 44.7" enable-background="new 0 0 283.5 44.7" xml:space="preserve">
|
||||
<g>
|
||||
<path fill="#222222" d="M3.9,16.7c0-9,3.5-12.5,14-12.5c2.2,0,5,0.2,6.5,0.5c0.8,0.2,1.5,0.8,1.5,1.5v2.7c0,0.8-0.6,1.5-1.5,1.5
|
||||
h-0.9c-1.1,0-2-0.4-3.4-0.4c-6.5,0-7.8,1.3-7.8,6.7v0.4h8.9c0.8,0,1.5,0.6,1.5,1.5v2.9c0,0.9-0.6,1.5-1.5,1.5h-8.9v15.2
|
||||
c0,0.8-0.6,1.5-1.5,1.5H5.4c-0.8,0-1.5-0.7-1.5-1.5V16.7z"/>
|
||||
<path fill="#222222" d="M36.4,28.4c0,4.1,1.9,5.8,4.7,5.8c2.4,0,4.7-1.7,6.5-3.5V14.4c0-0.8,0.7-1.5,1.5-1.5h5.5
|
||||
c0.8,0,1.5,0.7,1.5,1.5v23.8c0,0.8-0.6,1.5-1.5,1.5h-5.5c-0.8,0-1.5-0.7-1.5-1.5v-1.6c-2.3,2-4.8,3.6-8.5,3.6
|
||||
c-6.5,0-11.1-3.4-11.1-11.7v-14c0-0.8,0.6-1.5,1.5-1.5h5.5c0.8,0,1.5,0.7,1.5,1.5V28.4z"/>
|
||||
<path fill="#222222" d="M81.7,24.2c0-4.1-1.9-5.8-4.7-5.8c-2.4,0-4.8,1.7-6.6,3.5v16.4c0,0.8-0.6,1.5-1.5,1.5h-5.5
|
||||
c-0.9,0-1.5-0.7-1.5-1.5V14.4c0-0.8,0.6-1.5,1.5-1.5H69c0.8,0,1.5,0.7,1.5,1.5V16c2.3-2,4.8-3.6,8.6-3.6c6.5,0,11.1,3.4,11.1,11.7
|
||||
v14c0,0.8-0.6,1.5-1.5,1.5h-5.5c-0.8,0-1.5-0.7-1.5-1.5V24.2z"/>
|
||||
<path fill="#222222" d="M104.5,23.5c2.9,0,4.8-1.2,5.8-3.3l2.4-5c0.6-1.4,2.1-2.3,3.5-2.3h4.6c1.3,0,1.5,1,0.8,2.3l-3.2,6.7
|
||||
c-1,2.2-3,3.9-5.2,4.4c2,0.6,3.7,2,5.2,4.4l4.2,6.7c0.8,1.3,0.4,2.3-0.8,2.3h-4.6c-1.6,0-2.9-1-3.7-2.3l-3.1-5
|
||||
c-1.3-2.2-3.6-3.2-5.8-3.2v9.1c0,0.8-0.6,1.5-1.5,1.5h-5.5c-0.8,0-1.5-0.7-1.5-1.5v-32c0-0.8,0.6-1.5,1.5-1.5h5.5
|
||||
c0.8,0,1.5,0.7,1.5,1.5V23.5z"/>
|
||||
<path fill="#222222" d="M148.2,12.9c1.4,0,2.4,0.8,2.8,2.1l4,13.2l4-13.2c0.4-1.4,1.8-2.1,3.3-2.1h4.5c1.2,0,1.4,0.9,1,2.1
|
||||
l-7.4,22.5c-0.4,1.3-1.8,2.1-3.1,2.1h-3.5c-1.2,0-2.7-0.8-3.1-2.1L146,23l-4.7,14.6c-0.4,1.3-1.9,2.1-3.1,2.1h-3.5
|
||||
c-1.3,0-2.6-0.8-3.1-2.1l-7.4-22.5c-0.4-1.2-0.1-2.1,1-2.1h4.5c1.5,0,2.8,0.8,3.3,2.1l4,13.2l4-13.2c0.4-1.3,1.4-2.1,2.8-2.1H148.2
|
||||
z"/>
|
||||
<path fill="#222222" d="M191.1,24.2c0-4.1-1.9-5.8-4.6-5.8c-2.4,0-4.8,1.7-6.6,3.5v16.4c0,0.8-0.6,1.5-1.5,1.5h-5.5
|
||||
c-0.8,0-1.5-0.7-1.5-1.5v-32c0-0.8,0.7-1.5,1.5-1.5h5.5c0.8,0,1.5,0.7,1.5,1.5V16c2.3-2,4.8-3.6,8.6-3.6c6.5,0,11.1,3.4,11.1,11.7
|
||||
v14c0,0.8-0.7,1.5-1.5,1.5h-5.5c-0.8,0-1.5-0.7-1.5-1.5V24.2z"/>
|
||||
<path fill="#222222" d="M213.9,19.6c-0.6,0.8-1.6,1.3-2.8,1.3h-3.6c-0.8,0-1.5-0.6-1.5-1.5c0-5.2,5.2-7,13.3-7
|
||||
c7.2,0,12.9,3,12.9,10.6v15.1c0,0.8-0.7,1.5-1.5,1.5h-4.7c-0.8,0-1.5-0.7-1.5-1.5v-0.8c-2.3,1.6-5,2.8-8.9,2.8
|
||||
c-6.5,0-11.5-2.9-11.5-8.6s5-8.5,11.5-8.5h8.1c0-3.9-1.6-5.1-5-5.1C216.6,18,214.7,18.6,213.9,19.6z M223.7,32.4v-3.8h-7.5
|
||||
c-2.4,0-3.7,1.3-3.7,3c0,1.7,1.3,3,4,3C219.4,34.6,221.9,33.5,223.7,32.4z"/>
|
||||
<path fill="#222222" d="M239.6,39.7c-0.8,0-1.5-0.7-1.5-1.5v-32c0-0.8,0.7-1.5,1.5-1.5h5.5c0.8,0,1.5,0.7,1.5,1.5v32
|
||||
c0,0.8-0.6,1.5-1.5,1.5H239.6z"/>
|
||||
<path fill="#222222" d="M259.6,28.9c0.3,4,2.1,5.7,6.2,5.7c2.1,0,4-0.6,4.8-1.6c0.7-0.8,1.6-1.3,2.8-1.3h3.6c0.8,0,1.5,0.7,1.5,1.5
|
||||
c0,5.2-5.3,7-13.3,7c-8.9,0-14.3-4.8-14.3-13.8c0-9,5.4-13.9,14.3-13.9c8.9,0,14.2,4.8,14.2,13.6v1.4c0,0.8-0.6,1.5-1.5,1.5H259.6z
|
||||
M259.6,23.7h11.4c-0.2-3.7-2-5.7-5.7-5.7C261.7,18,259.8,20,259.6,23.7z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3 KiB |
BIN
front/src/assets/logo/logo.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
19
front/src/assets/logo/logo.svg
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<svg version="1.1" id="Calque_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 141.7 141.7" enable-background="new 0 0 141.7 141.7" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#4082B4" d="M70.9,86.1c11.7,0,21.2-9.5,21.2-21.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,6-4.9,11-11,11
|
||||
c-6,0-11-4.9-11-11c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C49.7,76.6,59.2,86.1,70.9,86.1z"/>
|
||||
<path fill="#4082B4" d="M70.9,106.1c22.7,0,41.2-18.5,41.2-41.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1
|
||||
c0,17.1-13.9,31-31,31c-17.1,0-31-13.9-31-31c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C29.6,87.6,48.1,106.1,70.9,106.1z"
|
||||
/>
|
||||
<path fill="#4082B4" d="M131.1,63.8h-8c-0.6,0-1.1,0.5-1.1,1.1C122,93.1,99,116,70.9,116c-28.2,0-51.1-22.9-51.1-51.1
|
||||
c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,33.8,27.5,61.3,61.3,61.3c33.8,0,61.3-27.5,61.3-61.3
|
||||
C132.2,64.3,131.7,63.8,131.1,63.8z"/>
|
||||
</g>
|
||||
<path fill="#222222" d="M43.3,37.3c4.1,2.1,8.5,2.5,12.5,4.8c2.6,1.5,4.2,3.2,5.8,5.7c2.5,3.8,2.4,8.5,2.4,8.5l0.3,5.2
|
||||
c0,0,2,5.2,6.4,5.2c4.7,0,6.4-5.2,6.4-5.2l0.3-5.2c0,0-0.1-4.7,2.4-8.5c1.6-2.5,3.2-4.3,5.8-5.7c4-2.3,8.4-2.7,12.5-4.8
|
||||
c4.1-2.1,8.1-4.8,10.8-8.6c2.7-3.8,4-8.8,2.5-13.2c-7.8-0.4-16.8,0.5-23.7,4.2c-9.6,5.1-15.4,3.3-17.1,10.9h-0.1
|
||||
c-1.7-7.7-7.5-5.8-17.1-10.9c-6.9-3.7-15.9-4.6-23.7-4.2c-1.5,4.4-0.2,9.4,2.5,13.2C35.2,32.5,39.2,35.2,43.3,37.3z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
front/src/assets/logo/logos.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
37
front/src/audio/backend.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import config from '@/config'
|
||||
|
||||
var Album = {
|
||||
clean (album) {
|
||||
// we manually rebind the album and artist to each child track
|
||||
album.tracks = album.tracks.map((track) => {
|
||||
track.artist = album.artist
|
||||
track.album = album
|
||||
return track
|
||||
})
|
||||
return album
|
||||
}
|
||||
}
|
||||
var Artist = {
|
||||
clean (artist) {
|
||||
// clean data as given by the API
|
||||
artist.albums = artist.albums.map((album) => {
|
||||
return Album.clean(album)
|
||||
})
|
||||
return artist
|
||||
}
|
||||
}
|
||||
export default {
|
||||
absoluteUrl (url) {
|
||||
if (url.startsWith('http')) {
|
||||
return url
|
||||
}
|
||||
if (url.startsWith('/')) {
|
||||
return config.BACKEND_URL + url.substr(1)
|
||||
} else {
|
||||
return config.BACKEND_URL + url
|
||||
}
|
||||
},
|
||||
Artist: Artist,
|
||||
Album: Album
|
||||
|
||||
}
|
||||
194
front/src/audio/index.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import logger from '@/logging'
|
||||
|
||||
const pad = (val) => {
|
||||
val = Math.floor(val)
|
||||
if (val < 10) {
|
||||
return '0' + val
|
||||
}
|
||||
return val + ''
|
||||
}
|
||||
|
||||
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.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
|
||||
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 = this.timeParse(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 = this.timeParse(this.state.duration)
|
||||
this.state.currentTimeFormat = this.timeParse(this.state.currentTime)
|
||||
this.state.lastTimeFormat = this.timeParse(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 () {
|
||||
logger.default.info('Playing track')
|
||||
if (this.state.startLoad) {
|
||||
if (!this.state.playing && this.$Audio.readyState >= 2) {
|
||||
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
|
||||
}
|
||||
|
||||
timeParse (sec) {
|
||||
let min = 0
|
||||
min = Math.floor(sec / 60)
|
||||
sec = sec - min * 60
|
||||
return pad(min) + ':' + pad(sec)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Audio
|
||||
215
front/src/audio/queue.js
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
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 Vue from 'vue'
|
||||
|
||||
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 = {
|
||||
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) {
|
||||
this.state.volume = newValue
|
||||
if (this.audio.setVolume) {
|
||||
this.audio.setVolume(newValue)
|
||||
} else {
|
||||
this.audio.state.volume = newValue
|
||||
}
|
||||
cache.set('volume', newValue)
|
||||
}
|
||||
append (track, index) {
|
||||
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 (this.ended) {
|
||||
this.play(this.currentIndex + 1)
|
||||
}
|
||||
this.cache()
|
||||
}
|
||||
|
||||
appendMany (tracks, index) {
|
||||
let self = this
|
||||
index = index || this.tracks.length - 1
|
||||
tracks.forEach((t) => {
|
||||
self.append(t, index)
|
||||
index += 1
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
this.tracks = []
|
||||
this.currentIndex = -1
|
||||
this.currentTrack = null
|
||||
}
|
||||
|
||||
cleanTrack (index) {
|
||||
if (index === this.currentIndex) {
|
||||
this.stop()
|
||||
}
|
||||
if (index < this.currentIndex) {
|
||||
this.currentIndex -= 1
|
||||
}
|
||||
this.tracks.splice(index, 1)
|
||||
}
|
||||
|
||||
stop () {
|
||||
this.audio.pause()
|
||||
this.audio.destroyed()
|
||||
}
|
||||
play (index) {
|
||||
if (this.audio.destroyed) {
|
||||
logger.default.debug('Destroying previous audio...')
|
||||
this.audio.destroyed()
|
||||
}
|
||||
this.currentIndex = index
|
||||
this.currentTrack = this.tracks[index]
|
||||
this.ended = false
|
||||
let file = this.currentTrack.files[0]
|
||||
if (!file) {
|
||||
return this.next()
|
||||
}
|
||||
this.audio = new Audio(backend.absoluteUrl(file.path), {
|
||||
preload: true,
|
||||
autoplay: true,
|
||||
rate: 1,
|
||||
loop: false,
|
||||
volume: this.state.volume,
|
||||
onEnded: this.handleAudioEnded.bind(this)
|
||||
})
|
||||
if (this.currentIndex === this.tracks.length - 1) {
|
||||
this.populateFromRadio()
|
||||
}
|
||||
this.cache()
|
||||
}
|
||||
|
||||
handleAudioEnded (e) {
|
||||
this.recordListen(this.currentTrack)
|
||||
if (this.currentIndex < this.tracks.length - 1) {
|
||||
logger.default.info('Audio track ended, playing next one')
|
||||
this.next()
|
||||
} else {
|
||||
logger.default.info('We reached the end of the queue')
|
||||
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) {
|
||||
this.play(this.currentIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
let queue = new Queue()
|
||||
|
||||
export default queue
|
||||
7
front/src/audio/track.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import backend from './backend'
|
||||
|
||||
export default {
|
||||
getCover (track) {
|
||||
return backend.absoluteUrl(track.album.cover)
|
||||
}
|
||||
}
|
||||
89
front/src/auth/index.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
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/'
|
||||
|
||||
export default {
|
||||
|
||||
// User object will let us check authentication status
|
||||
user: {
|
||||
authenticated: false,
|
||||
username: '',
|
||||
profile: null
|
||||
},
|
||||
|
||||
// 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 = cache.get('token')
|
||||
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
|
||||
}
|
||||
},
|
||||
|
||||
// The object to be passed as a header for authenticated requests
|
||||
getAuthHeader () {
|
||||
return 'JWT ' + cache.get('token')
|
||||
},
|
||||
|
||||
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)
|
||||
})
|
||||
favoriteTracks.fetch()
|
||||
}
|
||||
}
|
||||
29
front/src/cache/index.js
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import logger from '@/logging'
|
||||
export default {
|
||||
get (key, d) {
|
||||
let v = localStorage.getItem(key)
|
||||
if (v === null) {
|
||||
return d
|
||||
} else {
|
||||
try {
|
||||
return JSON.parse(v).value
|
||||
} catch (e) {
|
||||
logger.default.error('Removing unparsable cached value for key ' + key)
|
||||
this.remove(key)
|
||||
return d
|
||||
}
|
||||
}
|
||||
},
|
||||
set (key, value) {
|
||||
return localStorage.setItem(key, JSON.stringify({value: value}))
|
||||
},
|
||||
|
||||
remove (key) {
|
||||
return localStorage.removeItem(key)
|
||||
},
|
||||
|
||||
clear () {
|
||||
localStorage.clear()
|
||||
}
|
||||
|
||||
}
|
||||
157
front/src/components/Home.vue
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<template>
|
||||
<div class="main pusher">
|
||||
<div class="ui vertical center aligned stripe segment">
|
||||
<div class="ui text container">
|
||||
<h1 class="ui huge header">
|
||||
Welcome on funkwhale
|
||||
</h1>
|
||||
<p>We think listening music should be simple.</p>
|
||||
<router-link class="ui icon teal button" to="/browse">
|
||||
Get me to the library
|
||||
<i class="right arrow icon"></i>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui middle aligned stackable text container">
|
||||
<div class="ui grid">
|
||||
<div class="row">
|
||||
<div class="eight wide left floated column">
|
||||
<h2 class="ui header">
|
||||
Why funkwhale?
|
||||
</h2>
|
||||
<p>That's simple: we loved Grooveshark and we want to build something even better.</p>
|
||||
</div>
|
||||
<div class="four wide left floated column">
|
||||
<img class="ui medium image" src="../assets/logo/logo.png" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui middle aligned stackable text container">
|
||||
<div class="ui hidden divider"></div>
|
||||
<h2 class="ui header">
|
||||
Unlimited music
|
||||
</h2>
|
||||
<p>Funkwhale is designed to make it easy to listen to music you like, or to discover new artists.</p>
|
||||
<div class="ui list">
|
||||
<div class="item">
|
||||
<i class="sound icon"></i>
|
||||
<div class="content">
|
||||
Click once, listen for hours using built-in radios
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<i class="heart icon"></i>
|
||||
<div class="content">
|
||||
Keep a track of your favorite songs
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<i class="list icon"></i>
|
||||
<div class="content">
|
||||
Playlists? We got them
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui middle aligned stackable text container">
|
||||
<div class="ui hidden divider"></div>
|
||||
<h2 class="ui header">
|
||||
Clean library
|
||||
</h2>
|
||||
<p>Funkwhale takes care of fealing your music.</p>
|
||||
<div class="ui list">
|
||||
<div class="item">
|
||||
<i class="download icon"></i>
|
||||
<div class="content">
|
||||
Import music from various platforms, such as YouTube or SoundCloud
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<i class="tag icon"></i>
|
||||
<div class="content">
|
||||
Get quality metadata about your music thanks to <a href="https://musicbrainz.org" target="_blank">MusicBrainz</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<i class="plus icon"></i>
|
||||
<div class="content">
|
||||
Covers, lyrics, our goal is to have them all ;)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui middle aligned stackable text container">
|
||||
<div class="ui hidden divider"></div>
|
||||
<h2 class="ui header">
|
||||
Easy to use
|
||||
</h2>
|
||||
<p>Funkwhale is dead simple to use.</p>
|
||||
<div class="ui list">
|
||||
<div class="item">
|
||||
<i class="browser icon"></i>
|
||||
<div class="content">
|
||||
No add-ons, no plugins : you only need a web browser
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<i class="wizard icon"></i>
|
||||
<div class="content">
|
||||
Access your music from a clean interface that focus on what really matters
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui middle aligned stackable text container">
|
||||
<div class="ui hidden divider"></div>
|
||||
<h2 class="ui header">
|
||||
Your music, your way
|
||||
</h2>
|
||||
<p>Funkwhale is free and gives you control on your music.</p>
|
||||
<div class="ui list">
|
||||
<div class="item">
|
||||
<i class="smile icon"></i>
|
||||
<div class="content">
|
||||
The plaform is free and open-source, you can install it and modify it without worries
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<i class="protect icon"></i>
|
||||
<div class="content">
|
||||
We do not track you or bother you with ads
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<i class="users icon"></i>
|
||||
<div class="content">
|
||||
You can invite friends and family to your instance so they can enjoy your music
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'home',
|
||||
data () {
|
||||
return {
|
||||
msg: 'Welcome to Your Vue.js App'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
.stripe p {
|
||||
font-size: 120%;
|
||||
}
|
||||
.list.icon {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
33
front/src/components/Logo.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<template>
|
||||
<svg version="1.1" id="layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 141.7 141.7" enable-background="new 0 0 141.7 141.7" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#4082B4" d="M70.9,86.1c11.7,0,21.2-9.5,21.2-21.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,6-4.9,11-11,11
|
||||
c-6,0-11-4.9-11-11c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C49.7,76.6,59.2,86.1,70.9,86.1z"/>
|
||||
<path fill="#4082B4" d="M70.9,106.1c22.7,0,41.2-18.5,41.2-41.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1
|
||||
c0,17.1-13.9,31-31,31c-17.1,0-31-13.9-31-31c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C29.6,87.6,48.1,106.1,70.9,106.1z"
|
||||
/>
|
||||
<path fill="#4082B4" d="M131.1,63.8h-8c-0.6,0-1.1,0.5-1.1,1.1C122,93.1,99,116,70.9,116c-28.2,0-51.1-22.9-51.1-51.1
|
||||
c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,33.8,27.5,61.3,61.3,61.3c33.8,0,61.3-27.5,61.3-61.3
|
||||
C132.2,64.3,131.7,63.8,131.1,63.8z"/>
|
||||
</g>
|
||||
<path fill="#222222" d="M43.3,37.3c4.1,2.1,8.5,2.5,12.5,4.8c2.6,1.5,4.2,3.2,5.8,5.7c2.5,3.8,2.4,8.5,2.4,8.5l0.3,5.2
|
||||
c0,0,2,5.2,6.4,5.2c4.7,0,6.4-5.2,6.4-5.2l0.3-5.2c0,0-0.1-4.7,2.4-8.5c1.6-2.5,3.2-4.3,5.8-5.7c4-2.3,8.4-2.7,12.5-4.8
|
||||
c4.1-2.1,8.1-4.8,10.8-8.6c2.7-3.8,4-8.8,2.5-13.2c-7.8-0.4-16.8,0.5-23.7,4.2c-9.6,5.1-15.4,3.3-17.1,10.9h-0.1
|
||||
c-1.7-7.7-7.5-5.8-17.1-10.9c-6.9-3.7-15.9-4.6-23.7-4.2c-1.5,4.4-0.2,9.4,2.5,13.2C35.2,32.5,39.2,35.2,43.3,37.3z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
190
front/src/components/Sidebar.vue
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<template>
|
||||
<div class="ui vertical left visible wide sidebar">
|
||||
<div class="ui inverted segment header-wrapper">
|
||||
<search-bar>
|
||||
<router-link :title="'Funkwhale'" :to="{name: 'index'}">
|
||||
<i class="logo bordered inverted orange big icon">
|
||||
<logo class="logo"></logo>
|
||||
</i>
|
||||
</router-link>
|
||||
|
||||
</search-bar>
|
||||
</div>
|
||||
|
||||
<div class="menu-area">
|
||||
<div class="ui compact fluid two item inverted menu">
|
||||
<a class="active item" data-tab="browse">Browse</a>
|
||||
<a class="item" data-tab="queue">
|
||||
Queue
|
||||
<template v-if="queue.tracks.length === 0">
|
||||
(empty)
|
||||
</template>
|
||||
<template v-else>
|
||||
({{ queue.currentIndex + 1}} of {{ queue.tracks.length }})
|
||||
</template>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<div class="ui bottom attached active tab" data-tab="browse">
|
||||
<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-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link>
|
||||
<router-link class="item" :to="{path: '/browse'}"><i class="sound icon"> </i>Browse library</router-link>
|
||||
<router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="queue.previousQueue " class="ui black icon message">
|
||||
<i class="history icon"></i>
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
Do you want to restore your previous queue?
|
||||
</div>
|
||||
<p>{{ queue.previousQueue.tracks.length }} tracks</p>
|
||||
<div class="ui two buttons">
|
||||
<div @click="queue.restore()" class="ui basic inverted green button">Yes</div>
|
||||
<div @click="queue.removePrevious()" class="ui basic inverted red button">No</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui bottom attached tab" data-tab="queue">
|
||||
<table class="ui compact inverted very basic fixed single line table">
|
||||
<tbody>
|
||||
<tr @click="queue.play(index)" v-for="(track, index) in queue.tracks" :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)">
|
||||
<img class="ui mini image" v-else src="../assets/audio/default-cover.png">
|
||||
</td>
|
||||
<td colspan="4">
|
||||
<strong>{{ track.title }}</strong><br />
|
||||
{{ track.artist.name }}
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="favoriteTracks.objects[track.id]">
|
||||
<i @click.stop="queue.cleanTrack(index)" class="pink heart icon"></i>
|
||||
</template
|
||||
</td>
|
||||
<td>
|
||||
<i @click.stop="queue.cleanTrack(index)" class="circular trash icon"></i>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui inverted segment player-wrapper">
|
||||
<player></player>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
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 radios from '@/radios'
|
||||
|
||||
import $ from 'jquery'
|
||||
|
||||
export default {
|
||||
name: 'sidebar',
|
||||
components: {
|
||||
Player,
|
||||
SearchBar,
|
||||
Logo
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
auth: auth,
|
||||
backend: backend,
|
||||
queue: queue,
|
||||
radios,
|
||||
favoriteTracks
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
$(this.$el).find('.menu .item').tab()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
$sidebar-color: #1B1C1D;
|
||||
|
||||
.sidebar {
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
justify-content: space-between;
|
||||
|
||||
> div {
|
||||
margin: 0;
|
||||
background-color: $sidebar-color;
|
||||
}
|
||||
.menu {
|
||||
}
|
||||
}
|
||||
|
||||
.menu-area {
|
||||
padding: 0.5rem;
|
||||
.menu .item:not(.active):not(:hover) {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
}
|
||||
.tabs {
|
||||
overflow-y: auto;
|
||||
height: 0px;
|
||||
}
|
||||
.tab[data-tab="queue"] {
|
||||
tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.sidebar .segment {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.ui.inverted.segment.header-wrapper {
|
||||
padding: 0;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
.tabs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.player-wrapper {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
background-color: rgb(46, 46, 46) !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ui.search {
|
||||
display: inline-block;
|
||||
> a {
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
67
front/src/components/audio/PlayButton.vue
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<div :class="['ui', {'tiny': discrete}, 'buttons']">
|
||||
<button title="Add to current queue" @click="add" :class="['ui', {'mini': discrete}, 'button']">
|
||||
<i class="ui play icon"></i>
|
||||
<template v-if="!discrete"><slot>Play</slot></template>
|
||||
</button>
|
||||
<div v-if="!discrete" class="ui floating dropdown icon button">
|
||||
<i class="dropdown icon"></i>
|
||||
<div class="menu">
|
||||
<div class="item"@click="add"><i class="plus icon"></i> Add to queue</div>
|
||||
<div class="item"@click="addNext()"><i class="step forward icon"></i> Play next</div>
|
||||
<div class="item"@click="addNext(true)"><i class="arrow down icon"></i> Play now</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import logger from '@/logging'
|
||||
import queue from '@/audio/queue'
|
||||
import jQuery from 'jquery'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
// we can either have a single or multiple tracks to play when clicked
|
||||
tracks: {type: Array, required: false},
|
||||
track: {type: Object, required: false},
|
||||
discrete: {type: Boolean, default: false}
|
||||
},
|
||||
created () {
|
||||
if (!this.track & !this.tracks) {
|
||||
logger.default.error('You have to provide either a track or tracks property')
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (!this.discrete) {
|
||||
jQuery(this.$el).find('.ui.dropdown').dropdown()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
add () {
|
||||
if (this.track) {
|
||||
queue.append(this.track)
|
||||
} else {
|
||||
queue.appendMany(this.tracks)
|
||||
}
|
||||
},
|
||||
addNext (next) {
|
||||
if (this.track) {
|
||||
queue.append(this.track, queue.currentIndex + 1)
|
||||
} else {
|
||||
queue.appendMany(this.tracks, queue.currentIndex + 1)
|
||||
}
|
||||
if (next) {
|
||||
queue.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
i {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
189
front/src/components/audio/Player.vue
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
<template>
|
||||
<div class="player">
|
||||
<div v-if="queue.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-else src="../../assets/audio/default-cover.png">
|
||||
</div>
|
||||
<div class="middle aligned content">
|
||||
<router-link class="small header discrete link track" :to="{name: 'browse.track', params: {id: queue.currentTrack.id }}">
|
||||
{{ queue.currentTrack.title }}
|
||||
</router-link>
|
||||
<div class="meta">
|
||||
<router-link class="artist" :to="{name: 'browse.artist', params: {id: queue.currentTrack.artist.id }}">
|
||||
{{ queue.currentTrack.artist.name }}
|
||||
</router-link> /
|
||||
<router-link class="album" :to="{name: 'browse.album', params: {id: queue.currentTrack.album.id }}">
|
||||
{{ queue.currentTrack.album.title }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="description">
|
||||
<track-favorite-icon :track="queue.currentTrack"></track-favorite-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-area">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="right floated four wide column">
|
||||
<p class="timer total">{{queue.audio.state.durationTimerFormat}}</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>
|
||||
</div>
|
||||
|
||||
<div class="controls ui grid">
|
||||
<div class="volume-control four wide center aligned column">
|
||||
<input type="range" step="0.05" min="0" max="1" v-model="sliderVolume" />
|
||||
<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>
|
||||
</div>
|
||||
<div class="eight wide center aligned column">
|
||||
<i title="Previous track" @click="queue.previous()" :class="['ui', {'disabled': !hasPrevious}, 'step', 'backward', 'big', 'icon']" :disabled="!hasPrevious"></i>
|
||||
<i title="Play track" v-if="!queue.audio.state.playing" :class="['ui', 'play', {'disabled': !queue.currentTrack}, 'big', 'icon']" @click="pauseOrPlay"></i>
|
||||
<i title="Pause track" v-else :class="['ui', 'pause', {'disabled': !queue.currentTrack}, 'big', 'icon']" @click="pauseOrPlay"></i>
|
||||
<i title="Next track" @click="queue.next()" :class="['ui', 'step', 'forward', {'disabled': !hasNext}, 'big', 'icon']" :disabled="!hasNext"></i>
|
||||
</div>
|
||||
<div class="four wide center aligned column">
|
||||
<i title="Clear your queue" @click="queue.clean()" :class="['ui', 'trash', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" :disabled="queue.tracks.length === 0"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import queue from '@/audio/queue'
|
||||
import Track from '@/audio/track'
|
||||
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
||||
import radios from '@/radios'
|
||||
|
||||
export default {
|
||||
name: 'player',
|
||||
components: {
|
||||
TrackFavoriteIcon
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
sliderVolume: this.currentVolume,
|
||||
queue: queue,
|
||||
Track: Track,
|
||||
radios
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
// we trigger the watcher explicitely it does not work otherwise
|
||||
this.sliderVolume = this.currentVolume
|
||||
},
|
||||
methods: {
|
||||
pauseOrPlay () {
|
||||
if (this.queue.audio.state.playing) {
|
||||
this.queue.audio.pause()
|
||||
} else {
|
||||
this.queue.audio.play()
|
||||
}
|
||||
},
|
||||
touchProgress (e) {
|
||||
let time
|
||||
let target = this.$refs.progress
|
||||
time = e.layerX / target.offsetWidth * this.queue.audio.state.duration
|
||||
this.queue.audio.setTime(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
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentVolume (newValue) {
|
||||
this.sliderVolume = newValue
|
||||
},
|
||||
sliderVolume (newValue) {
|
||||
this.queue.setVolume(parseFloat(newValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
.ui.progress {
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
.progress {
|
||||
cursor: pointer;
|
||||
.bar {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ui.inverted.item > .content > .description {
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.ui.item {
|
||||
.meta {
|
||||
font-size: 90%;
|
||||
line-height: 1.2
|
||||
}
|
||||
}
|
||||
.timer.total {
|
||||
text-align: right;
|
||||
}
|
||||
.timer.start {
|
||||
cursor: pointer
|
||||
}
|
||||
.track-area {
|
||||
.header, .meta, .artist, .album {
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
.controls .icon.big {
|
||||
cursor: pointer;
|
||||
font-size: 2em !important;
|
||||
}
|
||||
|
||||
.controls .icon {
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.secondary.icon {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.progress-area .actions {
|
||||
text-align: center;
|
||||
}
|
||||
.volume-control {
|
||||
position: relative;
|
||||
.icon {
|
||||
margin: 0;
|
||||
}
|
||||
[type="range"] {
|
||||
max-width: 75%;
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 10%;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.ui.feed.icon {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
116
front/src/components/audio/Search.vue
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<template>
|
||||
<div>
|
||||
<h2>Search for some music</h2>
|
||||
<div :class="['ui', {'loading': isLoading }, 'search']">
|
||||
<div class="ui icon big input">
|
||||
<i class="search icon"></i>
|
||||
<input ref="search" class="prompt" placeholder="Artist, album, track..." v-model.trim="query" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="query.length > 0">
|
||||
<h3 class="ui title">Artists</h3>
|
||||
<div v-if="results.artists.length > 0" class="ui stackable three column grid">
|
||||
<div class="column" :key="artist.id" v-for="artist in results.artists">
|
||||
<artist-card class="fluid" :artist="artist" ></artist-card>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else>Sorry, we did not found any artist matching your query</p>
|
||||
</template>
|
||||
<template v-if="query.length > 0">
|
||||
<h3 class="ui title">Albums</h3>
|
||||
<div v-if="results.albums.length > 0" class="ui stackable three column grid">
|
||||
<div class="column" :key="album.id" v-for="album in results.albums">
|
||||
<album-card class="fluid" :album="album" ></album-card>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else>Sorry, we did not found any album matching your query</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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'
|
||||
import config from '@/config'
|
||||
|
||||
const SEARCH_URL = config.API_URL + 'search'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AlbumCard,
|
||||
ArtistCard
|
||||
},
|
||||
props: {
|
||||
autofocus: {type: Boolean, default: false}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
query: '',
|
||||
results: {
|
||||
albums: [],
|
||||
artists: []
|
||||
},
|
||||
backend: backend,
|
||||
isLoading: false,
|
||||
queue: queue
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.autofocus) {
|
||||
this.$refs.search.focus()
|
||||
}
|
||||
this.search()
|
||||
},
|
||||
methods: {
|
||||
search () {
|
||||
if (this.query.length < 1) {
|
||||
return
|
||||
}
|
||||
var self = this
|
||||
self.isLoading = true
|
||||
logger.default.debug('Searching track matching "' + this.query + '"')
|
||||
let params = {
|
||||
query: this.query
|
||||
}
|
||||
this.$http.get(SEARCH_URL, {
|
||||
params: params,
|
||||
before (request) {
|
||||
// abort previous request, if exists
|
||||
if (this.previousRequest) {
|
||||
this.previousRequest.abort()
|
||||
}
|
||||
|
||||
// set previous request on Vue instance
|
||||
this.previousRequest = request
|
||||
}
|
||||
}).then((response) => {
|
||||
self.results = self.castResults(response.data)
|
||||
self.isLoading = false
|
||||
})
|
||||
},
|
||||
castResults (results) {
|
||||
return {
|
||||
albums: results.albums.map((album) => {
|
||||
return backend.Album.clean(album)
|
||||
}),
|
||||
artists: results.artists.map((artist) => {
|
||||
return backend.Artist.clean(artist)
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
query () {
|
||||
this.search()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
101
front/src/components/audio/SearchBar.vue
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<template>
|
||||
<div class="ui fluid category search">
|
||||
<slot></slot>
|
||||
<div class="ui icon input">
|
||||
<input class="prompt" placeholder="Search for artists, albums, tracks..." type="text">
|
||||
<i class="search icon"></i>
|
||||
</div>
|
||||
<div class="results"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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}'
|
||||
|
||||
export default {
|
||||
mounted () {
|
||||
jQuery(this.$el).search({
|
||||
type: 'category',
|
||||
minCharacters: 3,
|
||||
onSelect (result, response) {
|
||||
router.push(result.routerUrl)
|
||||
},
|
||||
apiSettings: {
|
||||
beforeXHR: function (xhrObject) {
|
||||
xhrObject.setRequestHeader('Authorization', auth.getAuthHeader())
|
||||
return xhrObject
|
||||
},
|
||||
onResponse: function (initialResponse) {
|
||||
var results = {}
|
||||
let categories = [
|
||||
{
|
||||
code: 'artists',
|
||||
route: 'browse.artist',
|
||||
name: 'Artist',
|
||||
getTitle (r) {
|
||||
return r.name
|
||||
},
|
||||
getDescription (r) {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 'albums',
|
||||
route: 'browse.album',
|
||||
name: 'Album',
|
||||
getTitle (r) {
|
||||
return r.title
|
||||
},
|
||||
getDescription (r) {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 'tracks',
|
||||
route: 'browse.track',
|
||||
name: 'Track',
|
||||
getTitle (r) {
|
||||
return r.title
|
||||
},
|
||||
getDescription (r) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
]
|
||||
categories.forEach(category => {
|
||||
results[category.code] = {
|
||||
name: category.name,
|
||||
results: []
|
||||
}
|
||||
initialResponse[category.code].forEach(result => {
|
||||
results[category.code].results.push({
|
||||
title: category.getTitle(result),
|
||||
id: result.id,
|
||||
routerUrl: {
|
||||
name: category.route,
|
||||
params: {
|
||||
id: result.id
|
||||
}
|
||||
},
|
||||
description: category.getDescription(result)
|
||||
})
|
||||
})
|
||||
})
|
||||
return {results: results}
|
||||
},
|
||||
url: SEARCH_URL
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
98
front/src/components/audio/album/Card.vue
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<template>
|
||||
<div class="ui card">
|
||||
<div class="content">
|
||||
<div class="right floated tiny ui image">
|
||||
<img v-if="album.cover" :src="backend.absoluteUrl(album.cover)">
|
||||
<img v-else src="../../../assets/audio/default-cover.png">
|
||||
</div>
|
||||
<div class="header">
|
||||
<router-link class="discrete link" :to="{name: 'browse.album', params: {id: album.id }}">{{ album.title }}</router-link>
|
||||
</div>
|
||||
<div class="meta">
|
||||
By <router-link :to="{name: 'browse.artist', params: {id: album.artist.id }}">
|
||||
{{ album.artist.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="description" v-if="mode === 'rich'">
|
||||
<table class="ui very basic fixed single line compact table">
|
||||
<tbody>
|
||||
<tr v-for="track in tracks">
|
||||
<td>
|
||||
<play-button class="basic icon" :track="track" :discrete="true"></play-button>
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="track discrete link" :to="{name: 'browse.track', params: {id: track.id }}">
|
||||
{{ track.title }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<track-favorite-icon :track="track"></track-favorite-icon>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="center aligned segment" v-if="album.tracks.length > initialTracks">
|
||||
<em v-if="!showAllTracks" @click="showAllTracks = true" class="expand">Show {{ album.tracks.length - initialTracks }} more tracks</em>
|
||||
<em v-else @click="showAllTracks = false" class="expand">Collapse</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra content">
|
||||
<play-button class="mini basic orange right floated" :tracks="album.tracks">Play all</play-button>
|
||||
<span>
|
||||
<i class="music icon"></i>
|
||||
{{ album.tracks.length }} tracks
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import queue from '@/audio/queue'
|
||||
import backend from '@/audio/backend'
|
||||
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
album: {type: Object},
|
||||
mode: {type: String, default: 'rich'}
|
||||
},
|
||||
components: {
|
||||
TrackFavoriteIcon,
|
||||
PlayButton
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
backend: backend,
|
||||
queue: queue,
|
||||
initialTracks: 4,
|
||||
showAllTracks: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
tracks () {
|
||||
if (this.showAllTracks) {
|
||||
return this.album.tracks
|
||||
}
|
||||
return this.album.tracks.slice(0, this.initialTracks)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
tr {
|
||||
.favorite-icon:not(.favorited) {
|
||||
display: none;
|
||||
}
|
||||
&:hover .favorite-icon {
|
||||
display: inherit;
|
||||
}
|
||||
}
|
||||
.expand {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
84
front/src/components/audio/artist/Card.vue
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<template>
|
||||
<div class="ui card">
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<router-link class="discrete link" :to="{name: 'browse.artist', params: {id: artist.id }}">
|
||||
{{ artist.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="description">
|
||||
<table class="ui compact very basic fixed single line table">
|
||||
<tbody>
|
||||
<tr v-for="album in albums">
|
||||
<td>
|
||||
<img class="ui mini image" v-if="album.cover" :src="backend.absoluteUrl(album.cover)">
|
||||
<img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
|
||||
</td>
|
||||
<td colspan="4">
|
||||
<router-link class="discrete link":to="{name: 'browse.album', params: {id: album.id }}">
|
||||
<strong>{{ album.title }}</strong>
|
||||
</router-link><br />
|
||||
{{ album.tracks.length }} tracks
|
||||
</td>
|
||||
<td>
|
||||
<play-button class="right floated basic icon" :discrete="true" :tracks="album.tracks"></play-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="center aligned segment" v-if="artist.albums.length > initialAlbums">
|
||||
<em v-if="!showAllAlbums" @click="showAllAlbums = true" class="expand">Show {{ artist.albums.length - initialAlbums }} more albums</em>
|
||||
<em v-else @click="showAllAlbums = false" class="expand">Collapse</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra content">
|
||||
<span>
|
||||
<i class="sound icon"></i>
|
||||
{{ artist.albums.length }} albums
|
||||
</span>
|
||||
<play-button class="mini basic orange right floated" :tracks="allTracks">Play all</play-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import backend from '@/audio/backend'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
|
||||
export default {
|
||||
props: ['artist'],
|
||||
components: {
|
||||
PlayButton
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
backend: backend,
|
||||
initialAlbums: 3,
|
||||
showAllAlbums: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
albums () {
|
||||
if (this.showAllAlbums) {
|
||||
return this.artist.albums
|
||||
}
|
||||
return this.artist.albums.slice(0, this.initialAlbums)
|
||||
},
|
||||
allTracks () {
|
||||
let tracks = []
|
||||
this.artist.albums.forEach(album => {
|
||||
album.tracks.forEach(track => {
|
||||
tracks.push(track)
|
||||
})
|
||||
})
|
||||
return tracks
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
68
front/src/components/audio/track/Table.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<table class="ui compact very basic fixed single line table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th colspan="6">Title</th>
|
||||
<th colspan="6">Artist</th>
|
||||
<th colspan="6">Album</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="track in tracks">
|
||||
<td>
|
||||
<play-button class="basic icon" :discrete="true" :track="track"></play-button>
|
||||
</td>
|
||||
<td>
|
||||
<img class="ui mini image" v-if="track.album.cover" :src="backend.absoluteUrl(track.album.cover)">
|
||||
<img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="track" :to="{name: 'browse.track', params: {id: track.id }}">
|
||||
{{ track.title }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="artist discrete link" :to="{name: 'browse.artist', params: {id: track.artist.id }}">
|
||||
{{ track.artist.name }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="album discrete link" :to="{name: 'browse.album', params: {id: track.album.id }}">
|
||||
{{ track.album.title }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td><track-favorite-icon class="favorite-icon" :track="track"></track-favorite-icon></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import backend from '@/audio/backend'
|
||||
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
|
||||
export default {
|
||||
props: ['tracks'],
|
||||
components: {
|
||||
TrackFavoriteIcon,
|
||||
PlayButton
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
backend: backend
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
|
||||
tr:not(:hover) .favorite-icon:not(.favorited) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
90
front/src/components/auth/Login.vue
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<template>
|
||||
<div class="main pusher">
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui small text container">
|
||||
<h2>Log in to your Funkwhale account</h2>
|
||||
<form class="ui form" @submit.prevent="submit()">
|
||||
<div v-if="error" class="ui negative message">
|
||||
<div class="header">We cannot log you in</div>
|
||||
<ul class="list">
|
||||
<li v-if="error == 'invalid_credentials'">Please double-check your username/password couple is correct</li>
|
||||
<li v-else>An unknown error happend, this can mean the server is down or cannot be reached</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Username</label>
|
||||
<input
|
||||
ref="username"
|
||||
required
|
||||
type="text"
|
||||
autofocus
|
||||
placeholder="Enter your username"
|
||||
v-model="credentials.username"
|
||||
>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Password</label>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
v-model="credentials.password"
|
||||
>
|
||||
</div>
|
||||
<button :class="['ui', {'loading': isLoading}, 'button']" type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import auth from '@/auth'
|
||||
|
||||
export default {
|
||||
name: 'login',
|
||||
data () {
|
||||
return {
|
||||
// We need to initialize the component with any
|
||||
// properties that will be used in it
|
||||
credentials: {
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
error: '',
|
||||
isLoading: false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.$refs.username.focus()
|
||||
},
|
||||
methods: {
|
||||
submit () {
|
||||
var self = this
|
||||
self.isLoading = true
|
||||
this.error = ''
|
||||
var credentials = {
|
||||
username: this.credentials.username,
|
||||
password: this.credentials.password
|
||||
}
|
||||
// We need to pass the component's this context
|
||||
// to properly make use of http in the auth service
|
||||
auth.login(this, credentials, {path: '/browse'}, function (response) {
|
||||
// error callback
|
||||
if (response.status === 400) {
|
||||
self.error = 'invalid_credentials'
|
||||
} else {
|
||||
self.error = 'unknown_error'
|
||||
}
|
||||
}).then((response) => {
|
||||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
||||
37
front/src/components/auth/Logout.vue
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<template>
|
||||
<div class="main pusher">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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'})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
||||
62
front/src/components/auth/Profile.vue
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<template>
|
||||
<div class="main pusher">
|
||||
<div v-if="isLoading" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<template v-if="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 }}
|
||||
<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">
|
||||
<i class="star icon"></i>
|
||||
Staff member
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import auth from '@/auth'
|
||||
var 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
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
signupDate () {
|
||||
let d = new Date(this.profile.date_joined)
|
||||
return dateFormat(d, 'longDate')
|
||||
},
|
||||
isLoading () {
|
||||
return !this.profile
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
||||
105
front/src/components/browse/Album.vue
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="isLoading" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<template v-if="album">
|
||||
<div :class="['ui', 'head', {'with-background': album.cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle">
|
||||
<div class="segment-content">
|
||||
<h2 class="ui center aligned icon header">
|
||||
<i class="circular inverted sound yellow icon"></i>
|
||||
<div class="content">
|
||||
{{ album.title }}
|
||||
<div class="sub header">
|
||||
Album containing {{ album.tracks.length }} tracks,
|
||||
by <router-link :to="{name: 'browse.artist', params: {id: album.artist.id }}">
|
||||
{{ album.artist.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="ui hidden divider"></div>
|
||||
</button>
|
||||
<play-button class="orange" :tracks="album.tracks">Play all</play-button>
|
||||
|
||||
<a :href="wikipediaUrl" target="_blank" class="ui button">
|
||||
<i class="wikipedia icon"></i>
|
||||
Search on wikipedia
|
||||
</a>
|
||||
<a :href="musicbrainzUrl" target="_blank" class="ui button">
|
||||
<i class="external icon"></i>
|
||||
View on MusicBrainz
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<h2>Tracks</h2>
|
||||
<track-table v-if="album" :tracks="album.tracks"></track-table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import logger from '@/logging'
|
||||
import backend from '@/audio/backend'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
import TrackTable from '@/components/audio/track/Table'
|
||||
import config from '@/config'
|
||||
|
||||
const FETCH_URL = config.API_URL + 'albums/'
|
||||
|
||||
export default {
|
||||
props: ['id'],
|
||||
components: {
|
||||
PlayButton,
|
||||
TrackTable
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: true,
|
||||
album: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
let url = FETCH_URL + this.id + '/'
|
||||
logger.default.debug('Fetching album "' + this.id + '"')
|
||||
this.$http.get(url).then((response) => {
|
||||
self.album = backend.Album.clean(response.data)
|
||||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
wikipediaUrl () {
|
||||
return 'https://en.wikipedia.org/w/index.php?search=' + this.album.title + ' ' + this.album.artist.name
|
||||
},
|
||||
musicbrainzUrl () {
|
||||
return 'https://musicbrainz.org/release/' + this.album.mbid
|
||||
},
|
||||
headerStyle () {
|
||||
if (!this.album.cover) {
|
||||
return ''
|
||||
}
|
||||
return 'background-image: url(' + backend.absoluteUrl(this.album.cover) + ')'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
id () {
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
133
front/src/components/browse/Artist.vue
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="isLoading" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<template v-if="artist">
|
||||
<div :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle">
|
||||
<div class="segment-content">
|
||||
<h2 class="ui center aligned icon header">
|
||||
<i class="circular inverted users violet icon"></i>
|
||||
<div class="content">
|
||||
{{ artist.name }}
|
||||
<div class="sub header">{{ totalTracks }} tracks in {{ albums.length }} albums</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="ui hidden divider"></div>
|
||||
<radio-button type="artist" :object-id="artist.id"></radio-button>
|
||||
</button>
|
||||
<play-button class="orange" :tracks="allTracks">Play all albums</play-button>
|
||||
|
||||
<a :href="wikipediaUrl" target="_blank" class="ui button">
|
||||
<i class="wikipedia icon"></i>
|
||||
Search on wikipedia
|
||||
</a>
|
||||
<a :href="musicbrainzUrl" target="_blank" class="ui button">
|
||||
<i class="external icon"></i>
|
||||
View on MusicBrainz
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<h2>Albums by this artist</h2>
|
||||
<div class="ui stackable three column grid">
|
||||
<div class="column" :key="album.id" v-for="album in albums">
|
||||
<album-card :mode="'rich'" class="fluid" :album="album"></album-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import logger from '@/logging'
|
||||
import backend from '@/audio/backend'
|
||||
import AlbumCard from '@/components/audio/album/Card'
|
||||
import RadioButton from '@/components/radios/Button'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
import config from '@/config'
|
||||
|
||||
const FETCH_URL = config.API_URL + 'artists/'
|
||||
|
||||
export default {
|
||||
props: ['id'],
|
||||
components: {
|
||||
AlbumCard,
|
||||
RadioButton,
|
||||
PlayButton
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: true,
|
||||
artist: null,
|
||||
albums: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
let url = FETCH_URL + this.id + '/'
|
||||
logger.default.debug('Fetching artist "' + this.id + '"')
|
||||
this.$http.get(url).then((response) => {
|
||||
self.artist = response.data
|
||||
self.albums = JSON.parse(JSON.stringify(self.artist.albums)).map((album) => {
|
||||
return backend.Album.clean(album)
|
||||
})
|
||||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
totalTracks () {
|
||||
return this.albums.map((album) => {
|
||||
return album.tracks.length
|
||||
}).reduce((a, b) => {
|
||||
return a + b
|
||||
})
|
||||
},
|
||||
wikipediaUrl () {
|
||||
return 'https://en.wikipedia.org/w/index.php?search=' + this.artist.name
|
||||
},
|
||||
musicbrainzUrl () {
|
||||
return 'https://musicbrainz.org/artist/' + this.artist.mbid
|
||||
},
|
||||
allTracks () {
|
||||
let tracks = []
|
||||
this.albums.forEach(album => {
|
||||
album.tracks.forEach(track => {
|
||||
tracks.push(track)
|
||||
})
|
||||
})
|
||||
return tracks
|
||||
},
|
||||
cover () {
|
||||
return this.artist.albums.filter(album => {
|
||||
return album.cover
|
||||
}).map(album => {
|
||||
return album.cover
|
||||
})[0]
|
||||
},
|
||||
headerStyle () {
|
||||
if (!this.cover) {
|
||||
return ''
|
||||
}
|
||||
return 'background-image: url(' + backend.absoluteUrl(this.cover) + ')'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
id () {
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
||||
48
front/src/components/browse/Browse.vue
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<div class="main browse pusher">
|
||||
<div class="ui secondary pointing menu">
|
||||
<router-link class="ui item" to="/browse">Browse</router-link>
|
||||
</div>
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'browse'
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style lang="scss">
|
||||
.browse.pusher > .ui.secondary.menu {
|
||||
margin: 0 2.5rem;
|
||||
}
|
||||
|
||||
.browse {
|
||||
.ui.segment.head {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
padding: 0;
|
||||
.segment-content {
|
||||
margin: 0 auto;
|
||||
padding: 4em;
|
||||
}
|
||||
&.with-background {
|
||||
.header {
|
||||
&, .sub, a {
|
||||
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.8);
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
.segment-content {
|
||||
background-color: rgba(0, 0, 0, 0.5)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
80
front/src/components/browse/Home.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<search :autofocus="true"></search>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui stackable two column grid">
|
||||
<div class="column">
|
||||
<h2 class="ui header">Latest artists</h2>
|
||||
<div :class="['ui', {'active': isLoadingArtists}, 'inline', 'loader']"></div>
|
||||
<div v-if="artists.length > 0" v-for="artist in artists.slice(0, 3)" :key="artist" class="ui cards">
|
||||
<artist-card :artist="artist"></artist-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<h2 class="ui header">Radios</h2>
|
||||
<radio-card :type="'favorites'"></radio-card>
|
||||
<radio-card :type="'random'"></radio-card>
|
||||
<radio-card :type="'less-listened'"></radio-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Search from '@/components/audio/Search'
|
||||
import backend from '@/audio/backend'
|
||||
import logger from '@/logging'
|
||||
import ArtistCard from '@/components/audio/artist/Card'
|
||||
import config from '@/config'
|
||||
import RadioCard from '@/components/radios/Card'
|
||||
|
||||
const ARTISTS_URL = config.API_URL + 'artists/'
|
||||
|
||||
export default {
|
||||
name: 'browse',
|
||||
components: {
|
||||
Search,
|
||||
ArtistCard,
|
||||
RadioCard
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
artists: [],
|
||||
isLoadingArtists: false
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchArtists()
|
||||
},
|
||||
methods: {
|
||||
fetchArtists () {
|
||||
var self = this
|
||||
this.isLoadingArtists = true
|
||||
let params = {
|
||||
ordering: '-creation_date'
|
||||
}
|
||||
let url = ARTISTS_URL
|
||||
logger.default.time('Loading latest artists')
|
||||
this.$http.get(url, {params: params}).then((response) => {
|
||||
self.artists = response.data.results
|
||||
self.artists.map((artist) => {
|
||||
var albums = JSON.parse(JSON.stringify(artist.albums)).map((album) => {
|
||||
return backend.Album.clean(album)
|
||||
})
|
||||
artist.albums = albums
|
||||
return artist
|
||||
})
|
||||
logger.default.timeEnd('Loading latest artists')
|
||||
self.isLoadingArtists = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
||||
153
front/src/components/browse/Track.vue
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="isLoadingTrack" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<template v-if="track">
|
||||
<div :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle">
|
||||
<div class="segment-content">
|
||||
<h2 class="ui center aligned icon header">
|
||||
<i class="circular inverted music orange icon"></i>
|
||||
<div class="content">
|
||||
{{ track.title }}
|
||||
<div class="sub header">
|
||||
From album
|
||||
<router-link :to="{name: 'browse.album', params: {id: track.album.id }}">
|
||||
{{ track.album.title }}
|
||||
</router-link>
|
||||
by <router-link :to="{name: 'browse.artist', params: {id: track.artist.id }}">
|
||||
{{ track.artist.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
|
||||
<play-button class="orange" :track="track">Play</play-button>
|
||||
<track-favorite-icon :track="track" :button="true"></track-favorite-icon>
|
||||
<a :href="wikipediaUrl" target="_blank" class="ui button">
|
||||
<i class="wikipedia icon"></i>
|
||||
Search on wikipedia
|
||||
</a>
|
||||
<a :href="musicbrainzUrl" target="_blank" class="ui button">
|
||||
<i class="external icon"></i>
|
||||
View on MusicBrainz
|
||||
</a>
|
||||
<a v-if="downloadUrl" :href="downloadUrl" target="_blank" class="ui button">
|
||||
<i class="download icon"></i>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui vertical stripe center aligned segment">
|
||||
<h2>Lyrics</h2>
|
||||
<div v-if="isLoadingLyrics" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<div v-if="lyrics" v-html="lyrics.content_rendered">
|
||||
</div>
|
||||
<template v-if="!isLoadingLyrics & !lyrics">
|
||||
<p>
|
||||
No lyrics available for this track.
|
||||
</p>
|
||||
<a class="ui button" target="_blank" :href="lyricsSearchUrl">
|
||||
<i class="search icon"></i>
|
||||
Search on lyrics.wikia.com
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import logger from '@/logging'
|
||||
import backend from '@/audio/backend'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
||||
import config from '@/config'
|
||||
|
||||
const FETCH_URL = config.API_URL + 'tracks/'
|
||||
|
||||
export default {
|
||||
props: ['id'],
|
||||
components: {
|
||||
PlayButton,
|
||||
TrackFavoriteIcon
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoadingTrack: true,
|
||||
isLoadingLyrics: true,
|
||||
track: null,
|
||||
lyrics: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
this.fetchLyrics()
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
var self = this
|
||||
this.isLoadingTrack = true
|
||||
let url = FETCH_URL + this.id + '/'
|
||||
logger.default.debug('Fetching track "' + this.id + '"')
|
||||
this.$http.get(url).then((response) => {
|
||||
self.track = response.data
|
||||
self.isLoadingTrack = false
|
||||
})
|
||||
},
|
||||
fetchLyrics () {
|
||||
var self = this
|
||||
this.isLoadingLyrics = true
|
||||
let url = FETCH_URL + this.id + '/lyrics/'
|
||||
logger.default.debug('Fetching lyrics for track "' + this.id + '"')
|
||||
this.$http.get(url).then((response) => {
|
||||
self.lyrics = response.data
|
||||
self.isLoadingLyrics = false
|
||||
}, (response) => {
|
||||
console.error('No lyrics available')
|
||||
self.isLoadingLyrics = false
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
wikipediaUrl () {
|
||||
return 'https://en.wikipedia.org/w/index.php?search=' + this.track.title + ' ' + this.track.artist.name
|
||||
},
|
||||
musicbrainzUrl () {
|
||||
return 'https://musicbrainz.org/recording/' + this.track.mbid
|
||||
},
|
||||
downloadUrl () {
|
||||
if (this.track.files.length > 0) {
|
||||
return backend.absoluteUrl(this.track.files[0].path)
|
||||
}
|
||||
},
|
||||
lyricsSearchUrl () {
|
||||
let base = 'http://lyrics.wikia.com/wiki/Special:Search?query='
|
||||
let query = this.track.artist.name + ' ' + this.track.title
|
||||
return base + query
|
||||
},
|
||||
cover () {
|
||||
return null
|
||||
},
|
||||
headerStyle () {
|
||||
if (!this.cover) {
|
||||
return ''
|
||||
}
|
||||
return 'background-image: url(' + backend.absoluteUrl(this.cover) + ')'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
id () {
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
78
front/src/components/favorites/List.vue
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<template>
|
||||
<div class="main pusher">
|
||||
<div class="ui vertical center aligned stripe segment">
|
||||
<div :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']">
|
||||
<div class="ui text loader">Loading your favorites...</div>
|
||||
</div>
|
||||
<h2 v-if="results" class="ui center aligned icon header">
|
||||
<i class="circular inverted heart pink icon"></i>
|
||||
{{ favoriteTracks.count }} favorites
|
||||
</h2>
|
||||
<radio-button type="favorites"></radio-button>
|
||||
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<button class="ui left floated labeled icon button" @click="fetchFavorites(previousLink)" :disabled="!previousLink"><i class="left arrow icon"></i> Previous</button>
|
||||
<button class="ui right floated right labeled icon button" @click="fetchFavorites(nextLink)" :disabled="!nextLink">Next <i class="right arrow icon"></i></button>
|
||||
<div class="ui hidden clearing divider"></div>
|
||||
<div class="ui hidden clearing divider"></div>
|
||||
<track-table v-if="results" :tracks="results.results"></track-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
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'
|
||||
|
||||
const FAVORITES_URL = config.API_URL + 'tracks/'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
TrackTable,
|
||||
RadioButton
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
results: null,
|
||||
isLoading: false,
|
||||
nextLink: null,
|
||||
previousLink: null,
|
||||
favoriteTracks
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchFavorites(FAVORITES_URL)
|
||||
},
|
||||
methods: {
|
||||
fetchFavorites (url) {
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
let params = {
|
||||
favorites: 'true'
|
||||
}
|
||||
logger.default.time('Loading user favorites')
|
||||
this.$http.get(url, {params: params}).then((response) => {
|
||||
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.results.results.forEach((track) => {
|
||||
Vue.set(favoriteTracks.objects, track.id, true)
|
||||
})
|
||||
logger.default.timeEnd('Loading user favorites')
|
||||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
||||
50
front/src/components/favorites/TrackFavoriteIcon.vue
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<template>
|
||||
<button @click="favoriteTracks.set(track.id, !isFavorite)" v-if="button" :class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'button']">
|
||||
<i class="heart icon"></i>
|
||||
<template v-if="isFavorite">
|
||||
In favorites
|
||||
</template>
|
||||
<template v-else>
|
||||
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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import favoriteTracks from '@/favorites/tracks'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
track: {type: Object},
|
||||
button: {type: Boolean, default: false}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
favoriteTracks
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleFavorite () {
|
||||
this.isFavorite = !this.isFavorite
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
title () {
|
||||
if (this.isFavorite) {
|
||||
return 'Remove from favorites'
|
||||
} else {
|
||||
return 'Add to favorites'
|
||||
}
|
||||
},
|
||||
isFavorite () {
|
||||
return favoriteTracks.objects[this.track.id]
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
||||
50
front/src/components/radios/Button.vue
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<template>
|
||||
<button @click="toggleRadio" :class="['ui', 'blue', {'inverted': running}, 'button']">
|
||||
<i class="ui feed icon"></i>
|
||||
<template v-if="running">Stop</template>
|
||||
<template v-else>Start</template>
|
||||
radio
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<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()
|
||||
} else {
|
||||
radios.start(this.type, this.objectId)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
running () {
|
||||
if (!radios.running) {
|
||||
return false
|
||||
} else {
|
||||
return radios.current.type === this.type & radios.current.objectId === this.objectId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
i {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
37
front/src/components/radios/Card.vue
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<template>
|
||||
<div class="ui card">
|
||||
<div class="content">
|
||||
<div class="header">Radio : {{ radio.name }}</div>
|
||||
<div class="description">
|
||||
{{ radio.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra content">
|
||||
<radio-button class="right floated button" :type="type"></radio-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import radios from '@/radios'
|
||||
import RadioButton from './Button'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
type: {type: String, required: true}
|
||||
},
|
||||
components: {
|
||||
RadioButton
|
||||
},
|
||||
computed: {
|
||||
radio () {
|
||||
return radios.types[this.type]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
11
front/src/config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
class Config {
|
||||
constructor () {
|
||||
this.BACKEND_URL = process.env.BACKEND_URL
|
||||
if (!this.BACKEND_URL.endsWith('/')) {
|
||||
this.BACKEND_URL += '/'
|
||||
}
|
||||
this.API_URL = this.BACKEND_URL + 'api/'
|
||||
}
|
||||
}
|
||||
|
||||
export default new Config()
|
||||
53
front/src/favorites/tracks.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
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)
|
||||
})
|
||||
}
|
||||
},
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
8
front/src/logging.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import jsLogger from 'js-logger'
|
||||
|
||||
jsLogger.useDefaults()
|
||||
|
||||
export default {
|
||||
get: jsLogger.get,
|
||||
default: jsLogger.get('default')
|
||||
}
|
||||
47
front/src/main.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// The Vue build version to load with the `import` command
|
||||
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
|
||||
import logger from '@/logging'
|
||||
|
||||
logger.default.info('Loading environment:', process.env.NODE_ENV)
|
||||
logger.default.debug('Environment variables:', process.env)
|
||||
|
||||
import Vue from 'vue'
|
||||
import App from './App'
|
||||
import router from './router'
|
||||
import VueResource from 'vue-resource'
|
||||
import auth from './auth'
|
||||
|
||||
window.$ = window.jQuery = require('jquery')
|
||||
|
||||
// this is absolutely dirty but at the moment, semantic UI does not
|
||||
// play really nice with webpack and I want to get rid of Google Fonts
|
||||
// require('./semantic/semantic.css')
|
||||
require('semantic-ui-css/semantic.js')
|
||||
|
||||
Vue.use(VueResource)
|
||||
Vue.config.productionTip = false
|
||||
|
||||
Vue.http.interceptors.push(function (request, next) {
|
||||
// modify headers
|
||||
if (auth.user.authenticated) {
|
||||
request.headers.set('Authorization', auth.getAuthHeader())
|
||||
}
|
||||
next(function (response) {
|
||||
// redirect to login form when we get unauthorized response from server
|
||||
if (response.status === 401) {
|
||||
logger.default.warn('Received 401 response from API, redirecting to login form')
|
||||
router.push({name: 'login'})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
auth.checkAuth()
|
||||
/* eslint-disable no-new */
|
||||
new Vue({
|
||||
el: '#app',
|
||||
router,
|
||||
template: '<App/>',
|
||||
components: { App }
|
||||
})
|
||||
|
||||
logger.default.info('Everything loaded!')
|
||||
64
front/src/radios/index.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
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
|
||||
57
front/src/router/index.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import Vue from 'vue'
|
||||
import Router from 'vue-router'
|
||||
import Home from '@/components/Home'
|
||||
import Login from '@/components/auth/Login'
|
||||
import Profile from '@/components/auth/Profile'
|
||||
import Logout from '@/components/auth/Logout'
|
||||
import Browse from '@/components/browse/Browse'
|
||||
import BrowseHome from '@/components/browse/Home'
|
||||
import BrowseArtist from '@/components/browse/Artist'
|
||||
import BrowseAlbum from '@/components/browse/Album'
|
||||
import BrowseTrack from '@/components/browse/Track'
|
||||
import Favorites from '@/components/favorites/List'
|
||||
|
||||
Vue.use(Router)
|
||||
|
||||
export default new Router({
|
||||
mode: 'history',
|
||||
linkActiveClass: 'active',
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'index',
|
||||
component: Home
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: Login
|
||||
},
|
||||
{
|
||||
path: '/logout',
|
||||
name: 'logout',
|
||||
component: Logout
|
||||
},
|
||||
{
|
||||
path: '/@:username',
|
||||
name: 'profile',
|
||||
component: Profile,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/favorites',
|
||||
component: Favorites
|
||||
},
|
||||
{
|
||||
path: '/browse',
|
||||
component: Browse,
|
||||
children: [
|
||||
{ path: '', component: BrowseHome },
|
||||
{ path: 'artist/:id', name: 'browse.artist', component: BrowseArtist, props: true },
|
||||
{ path: 'album/:id', name: 'browse.album', component: BrowseAlbum, props: true },
|
||||
{ path: 'track/:id', name: 'browse.track', component: BrowseTrack, props: true }
|
||||
]
|
||||
}
|
||||
|
||||
]
|
||||
})
|
||||
36998
front/src/semantic/semantic.css
Executable file
BIN
front/src/semantic/themes/default/assets/fonts/icons.eot
Normal file
BIN
front/src/semantic/themes/default/assets/fonts/icons.otf
Normal file
2671
front/src/semantic/themes/default/assets/fonts/icons.svg
Normal file
|
After Width: | Height: | Size: 434 KiB |
BIN
front/src/semantic/themes/default/assets/fonts/icons.ttf
Normal file
BIN
front/src/semantic/themes/default/assets/fonts/icons.woff
Normal file
BIN
front/src/semantic/themes/default/assets/fonts/icons.woff2
Normal file
BIN
front/src/semantic/themes/default/assets/images/flags.png
Executable file
|
After Width: | Height: | Size: 28 KiB |