Initial commit that merge both the front end and the API in the same repository

This commit is contained in:
Eliot Berriot 2017-06-23 23:00:42 +02:00
commit 76f98b74dd
285 changed files with 51318 additions and 0 deletions

63
front/src/App.vue Normal file
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View 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
View 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
View 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
View 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
View 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
View 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()
}
}

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

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

View 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 &nbsp;
<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>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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
View 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()

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

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB