Extract core to submodule

This commit is contained in:
Phan An 2018-02-02 22:12:19 +01:00
parent a045c4f04e
commit 7292b94724
187 changed files with 6 additions and 15955 deletions

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "resources/assets"]
path = resources/assets
url = git@github.com:koel/core.git

1
resources/assets Submodule

@ -0,0 +1 @@
Subproject commit 8f2700dc589d4c23abfaf6824e8e0f67bbb83647

View file

@ -1 +0,0 @@
html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:'';content:none}table{border-collapse:collapse;border-spacing:0}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View file

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="176px" height="177px" viewBox="0 0 176 177" enable-background="new 0 0 176 177" xml:space="preserve">
<g>
<g>
<path fill="#fff" d="M88,0.5c-48.602,0-88,39.399-88,88c0,48.601,39.398,88,88,88c48.601,0,88-39.399,88-88
C176,39.899,136.601,0.5,88,0.5z M88,169.108c-44.52,0-80.608-36.09-80.608-80.608C7.392,43.981,43.48,7.892,88,7.892
c44.519,0,80.608,36.089,80.608,80.608C168.608,133.019,132.519,169.108,88,169.108z M126.254,31.462
c-1.221-1.033-3.796-0.399-3.796-0.399L70.117,41.451c0,0-2.253,0.057-3.795,1.998c-1.083,1.363-0.999,3.796-0.999,3.796v62.129
c0,0,0.188,2.209-1.398,3.796c-1.843,1.842-6.479,2.14-10.588,3.196c-6.84,1.76-13.585,4.634-13.585,12.985
c0,5.86,2.965,13.186,13.186,13.186c12.313,0,18.578-6.789,18.578-15.982c0-5.952,0-7.392,0-7.392l0.2-47.346
c0,0-0.299-2.608,0.6-3.596c1.264-1.389,3.995-1.598,3.995-1.598l40.354-8.19c0,0,2.452-0.845,3.596,0
c1.154,0.853,0.999,3.396,0.999,3.396v36.159c0,0,0.219,2.379-0.999,3.596c-2.288,2.289-6.141,2.242-9.988,3.196
c-7.225,1.792-14.783,4.386-14.783,13.785c0,9.929,9.67,12.785,12.785,12.785c13.62,0,19.378-7.352,19.378-16.182V35.458
C127.652,35.458,127.622,32.62,126.254,31.462z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

View file

@ -1,31 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid" width="443" height="443" viewBox="0 0 443 443">
<defs>
<style>
.cls-1, .cls-4 {
fill: #fff;
}
.cls-1, .cls-2, .cls-3 {
fill-rule: evenodd;
}
.cls-2 {
fill: #000;
}
.cls-3 {
fill: #fa0000;
}
</style>
</defs>
<g>
<path d="M341.199,181.514 C341.199,181.514 329.240,198.397 364.162,215.016 C364.162,215.016 310.908,209.376 296.786,195.998 C296.786,195.998 204.949,208.024 241.644,296.184 C241.644,296.184 211.447,274.452 202.192,246.099 C202.192,246.099 133.385,318.336 179.528,436.617 C179.528,436.617 129.141,382.779 122.046,329.902 C122.046,329.902 117.782,338.194 119.576,350.880 C119.576,350.880 99.298,292.397 104.504,261.508 C104.504,261.508 92.138,278.643 91.378,288.332 C91.378,288.332 93.107,266.615 101.661,253.245 C101.661,253.245 99.170,233.867 107.680,213.575 C116.171,193.332 113.463,171.640 111.827,167.782 C111.827,167.782 109.198,176.572 105.433,180.351 C105.433,180.351 117.471,132.757 112.356,109.929 C112.356,109.929 101.837,120.099 103.373,145.597 C103.373,145.597 97.287,123.077 103.298,98.997 C103.298,98.997 97.916,99.526 97.476,105.016 C97.476,105.016 91.763,89.607 99.801,71.864 C99.801,71.864 53.861,41.051 49.370,37.405 C49.370,37.405 42.748,34.052 34.576,34.782 C60.449,24.014 94.715,42.762 115.899,56.843 C127.423,64.850 133.085,69.917 145.649,73.271 C158.380,76.669 169.714,87.898 169.714,87.898 C156.987,69.315 152.956,79.582 118.199,54.835 C68.767,19.873 41.714,29.300 32.237,35.104 C31.001,35.334 29.742,35.658 28.475,36.118 C28.475,36.118 48.041,-5.329 130.152,21.206 C130.152,21.206 142.859,8.268 150.718,7.000 C150.718,7.000 139.765,14.805 137.775,21.256 C137.775,21.256 153.368,9.960 166.082,10.086 C166.082,10.086 151.494,17.506 144.164,24.376 C144.164,24.376 153.849,19.212 167.805,19.383 C167.805,19.383 159.135,23.576 156.156,25.016 C155.536,25.315 155.162,25.496 155.162,25.496 C155.162,25.496 163.938,23.635 168.699,23.535 C168.699,23.535 192.437,12.135 213.904,14.367 C213.904,14.367 206.439,14.422 196.089,22.692 C196.089,22.692 228.441,20.998 250.117,31.701 C250.117,31.701 231.766,28.100 220.340,28.180 C220.340,28.180 276.182,30.408 322.808,91.274 C371.863,155.310 373.908,160.510 413.969,190.628 C413.969,190.628 365.515,188.519 341.199,181.514 Z" class="cls-1"/>
<path d="M307.000,158.000 C307.000,158.000 298.389,172.789 324.000,184.000 C324.000,184.000 284.902,183.184 274.000,173.000 C274.000,173.000 202.346,191.137 234.000,261.000 C234.000,261.000 209.486,246.084 201.000,223.000 C201.000,223.000 147.602,292.965 190.000,382.000 C190.000,382.000 146.674,346.092 138.000,303.000 C138.000,303.000 134.801,310.563 137.000,321.000 C137.000,321.000 116.229,273.617 119.000,246.000 C119.000,246.000 109.084,262.424 109.000,271.000 C109.000,271.000 109.211,251.727 116.000,239.000 C116.000,239.000 112.623,221.998 119.000,203.000 C125.377,184.002 121.697,164.428 120.000,161.000 C120.000,161.000 118.160,169.258 115.000,173.000 C115.000,173.000 123.014,128.197 117.000,107.000 C117.000,107.000 107.994,117.129 111.000,141.000 C111.000,141.000 103.994,120.180 108.000,97.000 C108.000,97.000 103.051,97.730 103.000,103.000 C103.000,103.000 96.660,88.441 103.000,71.000 C103.000,71.000 56.725,41.609 52.000,38.000 C52.000,38.000 45.130,34.687 37.006,35.551 C61.771,24.269 96.266,42.627 117.000,56.000 C128.061,63.455 133.500,68.163 145.000,71.000 C156.500,73.837 167.000,84.000 167.000,84.000 C154.894,66.930 151.844,76.752 119.000,54.000 C69.846,20.172 43.925,29.888 34.829,35.879 C33.563,36.132 32.281,36.495 31.000,37.000 C31.000,37.000 47.998,-5.445 128.000,21.000 C128.000,21.000 138.914,8.246 146.000,7.000 C146.000,7.000 136.453,14.670 135.000,21.000 C135.000,21.000 148.561,9.895 160.000,10.000 C160.000,10.000 147.270,17.262 141.000,24.000 C141.000,24.000 149.486,18.910 162.000,19.000 C162.000,19.000 154.466,23.109 151.868,24.526 C151.327,24.822 151.000,25.000 151.000,25.000 C151.000,25.000 158.766,23.131 163.000,23.000 C163.000,23.000 183.369,11.930 202.000,14.000 C202.000,14.000 195.598,14.076 187.000,22.000 C187.000,22.000 214.584,20.193 233.000,30.000 C233.000,30.000 217.627,26.824 208.000,27.000 C208.000,27.000 254.348,28.752 292.000,82.000 C329.652,135.248 330.877,138.893 359.000,161.000 C359.000,161.000 324.770,162.277 307.000,158.000 Z" class="cls-2"/>
<path d="M118.000,37.000 C118.000,37.000 125.482,30.131 128.000,41.000 C128.000,41.000 124.428,44.504 122.000,43.000 C119.572,41.496 118.000,37.000 118.000,37.000 Z" class="cls-1"/>
<g>
<path d="M184.000,67.000 C184.000,67.000 189.596,53.143 207.000,58.000 C221.245,62.388 222.000,74.000 222.000,74.000 C222.000,74.000 215.773,89.560 204.000,88.000 C192.227,86.440 184.492,76.630 184.000,67.000 Z" class="cls-3"/>
<path d="M200.055,61.063 C207.079,60.796 213.823,64.926 214.031,70.403 C214.243,75.962 207.620,79.457 200.388,78.313 C194.177,77.332 189.733,73.180 189.712,68.967 C189.691,64.801 194.001,61.293 200.055,61.063 Z" class="cls-2"/>
<ellipse cx="196.5" cy="65" rx="4.5" ry="4" class="cls-4"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

View file

@ -1,20 +0,0 @@
import './static-loader'
import Vue from 'vue'
import App from './app.vue'
import { http } from './services'
import { VirtualScroller } from 'vue-virtual-scroller/dist/vue-virtual-scroller'
Vue.component('virtual-scroller', VirtualScroller)
/**
* For Ancelot, the ancient cross of war
* for the holy town of Gods
* Gloria, gloria perpetua
* in this dawn of victory
*/
new Vue({
el: '#app',
render: h => h(App),
created () {
http.init()
}
})

View file

@ -1,319 +0,0 @@
<template>
<div id="app">
<div id="main" tabindex="0" v-if="authenticated"
@keydown.space="togglePlayback"
@keydown.j = "playNext"
@keydown.k = "playPrev"
@keydown.f = "search"
@keydown.mediaPrev = "playPrev"
@keydown.mediaNext = "playNext"
@keydown.mediaToggle = "togglePlayback"
>
<site-header/>
<main-wrapper/>
<site-footer/>
<overlay ref="overlay"/>
<edit-songs-form ref="editSongsForm"/>
</div>
<div class="login-wrapper" v-else>
<login-form @loggedin="onUserLoggedIn"/>
</div>
</div>
</template>
<script>
import Vue from 'vue'
import siteHeader from './components/site-header/index.vue'
import siteFooter from './components/site-footer/index.vue'
import mainWrapper from './components/main-wrapper/index.vue'
import overlay from './components/shared/overlay.vue'
import loginForm from './components/auth/login-form.vue'
import editSongsForm from './components/modals/edit-songs-form.vue'
import { event, showOverlay, hideOverlay, forceReloadWindow, $ } from './utils'
import { sharedStore, userStore, favoriteStore, queueStore, preferenceStore as preferences } from './stores'
import { playback, ls, socket } from './services'
import { focusDirective, clickawayDirective } from './directives'
import router from './router'
let ipc
if (KOEL_ENV === 'app') {
ipc = require('electron').ipcRenderer
}
export default {
components: { siteHeader, siteFooter, mainWrapper, overlay, loginForm, editSongsForm },
data () {
return {
authenticated: false
}
},
mounted () {
// The app has just been initialized, check if we can get the user data with an already existing token
const token = ls.get('jwt-token')
if (token) {
this.authenticated = true
this.init()
}
// Create the element to be the ghost drag image.
const dragGhost = document.createElement('div')
dragGhost.id = 'dragGhost'
document.body.appendChild(dragGhost)
// And the textarea to copy stuff
const copyArea = document.createElement('textarea')
copyArea.id = 'copyArea'
document.body.appendChild(copyArea)
// Add an ugly mac/non-mac class for OS-targeting styles.
// I'm crying inside.
$.addClass(document.documentElement, navigator.userAgent.includes('Mac') ? 'mac' : 'non-mac')
},
methods: {
async init () {
showOverlay()
await socket.init()
// Make the most important HTTP request to get all necessary data from the server.
// Afterwards, init all mandatory stores and services.
try {
await sharedStore.init()
playback.init()
hideOverlay()
// Ask for user's notification permission.
this.requestNotifPermission()
// To confirm or not to confirm closing, it's a question.
window.onbeforeunload = e => {
if (!preferences.confirmClosing) {
return
}
// Notice that a custom message like this has ceased to be supported
// starting from Chrome 51.
return 'You asked Koel to confirm before closing, so here it is.'
}
this.subscribeToBroadcastedEvents()
// Let all other components know we're ready.
event.emit('koel:ready')
} catch (err) {
this.authenticated = false
}
},
/**
* Toggle playback when user presses Space key.
*
* @param {Object} e The keydown event
*/
togglePlayback (e) {
if (e && $.is(e.target, 'input,textarea,button,select')) {
return true
}
// Whatever play/pause control is there, we blindly click it.
const play = document.querySelector('#mainFooter .play')
play ? play.click() : document.querySelector('#mainFooter .pause').click()
e && e.preventDefault()
},
/**
* Play the previous song when user presses K.
*
* @param {Object} e The keydown event
*/
playPrev (e) {
if ($.is(e.target, 'input,textarea')) {
return true
}
playback.playPrev()
e.preventDefault()
},
/**
* Play the next song when user presses J.
*
* @param {Object} e The keydown event
*/
playNext (e) {
if ($.is(e.target, 'input,textarea')) {
return true
}
playback.playNext()
e.preventDefault()
},
/**
* Put focus into the search field when user presses F.
*
* @param {Object} e The keydown event
*/
search (e) {
if ($.is(e.target, 'input,textarea') || e.metaKey || e.ctrlKey) {
return true
}
const selectBox = document.querySelector('#searchForm input[type="search"]')
selectBox.focus()
selectBox.select()
e.preventDefault()
},
/**
* Request for notification permission if it's not provided and the user is OK with notifs.
*/
requestNotifPermission () {
if (window.Notification && preferences.notify && window.Notification.permission !== 'granted') {
window.Notification.requestPermission(result => {
if (result === 'denied') {
preferences.notify = false
}
})
}
},
/**
* When the user logs in, set the whole app to be "authenticated" and initialize it.
*/
onUserLoggedIn () {
this.authenticated = true
this.init()
},
/**
* Subscribes to the events broadcasted e.g. from the remote controller.
*/
subscribeToBroadcastedEvents () {
socket.listen('favorite:toggle', () => {
queueStore.current && favoriteStore.toggleOne(queueStore.current)
})
},
listenToGlobalShortcuts () {
ipc.on('shortcut', (e, msg) => {
switch (msg) {
case 'MediaNextTrack':
playback.playNext()
break
case 'MediaPreviousTrack':
playback.playPrev()
break
case 'MediaStop':
playback.stop()
break
case 'MediaPlayPause':
const play = document.querySelector('#mainFooter .play')
play ? play.click() : document.querySelector('#mainFooter .pause').click()
break
}
})
}
},
created () {
event.on({
/**
* Shows the "Edit Song" form.
*
* @param {Array.<Object>} An array of songs to edit
*/
'songs:edit': songs => this.$refs.editSongsForm.open(songs),
/**
* Log the current user out and reset the application state.
*/
async logout () {
await userStore.logout()
ls.remove('jwt-token')
forceReloadWindow()
},
'koel:ready': () => {
router.init()
KOEL_ENV === 'app' && this.listenToGlobalShortcuts()
}
})
}
}
// Register our custom key codes
Vue.config.keyCodes = {
a: 65,
j: 74,
k: 75,
f: 70,
mediaNext: 176,
mediaPrev: 177,
mediaToggle: 179
}
// and the global directives
Vue.directive('koel-focus', focusDirective)
Vue.directive('koel-clickaway', clickawayDirective)
</script>
<style lang="scss">
@import "~#/app.scss";
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
@import "~#/partials/_shared.scss";
#dragGhost {
position: absolute;
display: inline-block;
background: $colorGreen;
padding: .8rem;
border-radius: .2rem;
color: #fff;
font-family: $fontFamily;
font-size: 1rem;
font-weight: $fontWeight_Thin;
top: -100px;
left: 0px;
/**
* We can totally hide this element on touch devices, because there's
* no drag and drop support there anyway.
*/
html.touchevents & {
display: none;
}
}
#copyArea {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
bottom: 1px;
html.touchevents & {
display: none;
}
}
#main, .login-wrapper {
display: flex;
min-height: 100vh;
flex-direction: column;
padding-bottom: $footerHeight;
}
.login-wrapper {
@include vertical-center();
padding-bottom: 0;
}
</style>

View file

@ -1,107 +0,0 @@
<template>
<form @submit.prevent="login" :class="{ error: failed }">
<div class="logo">
<img src="~#/../img/logo.svg" width="156" height="auto">
</div>
<input v-model="email" type="email" placeholder="Email Address" autofocus required>
<input v-model="password" type="password" placeholder="Password" required>
<button type="submit">Log In</button>
</form>
</template>
<script>
import { userStore } from '@/stores'
export default {
data () {
return {
email: '',
password: '',
failed: false
}
},
methods: {
async login () {
try {
await userStore.login(this.email, this.password)
this.failed = false
// Reset the password so that the next login will have this field empty.
this.password = ''
this.$emit('loggedin')
} catch (err) {
this.failed = true
}
}
}
}
</script>
<style lang="scss" scoped>
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
@import "~#/partials/_shared.scss";
/**
* I like to move it move it
* I like to move it move it
* I like to move it move it
* You like to - move it!
*/
@keyframes shake {
8%, 41% {
-webkit-transform: translateX(-10px);
}
25%, 58% {
-webkit-transform: translateX(10px);
}
75% {
-webkit-transform: translateX(-5px);
}
92% {
-webkit-transform: translateX(5px);
}
0%, 100% {
-webkit-transform: translateX(0);
}
}
form {
width: 280px;
padding: 1.8rem;
background: rgba(255,255,255,.08);
border-radius: .6rem;
border: 1px solid #333;
&.error {
border-color: #8e4947;
animation: shake .5s;
}
.logo {
text-align: center;
}
@media only screen and (max-width : 414px) {
border: 0;
background: transparent;
}
}
input {
display: block;
margin-top: 12px;
border: 0;
background: #fff;
outline: none;
width: 100%;
}
button {
display: block;
margin-top: 12px;
width: 100%;
}
</style>

View file

@ -1,105 +0,0 @@
<template>
<article id="albumInfo" :class="mode">
<h1 class="name">
<span>{{ album.name }}</span>
<a class="shuffle" @click.prevent="shuffleAll"><i class="fa fa-random"></i></a>
</h1>
<div v-if="album.info">
<img v-if="album.info.image" :src="album.info.image" class="cover">
<div class="wiki" v-if="album.info.wiki && album.info.wiki.summary">
<div class="summary" v-show="showSummary" v-html="album.info.wiki.summary"/>
<div class="full" v-show="showFull" v-html="album.info.wiki.full"/>
<button class="more" v-if="showSummary" @click.prevent="showingFullWiki = true">
Full Wiki
</button>
</div>
<section class="track-listing" v-if="album.info.tracks.length">
<h1>Track Listing</h1>
<ul class="tracks">
<li is="track-list-item"
v-for="(track, idx) in album.info.tracks"
:album="album"
:track="track"
:index="idx"
/>
</ul>
</section>
<footer>Data &copy; <a target="_blank" :href="album.info.url">Last.fm</a></footer>
</div>
<p class="none" v-else>No album information found.</p>
</article>
</template>
<script>
import { sharedStore } from '@/stores'
import { playback, ls } from '@/services'
import trackListItem from '@/components/shared/track-list-item.vue'
export default {
props: {
album: Object,
mode: {
type: String,
default: 'sidebar',
validator: value => ['sidebar', 'full'].includes(value)
}
},
components: { trackListItem },
data () {
return {
showingFullWiki: false,
useiTunes: sharedStore.state.useiTunes
}
},
watch: {
/**
* Whenever a new album is loaded into this component, we reset the "full wiki" state.
* @return {Boolean}
*/
album () {
this.showingFullWiki = false
}
},
computed: {
showSummary () {
return this.mode !== 'full' && !this.showingFullWiki
},
showFull () {
return this.mode === 'full' || this.showingFullWiki
},
iTunesUrl () {
return `${window.BASE_URL}api/itunes/album/${this.album.id}&jwt-token=${ls.get('jwt-token')}`
}
},
methods: {
/**
* Shuffle all songs in the current album.
*/
shuffleAll () {
playback.playAllInAlbum(this.album)
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#albumInfo {
@include artist-album-info();
}
</style>

View file

@ -1,88 +0,0 @@
<template>
<article id="artistInfo" :class="mode">
<h1 class="name">
<span>{{ artist.name }}</span>
<a class="shuffle" @click.prevent="shuffleAll"><i class="fa fa-random"></i></a>
</h1>
<div v-if="artist.info">
<img v-if="artist.info.image" :src="artist.info.image"
title="They see me posin, they hatin"
class="cool-guys-posing cover">
<div class="bio" v-if="artist.info.bio.summary">
<div class="summary" v-show="showSummary" v-html="artist.info.bio.summary"/>
<div class="full" v-show="showFull" v-html="artist.info.bio.full"/>
<button class="more" v-show="showSummary" @click.prevent="showingFullBio = true">
Full Bio
</button>
</div>
<p class="none" v-else>This artist has no Last.fm biography yet.</p>
<footer>Data &copy; <a target="_blank" :href="artist.info.url">Last.fm</a></footer>
</div>
<p class="none" v-else>Nothing can be found. This artist is a mystery.</p>
</article>
</template>
<script>
import { playback } from '@/services'
export default {
props: {
artist: Object,
mode: {
type: String,
default: 'sidebar',
validator: value => ['sidebar', 'full'].includes(value)
}
},
data () {
return {
showingFullBio: false
}
},
watch: {
/**
* Whenever a new artist is loaded into this component, we reset the "full bio" state.
* @return {Boolean}
*/
artist () {
this.showingFullBio = false
}
},
computed: {
showSummary () {
return this.mode !== 'full' && !this.showingFullBio
},
showFull () {
return this.mode === 'full' || this.showingFullBio
}
},
methods: {
/**
* Shuffle all songs performed by the current song's artist.
*/
shuffleAll () {
playback.playAllByArtist(this.artist, false)
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#artistInfo {
@include artist-album-info();
}
</style>

View file

@ -1,177 +0,0 @@
<template>
<section id="extra" :class="{ showing: state.showExtraPanel }">
<div class="tabs">
<div class="header clear">
<a @click.prevent="currentView = 'lyrics'"
class="lyrics"
:class="{ active: currentView === 'lyrics' }">Lyrics</a>
<a @click.prevent="currentView = 'artistInfo'"
class="artist"
:class="{ active: currentView === 'artistInfo' }">Artist</a>
<a @click.prevent="currentView = 'albumInfo'"
class="album"
:class="{ active: currentView === 'albumInfo' }">Album</a>
<a @click.prevent="currentView = 'youtube'"
v-if="sharedState.useYouTube"
class="youtube"
:class="{ active: currentView === 'youtube' }"><i class="fa fa-youtube-play"></i></a>
</div>
<div class="panes">
<lyrics :song="song" ref="lyrics" v-show="currentView === 'lyrics'"/>
<artist-info v-if="song.artist.id"
:artist="song.artist"
mode="sidebar"
ref="artist-info"
v-show="currentView === 'artistInfo'"/>
<album-info v-if="song.album.id"
:album="song.album"
mode="sidebar"
ref="album-info"
v-show="currentView === 'albumInfo'"/>
<youtube v-if="sharedState.useYouTube"
:song="song" :youtube="song.youtube"
ref="youtube"
v-show="currentView === 'youtube'"/>
</div>
</div>
</section>
</template>
<script>
import isMobile from 'ismobilejs'
import { event, $ } from '@/utils'
import { sharedStore, songStore, preferenceStore as preferences } from '@/stores'
import { songInfo } from '@/services'
import lyrics from './lyrics.vue'
import artistInfo from './artist-info.vue'
import albumInfo from './album-info.vue'
import youtube from './youtube.vue'
export default {
name: 'main-wrapper--extra--index',
components: { lyrics, artistInfo, albumInfo, youtube },
data () {
return {
song: songStore.stub,
state: preferences.state,
sharedState: sharedStore.state,
currentView: 'lyrics'
}
},
watch: {
/**
* Watch the "showExtraPanel" property to add/remove the corresponding class
* to/from the html tag.
* Some element's CSS can then be controlled based on this class.
*/
'state.showExtraPanel' (showingExtraPanel) {
if (showingExtraPanel && !isMobile.any) {
$.addClass(document.documentElement, 'with-extra-panel')
} else {
$.removeClass(document.documentElement, 'with-extra-panel')
}
}
},
mounted () {
// On ready, add 'with-extra-panel' class.
if (!isMobile.any) {
$.addClass(document.documentElement, 'with-extra-panel')
}
if (isMobile.phone) {
// On a mobile device, we always hide the panel initially regardless of
// the saved preference.
preferences.showExtraPanel = false
}
},
methods: {
/**
* Reset all self and applicable child components' states.
*/
resetState () {
this.currentView = 'lyrics'
this.song = songStore.stub
},
async fetchSongInfo (song) {
try {
this.song = await songInfo.fetch(song)
} catch (err) {
this.song = song
}
}
},
created () {
event.on({
'main-content-view:load': view => {
// Hide the panel away if a main view is triggered on mobile.
if (isMobile.phone) {
preferences.showExtraPanel = false
}
},
'song:played': song => this.fetchSongInfo(song)
})
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#extra {
flex: 0 0 $extraPanelWidth;
padding: 24px 16px $footerHeight;
background: $colorExtraBgr;
max-height: calc(100vh - #{$headerHeight + $footerHeight});
display: none;
color: $color2ndText;
overflow: auto;
-ms-overflow-style: -ms-autohiding-scrollbar;
html.touchevents & {
// Enable scroll with momentum on touch devices
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
}
&.showing {
display: block;
}
h1 {
font-weight: $fontWeight_UltraThin;
font-size: 2.2rem;
margin-bottom: 16px;
line-height: 2.8rem;
}
@media only screen and (max-width : 1024px) {
position: fixed;
height: calc(100vh - #{$headerHeight + $footerHeight});
padding-bottom: $footerHeight; // make sure the footer can never overlap the content
width: $extraPanelWidth;
z-index: 5;
top: $headerHeight;
right: -100%;
transition: right .3s ease-in;
&.showing {
right: 0;
}
}
@media only screen and (max-width : 667px) {
width: 100%;
}
}
</style>

View file

@ -1,24 +0,0 @@
<template>
<article id="lyrics">
<div class="content">
<div v-if="song.lyrics" v-html="song.lyrics"/>
<p class="none" v-if="song.id && !song.lyrics">No lyrics found. Are you not listening to Bach?</p>
</div>
</article>
</template>
<script>
export default {
props: {
song: {
type: Object,
required: true
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
</style>

View file

@ -1,101 +0,0 @@
<template>
<div id="youtube-extra-wrapper">
<template v-if="videos && videos.length">
<a class="video" v-for="video in videos" :href="`https://youtu.be/${video.id.videoId}`"
@click.prevent="play(video)">
<div class="thumb">
<img :src="video.snippet.thumbnails.default.url" width="90">
</div>
<div class="meta">
<h3 class="title">{{ video.snippet.title }}</h3>
<p class="desc">{{ video.snippet.description }}</p>
</div>
</a>
<button @click.prevent="loadMore" v-if="!loading" class="more btn-blue">Load More</button>
</template>
<p class="nope" v-else>Play a song to retrieve related YouTube videos.</p>
<p class="nope" v-show="loading">Loading</p>
</div>
</template>
<script>
import { youtube as youtubeService } from '@/services'
export default {
name: 'main-wrapper--extra--youtube',
props: {
song: {
type: Object,
required: true
}
},
data () {
return {
loading: false,
videos: []
}
},
watch: {
song (val) {
this.videos = val.youtube ? val.youtube.items : []
}
},
methods: {
play (video) {
youtubeService.play(video)
},
/**
* Load more videos.
*/
async loadMore () {
this.loading = true
try {
await youtubeService.searchVideosRelatedToSong(this.song)
this.videos = this.song.youtube.items
} catch (e) {
} finally {
this.loading = false
}
}
}
}
</script>
<style lang="scss" scoped>
#youtube-extra-wrapper {
overflow-x: hidden;
.video {
display: flex;
padding: 12px 0;
border-bottom: 1px solid #333;
.thumb {
margin-right: 10px;
}
.title {
font-size: 1.1rem;
margin-bottom: .4rem;
}
.desc {
font-size: .9rem;
}
&:hover {
.title {
color: #fff;
}
}
&:last-of-type {
margin-bottom: 16px;
}
}
}
</style>

View file

@ -1,27 +0,0 @@
<template>
<div id="mainWrapper">
<sidebar/>
<main-content/>
<extra/>
</div>
</template>
<script>
import sidebar from './sidebar/index.vue'
import mainContent from './main-content/index.vue'
import extra from './extra/index.vue'
export default {
components: { sidebar, mainContent, extra }
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#mainWrapper {
display: flex;
flex: 1;
}
</style>

View file

@ -1,192 +0,0 @@
<template>
<section id="albumWrapper">
<h1 class="heading">
<span class="overview">
<img :src="album.cover" width="64" height="64" class="cover">
{{ album.name }}
<controls-toggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
<span class="meta" v-show="album.songs.length">
by
<a class="artist" v-if="isNormalArtist" :href="`#!/artist/${album.artist.id}`">{{ album.artist.name }}</a>
<span class="nope" v-else>{{ album.artist.name }}</span>
{{ album.songs.length | pluralize('song') }}
{{ fmtLength }}
<template v-if="sharedState.useLastfm">
<a class="info" href @click.prevent="showInfo" title="View album's extra information">Info</a>
</template>
<template v-if="sharedState.allowDownload">
<a class="download" href @click.prevent="download" title="Download all songs in album">
Download All
</a>
</template>
</span>
</span>
<song-list-controls
v-show="album.songs.length && (!isPhone || showingControls)"
@shuffleAll="shuffleAll"
@shuffleSelected="shuffleSelected"
:config="songListControlConfig"
:selectedSongs="selectedSongs"
/>
</h1>
<song-list :items="album.songs" type="album" ref="songList"/>
<section class="info-wrapper" v-if="sharedState.useLastfm && info.showing">
<a href class="close" @click.prevent="info.showing = false"><i class="fa fa-times"></i></a>
<div class="inner">
<div class="loading" v-if="info.loading"><sound-bar/></div>
<album-info :album="album" mode="full" v-else/>
</div>
</section>
</section>
</template>
<script>
import { pluralize, event } from '@/utils'
import { albumStore, artistStore, sharedStore } from '@/stores'
import { playback, download, albumInfo as albumInfoService } from '@/services'
import router from '@/router'
import hasSongList from '@/mixins/has-song-list'
import albumAttributes from '@/mixins/album-attributes'
import albumInfo from '@/components/main-wrapper/extra/album-info.vue'
import soundBar from '@/components/shared/sound-bar.vue'
export default {
name: 'main-wrapper--main-content--album',
mixins: [hasSongList, albumAttributes],
components: { albumInfo, soundBar },
filters: { pluralize },
data () {
return {
sharedState: sharedStore.state,
album: albumStore.stub,
info: {
showing: false,
loading: true
}
}
},
computed: {
isNormalArtist () {
return !artistStore.isVariousArtists(this.album.artist) &&
!artistStore.isUnknownArtist(this.album.artist)
}
},
watch: {
/**
* Watch the album's song count.
* If this is changed to 0, the user has edit the songs in this album
* and move all of them into another album.
* We should then go back to the album list.
*/
'album.songs.length' (newSongCount) {
newSongCount || router.go('albums')
}
},
created () {
/**
* Listen to 'main-content-view:load' event to load the requested album
* into view if applicable.
*
* @param {String} view The view name
* @param {Object} album The album object
*/
event.on('main-content-view:load', (view, album) => {
if (view === 'album') {
this.info.showing = false
this.album = album
// #530
this.$nextTick(() => this.$refs.songList && this.$refs.songList.sort())
}
})
},
methods: {
/**
* Shuffle the songs in the current album.
* Overriding the mixin.
*/
shuffleAll () {
playback.queueAndPlay(this.album.songs, true)
},
/**
* Download all songs from the album.
*/
download () {
download.fromAlbum(this.album)
},
async showInfo () {
this.info.showing = true
if (!this.album.info) {
this.info.loading = true
try {
await albumInfoService.fetch(this.album)
} catch (e) {
} finally {
this.info.loading = false
}
} else {
this.info.loading = false
}
}
}
}
</script>
<style lang="scss" scoped>
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#albumWrapper {
button.play-shuffle {
i {
margin-right: 0 !important;
}
}
.heading {
.overview {
position: relative;
padding-left: 84px;
@media only screen and (max-width : 768px) {
padding-left: 0;
}
}
.cover {
position: absolute;
left: 0;
@media only screen and (max-width : 768px) {
display: none;
}
}
a.artist {
color: $colorMainText;
display: inline;
&:hover {
color: $colorHighlight;
}
}
}
@include artist-album-info-wrapper();
}
</style>

View file

@ -1,74 +0,0 @@
<template>
<section id="albumsWrapper">
<h1 class="heading">
<span>Albums</span>
<view-mode-switch :mode="viewMode" for="albums" @viewModeChanged="changeViewMode"/>
</h1>
<div ref="scroller" class="albums main-scroll-wrap" :class="`as-${viewMode}`" @scroll="scrolling">
<album-item v-for="item in displayedItems" :album="item" :key="item.id"/>
<span class="item filler" v-for="n in 6"/>
<to-top-button/>
</div>
</section>
</template>
<script>
import { filterBy, limitBy, event } from '@/utils'
import { albumStore } from '@/stores'
import albumItem from '@/components/shared/album-item.vue'
import viewModeSwitch from '@/components/shared/view-mode-switch.vue'
import infiniteScroll from '@/mixins/infinite-scroll'
export default {
name: 'main-wrapper--main-content--albums',
mixins: [infiniteScroll],
components: { albumItem, viewModeSwitch },
data () {
return {
perPage: 9,
numOfItems: 9,
q: '',
viewMode: null,
albums: []
}
},
computed: {
displayedItems () {
return limitBy(
filterBy(this.albums, this.q, 'name', 'artist.name'),
this.numOfItems
)
}
},
methods: {
changeViewMode (mode) {
this.viewMode = mode
}
},
created () {
event.on('koel:ready', () => {
this.albums = albumStore.all
})
event.on('filter:changed', q => {
this.q = q
})
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#albumsWrapper {
.albums {
@include artist-album-wrapper();
}
}
</style>

View file

@ -1,177 +0,0 @@
<template>
<section id="artistWrapper">
<h1 class="heading">
<span class="overview">
<img :src="image" width="64" height="64" class="cover">
{{ artist.name }}
<controls-toggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
<span class="meta" v-show="artist.songs.length">
{{ artist.albums.length | pluralize('album') }}
{{ artist.songs.length | pluralize('song') }}
{{ fmtLength }}
<template v-if="sharedState.useLastfm">
<a class="info" href @click.prevent="showInfo" title="View artist's extra information">Info</a>
</template>
<template v-if="sharedState.allowDownload">
<a class="download" href @click.prevent="download" title="Download all songs by this artist">
Download All
</a>
</template>
</span>
</span>
<song-list-controls
v-show="artist.songs.length && (!isPhone || showingControls)"
@shuffleAll="shuffleAll"
@shuffleSelected="shuffleSelected"
:config="songListControlConfig"
:selectedSongs="selectedSongs"
/>
</h1>
<song-list :items="artist.songs" type="artist" ref="songList"/>
<section class="info-wrapper" v-if="sharedState.useLastfm && info.showing">
<a href class="close" @click.prevent="info.showing = false"><i class="fa fa-times"></i></a>
<div class="inner">
<div class="loading" v-if="info.loading"><sound-bar/></div>
<artist-info :artist="artist" mode="full" v-else/>
</div>
</section>
</section>
</template>
<script>
import { pluralize, event } from '@/utils'
import { sharedStore, artistStore } from '@/stores'
import { playback, download, artistInfo as artistInfoService } from '@/services'
import router from '@/router'
import hasSongList from '@/mixins/has-song-list'
import artistAttributes from '@/mixins/artist-attributes'
import artistInfo from '@/components/main-wrapper/extra/artist-info.vue'
import soundBar from '@/components/shared/sound-bar.vue'
export default {
name: 'main-wrapper--main-content--artist',
mixins: [hasSongList, artistAttributes],
components: { artistInfo, soundBar },
filters: { pluralize },
data () {
return {
sharedState: sharedStore.state,
artist: artistStore.stub,
info: {
showing: false,
loading: true
}
}
},
watch: {
/**
* Watch the artist's album count.
* If this is changed to 0, the user has edit the songs by this artist
* and move all of them to another artist (thus delete this artist entirely).
* We should then go back to the artist list.
*/
'artist.albums.length' (newVal) {
if (!newVal) {
router.go('artists')
}
}
},
created () {
/**
* Listen to 'main-content-view:load' event to load the requested artist
* into view if applicable.
*
* @param {String} view The view's name
* @param {Object} artist
*/
event.on('main-content-view:load', (view, artist) => {
if (view === 'artist') {
this.info.showing = false
this.artist = artist
// #530
this.$nextTick(() => this.$refs.songList && this.$refs.songList.sort())
}
})
},
methods: {
/**
* Shuffle the songs by the current artist.
* Overriding the mixin.
*/
shuffleAll () {
playback.queueAndPlay(this.artist.songs, true)
},
/**
* Download all songs by the artist.
*/
download () {
download.fromArtist(this.artist)
},
async showInfo () {
this.info.showing = true
if (!this.artist.info) {
this.info.loading = true
try {
await artistInfoService.fetch(this.artist)
} catch (e) {
} finally {
this.info.loading = false
}
} else {
this.info.loading = false
}
}
}
}
</script>
<style lang="scss" scoped>
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#artistWrapper {
button.play-shuffle {
i {
margin-right: 0 !important;
}
}
.heading {
.overview {
position: relative;
padding-left: 84px;
@media only screen and (max-width : 768px) {
padding-left: 0;
}
}
.cover {
position: absolute;
left: 0;
@media only screen and (max-width : 768px) {
display: none;
}
}
}
@include artist-album-info-wrapper();
}
</style>

View file

@ -1,77 +0,0 @@
<template>
<section id="artistsWrapper">
<h1 class="heading">
<span>Artists</span>
<view-mode-switch :mode="viewMode" for="artists" @viewModeChanged="changeViewMode"/>
</h1>
<div class="artists main-scroll-wrap" :class="`as-${viewMode}`" @scroll="scrolling">
<artist-item v-for="item in displayedItems" :artist="item" :key="item.id"/>
<span class="item filler" v-for="n in 6"/>
<to-top-button/>
</div>
</section>
</template>
<script>
import { filterBy, limitBy, event } from '@/utils'
import { artistStore } from '@/stores'
import artistItem from '@/components/shared/artist-item.vue'
import viewModeSwitch from '@/components/shared/view-mode-switch.vue'
import infiniteScroll from '@/mixins/infinite-scroll'
export default {
name: 'main-wrapper--main-content--artists',
mixins: [infiniteScroll],
components: { artistItem, viewModeSwitch },
data () {
return {
perPage: 9,
numOfItems: 9,
q: '',
viewMode: null,
artists: []
}
},
computed: {
displayedItems () {
return limitBy(
filterBy(this.artists, this.q, 'name'),
this.numOfItems
)
}
},
methods: {
changeViewMode (mode) {
this.viewMode = mode
}
},
created () {
event.on('koel:ready', () => {
this.artists = artistStore.all
})
event.on({
'filter:changed': q => {
this.q = q
}
})
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#artistsWrapper {
.artists {
@include artist-album-wrapper();
}
}
</style>

View file

@ -1,82 +0,0 @@
<template>
<section id="favoritesWrapper">
<h1 class="heading">
<span>Songs You Love
<controls-toggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
<span class="meta" v-show="meta.songCount">
{{ meta.songCount | pluralize('song') }}
{{ meta.totalLength }}
<template v-if="sharedState.allowDownload && state.songs.length">
<a href @click.prevent="download" class="download" title="Download all songs in playlist">
Download All
</a>
</template>
</span>
</span>
<song-list-controls
v-show="state.songs.length && (!isPhone || showingControls)"
@shuffleAll="shuffleAll"
@shuffleSelected="shuffleSelected"
:config="songListControlConfig"
:selectedSongs="selectedSongs"
/>
</h1>
<song-list v-show="state.songs.length" :items="state.songs" type="favorites"/>
<div v-if="!state.songs.length" class="none">
Start loving!
Click the <i style="margin: 0 5px" class="fa fa-heart"></i> icon when a song is playing to add it
to this list.
</div>
</section>
</template>
<script>
import { pluralize } from '@/utils'
import { favoriteStore, sharedStore } from '@/stores'
import { download } from '@/services'
import hasSongList from '@/mixins/has-song-list'
export default {
name: 'main-wrapper--main-content--favorites',
mixins: [hasSongList],
filters: { pluralize },
data () {
return {
state: favoriteStore.state,
sharedState: sharedStore.state
}
},
methods: {
/**
* Download all favorite songs.
*/
download () {
download.fromFavorites()
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#favoritesWrapper {
.none {
color: $color2ndText;
padding: 16px 24px;
a {
color: $colorHighlight;
}
}
}
</style>

View file

@ -1,225 +0,0 @@
<template>
<section id="homeWrapper">
<h1 class="heading">
<span>{{ greeting }}</span>
</h1>
<div class="main-scroll-wrap" @scroll="scrolling" ref="wrapper">
<div class="two-cols">
<section v-show="top.songs.length">
<h1>Most Played</h1>
<ol class="top-song-list">
<li v-for="song in top.songs"
:top-play-count="top.songs.length ? top.songs[0].playCount : 0"
:song="song"
:key="song.id"
is="song-item"/>
</ol>
</section>
<section class="recent">
<h1>Recently Played</h1>
<ol class="recent-song-list" v-show="recentSongs.length">
<li v-for="song in recentSongs"
:top-play-count="top.songs.length ? top.songs[0].playCount : 0"
:song="song"
:key="song.id"
is="song-item"/>
</ol>
<p class="none" v-show="!recentSongs.length">
Your recently played songs will be displayed here.<br />
Start listening!
</p>
</section>
</div>
<section class="recently-added" v-show="showRecentlyAddedSection">
<h1>Recently Added</h1>
<div class="two-cols">
<div class="wrapper as-list">
<album-item v-for="album in recentlyAdded.albums" :album="album" :key="album.id"/>
<span class="item filler" v-for="n in 3"/>
</div>
<div>
<ul class="recently-added-song-list" v-show="recentlyAdded.songs.length">
<li v-for="song in recentlyAdded.songs" :song="song" :key="song.id" is="song-item"/>
</ul>
</div>
</div>
</section>
<section class="top-artists" v-show="top.artists.length">
<h1>Top Artists</h1>
<div class="wrapper" :class="`as-${preferences.artistsViewMode}`">
<artist-item v-for="artist in top.artists" :artist="artist" :key="artist.id"/>
<span class="item filler" v-for="n in 3"/>
</div>
</section>
<section class="top-albums" :class="`as-${preferences.albumsViewMode}`" v-show="top.albums.length">
<h1>Top Albums</h1>
<div class="wrapper">
<album-item v-for="album in top.albums" :album="album" :key="album.id"/>
<span class="item filler" v-for="n in 3"/>
</div>
</section>
<to-top-button/>
</div>
</section>
</template>
<script>
import { sample } from 'lodash'
import { event } from '@/utils'
import { songStore, albumStore, artistStore, userStore, preferenceStore } from '@/stores'
import infiniteScroll from '@/mixins/infinite-scroll'
import albumItem from '@/components/shared/album-item.vue'
import artistItem from '@/components/shared/artist-item.vue'
import songItem from '@/components/shared/home-song-item.vue'
export default {
name: 'main-wrapper--main-content--home',
components: { albumItem, artistItem, songItem },
/**
* Note: We're not really using infinite scrolling here,
* but only the handy "Back to Top" button.
*/
mixins: [infiniteScroll],
data () {
return {
greetings: [
'Oh hai!',
'Hey, %s!',
'Howdy, %s!',
'Yo!',
'Hows it going, %s?',
'Sup, %s?',
'Hows life, %s?',
'Hows your day, %s?',
'How have you been, %s?'
],
recentSongs: [],
top: {
songs: [],
albums: [],
artists: []
},
recentlyAdded: {
albums: [],
songs: []
},
preferences: preferenceStore.state
}
},
computed: {
greeting () {
return sample(this.greetings).replace('%s', userStore.current.name)
},
showRecentlyAddedSection () {
return this.recentlyAdded.albums.length || this.recentlyAdded.songs.length
}
},
methods: {
/**
* Refresh the dashboard with latest data.
*/
refreshDashboard () {
this.top.songs = songStore.getMostPlayed(7)
this.top.albums = albumStore.getMostPlayed(6)
this.top.artists = artistStore.getMostPlayed(6)
this.recentlyAdded.albums = albumStore.getRecentlyAdded(6)
this.recentlyAdded.songs = songStore.getRecentlyAdded(10)
this.recentSongs = songStore.recentlyPlayed
}
},
created () {
event.on({
'koel:ready': () => this.refreshDashboard(),
'song:played': () => this.refreshDashboard()
})
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#homeWrapper {
.two-cols {
display: flex;
> section, > div {
flex-grow: 1;
flex-basis: 0;
&:first-of-type {
margin-right: 8px;
}
}
}
.none {
color: $color2ndText;
padding: 0;
a {
color: $colorHighlight;
}
}
.recently-added {
.song-item-home .details {
background: rgba(255, 255, 255, .02);
}
.item {
margin-bottom: 8px;
}
}
.top-artists .wrapper, .top-albums .wrapper, .recently-added .wrapper {
@include artist-album-wrapper();
}
.main-scroll-wrap {
section {
margin-bottom: 48px;
}
h1 {
font-size: 1.4rem;
margin: 0 0 1.8rem;
font-weight: $fontWeight_UltraThin;
}
}
@media only screen and (max-width: 768px) {
.two-cols {
display: block;
> section, > div {
&:first-of-type {
margin-right: 0;
}
}
}
}
}
</style>

View file

@ -1,201 +0,0 @@
<template>
<section id="mainContent">
<div class="translucent" :style="{ backgroundImage: albumCover ? `url${albumCover}` : 'none' }"></div>
<home v-show="view === 'home'"/>
<queue v-show="view === 'queue'"/>
<songs v-show="view === 'songs'"/>
<albums v-show="view === 'albums'"/>
<album v-show="view === 'album'"/>
<artists v-show="view === 'artists'"/>
<artist v-show="view === 'artist'"/>
<users v-show="view === 'users'"/>
<settings v-show="view === 'settings'"/>
<playlist v-show="view === 'playlist'"/>
<favorites v-show="view === 'favorites'"/>
<profile v-show="view === 'profile'"/>
<youtube-player v-if="sharedState.useYouTube" v-show="view === 'youtubePlayer'"/>
</section>
</template>
<script>
import { event } from '@/utils'
import { albumStore, sharedStore } from '@/stores'
import albums from './albums.vue'
import album from './album.vue'
import artists from './artists.vue'
import artist from './artist.vue'
import songs from './songs.vue'
import settings from './settings.vue'
import users from './users.vue'
import queue from './queue.vue'
import home from './home.vue'
import playlist from './playlist.vue'
import favorites from './favorites.vue'
import profile from './profile.vue'
import youtubePlayer from './youtube-player.vue'
export default {
components: { albums, album, artists, artist, songs, settings,
users, home, queue, playlist, favorites, profile, youtubePlayer },
data () {
return {
view: 'home', // The default view
albumCover: null,
sharedState: sharedStore.state
}
},
created () {
event.on({
'main-content-view:load': view => {
this.view = view
},
/**
* When a new song is played, find its cover for the translucent effect.
*
* @param {Object} song
*
* @return {Boolean}
*/
'song:played': song => {
this.albumCover = song.album.cover === albumStore.stub.cover ? null : song.album.cover
}
})
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#mainContent {
flex: 1;
position: relative;
overflow: hidden;
> section {
position: absolute;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
backface-visibility: hidden;
.main-scroll-wrap {
padding: 24px 24px 48px;
overflow: auto;
flex: 1;
-ms-overflow-style: -ms-autohiding-scrollbar;
html.touchevents & {
// Enable scroll with momentum on touch devices
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
}
}
}
h1.heading {
font-weight: $fontWeight_UltraThin;
font-size: 2.76rem;
padding: 1rem 1.8rem;
border-bottom: 1px solid $color2ndBgr;
min-height: 96px;
position: relative;
align-items: center;
align-content: stretch;
display: flex;
line-height: normal;
background: rgba(0, 0, 0, .1);
span:first-child {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.meta {
display: block;
font-size: .9rem;
color: $color2ndText;
margin: 6px 0 0 2px;
a {
color: #fff;
&:hover {
color: $colorHighlight;
}
}
}
.buttons {
text-align: right;
z-index: 2;
@include button-group();
}
}
.translucent {
position: absolute;
top: -20px;
left: -20px;
right: -20px;
bottom: -20px;
filter: blur(20px);
opacity: .07;
z-index: 2;
overflow: hidden;
background-size: cover;
background-position: center;
transform: translateZ(0);
backface-visibility: hidden;
perspective: 1000;
pointer-events: none;
}
@media only screen and (max-width: 768px) {
h1.heading {
font-size: 1.38rem;
min-height: 0;
line-height: 1.85rem;
text-align: center;
flex-direction: column;
.meta {
display: none;
}
.buttons {
justify-content: center;
margin-top: 8px;
}
span:first-child {
flex: 0 0 28px;
}
}
> section {
.main-scroll-wrap {
padding: 12px;
}
}
}
@media only screen and (max-width: 375px) {
> section {
// Leave some space for the "Back to top" button
.main-scroll-wrap {
padding-bottom: 64px;
}
}
}
}
</style>

View file

@ -1,166 +0,0 @@
<template>
<section id="playlistWrapper">
<template v-if="playlist.populated">
<h1 class="heading">
<span>{{ playlist.name }}
<controls-toggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
<span class="meta" v-show="meta.songCount">
{{ meta.songCount | pluralize('song') }}
{{ meta.totalLength }}
<template v-if="sharedState.allowDownload && playlist.songs.length">
<a href @click.prevent="download" title="Download all songs in playlist">
Download All
</a>
</template>
</span>
</span>
<song-list-controls
v-show="!isPhone || showingControls"
@shuffleAll="shuffleAll"
@shuffleSelected="shuffleSelected"
@deletePlaylist="confirmDelete"
:config="songListControlConfig"
:selectedSongs="selectedSongs"
/>
</h1>
<song-list v-show="playlist.songs.length"
:items="playlist.songs"
:playlist="playlist"
type="playlist"
ref="songList"
/>
<div v-show="!playlist.songs.length" class="none">
The playlist is currently empty. You can fill it up by dragging songs into its name in the sidebar,
or use the &quot;Add To&quot; button.
</div>
</template>
</section>
</template>
<script>
import { pluralize, event, alerts } from '@/utils'
import { playlistStore, sharedStore } from '@/stores'
import { playback, download } from '@/services'
import router from '@/router'
import hasSongList from '@/mixins/has-song-list'
export default {
name: 'main-wrapper--main-content--playlist',
mixins: [hasSongList],
filters: { pluralize },
data () {
return {
playlist: playlistStore.stub,
sharedState: sharedStore.state,
songListControlConfig: {
deletePlaylist: true
}
}
},
created () {
/**
* Listen to 'main-content-view:load' event to load the requested
* playlist into view if applicable.
*
* @param {String} view The view's name.
* @param {Object} playlist
*/
event.on('main-content-view:load', (view, playlist) => {
if (view !== 'playlist') {
return
}
if (typeof this.playlist.populated === 'undefined') {
this.populate(playlist)
} else {
this.playlist = playlist
}
})
},
methods: {
/**
* Shuffle the songs in the current playlist.
* Overriding the mixin.
*/
shuffleAll () {
playback.queueAndPlay(this.playlist.songs, true)
},
/**
* Confirm deleting the playlist.
*/
confirmDelete () {
// If the playlist is empty, just go ahead and delete it.
if (!this.playlist.songs.length) {
this.del()
return
}
alerts.confirm('Are you sure? This is a one-way street!', this.del)
},
/**
* Delete the current playlist.
*/
async del () {
await playlistStore.delete(this.playlist)
// Reset the current playlist to our stub, so that we don't encounter
// any property reference error.
this.playlist = playlistStore.stub
// Switch back to Home screen
router.go('home')
},
/**
* Download all songs in the current playlist.
*/
download () {
return download.fromPlaylist(this.playlist)
},
/**
* Fetch a playlist's content from the server, populate it, and use it afterwards.
*
* @param {Object} playlist
*/
async populate (playlist) {
await playlistStore.fetchSongs(playlist)
playlist.populated = true
this.playlist = playlist
this.$nextTick(() => this.$refs.songList && this.$refs.songList.sort())
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#playlistWrapper {
button.play-shuffle, button.del {
i {
margin-right: 0 !important;
}
}
.none {
color: $color2ndText;
padding: 16px 24px;
a {
color: $colorHighlight;
}
}
}
</style>

View file

@ -1,266 +0,0 @@
<template>
<section id="profileWrapper">
<h1 class="heading">
<span>Profile &amp; Preferences</span>
</h1>
<div class="main-scroll-wrap">
<form @submit.prevent="update">
<div class="form-row">
<label for="inputProfileName">Name</label>
<input type="text" name="name" id="inputProfileName" v-model="state.current.name">
</div>
<div class="form-row">
<label for="inputProfileEmail">Email Address</label>
<input type="email" name="email" id="inputProfileEmail" v-model="state.current.email">
</div>
<div class="change-pwd">
<div class="form-row">
<p class="help">If you want to change your password, enter it below. <br>
Otherwise, just leave the next two fields empty. Its OK no one will judge you.</p>
</div>
<div class="form-row">
<label for="inputProfilePassword">New Password</label>
<input :class="{ error: validation.error }"
v-model="pwd"
name="password"
type="password"
id="inputProfilePassword"
autocomplete="off">
</div>
<div class="form-row">
<label for="inputProfileConfirmPassword">Confirm Password</label>
<input :class="{ error: validation.error }"
v-model="confirmPwd"
name="confirmPassword"
type="password"
id="inputProfileConfirmPassword"
autocomplete="off">
</div>
</div>
<div class="form-row">
<button type="submit" class="btn btn-submit">Save</button>
</div>
</form>
<div class="preferences">
<div class="form-row">
<label>
<input type="checkbox" name="notify" v-model="prefs.notify">
Show Now Playing song notification
</label>
</div>
<div class="form-row">
<label>
<input type="checkbox" name="confirmClosing" v-model="prefs.confirmClosing">
Confirm before closing Koel
</label>
</div>
<div class="form-row">
<label>
<input type="checkbox" name="transcodeOnMobile" v-model="prefs.transcodeOnMobile">
Convert and play media at 128kbps on mobile
</label>
</div>
</div>
<section class="lastfm" >
<h1>Last.fm Integration</h1>
<div v-if="sharedState.useLastfm">
<p>This installation of Koel integrates with Last.fm.
<span v-if="state.current.preferences.lastfm_session_key">
It appears that you have connected your Last.fm account as well Perfect!
</span>
<span v-else>
It appears that you havent connected to your Last.fm account though.
</span>
</p>
<p>
Connecting Koel and your Last.fm account enables exciting features scrobbling is one of them.
</p>
<p v-if="state.current.preferences.lastfm_session_key">
For the sake of democracy, you have the option to disconnect from Last.fm too.
Doing so will reload Koel, though.
</p>
<div class="buttons">
<button @click.prevent="connectToLastfm" class="connect">
<i class="fa fa-lastfm"></i>
{{ state.current.preferences.lastfm_session_key ? 'Reconnect' : 'Connect' }}
</button>
<button
v-if="state.current.preferences.lastfm_session_key"
@click.prevent="disconnectFromLastfm"
class="disconnect"
>
Disconnect
</button>
</div>
</div>
<div v-else>
<p>This installation of Koel has no Last.fm integration.
<span v-if="state.current.is_admin">Visit
<a href="https://koel.phanan.net/docs/#/3rd-party?id=last-fm" target="_blank">Koels Wiki</a>
for a quick how-to.
</span>
<span v-else>Try politely asking your administrator to enable it.</span>
</p>
</div>
</section>
</div>
</section>
</template>
<script>
import { userStore, preferenceStore, sharedStore } from '@/stores'
import { forceReloadWindow } from '@/utils'
import { http, ls } from '@/services'
export default {
name: 'main-wrapper--main-content--profile',
data () {
return {
state: userStore.state,
cache: userStore.stub,
pwd: '',
confirmPwd: '',
prefs: preferenceStore.state,
sharedState: sharedStore.state,
validation: {
error: false
}
}
},
watch: {
prefs: {
handler: () => preferenceStore.save(),
deep: true
}
},
methods: {
/**
* Update the current user's profile.
*/
async update () {
this.validation.error = (this.pwd || this.confirmPwd) && this.pwd !== this.confirmPwd
if (this.validation.error) {
return
}
await userStore.updateProfile(this.pwd)
this.pwd = ''
this.confirmPwd = ''
},
/**
* Connect the current user to Last.fm.
* This method opens a new window.
* Koel will reload once the connection is successful.
*/
connectToLastfm () {
window.open(
`${window.BASE_URL}api/lastfm/connect?jwt-token=${ls.get('jwt-token')}`,
'_blank',
'toolbar=no,titlebar=no,location=no,width=1024,height=640'
)
},
/**
* Disconnect the current user from Last.fm.
* Oh God why.
*/
disconnectFromLastfm () {
// Should we use userStore?
// - We shouldn't. This doesn't have anything to do with stores.
// Should we confirm the user?
// - Nope. Users should be grown-ass adults who take responsibilty of their actions.
// But one of my users is my new born kid!
// - Then? Kids will fuck things up anyway.
http.delete('lastfm/disconnect', {}, forceReloadWindow)
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#profileWrapper {
input {
&[type="text"], &[type="email"], &[type="password"] {
width: 192px;
}
&.error {
// Chrome won't give up its autofill style, so this is kind of a hack.
box-shadow: 0 0 0px 1000px #ff867a inset;
}
}
.change-pwd {
margin-top: 24px;
}
.status {
margin-left: 8px;
color: $colorGreen;
}
.preferences {
margin-top: 32px;
border-top: 1px solid $color2ndBgr;
label {
font-size: $fontSize;
}
}
.lastfm {
border-top: 1px solid $color2ndBgr;
color: $color2ndText;
margin-top: 16px;
padding-top: 16px;
a {
color: $colorHighlight;
}
h1 {
font-size: 24px;
margin-bottom: 16px;
}
.buttons {
margin-top: 16px;
.connect {
background: #d31f27; // Last.fm color yo!
}
.disconnect {
background: $colorGrey; // Our color yo!
}
}
}
@media only screen and (max-width : 667px) {
input {
&[type="text"], &[type="email"], &[type="password"] {
width: 100%;
height: 32px;
}
}
}
}
</style>

View file

@ -1,104 +0,0 @@
<template>
<section id="queueWrapper">
<h1 class="heading">
<span title="That's a freaking lot of U's and E's">Current Queue
<controls-toggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
<span class="meta" v-show="meta.songCount">
{{ meta.songCount | pluralize('song') }}
{{ meta.totalLength }}
</span>
</span>
<song-list-controls
v-show="state.songs.length && (!isPhone || showingControls)"
@shuffleAll="shuffleAll"
@shuffleSelected="shuffleSelected"
@clearQueue="clearQueue"
:config="songListControlConfig"
:selectedSongs="selectedSongs"
/>
</h1>
<song-list v-show="state.songs.length" :items="state.songs" :sortable="false" type="queue"/>
<div v-show="!state.songs.length" class="none">
<p>Empty spaces. Abandoned places.</p>
<p v-if="showShufflingAllOption">How about
<a class="start" @click.prevent="shuffleAll">shuffling all songs</a>?
</p>
</div>
</section>
</template>
<script>
import { pluralize } from '@/utils'
import { queueStore, songStore } from '@/stores'
import { playback } from '@/services'
import hasSongList from '@/mixins/has-song-list'
export default {
name: 'main-wrapper--main-content--queue',
mixins: [hasSongList],
filters: { pluralize },
data () {
return {
state: queueStore.state,
songListControlConfig: {
clearQueue: true
}
}
},
computed: {
/**
* Determine if we should display a "Shuffle All" link.
*/
showShufflingAllOption () {
return songStore.all.length
}
},
methods: {
/**
* Shuffle all songs we have.
* Overriding the mixin.
*/
shuffleAll () {
playback.queueAndPlay(this.state.songs.length ? this.state.songs : songStore.all, true)
},
/**
* Clear the queue.
*/
clearQueue () {
queueStore.clear()
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#queueWrapper {
.none {
color: $color2ndText;
padding: 16px 24px;
a {
color: $colorHighlight;
}
}
button.play-shuffle {
i {
margin-right: 0 !important;
}
}
}
</style>

View file

@ -1,106 +0,0 @@
<template>
<section id="settingsWrapper">
<h1 class="heading">
<span>Settings</span>
</h1>
<form @submit.prevent="confirmThenSave" class="main-scroll-wrap">
<div class="form-row">
<label for="inputSettingsPath">Media Path</label>
<p class="help">
The <em>absolute</em> path to the server directory containing your media.
Koel will scan this directory for songs and extract any available information.<br>
Scanning may take a while, especially if you have a lot of songs, so be patient.
</p>
<input type="text" v-model="state.settings.media_path" id="inputSettingsPath">
</div>
<div class="form-row">
<button type="submit">Scan</button>
</div>
</form>
</section>
</template>
<script>
import { settingStore, sharedStore } from '@/stores'
import { parseValidationError, forceReloadWindow, showOverlay, hideOverlay, alerts } from '@/utils'
import router from '@/router'
export default {
name: 'main-wrapper--main-content--settings',
data () {
return {
state: settingStore.state,
sharedState: sharedStore.state
}
},
computed: {
/**
* Determine if we should warn the user upon saving.
*
* @return {boolean}
*/
shouldWarn () {
// Warn the user if the media path is not empty and about to change.
return this.sharedState.originalMediaPath &&
this.sharedState.originalMediaPath !== this.state.settings.media_path.trim()
}
},
methods: {
confirmThenSave () {
if (this.shouldWarn) {
alerts.confirm('Warning: Changing the media path will essentially remove all existing data songs, artists, \
albums, favorites, everything and empty your playlists! Sure you want to proceed?', this.save)
} else {
this.save()
}
},
/**
* Save the settings.
*/
async save () {
showOverlay()
try {
await settingStore.update()
// Make sure we're back to home first.
router.go('home')
forceReloadWindow()
} catch (err) {
let msg = 'Unknown error.'
if (err.response.status === 422) {
msg = parseValidationError(err.response.data)[0]
}
hideOverlay()
alerts.error(msg)
}
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#settingsWrapper {
input[type="text"] {
width: 384px;
margin-top: 12px;
}
@media only screen and (max-width : 667px) {
input[type="text"] {
width: 100%;
}
}
}
</style>

View file

@ -1,49 +0,0 @@
<template>
<section id="songsWrapper">
<h1 class="heading">
<span>All Songs
<controls-toggler :showing-controls="showingControls" @toggleControls="toggleControls"/>
<span class="meta" v-show="meta.songCount">
{{ meta.songCount | pluralize('song') }}
{{ meta.totalLength }}
</span>
</span>
<song-list-controls
v-show="state.songs.length && (!isPhone || showingControls)"
@shuffleAll="shuffleAll"
@shuffleSelected="shuffleSelected"
:config="songListControlConfig"
:selectedSongs="selectedSongs"
/>
</h1>
<song-list :items="state.songs" type="allSongs"/>
</section>
</template>
<script>
import { pluralize } from '@/utils'
import { songStore } from '@/stores'
import hasSongList from '@/mixins/has-song-list'
export default {
name: 'main-wrapper--main-content--songs',
mixins: [hasSongList],
filters: { pluralize },
data () {
return {
state: songStore.state
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
</style>

View file

@ -1,145 +0,0 @@
<template>
<section id="usersWrapper">
<h1 class="heading">
<span>Users
<i class="fa fa-angle-down toggler" v-show="isPhone && !showingControls" @click="showingControls = true"/>
<i class="fa fa-angle-up toggler" v-show="isPhone && showingControls" @click.prevent="showingControls = false"/>
</span>
<div class="buttons" v-show="!isPhone || showingControls">
<button class="btn btn-green btn-add" @click="addUser">
<i class="fa fa-plus"></i>
Add</button>
</div>
</h1>
<div class="main-scroll-wrap">
<div class="users">
<user-item v-for="user in state.users" :user="user" @editUser="editUser" :key="user.id"/>
<article class="user-item" v-for="n in 6"/>
</div>
</div>
<edit-user-form ref="editUserForm"/>
<add-user-form ref="addUserForm"/>
</section>
</template>
<script>
import isMobile from 'ismobilejs'
import { userStore } from '@/stores'
import userItem from '@/components/shared/user-item.vue'
import editUserForm from '@/components/modals/edit-user-form.vue'
import addUserForm from '@/components/modals/add-user-form.vue'
export default {
name: 'main-wrapper--main-content--users',
components: { userItem, editUserForm, addUserForm },
data () {
return {
state: userStore.state,
isPhone: isMobile.phone,
showingControls: false
}
},
methods: {
/**
* Open the "Add User" form.
*/
addUser () {
this.$refs.addUserForm.open()
},
/**
* Open the "Edit User" form.
*
* @param {Object} user
*/
editUser (user) {
this.$refs.editUserForm.open(user)
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#usersWrapper {
.users {
justify-content: space-between;
flex-wrap: wrap;
display: flex;
}
button {
margin-right: 3px;
}
@media only screen and (max-width: 768px) {
.users {
flex-direction: column;
.buttons {
margin-top: 12px;
display: block;
}
}
}
}
.user-item {
width: 32%;
margin-bottom: 16px;
.info {
display: flex;
img {
flex: 0 0 128px;
}
.right {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
background-color: rgba(255, 255, 255, .02);
}
h1 {
font-size: 1.4rem;
margin-bottom: 5px;
.you {
color: $colorHighlight;
margin-left: 5px;
}
}
.buttons {
display: none;
margin-top: 16px;
}
&:hover, html.touchevents & {
.buttons {
display: block;
}
}
}
html.with-extra-panel & {
width: 49%;
}
@media only screen and (max-width: 1024px) {
width: 100%;
}
}
</style>

View file

@ -1,74 +0,0 @@
<template>
<section id="youtubeWrapper">
<h1 class="heading"><span>{{ title }}</span></h1>
<div id="player">
<p class="none">Your YouTube video will be played here.<br/>
You can start a video playback from the right sidebar. When a song is playing, that is.<br>
It might also be worth noting that videos volume, progress and such are controlled from within
the video itself, and not via Koels controls.</p>
</div>
</section>
</template>
<script>
import { event } from '@/utils'
import { playback } from '@/services'
import YouTubePlayer from 'youtube-player'
let player
export default {
name: 'main-wrapper--main-content--youtube-player',
data () {
return {
title: 'YouTube Video'
}
},
methods: {
/**
* Initialize the YouTube player. This should only be called once.
*/
initPlayer () {
if (!player) {
player = YouTubePlayer('player', {
width: '100%',
height: '100%'
})
player.on('stateChange', event => {
// Pause song playback when video is played
event.data === 1 && playback.pause()
})
}
}
},
created () {
event.on({
'youtube:play': ({ id, title }) => {
this.title = title
this.initPlayer()
player.loadVideoById(id)
player.playVideo()
},
/**
* Stop video playback when a song is played/resumed.
*/
'song:played': () => player && player.pauseVideo()
})
}
}
</script>
<style lang="scss" scoped>
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
.none {
color: $color2ndText;
padding: 16px 24px;
}
</style>

View file

@ -1,294 +0,0 @@
<template>
<nav class="side side-nav" id="sidebar" :class="{ showing: showing }">
<section class="music">
<h1>Your Music</h1>
<ul class="menu">
<li>
<a :class="['home', currentView == 'home' ? 'active' : '']" href="#!/home">Home</a>
</li>
<li>
<a :class="['queue', currentView == 'queue' ? 'active' : '']"
href="#!/queue"
@dragleave="removeDroppableState"
@dragenter.prevent="allowDrop"
@dragover.prevent
@drop.stop.prevent="handleDrop">Current Queue</a>
</li>
<li>
<a :class="['songs', currentView == 'songs' ? 'active' : '']" href="#!/songs">All Songs</a>
</li>
<li>
<a :class="['albums', currentView == 'albums' ? 'active' : '']" href="#!/albums">Albums</a>
</li>
<li>
<a :class="['artists', currentView == 'artists' ? 'active' : '']" href="#!/artists">Artists</a>
</li>
<li v-if="sharedState.useYouTube">
<a :class="['youtube', currentView == 'youtubePlayer' ? 'active' : '']" href="#!/youtube">YouTube Video</a>
</li>
</ul>
</section>
<playlists :current-view="currentView"/>
<section v-if="userState.current.is_admin" class="manage">
<h1>Manage</h1>
<ul class="menu">
<li>
<a :class="['settings', currentView == 'settings' ? 'active' : '']" href="#!/settings">Settings</a>
</li>
<li>
<a :class="['users', currentView == 'users' ? 'active' : '']" href="#!/users">Users</a>
</li>
</ul>
</section>
<a
:href="latestVersionUrl"
target="_blank"
v-if="displayNewVersion"
class="new-ver">
Koel version {{ sharedState.latestVersion }} is available!
</a>
</nav>
</template>
<script>
import isMobile from 'ismobilejs'
import { event, $ } from '@/utils'
import { sharedStore, userStore, songStore, queueStore } from '@/stores'
import playlists from './playlists.vue'
export default {
components: { playlists },
data () {
return {
currentView: 'home',
userState: userStore.state,
showing: !isMobile.phone,
sharedState: sharedStore.state
}
},
computed: {
latestVersionUrl () {
return `https://github.com/phanan/koel/releases/tag/${this.sharedState.latestVersion}`
},
displayNewVersion () {
return this.userState.current.is_admin &&
this.sharedState.currentVersion < this.sharedState.latestVersion
}
},
methods: {
/**
* Remove the droppable state when a dragleave event occurs on the playlist's DOM element.
*
* @param {Object} e The dragleave event.
*/
removeDroppableState (e) {
$.removeClass(e.target, 'droppable')
},
/**
* Add a "droppable" class and set the drop effect when an item is dragged over "Queue" menu.
*
* @param {Object} e The dragover event.
*/
allowDrop (e) {
$.addClass(e.target, 'droppable')
e.dataTransfer.dropEffect = 'move'
return false
},
/**
* Handle songs dropped to our Queue menu item.
*
* @param {Object} e The event
*
* @return {Boolean}
*/
handleDrop (e) {
this.removeDroppableState(e)
if (!e.dataTransfer.getData('application/x-koel.text+plain')) {
return false
}
const songs = songStore.byIds(e.dataTransfer.getData('application/x-koel.text+plain').split(','))
if (!songs.length) {
return false
}
queueStore.queue(songs)
return false
}
},
created () {
event.on('main-content-view:load', view => {
this.currentView = view
// Hide the sidebar if on mobile
if (isMobile.phone) {
this.showing = false
}
})
/**
* Listen to sidebar:toggle event to show or hide the sidebar.
* This should only be triggered on a mobile device.
*/
event.on('sidebar:toggle', () => {
this.showing = !this.showing
})
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#sidebar {
flex: 0 0 256px;
background-color: $colorSidebarBgr;
padding: 22px 0 0;
max-height: calc(100vh - #{$headerHeight + $footerHeight});
overflow: auto;
overflow-x: hidden;
-ms-overflow-style: -ms-autohiding-scrollbar;
html.touchevents & {
// Enable scroll with momentum on touch devices
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
}
a.droppable {
transform: scale(1.2);
transition: .3s;
transform-origin: center left;
color: $colorMainText;
background-color: rgba(0, 0, 0, .3);
}
section {
margin-bottom: 32px;
h1 {
text-transform: uppercase;
letter-spacing: 1px;
padding: 0 16px;
margin-bottom: 12px;
i {
float: right;
}
}
a {
display: block;
height: 36px;
line-height: 36px;
padding: 0 12px 0 16px;
border-left: 4px solid transparent;
&.active, &:hover {
border-left-color: $colorHighlight;
color: $colorLinkHovered;
background: rgba(255, 255, 255, .05);
box-shadow: 0 1px 0 rgba(0, 0, 0, .1);
}
&:active {
opacity: .5;
}
&:hover {
border-left-color: darken($colorHighlight, 20%);
}
&::before {
width: 24px;
display: inline-block;
font-family: FontAwesome;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
&.home::before {
content: "\f015";
}
&.queue::before {
content: "\f0cb";
}
&.songs::before {
content: "\f001";
}
&.albums::before {
content: "\f152";
}
&.artists::before {
content: "\f130";
}
&.youtube::before {
content: "\f16a";
}
&.settings::before {
content: "\f013";
}
&.users::before {
content: "\f0c0";
}
}
}
.new-ver {
margin: 16px;
padding: 16px;
border: 1px solid $color2ndText;
color: $colorMainText;
opacity: .3;
font-size: .9rem;
display: block;
transition: .3s;
&:hover {
opacity: .7;
}
}
@media only screen and (max-width : 667px) {
position: fixed;
height: calc(100vh - #{$headerHeight + $footerHeight});
padding-bottom: $footerHeight; // make sure the footer can never overlap the content
width: 100%;
z-index: 99;
top: $headerHeight;
left: -100%;
transition: left .3s ease-in;
&.showing {
left: 0;
}
}
}
</style>

View file

@ -1,206 +0,0 @@
<template>
<li @dblclick.prevent="edit" :class="['playlist', type, editing ? 'editing' : '']">
<a :href="playlistUrl"
@dragleave="removeDroppableState"
@dragenter.prevent="allowDrop"
@dragover.prevent
@drop.stop.prevent="handleDrop"
:class="{ active: active }"
>{{ playlist.name }}</a>
<input type="text"
@keyup.esc="cancelEdit"
@keyup.enter="update"
@blur="update"
v-model="playlist.name"
v-if="editing"
v-koel-focus
required
>
</li>
</template>
<script>
import { event, $ } from '@/utils'
import { songStore, playlistStore, favoriteStore } from '@/stores'
export default {
name: 'sidebar--playlist-item',
props: {
playlist: {
type: Object,
required: true
},
type: {
type: String,
default: 'playlist',
validator: value => ['playlist', 'favorites'].includes(value)
}
},
data () {
return {
editing: false,
active: false
}
},
computed: {
/**
* Determine if the current menu item is the "Favorites" one.
*
* @return {Boolean}
*/
isFavorites () {
return this.type === 'favorites'
},
playlistUrl () {
return this.isFavorites ? '#!/favorites' : `#!/playlist/${this.playlist.id}`
}
},
methods: {
/**
* Show the form to edit the playlist.
*/
edit () {
if (this.isFavorites) {
return
}
this.beforeEditCache = this.playlist.name
this.editing = true
},
/**
* Update the playlist's name.
*/
update () {
if (this.isFavorites || !this.editing) {
return
}
this.editing = false
this.playlist.name = this.playlist.name.trim()
if (!this.playlist.name) {
this.playlist.name = this.beforeEditCache
return
}
playlistStore.update(this.playlist)
},
/**
* Cancel editing.
*/
cancelEdit () {
this.editing = false
this.playlist.name = this.beforeEditCache
},
/**
* Remove the droppable state when a dragleave event occurs on the playlist's DOM element.
*
* @param {Object} e The dragleave event.
*/
removeDroppableState (e) {
$.removeClass(e.target, 'droppable')
},
/**
* Add a "droppable" class and set the drop effect when an item is dragged over the playlist's
* DOM element.
*
* @param {Object} e The dragover event.
*/
allowDrop (e) {
$.addClass(e.target, 'droppable')
e.dataTransfer.dropEffect = 'move'
return false
},
/**
* Handle songs dropped to our favorite or playlist menu item.
*
* @param {Object} e The event
*
* @return {Boolean}
*/
handleDrop (e) {
this.removeDroppableState(e)
if (!e.dataTransfer.getData('application/x-koel.text+plain')) {
return false
}
const songs = songStore.byIds(e.dataTransfer.getData('application/x-koel.text+plain').split(','))
if (!songs.length) {
return false
}
if (this.type === 'favorites') {
favoriteStore.like(songs)
} else {
playlistStore.addSongs(this.playlist, songs)
}
return false
}
},
created () {
event.on('main-content-view:load', (view, playlist) => {
if (view === 'favorites') {
this.active = this.isFavorites
} else if (view === 'playlist') {
this.active = this.playlist === playlist
} else {
this.active = false
}
})
}
}
</script>
<style lang="scss" scoped>
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
.playlist {
user-select: none;
a {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
span {
pointer-events: none;
}
&::before {
content: "\f0f6";
}
}
&.favorites a::before {
content: "\f004";
color: $colorHeart;
}
input {
width: calc(100% - 32px);
margin: 5px 16px;
}
&.editing {
a {
display: none !important;
}
}
}
</style>

View file

@ -1,86 +0,0 @@
<template>
<section id="playlists">
<h1>Playlists
<i class="fa fa-plus-circle control create" :class="{ creating: creating }" @click="creating = !creating"/>
</h1>
<form v-if="creating" @submit.prevent="store" class="create">
<input type="text"
@keyup.esc.prevent="creating = false"
v-model="newName"
v-koel-focus
placeholder="↵ to save"
required
>
</form>
<ul class="menu">
<playlist-item type="favorites" :playlist="{ name: 'Favorites', songs: favoriteState.songs }"/>
<playlist-item
v-for="playlist in playlistState.playlists"
type="playlist"
:playlist="playlist"
:key="playlist.id"/>
</ul>
</section>
</template>
<script>
import { playlistStore, favoriteStore } from '../../../stores'
import router from '../../../router'
import playlistItem from './playlist-item.vue'
export default {
name: 'sidebar--playlists',
components: { playlistItem },
data () {
return {
playlistState: playlistStore.state,
favoriteState: favoriteStore.state,
creating: false,
newName: ''
}
},
methods: {
/**
* Store/create a new playlist.
*/
async store () {
this.creating = false
const playlist = await playlistStore.store(this.newName)
this.newName = ''
// Activate the new playlist right away
this.$nextTick(() => router.go(`playlist/${playlist.id}`))
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#playlists {
.control.create {
margin-top: 2px;
font-size: 16px;
transition: .3s;
&.creating {
transform: rotate(135deg);
}
}
form.create {
padding: 8px 16px;
input[type="text"] {
width: 100%;
}
}
}
</style>

View file

@ -1,65 +0,0 @@
<template>
<div class="overlay" v-if="newUser">
<sound-bar v-if="loading"/>
<form class="user-add" @submit.prevent="submit" v-else>
<header>
<h1>Add New User</h1>
</header>
<div>
<div class="form-row">
<label>Name</label>
<input type="text" name="name" v-model="newUser.name" required v-koel-focus>
</div>
<div class="form-row">
<label>Email</label>
<input type="email" name="email" v-model="newUser.email" required>
</div>
<div class="form-row">
<label>Password</label>
<input type="password" name="password" v-model="newUser.password">
</div>
</div>
<footer>
<button class="btn btn-green btn-add">Save</button>
<button class="btn btn-white btn-cancel" @click.prevent="cancel">Cancel</button>
</footer>
</form>
</div>
</template>
<script>
import { clone } from 'lodash'
import soundBar from '@/components/shared/sound-bar.vue'
import { userStore } from '@/stores'
export default {
name: 'modals--add-user-form',
components: { soundBar },
data () {
return {
loading: false,
newUser: null
}
},
methods: {
open () {
this.newUser = clone(userStore.stub)
},
async submit () {
this.loading = true
await userStore.store(this.newUser.name, this.newUser.email, this.newUser.password)
this.loading = false
this.newUser = null
},
cancel () {
this.newUser = null
}
}
}
</script>

View file

@ -1,350 +0,0 @@
<template>
<div id="editSongsOverlay" v-if="shown" class="overlay">
<sound-bar v-if="loading"></sound-bar>
<form v-else @submit.prevent="submit">
<header>
<img :src="coverUrl" width="96" height="96">
<hgroup class="meta">
<h1 :class="{ mixed: !editSingle }">{{ displayedTitle }}</h1>
<h2 :class="{ mixed: !bySameArtist && !formData.artistName }">
{{ bySameArtist || formData.artistName ? formData.artistName : 'Mixed Artists' }}
</h2>
<h2 :class="{ mixed: !inSameAlbum && !formData.albumName }">
{{ inSameAlbum || formData.albumName ? formData.albumName : 'Mixed Albums' }}
</h2>
</hgroup>
</header>
<div>
<div class="tabs tabs-white">
<div class="header clear">
<a @click.prevent="currentView = 'details'"
class="tab-details"
:class="{ active: currentView === 'details' }">Details</a>
<a @click.prevent="currentView = 'lyrics'"
v-if="editSingle"
class="tab-lyrics"
:class="{ active: currentView === 'lyrics' }">Lyrics</a>
</div>
<div class="panes">
<div v-show="currentView === 'details'">
<div class="form-row" v-if="editSingle">
<label>Title</label>
<input name="title" type="text" v-model="formData.title">
</div>
<div class="form-row">
<label>Artist</label>
<typeahead
:items="artistState.artists"
:options="artistTypeaheadOptions"
v-model="formData.artistName"/>
</div>
<div class="form-row">
<label>Album</label>
<typeahead
:items="albumState.albums"
:options="albumTypeaheadOptions"
v-model="formData.albumName"/>
</div>
<div class="form-row">
<label class="small">
<input type="checkbox" @change="changeCompilationState" ref="compilationStateChk" />
Album is a compilation of songs by various artists
</label>
</div>
<div class="form-row" v-show="editSingle">
<label>Track</label>
<input name="track" type="text" pattern="\d*" v-model="formData.track"
title="Empty or a number">
</div>
</div>
<div v-if="editSingle" v-show="currentView === 'lyrics'">
<div class="form-row">
<textarea name="lyrics" v-model="formData.lyrics"/>
</div>
</div>
</div>
</div>
</div>
<footer>
<input type="submit" value="Update">
<a @click.prevent="close" class="btn btn-white">Cancel</a>
</footer>
</form>
</div>
</template>
<script>
import { every, filter, union } from 'lodash'
import { br2nl } from '@/utils'
import { songInfo } from '@/services/info'
import { artistStore, albumStore, songStore } from '@/stores'
import config from '@/config'
import soundBar from '@/components/shared/sound-bar.vue'
import typeahead from '@/components/shared/typeahead.vue'
const COMPILATION_STATES = {
NONE: 0, // No songs belong to a compilation album
ALL: 1, // All songs belong to compilation album(s)
SOME: 2 // Some of the songs belong to compilation album(s)
}
export default {
components: { soundBar, typeahead },
data () {
return {
shown: false,
songs: [],
currentView: '',
loading: true,
artistState: artistStore.state,
artistTypeaheadOptions: {
displayKey: 'name',
filterKey: 'name',
name: 'artist'
},
albumState: albumStore.state,
albumTypeaheadOptions: {
displayKey: 'name',
filterKey: 'name',
name: 'album'
},
/**
* In order not to mess up the original songs, we manually assign and manipulate
* their attributes.
*
* @type {Object}
*/
formData: {
title: '',
albumName: '',
artistName: '',
lyrics: '',
track: '',
compilationState: null
}
}
},
computed: {
/**
* Determine if we're editing but one song.
*
* @return {boolean}
*/
editSingle () {
return this.songs.length === 1
},
/**
* Determine if all songs we're editing are by the same artist.
*
* @return {boolean}
*/
bySameArtist () {
return every(this.songs, song => song.artist.id === this.songs[0].artist.id)
},
/**
* Determine if all songs we're editing are from the same album.
*
* @return {boolean}
*/
inSameAlbum () {
return every(this.songs, song => song.album.id === this.songs[0].album.id)
},
/**
* URL of the cover to display.
*
* @return {string}
*/
coverUrl () {
return this.inSameAlbum ? this.songs[0].album.cover : config.unknownCover
},
/**
* Determine the compilation state of the songs.
*
* @return {Number}
*/
compilationState () {
const albums = this.songs.reduce((acc, song) => {
return union(acc, [song.album])
}, [])
const compiledAlbums = filter(albums, album => album.is_compilation)
if (!compiledAlbums.length) {
this.formData.compilationState = COMPILATION_STATES.NONE
} else if (compiledAlbums.length === albums.length) {
this.formData.compilationState = COMPILATION_STATES.ALL
} else {
this.formData.compilationState = COMPILATION_STATES.SOME
}
return this.formData.compilationState
},
/**
* The song title to be displayed.
*
* @return {string}
*/
displayedTitle () {
return this.editSingle ? this.formData.title : `${this.songs.length} songs selected`
},
/**
* The album name to be displayed.
*
* @return {string}
*/
displayedAlbum () {
if (this.editSingle) {
return this.formData.albumName
} else {
return this.formData.albumName ? this.formData.albumName : 'Mixed Albums'
}
},
/**
* The artist name to be displayed.
*
* @return {string}
*/
displayedArtist () {
if (this.editSingle) {
return this.formData.artistName
} else {
return this.formData.artistName ? this.formData.artistName : 'Mixed Artists'
}
}
},
methods: {
async open (songs) {
this.shown = true
this.songs = [].concat(songs)
this.currentView = 'details'
if (this.editSingle) {
this.formData.title = this.songs[0].title
this.formData.albumName = this.songs[0].album.name
this.formData.artistName = this.songs[0].artist.name
// If we're editing only one song and the song's info (including lyrics)
// hasn't been loaded, load it now.
if (!this.songs[0].infoRetrieved) {
this.loading = true
await songInfo.fetch(this.songs[0])
this.loading = false
this.formData.lyrics = br2nl(this.songs[0].lyrics)
this.formData.track = this.songs[0].track || ''
this.initCompilationStateCheckbox()
} else {
this.loading = false
this.formData.lyrics = br2nl(this.songs[0].lyrics)
this.formData.track = this.songs[0].track || ''
this.initCompilationStateCheckbox()
}
} else {
this.formData.albumName = this.inSameAlbum ? this.songs[0].album.name : ''
this.formData.artistName = this.bySameArtist ? this.songs[0].artist.name : ''
this.loading = false
this.initCompilationStateCheckbox()
}
},
/**
* Initialize the compilation state's checkbox of the editing songs' album(s).
*/
initCompilationStateCheckbox () {
// This must be wrapped in a $nextTick callback, because the form is dynamically
// attached into DOM in conjunction with `this.loading` data binding.
this.$nextTick(() => {
const chk = this.$refs.compilationStateChk
switch (this.compilationState) {
case COMPILATION_STATES.ALL:
chk.checked = true
chk.indeterminate = false
break
case COMPILATION_STATES.NONE:
chk.checked = false
chk.indeterminate = false
break
default:
chk.checked = false
chk.indeterminate = true
break
}
})
},
/**
* Manually set the compilation state.
* We can't use v-model here due to the tri-state nature of the property.
* Also, following iTunes style, we don't support circular switching of the states -
* once the user clicks the checkbox, there's no going back to indeterminate state.
*/
changeCompilationState (e) {
this.formData.compilationState = e.target.checked ? COMPILATION_STATES.ALL : COMPILATION_STATES.NONE
},
/**
* Close the modal.
*/
close () {
this.shown = false
},
/**
* Submit the form.
*/
async submit () {
this.loading = true
try {
await songStore.update(this.songs, this.formData)
this.close()
} finally {
this.loading = false
}
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#editSongsOverlay {
form {
> header {
img {
flex: 0 0 96px;
}
.meta {
flex: 1;
padding-left: 8px;
.mixed {
opacity: .5;
}
}
}
}
}
</style>

View file

@ -1,69 +0,0 @@
<template>
<div class="overlay" v-if="copiedUser">
<sound-bar v-if="loading"/>
<form class="user-edit" @submit.prevent="submit" v-else>
<header>
<h1>Edit User</h1>
</header>
<div>
<div class="form-row">
<label>Name</label>
<input type="text" name="name" v-model="copiedUser.name" required v-koel-focus>
</div>
<div class="form-row">
<label>Email</label>
<input type="email" name="email" v-model="copiedUser.email" required>
</div>
<div class="form-row">
<label>Password</label>
<input type="password" name="password" v-model="copiedUser.password" placeholder="Leave blank for no changes">
</div>
</div>
<footer>
<button class="btn btn-green btn-update">Update</button>
<button class="btn btn-white btn-cancel" @click.prevent="cancel">Cancel</button>
</footer>
</form>
</div>
</template>
<script>
import { clone } from 'lodash'
import soundBar from '@/components/shared/sound-bar.vue'
import { userStore } from '@/stores'
export default {
name: 'modals--edit-user-form',
components: { soundBar },
data () {
return {
user: null,
loading: false,
// We work on a cloned version of the user
copiedUser: null
}
},
methods: {
open (user) {
this.copiedUser = clone(user)
// Keep a reference
this.user = user
},
async submit () {
this.loading = true
await userStore.update(this.user, this.copiedUser.name, this.copiedUser.email, this.copiedUser.password)
this.loading = false
this.copiedUser = null
},
cancel () {
this.copiedUser = null
}
}
}
</script>

View file

@ -1,179 +0,0 @@
<template>
<div class="add-to" v-show="showing" v-koel-clickaway="close">
<p>Add {{ songs.length | pluralize('song') }} to</p>
<ul>
<template v-if="config.queue">
<li class="after-current" @click="queueSongsAfterCurrent">After Current Song</li>
<li class="bottom-queue" @click="queueSongsToBottom">Bottom of Queue</li>
<li class="top-queue" @click="queueSongsToTop">Top of Queue</li>
</template>
<li class="favorites" v-if="config.favorites" @click="addSongsToFavorite">Favorites</li>
<template v-if="config.playlists">
<li class="playlist" v-for="playlist in playlistState.playlists"
@click="addSongsToExistingPlaylist(playlist)">{{ playlist.name }}</li>
</template>
</ul>
<template v-if="config.newPlaylist">
<p>or create a new playlist</p>
<form class="form-save form-simple form-new-playlist" @submit.prevent="createNewPlaylistFromSongs">
<input type="text"
@keyup.esc.prevent="close"
v-model="newPlaylistName"
placeholder="Playlist name"
required>
<button type="submit">
<i class="fa fa-save"></i>
</button>
</form>
</template>
</div>
</template>
<script>
import { pluralize } from '@/utils'
import { playlistStore } from '@/stores'
import router from '@/router'
import songMenuMethods from '@/mixins/song-menu-methods'
export default {
name: 'shared--add-to-menu',
props: {
songs: {
type: Array,
required: true
},
showing: {
type: Boolean,
default: false
},
config: Object
},
mixins: [songMenuMethods],
filters: { pluralize },
data () {
return {
newPlaylistName: '',
playlistState: playlistStore.state
}
},
watch: {
songs () {
this.songs.length || this.close()
}
},
methods: {
/**
* Save the selected songs as a playlist.
* As of current we don't have selective save.
*/
async createNewPlaylistFromSongs () {
this.newPlaylistName = this.newPlaylistName.trim()
if (!this.newPlaylistName) {
return
}
const playlist = await playlistStore.store(this.newPlaylistName, this.songs)
this.newPlaylistName = ''
// Activate the new playlist right away
this.$nextTick(() => router.go(`playlist/${playlist.id}`))
this.close()
},
close () {
this.$parent.closeAddToMenu()
}
}
}
</script>
<style lang="scss" scoped>
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
.add-to {
@include context-menu();
position: absolute;
padding: 8px;
top: 36px;
left: 0;
width: 100%;
p {
margin: 4px 0;
font-size: .9rem;
&::first-of-type {
margin-top: 0;
}
}
$itemHeight: 28px;
$itemMargin: 2px;
ul {
max-height: 5 * ($itemHeight + $itemMargin);
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
}
li {
background: rgba(255, 255, 255, .2);
height: $itemHeight;
line-height: $itemHeight;
padding: 0 8px;
margin: $itemMargin 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-radius: 3px;
background: #fff;
&:hover {
background: $colorHighlight;
color: #fff;
}
}
&::before {
display: block;
content: " ";
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 10px solid rgb(232, 232, 232);
position: absolute;
top: -7px;
left: calc(50% - 10px);
}
form {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
input[type="text"] {
width: 100%;
border-radius: 5px 0 0 5px;
height: 28px;
}
button[type="submit"] {
margin-top: 0;
border-radius: 0 5px 5px 0 !important;
height: 28px;
margin-left: -2px !important;
}
}
}
</style>

View file

@ -1,127 +0,0 @@
<template>
<article class="item" v-if="album.songs.length" draggable="true" @dragstart="dragStart">
<span class="cover" :style="{ backgroundImage: `url(${album.cover})` }">
<a class="control control-play" @click.prevent="play">
<i class="fa fa-play"></i>
</a>
</span>
<footer>
<div class="info">
<a class="name" :href="`#!/album/${album.id}`">{{ album.name }}</a>
<span class="sep">by</span>
<a class="artist" v-if="isNormalArtist" :href="`#!/artist/${album.artist.id}`">{{ album.artist.name }}</a>
<span class="artist nope" v-else>{{ album.artist.name }}</span>
</div>
<p class="meta">
<span class="left">
{{ album.songs.length | pluralize('song') }}
{{ fmtLength }}
{{ album.playCount | pluralize('play') }}
</span>
<span class="right">
<a href @click.prevent="shuffle" title="Shuffle" class="shuffle-album">
<i class="fa fa-random"></i>
</a>
<a href @click.prevent="download" v-if="sharedState.allowDownload"
class="download-album"
title="Download all songs in album">
<i class="fa fa-download"></i>
</a>
</span>
</p>
</footer>
</article>
</template>
<script>
import { orderBy } from 'lodash'
import { pluralize } from '@/utils'
import { queueStore, artistStore, sharedStore } from '@/stores'
import { playback, download } from '@/services'
import albumAttributes from '@/mixins/album-attributes'
export default {
name: 'shared--album-item',
props: {
album: {
type: Object,
required: true
}
},
filters: { pluralize },
mixins: [albumAttributes],
data () {
return {
sharedState: sharedStore.state
}
},
computed: {
isNormalArtist () {
return !artistStore.isVariousArtists(this.album.artist) &&
!artistStore.isUnknownArtist(this.album.artist)
}
},
methods: {
/**
* Play all songs in the current album in track order,
* or queue them up if Ctrl/Cmd key is pressed.
*/
play (e) {
if (e.metaKey || e.ctrlKey) {
queueStore.queue(orderBy(this.album.songs, ['disc', 'track']))
} else {
playback.playAllInAlbum(this.album, false)
}
},
/**
* Shuffle all songs in album.
*/
shuffle () {
playback.playAllInAlbum(this.album, true)
},
/**
* Download all songs in album.
*/
download () {
download.fromAlbum(this.album)
},
/**
* Allow dragging the album (actually, its songs).
*/
dragStart (e) {
const songIds = this.album.songs.map(song => song.id)
e.dataTransfer.setData('application/x-koel.text+plain', songIds)
e.dataTransfer.effectAllowed = 'move'
// Set a fancy drop image using our ghost element.
const ghost = document.getElementById('dragGhost')
ghost.innerText = `All ${pluralize(songIds.length, 'song')} in ${this.album.name}`
e.dataTransfer.setDragImage(ghost, 0, 0)
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
@include artist-album-card();
.sep {
display: none;
color: $color2ndText;
.as-list & {
display: inline;
}
}
</style>

View file

@ -1,117 +0,0 @@
<template>
<article class="item" v-if="showing" draggable="true" @dragstart="dragStart">
<span class="cover" :style="{ backgroundImage: `url(${image})` }">
<a class="control control-play" @click.prevent="play">
<i class="fa fa-play"></i>
</a>
</span>
<footer>
<div class="info">
<a class="name" :href="`#!/artist/${artist.id}`">{{ artist.name }}</a>
</div>
<p class="meta">
<span class="left">
{{ artist.albums.length | pluralize('album') }}
{{ artist.songs.length | pluralize('song') }}
{{ artist.playCount | pluralize('play') }}
</span>
<span class="right">
<a href @click.prevent="shuffle" title="Shuffle" class="shuffle-artist">
<i class="fa fa-random"></i>
</a>
<a href @click.prevent="download" v-if="sharedState.allowDownload" title="Download all songs by artist" class="download-artist">
<i class="fa fa-download"></i>
</a>
</span>
</p>
</footer>
</article>
</template>
<script>
import { orderBy } from 'lodash'
import { pluralize } from '@/utils'
import { artistStore, queueStore, sharedStore } from '@/stores'
import { playback, download } from '@/services'
import artistAttributes from '@/mixins/artist-attributes'
export default {
name: 'shared--artist-item',
props: {
artist: {
type: Object,
required: true
}
},
filters: { pluralize },
mixins: [artistAttributes],
data () {
return {
sharedState: sharedStore.state
}
},
computed: {
/**
* Determine if the artist item should be shown.
* We're not showing those without any songs, or the special "Various Artists".
*
* @return {Boolean}
*/
showing () {
return this.artist.songs.length && !artistStore.isVariousArtists(this.artist)
}
},
methods: {
/**
* Play all songs by the current artist, or queue them up if Ctrl/Cmd key is pressed.
*/
play (e) {
if (e.metaKey || e.ctrlKey) {
queueStore.queue(orderBy(this.artist.songs, ['album_id', 'disc', 'track']))
} else {
playback.playAllByArtist(this.artist, false)
}
},
/**
* Shuffle all songs by the artist.
*/
shuffle () {
playback.playAllByArtist(this.artist, true)
},
/**
* Download all songs by artist.
*/
download () {
download.fromArtist(this.artist)
},
/**
* Allow dragging the artist (actually, their songs).
*/
dragStart (e) {
const songIds = this.artist.songs.map(song => song.id)
e.dataTransfer.setData('application/x-koel.text+plain', songIds)
e.dataTransfer.effectAllowed = 'move'
// Set a fancy drop image using our ghost element.
const ghost = document.getElementById('dragGhost')
ghost.innerText = `All ${pluralize(songIds.length, 'song')} by ${this.artist.name}`
e.dataTransfer.setDragImage(ghost, 0, 0)
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
@include artist-album-card();
</style>

View file

@ -1,173 +0,0 @@
<template>
<li class="song-item-home"
:class="{ playing: song.playbackState === 'playing' || song.playbackState === 'paused' }"
@dblclick.prevent="play"
>
<span class="cover" :style="{ backgroundImage: 'url('+song.album.cover+')' }">
<a class="control" @click.prevent="changeSongState">
<i class="fa fa-play" v-if="song.playbackState !== 'playing'"></i>
<i class="fa fa-pause" v-else></i>
</a>
</span>
<span class="details">
<span v-if="showPlayCount" :style="{ width: song.playCount*100/topPlayCount+'%' }" class="play-count"/>
{{ song.title }}
<span class="by">
<a :href="`#!/artist/${song.artist.id}`">{{ song.artist.name }}</a>
<template v-if="showPlayCount">- {{ song.playCount | pluralize('play') }}</template>
</span>
</span>
</li>
</template>
<script>
import { pluralize } from '@/utils'
import { queueStore } from '@/stores'
import { playback } from '@/services'
export default {
name: 'shared--home-song-item',
props: {
song: {
type: Object,
required: true
},
topPlayCount: {
type: Number,
default: 0
}
},
filters: { pluralize },
computed: {
showPlayCount () {
return this.topPlayCount && this.song.playCount
}
},
methods: {
play () {
queueStore.contains(this.song) || queueStore.queueAfterCurrent(this.song)
playback.play(this.song)
},
changeSongState () {
if (this.song.playbackState === 'stopped') {
this.play()
} else if (this.song.playbackState === 'paused') {
playback.resume()
} else {
playback.pause()
}
}
}
}
</script>
<style lang="scss" scoped>
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
.song-item-home {
display: flex;
&.playing {
color: $colorHighlight;
}
&:hover .cover {
.control {
display: block;
}
&::before {
opacity: .7;
}
}
.cover {
flex: 0 0 48px;
height: 48px;
background-size: cover;
position: relative;
@include vertical-center();
&::before {
content: " ";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: #000;
opacity: 0;
html.touchevents & {
opacity: .7;
}
}
.control {
border-radius: 50%;
width: 28px;
height: 28px;
background: rgba(0, 0, 0, .7);
border: 1px solid transparent;
line-height: 2rem;
font-size: 1rem;
text-align: center;
z-index: 1;
display: none;
color: #fff;
transition: .3s;
&:hover {
transform: scale(1.2);
border-color: #fff;
}
html.touchevents & {
display: block;
}
}
}
.details {
flex: 1;
padding: 4px 8px;
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
.play-count {
background: rgba(255, 255, 255, 0.08);
position: absolute;
height: 100%;
top: 0;
left: 0;
pointer-events: none;
}
.by {
display: block;
font-size: .9rem;
margin-top: 2px;
color: $color2ndText;
opacity: .8;
a {
color: #fff;
&:hover {
color: $colorHighlight;
}
}
}
}
margin-bottom: 8px;
}
</style>

View file

@ -1,113 +0,0 @@
<template>
<div id="overlay" v-if="state.showing" class="overlay" :class="state.type">
<div class="display">
<sound-bar v-if="state.type === 'loading'"/>
<i class="fa fa-exclamation-circle" v-show="state.type === 'error'"/>
<i class="fa fa-exclamation-triangle" v-show="state.type === 'warning'"/>
<i class="fa fa-info-circle" v-show="state.type === 'info'"/>
<i class="fa fa-check-circle" v-show="state.type === 'success'"/>
<span class="message" v-html="state.message"/>
</div>
<button class="btn-dismiss" v-if="state.dismissable" @click.prevent="state.showing = false">Close</button>
</div>
</template>
<script>
import { assign } from 'lodash'
import { event } from '@/utils'
import soundBar from './sound-bar.vue'
export default {
components: { soundBar },
data () {
return {
state: {
showing: true,
dismissable: false,
/**
* Either 'loading', 'success', 'info', 'warning', or 'error'.
* This dictates the icon as well as possibly other visual appearances.
*
* @type {String}
*/
type: 'loading',
message: ''
}
}
},
methods: {
/**
* Shows the overlay.
*
* @param {String} message The message to display.
* @param {String} type (loading|success|info|warning|error)
* @param {Boolean} dismissable Whether to show the Close button
*/
show (options) {
assign(this.state, options)
this.state.showing = true
},
/**
* Hide the overlay.
*/
hide () {
this.state.showing = false
}
},
created () {
event.on({
'overlay:show': options => this.show(options),
'overlay:hide': () => this.hide()
})
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#overlay {
background-color: rgba(0, 0, 0, 1);
flex-direction: column;
.display {
@include vertical-center();
i {
margin-right: 6px;
}
}
button {
margin-top: 16px;
}
&.error {
color: $colorRed;
}
&.success {
color: $colorGreen;
}
&.info {
color: $colorBlue;
}
&.loading {
color: $color2ndText;
}
&.warning {
color: $colorOrange;
}
}
</style>

View file

@ -1,184 +0,0 @@
<template>
<tr
class="song-item"
draggable="true"
:data-song-id="song.id"
@click="clicked"
@dblclick.prevent="playRightAwayyyyyyy"
@dragstart="dragStart"
@dragleave="dragLeave"
@dragenter.prevent="dragEnter"
@dragover.prevent
@drop.stop.prevent="drop"
@contextmenu.prevent="contextMenu"
:class="{ selected: item.selected, playing: playing }"
>
<td class="track-number">{{ song.track || '' }}</td>
<td class="title">{{ song.title }}</td>
<td class="artist">{{ song.artist.name }}</td>
<td class="album">{{ song.album.name }}</td>
<td class="time">{{ song.fmtLength }}</td>
<td class="play" @click.stop="doPlayback">
<i class="fa fa-pause-circle" v-if="song.playbackState === 'playing'"/>
<i class="fa fa-play-circle" v-else/>
</td>
</tr>
</template>
<script>
import { playback } from '@/services'
import { queueStore } from '@/stores'
import $ from 'vuequery'
export default {
name: 'song-item',
props: {
item: {
type: Object,
required: true
}
},
data () {
return {
parentSongList: null
}
},
computed: {
/**
* A shortcut to access the current vm's song (instead of this.item.song).
* @return {Object}
*/
song () {
return this.item.song
},
/**
* Determine if the current song is being played (or paused).
* @return {Boolean}
*/
playing () {
return this.song.playbackState === 'playing' || this.song.playbackState === 'paused'
}
},
mounted () {
this.parentSongList = window.__UNIT_TESTING__ || $(this).closest('song-list').vm
},
methods: {
/**
* Play the song right away.
*/
playRightAwayyyyyyy () {
queueStore.contains(this.song) || queueStore.queueAfterCurrent(this.song)
playback.play(this.song)
},
/**
* Take the right playback action based on the current playback state.
*/
doPlayback () {
switch (this.song.playbackState) {
case 'playing':
playback.pause()
break
case 'paused':
playback.resume()
break
default:
this.playRightAwayyyyyyy()
break
}
},
/**
* Proxy the click event to the parent song list component.
* @param {Event} event
*/
clicked (event) {
this.parentSongList.rowClicked(this, event)
},
/**
* Proxy the dragstart event to the parent song list component.
* @param {Event} event
*/
dragStart (event) {
this.parentSongList.dragStart(this, event)
},
/**
* Proxy the dragleave event to the parent song list component.
* @param {Event} event
*/
dragLeave (event) {
this.parentSongList.removeDroppableState(event)
},
/**
* Proxy the dragover event to the parent song list component.
* @param {Event} event The dragover event.
*/
dragEnter (event) {
this.parentSongList.allowDrop(event)
},
/**
* Proxy the dropstop event to the parent song list component.
* @param {Event} event
*/
drop (event) {
this.parentSongList.handleDrop(this, event)
},
/**
* Proxy the contextmenu event to the parent song list component.
* @param {Event} event
*/
contextMenu (event) {
this.parentSongList.openContextMenu(this, event)
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
.song-item {
border-bottom: 1px solid $color2ndBgr;
height: 35px;
html.no-touchevents &:hover {
background: rgba(255, 255, 255, .05);
}
.time, .track-number {
color: $color2ndText;
}
.title {
min-width: 192px;
}
.play {
max-width: 32px;
opacity: .5;
i {
font-size: 1.5rem;
}
}
&.selected {
background-color: rgba(255, 255, 255, .08);
}
&.playing td {
color: $colorHighlight;
}
}
</style>

View file

@ -1,43 +0,0 @@
<template>
<span class="song-list-controls-toggler" v-if="isPhone" @click="toggleControls">
<i class="fa fa-angle-up toggler" v-if="showingControls"/>
<i class="fa fa-angle-down toggler" v-else/>
</span>
</template>
<script>
import isMobile from 'ismobilejs'
export default {
name: 'shared--song-list-controls-toggler',
props: {
showingControls: {
type: Boolean,
default: true
}
},
data () {
return {
isPhone: isMobile.phone
}
},
methods: {
toggleControls () {
this.$emit('toggleControls')
}
}
}
</script>
<style lang="scss" scoped>
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
.toggler {
font-size: 1rem;
margin-left: 4px;
color: $colorHighlight;
}
</style>

View file

@ -1,112 +0,0 @@
<template>
<div class="buttons song-list-controls">
<button class="btn btn-orange btn-shuffle-all"
@click.prevent="shuffle"
v-if="fullConfig.shuffle && selectedSongs.length < 2">
<i class="fa fa-random"></i> All
</button>
<button class="btn btn-orange btn-shuffle-selected"
@click.prevent="shuffleSelected"
v-if="fullConfig.shuffle && selectedSongs.length > 1">
<i class="fa fa-random"></i> Selected
</button>
<button class="btn btn-green btn-add-to"
@click.prevent.stop="showingAddToMenu = !showingAddToMenu"
v-if="selectedSongs.length">
{{ showingAddToMenu ? 'Cancel' : 'Add To…' }}
</button>
<button class="btn btn-red btn-clear-queue"
@click.prevent="clearQueue"
v-if="showClearQueueButton">
Clear
</button>
<button class="del btn btn-red btn-delete-playlist" v-if="showDeletePlaylistButton"
title="Delete this playlist"
@click.prevent="deletePlaylist">
<i class="fa fa-times"></i> Playlist
</button>
<add-to-menu v-koel-clickaway="closeAddToMenu"
:config="fullConfig.addTo"
:songs="selectedSongs"
:showing="showingAddToMenu"
/>
</div>
</template>
<script>
import addToMenu from './add-to-menu.vue'
export default {
name: 'shared--song-list-controls',
props: {
config: Object,
selectedSongs: {
type: Array,
default: []
}
},
components: { addToMenu },
data () {
return {
fullConfig: {
shuffle: true,
addTo: {
queue: true,
favorites: true,
playlists: true,
newPlaylist: true
},
clearQueue: false,
deletePlaylist: false
},
showingAddToMenu: false,
numberOfQueuedSongs: 0
}
},
computed: {
showClearQueueButton () {
return this.fullConfig.clearQueue
},
showDeletePlaylistButton () {
return this.fullConfig.deletePlaylist
}
},
mounted () {
this.fullConfig = Object.assign(this.fullConfig, this.config)
},
methods: {
shuffle () {
this.$emit('shuffleAll')
},
shuffleSelected () {
this.$emit('shuffleSelected')
},
clearQueue () {
this.$emit('clearQueue')
},
deletePlaylist () {
this.$emit('deletePlaylist')
},
closeAddToMenu () {
this.showingAddToMenu = false
}
}
}
</script>
<style lang="scss"></style>

View file

@ -1,645 +0,0 @@
<template>
<div class="song-list-wrap main-scroll-wrap" :class="type"
ref="wrapper"
tabindex="1"
@keydown.delete.prevent.stop="handleDelete"
@keydown.enter.prevent.stop="handleEnter"
@keydown.a.prevent="handleA"
>
<table class="song-list-header">
<thead>
<tr>
<th @click="sort('song.track')" class="track-number">#
<i class="fa fa-angle-down" v-show="sortKey === 'song.track' && order > 0"/>
<i class="fa fa-angle-up" v-show="sortKey === 'song.track' && order < 0"/>
</th>
<th @click="sort('song.title')" class="title">Title
<i class="fa fa-angle-down" v-show="sortKey === 'song.title' && order > 0"/>
<i class="fa fa-angle-up" v-show="sortKey === 'song.title' && order < 0"/>
</th>
<th @click="sort(['song.album.artist.name', 'song.album.name', 'song.track'])" class="artist">Artist
<i class="fa fa-angle-down" v-show="sortingByArtist && order > 0"/>
<i class="fa fa-angle-up" v-show="sortingByArtist && order < 0"/>
</th>
<th @click="sort(['song.album.name', 'song.track'])" class="album">Album
<i class="fa fa-angle-down" v-show="sortingByAlbum && order > 0"/>
<i class="fa fa-angle-up" v-show="sortingByAlbum && order < 0"/>
</th>
<th @click="sort('song.length')" class="time">Time
<i class="fa fa-angle-down" v-show="sortKey === 'song.length' && order > 0"/>
<i class="fa fa-angle-up" v-show="sortKey === 'song.length' && order < 0"/>
</th>
<th class="play"></th>
</tr>
</thead>
</table>
<virtual-scroller
class="scroller"
content-tag="table"
:items="filteredItems"
item-height="35"
:renderers="renderers"
key-field="song.id"
/>
<song-menu ref="contextMenu" :songs="selectedSongs"/>
</div>
</template>
<script>
import isMobile from 'ismobilejs'
import { filterBy, orderBy, event, pluralize, $ } from '@/utils'
import { playlistStore, queueStore, songStore, favoriteStore } from '@/stores'
import { playback } from '@/services'
import router from '@/router'
import songItem from './song-item.vue'
import songMenu from './song-menu.vue'
export default {
name: 'song-list',
props: {
items: {
type: Array,
required: true
},
type: {
type: String,
default: 'allSongs',
validator: value => ['allSongs', 'queue', 'playlist', 'favorites', 'artist', 'album'].includes(value)
},
sortable: {
type: Boolean,
default: true
},
playlist: {
type: Object
}
},
components: { songItem, songMenu },
data () {
return {
renderers: Object.freeze({
song: songItem
}),
lastSelectedRow: null,
q: '', // The filter query
sortKey: '',
order: -1,
sortingByAlbum: false,
sortingByArtist: false,
songRows: []
}
},
watch: {
/**
* Watch the items.
*/
items () {
this.render()
},
selectedSongs (val) {
event.emit('setSelectedSongs', val, this.$parent)
}
},
computed: {
filteredItems () {
const { keywords, fields } = this.extractSearchData(this.q)
return keywords ? filterBy(this.songRows, keywords, ...fields) : this.songRows
},
/**
* Determine if the songs in the current list can be reordered by drag-and-dropping.
* @return {Boolean}
*/
allowSongReordering () {
return this.type === 'queue'
},
/**
* Songs that are currently selected (their rows are highlighted).
* @return {Array.<Object>}
*/
selectedSongs () {
return this.filteredItems.filter(row => row.selected).map(row => row.song)
}
},
methods: {
render () {
if (this.sortable === false) {
this.sortKey = ''
}
// Update the song count and duration status on parent.
event.emit('updateMeta', {
songCount: this.items.length,
totalLength: songStore.getFormattedLength(this.items)
}, this.$parent)
this.generateSongRows()
},
/**
* Generate an array of "song row" or "song wrapper" objects. Since song objects themselves are
* shared by all song lists, we can't use them directly to determine their selection status
* (selected/unselected). Therefore, for each song list, we maintain an array of "song row"
* objects, with each object contain the song itself, and the "selected" flag. In order to
* comply with virtual-scroller, a "type" attribute also presents.
*/
generateSongRows () {
// Since this method re-generates the song wrappers, we need to keep track of the
// selected songs manually.
const selectedSongIds = this.selectedSongs.map(song => song.id)
this.songRows = this.items.map(song => {
return {
song,
selected: selectedSongIds.includes(song.id),
type: 'song'
}
})
},
/**
* Handle sorting the song list.
*
* @param {String} key The sort key. Can be 'title', 'album', 'artist', or 'length'
*/
sort (key = null) {
// there are certain cirscumstances where sorting is simply disallowed, e.g. in Queue
if (this.sortable === false) {
return
}
if (key) {
this.sortKey = key
this.order *= -1
}
// if this is an album's song list, default to sorting by track number
// and additionally sort by disc number
if (this.type === 'album') {
this.sortKey = this.sortKey ? this.sortKey : ['song.track']
this.sortKey = [].concat(this.sortKey)
if (!this.sortKey.includes('song.disc')) {
this.sortKey.push('song.disc')
}
}
this.sortingByAlbum = this.sortKey[0] === 'song.album.name'
this.sortingByArtist = this.sortKey[0] === 'song.album.artist.name'
this.songRows = orderBy(this.songRows, this.sortKey, this.order)
},
/**
* Execute the corresponding reaction(s) when the user presses Delete.
*/
handleDelete () {
if (!this.selectedSongs.length) {
return
}
switch (this.type) {
case 'queue':
queueStore.unqueue(this.selectedSongs)
break
case 'favorites':
favoriteStore.unlike(this.selectedSongs)
break
case 'playlist':
playlistStore.removeSongs(this.playlist, this.selectedSongs)
break
default:
break
}
this.clearSelection()
},
/**
* Execute the corresponding reaction(s) when the user presses Enter.
*
* @param {Event} event The keydown event.
*/
handleEnter (event) {
if (!this.selectedSongs.length) {
return
}
if (this.selectedSongs.length === 1) {
// Just play the song
playback.play(this.selectedSongs[0])
return
}
switch (this.type) {
case 'queue':
// Play the first song selected if we're in Queue screen.
playback.play(this.selectedSongs[0])
break
default:
//
// --------------------------------------------------------------------
// For other screens, follow this map:
//
// Enter: Queue songs to bottom
// Shift+Enter: Queues song to top
// Cmd/Ctrl+Enter: Queues song to bottom and play the first selected song
// Cmd/Ctrl+Shift+Enter: Queue songs to top and play the first queued song
// --------------------------------------------------------------------
//
queueStore.queue(this.selectedSongs, false, event.shiftKey)
if (event.ctrlKey || event.metaKey) {
playback.play(this.selectedSongs[0])
}
router.go('queue')
break
}
},
/**
* Capture A keydown event and select all if applicable.
*
* @param {Event} event The keydown event.
*/
handleA (event) {
if (!event.metaKey && !event.ctrlKey) {
return
}
this.selectAllRows()
},
/**
* Select all (filtered) rows in the current list.
*/
selectAllRows () {
this.filteredItems.forEach(row => {
row.selected = true
})
},
/**
* Handle the click event on a row to perform selection.
*
* @param {VueComponent} rowVm
* @param {Event} e
*/
rowClicked (rowVm, event) {
// If we're on a touch device, or if Ctrl/Cmd key is pressed, just toggle selection.
if (isMobile.any) {
this.toggleRow(rowVm)
return
}
if (event.ctrlKey || event.metaKey) {
this.toggleRow(rowVm)
}
if (event.button === 0) {
if (!(event.ctrlKey || event.metaKey || event.shiftKey)) {
this.clearSelection()
this.toggleRow(rowVm)
}
if (event.shiftKey && this.lastSelectedRow) {
this.selectRowsBetween(this.lastSelectedRow, rowVm)
}
}
},
/**
* Toggle select/unslect a row.
*
* @param {VueComponent} rowVm The song-item component
*/
toggleRow (rowVm) {
rowVm.item.selected = !rowVm.item.selected
this.lastSelectedRow = rowVm
},
/**
* Select all rows between two rows.
*
* @param {VueComponent} firstRowVm The first row's component
* @param {VueComponent} secondRowVm The second row's component
*/
selectRowsBetween (firstRowVm, secondRowVm) {
const indexes = [
this.filteredItems.indexOf(firstRowVm.item),
this.filteredItems.indexOf(secondRowVm.item)
]
indexes.sort((a, b) => a - b)
for (let i = indexes[0]; i <= indexes[1]; ++i) {
this.filteredItems[i].selected = true
}
},
/**
* Clear the current selection on this song list.
*/
clearSelection () {
this.filteredItems.forEach(row => {
row.selected = false
})
},
/**
* Enable dragging songs by capturing the dragstart event on a table row.
* Even though the event is triggered on one row only, we'll collect other
* selected rows, if any, as well.
*
* @param {VueComponent} The row's Vue component
* @param {Event} event The event
*/
dragStart (rowVm, event) {
// If the user is dragging an unselected row, clear the current selection.
if (!rowVm.item.selected) {
this.clearSelection()
rowVm.item.selected = true
}
const songIds = this.selectedSongs.map(song => song.id)
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('application/x-koel.text+plain', songIds)
// Set a fancy drop image using our ghost element.
const ghost = document.getElementById('dragGhost')
ghost.innerText = `${pluralize(songIds.length, 'song')}`
event.dataTransfer.setDragImage(ghost, 0, 0)
},
/**
* Add a "droppable" class and set the drop effect when other songs are dragged over a row.
*
* @param {Event} event The dragover event.
*/
allowDrop (event) {
if (!this.allowSongReordering) {
return
}
$.addClass(event.target.parentNode, 'droppable')
event.dataTransfer.dropEffect = 'move'
return false
},
/**
* Perform reordering songs upon dropping if the current song list is of type Queue.
*
* @param {VueComponent} rowVm The row's Vue Component
* @param {Event} event
*/
handleDrop (rowVm, event) {
if (
!this.allowSongReordering ||
!event.dataTransfer.getData('application/x-koel.text+plain') ||
!this.selectedSongs.length
) {
return this.removeDroppableState(event)
}
queueStore.move(this.selectedSongs, rowVm.song)
return this.removeDroppableState(event)
},
/**
* Remove the droppable state (and the styles) from a row.
*
* @param {Event} event
*/
removeDroppableState (event) {
$.removeClass(event.target.parentNode, 'droppable')
return false
},
/**
* Open the context menu.
*
* @param {VueComponent} rowVm The right-clicked row's component
* @param {Event} event
*/
openContextMenu (rowVm, event) {
// If the user is right-clicking an unselected row,
// clear the current selection and select it instead.
if (!rowVm.item.selected) {
this.clearSelection()
this.toggleRow(rowVm)
}
this.$nextTick(() => this.$refs.contextMenu.open(event.pageY, event.pageX))
},
/**
* Extract the search data from a search query.
* @param {String} q
* @return { Object } A { keywords, fields } object
*/
extractSearchData (q) {
const re = /in:(title|album|artist)/ig
const fields = []
const matches = q.match(re)
let keywords = q
if (matches) {
keywords = q.replace(re, '').trim()
if (keywords) {
matches.forEach(match => {
const field = match.split(':')[1].toLowerCase()
fields.push(field === 'title' ? `song.${field}` : `song.${field}.name`)
})
}
}
return {
keywords,
fields: fields.length ? fields : ['song.title', 'song.album.name', 'song.artist.name']
}
}
},
mounted () {
if (this.items) {
this.render()
}
},
created () {
event.on({
/**
* Listen to 'filter:changed' event to filter the current list.
*/
'filter:changed': q => {
this.q = q
}
})
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
.song-list-wrap {
position: relative;
padding: 8px 24px;
.song-list-header {
position: absolute;
top: 0;
left: 24px;
right: 24px;
padding: 0 24px;
background: #1b1b1b;
z-index: 1;
width: calc(100% - 48px);
}
table {
width: 100%;
table-layout: fixed;
}
tr.droppable {
border-bottom-width: 3px;
border-bottom-color: $colorGreen;
}
td, th {
text-align: left;
padding: 8px;
vertical-align: middle;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
&.time {
width: 72px;
text-align: right;
}
&.track-number {
width: 42px;
}
&.artist {
width: 23%;
}
&.album {
width: 27%;
}
&.play {
display: none;
html.touchevents & {
display: block;
position: absolute;
top: 8px;
right: 4px;
}
}
}
th {
color: $color2ndText;
letter-spacing: 1px;
text-transform: uppercase;
cursor: pointer;
i {
color: $colorHighlight;
font-size: 1.2rem;
}
}
/**
* Since the Queue screen doesn't allow sorting, we reset the cursor style.
*/
&.queue th {
cursor: default;
}
.scroller {
overflow: auto;
position: absolute;
top: 35px;
left: 0;
bottom: 0;
right: 0;
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
.item-container {
position: absolute;
left: 24px;
right: 24px;
}
.item {
margin-bottom: 0;
}
}
@media only screen and (max-width: 768px) {
table, tbody, tr {
display: block;
}
thead, tfoot {
display: none;
}
.scroller {
top: 0;
bottom: 24px;
.item-container {
left: 12px;
right: 12px;
}
}
tr {
padding: 8px 32px 8px 4px;
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: $color2ndText;
width: 100%;
}
td {
display: inline;
padding: 0;
vertical-align: bottom;
color: $colorMainText;
&.album, &.time, &.track-number {
display: none;
}
&.artist {
color: $color2ndText;
font-size: .9rem;
padding: 0 4px;
}
}
}
}
</style>

View file

@ -1,226 +0,0 @@
<template>
<ul ref="menu" class="menu song-menu" v-show="shown" tabindex="-1" @contextmenu.prevent @blur="close" :style="{ top: `${top}px`, left: `${left}px` }"
>
<template v-show="onlyOneSongSelected">
<li class="playback" @click.stop.prevent="doPlayback">
<span v-if="firstSongPlaying">Pause</span>
<span v-else>Play</span>
</li>
<li class="go-to-album" @click="viewAlbumDetails(songs[0].album)">Go to Album</li>
<li class="go-to-artist" @click="viewArtistDetails(songs[0].artist)">Go to Artist</li>
</template>
<li class="has-sub">Add To
<ul class="menu submenu menu-add-to">
<li class="after-current" @click="queueSongsAfterCurrent">After Current Song</li>
<li class="bottom-queue" @click="queueSongsToBottom">Bottom of Queue</li>
<li class="top-queue" @click="queueSongsToTop">Top of Queue</li>
<li class="separator"></li>
<li class="favorite" @click="addSongsToFavorite">Favorites</li>
<li class="separator" v-if="playlistState.playlists.length"></li>
<li class="playlist" v-for="p in playlistState.playlists" @click="addSongsToExistingPlaylist(p)">{{ p.name }}</li>
</ul>
</li>
<li class="open-edit-form" v-if="isAdmin" @click="openEditForm">Edit</li>
<li class="download" v-if="sharedState.allowDownload" @click="download">Download</li>
<li class="copy-url" v-if="copyable && onlyOneSongSelected" @click="copyUrl">Copy Shareable URL</li>
</ul>
</template>
<script>
import { each } from 'lodash'
import songMenuMethods from '@/mixins/song-menu-methods'
import { event, isClipboardSupported, copyText } from '@/utils'
import { sharedStore, songStore, queueStore, userStore, playlistStore } from '@/stores'
import { playback, download } from '@/services'
import router from '@/router'
export default {
name: 'song-menu',
props: {
songs: {
type: Array,
required: true
}
},
mixins: [songMenuMethods],
data () {
return {
playlistState: playlistStore.state,
sharedState: sharedStore.state,
copyable: isClipboardSupported()
}
},
computed: {
onlyOneSongSelected () {
return this.songs.length === 1
},
firstSongPlaying () {
return this.songs[0] ? this.songs[0].playbackState === 'playing' : false
},
isAdmin () {
return userStore.current.is_admin
}
},
methods: {
open (top = 0, left = 0) {
if (!this.songs.length) {
return
}
this.top = top
this.left = left
this.shown = true
this.$nextTick(() => {
// Make sure the menu isn't off-screen
if (this.$el.getBoundingClientRect().bottom > window.innerHeight) {
this.$el.style.top = 'auto'
this.$el.style.bottom = 0
} else {
this.$el.style.top = this.top
this.$el.style.bottom = 'auto'
}
this.$refs.menu.focus()
})
},
/**
* Take the right playback action based on the current playback state.
*/
doPlayback () {
switch (this.songs[0].playbackState) {
case 'playing':
playback.pause()
break
case 'paused':
playback.resume()
break
default:
queueStore.contains(this.songs[0]) || queueStore.queueAfterCurrent(this.songs[0])
playback.play(this.songs[0])
break
}
this.close()
},
/**
* Trigger opening the "Edit Song" form/overlay.
*/
openEditForm () {
this.songs.length && event.emit('songs:edit', this.songs)
this.close()
},
/**
* Load the album details screen.
*/
viewAlbumDetails (album) {
router.go(`album/${album.id}`)
this.close()
},
/**
* Load the artist details screen.
*/
viewArtistDetails (artist) {
router.go(`artist/${artist.id}`)
this.close()
},
download () {
download.fromSongs(this.songs)
this.close()
},
copyUrl () {
copyText(songStore.getShareableUrl(this.songs[0]))
}
},
/**
* On component mounted(), we use some JavaScript to prepare the submenu triggering.
* With this, we can catch when the submenus shown or hidden, and can make sure
* they don't appear off-screen.
*/
mounted () {
each(Array.from(this.$el.querySelectorAll('.has-sub')), item => {
const submenu = item.querySelector('.submenu')
if (!submenu) {
return
}
item.addEventListener('mouseenter', e => {
submenu.style.display = 'block'
// Make sure the submenu isn't off-screen
if (submenu.getBoundingClientRect().bottom > window.innerHeight) {
submenu.style.top = 'auto'
submenu.style.bottom = 0
}
})
item.addEventListener('mouseleave', e => {
submenu.style.top = 0
submenu.style.bottom = 'auto'
submenu.style.display = 'none'
})
})
}
}
</script>
<style lang="scss" scoped>
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
.menu {
@include context-menu();
position: fixed;
li {
position: relative;
padding: 4px 12px;
cursor: default;
white-space: nowrap;
&:hover {
background: $colorOrange;
color: #fff;
}
&.separator {
pointer-event: none;
padding: 1px 0;
background: #ccc;
}
&.has-sub {
padding-right: 24px;
&:after {
position: absolute;
right: 12px;
top: 4px;
content: "▸";
width: 16px;
text-align: right;
}
}
}
.submenu {
position: absolute;
display: none;
left: 100%;
top: 0;
}
}
</style>

View file

@ -1,57 +0,0 @@
<template>
<div class="bars">
<img src="public/img/bars.gif" alt="Sound bars" height="13" width="auto">
</div>
</template>
<script>
export default {
// Since we don't have anything here, let us sing a song instead.
//
// "The House Of The Rising Sun"
// -- by The Animals
//
// There is a house in New Orleans
// They call the Rising Sun
// And it's been the ruin of many a poor boy
// And God, I know I'm one
//
// My mother was a tailor
// She sewed my new blue jeans
// My father was a gamblin' man
// Down in New Orleans
//
// Now the only thing a gambler needs
// Is a suitcase and trunk
// And the only time he's satisfied
// Is when he's on a drunk
//
// [Organ Solo]
//
// Oh mother, tell your children
// Not to do what I have done
// Spend your lives in sin and misery
// In the House of the Rising Sun
//
// Well, I got one foot on the platform
// The other foot on the train
// I'm goin' back to New Orleans
// To wear that ball and chain
//
// Well, there is a house in New Orleans
// They call the Rising Sun
// And it's been the ruin of many a poor boy
// And God, I know I'm one.
}
</script>
<style lang="scss" scoped>
.bars {
width: 28px;
height: 13px;
position: relative;
display: inline-block;
backface-visibility: hidden;
margin-bottom: 5px;
}
</style>

View file

@ -1,72 +0,0 @@
<template>
<transition name="fade">
<div class="to-top-btn-wrapper" v-show="showing">
<button @click="scrollToTop">
<i class="fa fa-arrow-circle-up"/> Top
</button>
</div>
</transition>
</template>
<script>
import { $ } from '@/utils'
export default {
data () {
return {
showing: false
}
},
methods: {
scrollToTop () {
$.scrollTo(this.$el.parentNode, 0, 500, () => {
this.showing = false
})
}
},
mounted () {
this.$el.parentNode && this.$el.parentNode.addEventListener('scroll', e => {
this.showing = e.target.scrollTop > 64
})
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
.to-top-btn-wrapper {
position: fixed;
width: 100%;
bottom: $footerHeightMobile + 26px;
left: 0;
text-align: center;
z-index: 20;
opacity: 1;
transition: opacity .5s;
&.fade-enter, &.fade-leave-to {
opacity: 0;
}
button {
border-radius: 18px;
padding: 8px 16px;
background: rgba(0, 0, 0, .5);
border: 1px solid $colorMainText;
i {
margin-right: 4px;
}
}
}
@media screen and (min-width: 415px) {
.to-top-btn-wrapper {
display: none;
}
}
</style>

View file

@ -1,91 +0,0 @@
<template>
<li :class="{ available: song }" :title="tooltip" @click="play">
<span class="no">{{ index + 1 }}</span>
<span class="title">{{ track.title }}</span>
<a
:href="iTunesUrl"
v-if="useiTunes && !song"
target="_blank"
class="view-on-itunes"
title="View on iTunes"
>
iTunes
</a>
<span class="length">{{ track.fmtLength }}</span>
</li>
</template>
<script>
import { songStore, queueStore, sharedStore } from '@/stores'
import { ls, playback } from '@/services'
export default {
name: 'shared--track-list-item',
props: {
album: {
type: Object,
required: true
},
track: {
type: Object,
required: true
},
index: {
type: Number,
required: true
}
},
data () {
return {
useiTunes: sharedStore.state.useiTunes
}
},
computed: {
song () {
return songStore.guess(this.track.title, this.album)
},
tooltip () {
return this.song ? 'Click to play' : ''
},
iTunesUrl () {
return `${window.BASE_URL}api/itunes/song/${this.album.id}?q=${encodeURIComponent(this.track.title)}&jwt-token=${ls.get('jwt-token')}`
}
},
methods: {
play () {
if (this.song) {
queueStore.contains(this.song) || queueStore.queueAfterCurrent(this.song)
playback.play(this.song)
}
}
}
}
</script>
<style lang="scss">
a.view-on-itunes {
display: inline-block;
border-radius: 3px;
font-size: .8rem;
padding: 0 5px;
color: #fff;
background: rgba(255, 255, 255, .1);
height: 20px;
line-height: 20px;
margin-left: 4px;
&:hover {
background: linear-gradient(27deg, #fe5c52 0%,#c74bd5 50%,#2daaff 100%);
color: #fff;
}
&:active {
box-shadow: inset 0px 5px 5px -5px #000;
}
}
</style>

View file

@ -1,188 +0,0 @@
<template>
<div>
<input type="text" :name="options.name"
:placeholder="options.placeholder || 'No change'"
v-model="mutatedValue"
@keydown.down.prevent="down"
@keydown.up.prevent="up"
@keydown.enter.prevent.stop="enter"
@keydown.tab="enter"
@keyup="keyup"
@click="showingResult = true"
@blur="apply"
v-koel-clickaway="hideResults"
>
<ul class="result" v-show="showingResult">
<li v-for="item in displayedItems" @click.prevent="resultClick($event)">
{{ item[options.displayKey] }}
</li>
</ul>
</div>
</template>
<script>
import { filterBy, $ } from '@/utils'
export default {
props: {
options: Object,
items: {
type: Array,
required: true
},
value: String
},
data () {
return {
filter: '',
showingResult: false,
mutatedValue: this.value
}
},
computed: {
displayedItems () {
return filterBy(this.items, this.filter, this.options.filterKey)
}
},
methods: {
/**
* Navigate down the result list.
*/
down (e) {
const selected = this.$el.querySelector('.result li.selected')
if (!selected || !selected.nextElementSibling) {
// No item selected, or we're at the end of the list.
// Select the first item now.
$.addClass(this.$el.querySelector('.result li:first-child'), 'selected')
selected && $.removeClass(selected, 'selected')
} else {
$.removeClass(selected, 'selected')
$.addClass(selected.nextElementSibling, 'selected')
}
this.scrollSelectedIntoView(false)
this.apply()
},
/**
* Navigate up the result list.
*/
up (e) {
const selected = this.$el.querySelector('.result li.selected')
if (!selected || !selected.previousElementSibling) {
$.addClass(this.$el.querySelector('.result li:last-child'), 'selected')
selected && $.removeClass(selected, 'selected')
} else {
$.removeClass(selected, 'selected')
$.addClass(selected.previousElementSibling, 'selected')
}
this.scrollSelectedIntoView(true)
this.apply()
},
/**
* Handle ENTER or TAB keydown events.
*/
enter () {
this.apply()
this.hideResults()
},
keyup (e) {
/**
* If it's an UP or DOWN arrow key, stop event bubbling.
* The actually result navigation is handled by this.up() and this.down().
*/
if (e.keyCode === 38 || e.keyCode === 40) {
e.stopPropagation()
e.preventDefault()
return
}
// If it's an ENTER or TAB key, don't do anything.
// We've handled ENTER & TAB on keydown.
if (e.keyCode === 13 || e.keyCode === 9) {
return
}
// Hide the typeahead results and reset the value if ESC is pressed.
if (e.keyCode === 27) {
this.mutatedValue = this.value
this.hideResults()
return
}
this.filter = this.mutatedValue
this.showingResult = true
},
resultClick (e) {
const selected = this.$el.querySelector('.result li.selected')
$.removeClass(selected, 'selected')
$.addClass(e.target, 'selected')
this.enter()
},
apply () {
const selected = this.$el.querySelector('.result li.selected')
this.mutatedValue = (selected && selected.innerText.trim()) || this.mutatedValue
this.$emit('input', this.mutatedValue)
},
/**
* Scroll the selected item into the view.
*
* @param {boolean} alignTop Whether the item should be aligned to top of its container.
*/
scrollSelectedIntoView (alignTop) {
const elem = this.$el.querySelector('.result li.selected')
if (!elem) {
return
}
const elemRect = elem.getBoundingClientRect()
const containerRect = elem.offsetParent.getBoundingClientRect()
if (elemRect.bottom > containerRect.bottom || elemRect.top < containerRect.top) {
elem.scrollIntoView(alignTop)
}
},
hideResults () {
this.showingResult = false
}
}
}
</script>
<style lang="scss" scoped>
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
.result {
position: absolute;
background: #f2f2f2;
max-height: 96px;
border-radius: 0 0 3px 3px;
width: 100%;
overflow-y: scroll;
z-index: 1;
li {
padding: 2px 8px;
&.selected, &:hover {
background: $colorHighlight;
color: #fff;
}
}
}
</style>

View file

@ -1,84 +0,0 @@
<template>
<article class="user-item" :class="{ me: isCurrentUser }">
<div class="info">
<img :src="user.avatar" width="128" height="128">
<div class="right">
<div>
<h1>{{ user.name }}
<i v-if="isCurrentUser" class="you fa fa-check-circle"/>
</h1>
<p>{{ user.email }}</p>
</div>
<div class="buttons">
<button class="btn btn-blue btn-edit" @click="edit">
{{ isCurrentUser ? 'Update Profile' : 'Edit' }}
</button>
<button v-if="!isCurrentUser" class="btn btn-red btn-delete" @click="del">Delete</button>
</div>
</div>
</div>
</article>
</template>
<script>
import { userStore } from '@/stores'
import router from '@/router'
import { alerts } from '@/utils'
export default {
name: 'shared--user-item',
props: {
user: {
type: Object,
required: true
}
},
data () {
return {
confirmingDelete: false
}
},
computed: {
/**
* Determine if the current logged in user is the user bound to this component.
*
* @return {Boolean}
*/
isCurrentUser () {
return this.user.id === userStore.current.id
}
},
methods: {
/**
* Trigger editing a user.
* If the user is the current logged-in user, redirect to the profile screen instead.
*/
edit () {
this.isCurrentUser ? router.go('profile') : this.$emit('editUser', this.user)
},
/**
* Kill off the freaking user.
*/
del () {
alerts.confirm(`Youre about to unperson ${this.user.name}. Are you sure?`, this.doDelete)
},
doDelete () {
userStore.destroy(this.user)
this.$destroy()
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
</style>

View file

@ -1,103 +0,0 @@
<template>
<span class="view-modes">
<a class="thumbnails" :class="{ active: mutatedMode === 'thumbnails' }"
title="View as thumbnails"
@click.prevent="setMode('thumbnails')"><i class="fa fa-th-large"></i></a>
<a class="list" :class="{ active: mutatedMode === 'list' }"
title="View as list"
@click.prevent="setMode('list')"><i class="fa fa-list"></i></a>
</span>
</template>
<script>
import isMobile from 'ismobilejs'
import { event } from '@/utils'
import { preferenceStore as preferences } from '@/stores'
export default {
props: {
mode: {
type: String,
default: 'thumbnails',
validator: value => ['thumbnails', 'list'].includes(value)
},
for: {
type: String,
required: true,
validator: value => ['albums', 'artists'].includes(value)
}
},
data () {
return {
mutatedMode: this.mode
}
},
computed: {
/**
* The preference key for local storage for persistent mode.
*
* @return {string}
*/
preferenceKey () {
return `${this.for}ViewMode`
}
},
methods: {
setMode (mode) {
preferences[this.preferenceKey] = this.mutatedMode = mode
this.$emit('viewModeChanged', mode)
}
},
created () {
event.on('koel:ready', () => {
this.mutatedMode = preferences[this.preferenceKey]
// If the value is empty, we set a default mode.
// On mobile, the mode should be 'listing'.
// For desktop, 'thumbnails'.
if (!this.mutatedMode) {
this.mutatedMode = isMobile.phone ? 'list' : 'thumbnails'
}
this.setMode(this.mutatedMode)
})
}
}
</script>
<style lang="scss" scoped>
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
.view-modes {
display: flex;
flex: 0 0 64px;
border: 1px solid rgba(255, 255, 255, .2);
height: 2rem;
border-radius: 5px;
overflow: hidden;
a {
width: 50%;
text-align: center;
line-height: 2rem;
font-size: 1rem;
&.active {
background: #fff;
color: #111;
}
}
@media only screen and(max-width: 768px) {
flex: auto;
width: 64px;
margin-top: 8px;
}
}
</style>

View file

@ -1,400 +0,0 @@
<template>
<div id="equalizer">
<div class="presets">
<label class="select-wrapper">
<select v-model="selectedPresetIndex">
<option v-for="p in presets" :value="p.id" v-once>{{ p.name }}</option>
</select>
</label>
</div>
<div class="bands">
<span class="band preamp">
<span class="slider"></span>
<label>Preamp</label>
</span>
<span class="indicators">
<span>+20</span>
<span>0</span>
<span>-20</span>
</span>
<span class="band amp" v-for="band in bands">
<span class="slider"></span>
<label>{{ band.label }}</label>
</span>
</div>
</div>
</template>
<script>
import { cloneDeep } from 'lodash'
import nouislider from 'nouislider'
import { isAudioContextSupported, event, $ } from '@/utils'
import { equalizerStore, preferenceStore as preferences } from '@/stores'
let context
export default {
data () {
return {
bands: [],
preampGainValue: 0,
selectedPresetIndex: -1
}
},
computed: {
presets () {
const clonedPreset = cloneDeep(equalizerStore.presets)
// Prepend an empty option for instruction purpose.
clonedPreset.unshift({
id: -1,
name: 'Preset'
})
return clonedPreset
}
},
watch: {
/**
* Watch selectedPresetIndex and trigger our logic.
* @param {Number} val
*/
selectedPresetIndex (val) {
// Save the selected preset (index) every time the value's changed.
preferences.selectedPreset = val
if (~~val !== -1) {
this.loadPreset(equalizerStore.getPresetById(val))
}
}
},
methods: {
/**
* Init the equalizer.
* @param {Element} player The audio player's node.
*/
init (player) {
const settings = equalizerStore.get()
const AudioContext = window.AudioContext ||
window.webkitAudioContext ||
window.mozAudioContext ||
window.oAudioContext ||
window.msAudioContext
context = new AudioContext()
this.preampGainNode = context.createGain()
this.changePreampGain(settings.preamp)
const source = context.createMediaElementSource(player)
source.connect(this.preampGainNode)
let prevFilter = null
// Create 10 bands with the frequencies similar to those of Winamp and connect them together.
const frequencies = [60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000]
frequencies.forEach((frequency, i) => {
const filter = context.createBiquadFilter()
if (i === 0) {
filter.type = 'lowshelf'
} else if (i === 9) {
filter.type = 'highshelf'
} else {
filter.type = 'peaking'
}
filter.gain.setTargetAtTime(settings[i] || 0, context.currentTime, 0.01)
filter.Q.setTargetAtTime(1, context.currentTime, 0.01)
filter.frequency.setTargetAtTime(frequency, context.currentTime, 0.01)
prevFilter ? prevFilter.connect(filter) : this.preampGainNode.connect(filter)
prevFilter = filter
this.bands.push({
filter,
label: (frequency + '').replace('000', 'K')
})
})
prevFilter.connect(context.destination)
this.$nextTick(this.createSliders)
},
/**
* Create the UI sliders for both the preamp and the normal bands.
*/
createSliders () {
const config = equalizerStore.get()
Array.from(document.querySelectorAll('#equalizer .slider')).forEach((el, i) => {
nouislider.create(el, {
connect: [false, true],
// the first element is the preamp. The rest are gains.
start: i === 0 ? config.preamp : config.gains[i - 1],
range: { min: -20, max: 20 },
orientation: 'vertical',
direction: 'rtl'
})
/**
* Update the audio effect upon sliding / tapping.
*/
el.noUiSlider.on('slide', (values, handle) => {
const value = values[handle]
if (el.parentNode.matches('.preamp')) {
this.changePreampGain(value)
} else {
this.changeFilterGain(this.bands[i - 1].filter, value)
}
})
/**
* Save the equalizer values after the change is done.
*/
el.noUiSlider.on('change', () => {
// User has customized the equalizer. No preset should be selected.
this.selectedPresetIndex = -1
this.save()
})
})
// Now we set this value to trigger the audio processing.
this.selectedPresetIndex = preferences.selectedPreset
},
/**
* Change the gain value for the preamp.
*
* @param {Number} dbValue The value of the gain, in dB.
*/
changePreampGain (dbValue) {
this.preampGainValue = dbValue
this.preampGainNode.gain.setTargetAtTime(Math.pow(10, dbValue / 20), context.currentTime, 0.01)
},
/**
* Change the gain value for a band/filter.
*
* @param {Object} filter The filter object
* @param {Object} value Value of the gain, in dB.
*/
changeFilterGain (filter, value) {
filter.gain.setTargetAtTime(value, context.currentTime, 0.01)
},
/**
* Load a preset when the user select it from the dropdown.
*/
loadPreset (preset) {
Array.from(document.querySelectorAll('#equalizer .slider')).forEach((el, i) => {
// We treat our preamp slider differently.
if ($.is(el.parentNode, '.preamp')) {
this.changePreampGain(preset.preamp)
// Update the slider values into GUI.
el.noUiSlider.set(preset.preamp)
} else {
this.changeFilterGain(this.bands[i - 1].filter, preset.gains[i - 1])
// Update the slider values into GUI.
el.noUiSlider.set(preset.gains[i - 1])
}
})
this.save()
},
/**
* Save the current user's equalizer preferences into local storage.
*/
save () {
equalizerStore.set(this.preampGainValue, this.bands.map(band => band.filter.gain.value))
}
},
mounted () {
event.on('equalizer:init', player => isAudioContextSupported() && this.init(player))
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#equalizer {
user-select: none;
position: absolute;
bottom: $footerHeight;
width: 100%;
background: rgba(0, 0, 0, 0.9);
display: flex;
flex-direction: column;
left: 0;
label {
margin-top: 8px;
margin-bottom: 0;
text-align: left;
}
.presets {
padding: 8px 16px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
flex: 1;
align-content: center;
z-index: 1;
border-bottom: 1px solid rgba(255, 255, 255, .1);
.select-wrapper {
position: relative;
margin-bottom: 0;
&::after {
content: '\f107';
font-family: FontAwesome;
color: $colorHighlight;
display: inline-block;
position: absolute;
right: 8px;
top: 3px;
pointer-events: none;
}
}
select {
background: none;
color: $colorMainText;
padding-left: 0;
width: 100px;
text-transform: none;
option {
color: #333;
}
}
}
.bands {
padding: 16px;
z-index: 1;
left: 0;
display: flex;
justify-content: space-between;
align-items: flex-start;
label, .indicators {
font-size: .8rem;
}
.band {
display: flex;
flex-direction: column;
align-items: center;
}
.slider {
height: 100px;
}
.indicators {
height: 100px;
width: 20px;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
margin-left: -16px;
opacity: 0;
transition: .4s;
span:first-child {
line-height: 8px;
}
span:last-child {
line-height: 8px;
}
}
&:hover .indicators {
opacity: 1;
}
}
.noUi {
&-connect {
background: none;
box-shadow: none;
&::after {
content: " ";
position: absolute;
width: 2px;
height: 100%;
background: #333;
top: 0;
left: 7px;
}
}
&-target {
background: transparent;
border-radius: 0;
border: 0;
box-shadow: none;
width: 16px;
&::after {
content: " ";
position: absolute;
width: 2px;
height: 100%;
background: #fff;
top: 0;
left: 7px;
}
}
&-handle {
border: 0;
border-radius: 0;
box-shadow: none;
cursor: pointer;
&::before, &::after {
display: none;
}
}
&-vertical {
.noUi-handle {
width: 16px;
height: 2px;
left: 0;
top: 0;
}
}
}
@media only screen and (max-width : 768px) {
position: fixed;
max-width: 414px;
left: auto;
right: 0;
bottom: $footerHeightMobile + 14px;
display: block;
height: auto;
label {
line-height: 20px;
}
}
}
</style>

View file

@ -1,469 +0,0 @@
<template>
<footer id="mainFooter">
<div class="side player-controls" id="playerControls">
<i class="prev fa fa-step-backward control" @click.prevent="playPrev"/>
<span class="play control" v-if="song.playbackState !== 'playing'" @click.prevent="resume">
<i class="fa fa-play"></i>
</span>
<span class="pause control" v-else @click.prevent="pause">
<i class="fa fa-pause"></i>
</span>
<i class="next fa fa-step-forward control" @click.prevent="playNext"/>
</div>
<div class="media-info-wrap">
<div class="middle-pane">
<span class="album-thumb" v-if="cover" :style="{ backgroundImage: 'url('+cover+')' }"/>
<div class="progress" id="progressPane">
<h3 class="title">{{ song.title }}</h3>
<p class="meta">
<a class="artist" :href="`#!/artist/${song.artist.id}`">{{ song.artist.name }}</a>
<a class="album" :href="`#!/album/${song.album.id}`">{{ song.album.name }}</a>
</p>
<div class="plyr">
<audio crossorigin="anonymous" controls></audio>
</div>
</div>
</div>
<div class="other-controls" :class="{ 'with-gradient': prefs.showExtraPanel }">
<div class="wrapper" v-koel-clickaway="closeEqualizer">
<equalizer v-if="useEqualizer" v-show="showEqualizer"/>
<sound-bar v-show="song.playbackState === 'playing'"/>
<i v-if="song.id"
class="like control fa fa-heart"
:class="{ liked: song.liked }"
@click.prevent="like"/>
<span class="control info"
@click.prevent="toggleExtraPanel"
:class="{ active: prefs.showExtraPanel }">Info</span>
<i class="fa fa-sliders control equalizer"
v-if="useEqualizer"
@click="showEqualizer = !showEqualizer"
:class="{ active: showEqualizer }"/>
<a v-else class="queue control" :class="{ active: viewingQueue }" href="#!/queue">
<i class="fa fa-list-ol"></i>
</a>
<span class="repeat control" :class="prefs.repeatMode" @click.prevent="changeRepeatMode">
<i class="fa fa-repeat"></i>
</span>
<volume/>
</div>
</div>
</div>
</footer>
</template>
<script>
import { playback, socket } from '@/services'
import { isAudioContextSupported, event } from '@/utils'
import { songStore, favoriteStore, preferenceStore } from '@/stores'
import soundBar from '../shared/sound-bar.vue'
import equalizer from './equalizer.vue'
import volume from './volume.vue'
export default {
data () {
return {
song: songStore.stub,
viewingQueue: false,
prefs: preferenceStore.state,
showEqualizer: false,
cover: null,
/**
* Indicate if we should build and use an equalizer.
*
* @type {Boolean}
*/
useEqualizer: isAudioContextSupported()
}
},
components: { soundBar, equalizer, volume },
computed: {
/**
* Get the previous song in queue.
*
* @return {?Object}
*/
prev () {
return playback.previous
},
/**
* Get the next song in queue.
*
* @return {?Object}
*/
next () {
return playback.next
}
},
methods: {
/**
* Play the previous song in queue.
*/
playPrev () {
return playback.playPrev()
},
/**
* Play the next song in queue.
*/
playNext () {
return playback.playNext()
},
/**
* Resume the current song.
* If the current song is the stub, just play the first song in the queue.
*/
resume () {
this.song.id ? playback.resume() : playback.playFirstInQueue()
},
/**
* Pause the playback.
*/
pause () {
playback.pause()
},
/**
* Change the repeat mode.
*/
changeRepeatMode () {
return playback.changeRepeatMode()
},
/**
* Like the current song.
*/
like () {
if (this.song.id) {
favoriteStore.toggleOne(this.song)
socket.broadcast('song', songStore.generateDataToBroadcast(this.song))
}
},
/**
* Toggle hide or show the extra panel.
*/
toggleExtraPanel () {
preferenceStore.set('showExtraPanel', !this.prefs.showExtraPanel)
},
closeEqualizer () {
this.showEqualizer = false
}
},
created () {
event.on({
/**
* Listen to song:played event to set the current playing song and the cover image.
*
* @param {Object} song
*
* @return {Boolean}
*/
'song:played': song => {
this.song = song
this.cover = this.song.album.cover
},
/**
* Listen to main-content-view:load event and highlight the Queue icon if
* the Queue screen is being loaded.
*/
'main-content-view:load': view => {
this.viewingQueue = view === 'queue'
}
})
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
@mixin hasSoftGradientOnTop($startColor) {
position: relative;
// Add a reverse gradient here to elimate the "hard cut" feel when the
// song list is too long.
&::before {
$gradientHeight: 2*$footerHeight/3;
content: " ";
position: absolute;
width: 100%;
height: $gradientHeight;
top: -$gradientHeight;
left: 0;
// Safari 8 won't recognize rgba(255, 255, 255, 0) and treat it as black.
// rgba($startColor, 0) is a workaround.
background-image: linear-gradient(to bottom, rgba($startColor, 0) 0%, rgba($startColor, 1) 100%);
pointer-events: none; // click-through
}
}
#mainFooter {
background: $color2ndBgr;
position: fixed;
width: 100%;
height: $footerHeight;
bottom: 0;
left: 0;
border-top: 1px solid $colorMainBgr;
display: flex;
flex: 1;
z-index: 1000;
.media-info-wrap {
flex: 1;
display: flex;
}
.other-controls {
@include vertical-center();
@include hasSoftGradientOnTop($colorMainBgr);
&.with-gradient {
@include hasSoftGradientOnTop($colorExtraBgr);
}
text-transform: uppercase;
flex: 0 0 $extraPanelWidth;
color: $colorLink;
.wrapper {
display: inline-table;
}
.control {
display: inline-block;
padding: 0 8px;
&.active {
color: $colorHighlight;
}
&:last-child {
padding-right: 0;
}
}
.repeat {
position: relative;
&.REPEAT_ALL, &.REPEAT_ONE {
color: $colorHighlight;
}
&.REPEAT_ONE::after {
content: "1";
position: absolute;
top: 0;
left: 0;
font-weight: 700;
font-size: .5rem;
text-align: center;
width: 100%;
}
}
.like {
&:hover {
}
&.liked {
color: $colorHeart;
}
}
@media only screen and (max-width: 768px) {
position: absolute !important;
right: 0;
top: 0;
height: 100%;
width: 188px;
&::before {
display: none;
}
.queue {
display: none;
}
.control {
padding: 0 8px;
}
}
}
@media only screen and (max-width: 768px) {
height: $footerHeightMobile;
}
}
#playerControls {
@include vertical-center();
flex: 0 0 256px;
font-size: 1.8rem;
background: $colorPlayerControlsBgr;
@include hasSoftGradientOnTop($colorSidebarBgr);
.prev, .next {
transition: .3s;
}
.play, .pause {
font-size: 2rem;
display: inline-block;
width: 42px;
height: 42px;
border-radius: 50%;
line-height: 40px;
text-align: center;
border: 1px solid #a0a0a0;
margin: 0 16px;
text-indent: 2px;
}
.pause {
text-indent: 0;
font-size: 18px;
}
.enabled {
opacity: 1;
}
@media only screen and (max-width: 768px) {
flex: 1;
&::before {
display: none;
}
}
}
.middle-pane {
flex: 1;
display: flex;
.album-thumb {
flex: 0 0 $footerHeight;
height: $footerHeight;
background: url("~#/../img/covers/unknown-album.png");
background-size: cover;
position: relative;
}
@include hasSoftGradientOnTop($colorMainBgr);
@media only screen and (max-width: 768px) {
width: 100%;
position: absolute;
top: 0;
left: 0;
height: 8px;
.album-thumb {
display: none;
}
::before {
display: none;
}
}
}
#progressPane {
flex: 1;
text-align: center;
padding-top: 16px;
line-height: 18px;
background: rgba(1, 1, 1, .2);
position: relative;
.meta {
font-size: .9rem;
a {
&:hover {
color: $colorHighlight;
}
}
}
// Some little tweaks here and there
.plyr {
width: 100%;
position: absolute;
top: 0;
left: 0;
}
.plyr__progress {
overflow: hidden;
height: 1px;
html.touch &, .middle-pane:hover & {
overflow: visible;
height: $plyr-volume-track-height;
}
}
.plyr__controls {
position: absolute;
top: 0;
left: 0;
width: 100%;
padding: 0;
}
.plyr__controls--left, .plyr__controls--right {
display: none;
}
@media only screen and (max-width: 768px) {
.meta, .title {
display: none;
}
top: -15px;
padding-top: 0;
width: 100%;
position: absolute;
.plyr {
&__progress {
height: 16px;
&--buffer[value],
&--played[value],
&--seek[type='range'] {
height: 16px;
}
}
}
}
}
</style>

View file

@ -1,82 +0,0 @@
<template>
<span class="volume control" id="volume">
<i class="fa fa-volume-off unmute" @click.prevent="unmute" v-if="muted"/>
<i class="fa fa-volume-up mute" @click.prevent="mute" v-else/>
<input type="range" id="volumeRange" max="10" step="0.1"
@change="broadcastVolume" class="plyr__volume"
@input="setVolume"
>
</span>
</template>
<script>
import { playback, socket } from '@/services'
export default {
data () {
return {
muted: false
}
},
methods: {
/**
* Mute the volume.
*/
mute () {
this.muted = true
playback.mute()
},
/**
* Unmute the volume.
*/
unmute () {
this.muted = false
playback.unmute()
},
/**
* Set the volume.
*
* @param {Event} e
*/
setVolume (e) {
const volume = parseFloat(e.target.value)
playback.setVolume(volume)
this.muted = volume === 0
},
/**
* Broadcast the volume changed event to remote controller.
*
* @param {Event} e
*/
broadcastVolume (e) {
socket.broadcast('volume:changed', parseFloat(e.target.value))
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#volume {
@include vertical-center();
// More tweaks
input[type=range] {
margin-top: -3px;
}
i {
width: 16px;
}
@media only screen and (max-width: 768px) {
display: none !important;
}
}
</style>

View file

@ -1,92 +0,0 @@
<template>
<header id="mainHeader">
<h1 class="brand" v-once>{{ appTitle }}</h1>
<span class="hamburger" @click="toggleSidebar">
<i class="fa fa-bars"></i>
</span>
<span class="magnifier" @click="toggleSearchForm">
<i class="fa fa-search"></i>
</span>
<search-form/>
<user-badge/>
</header>
</template>
<script>
import config from '@/config'
import { event } from '@/utils'
import searchForm from './search-form.vue'
import userBadge from './user-badge.vue'
export default {
components: { searchForm, userBadge },
data () {
return {
appTitle: config.appTitle
}
},
methods: {
/**
* No I'm not documenting this.
*/
toggleSidebar () {
event.emit('sidebar:toggle')
},
/**
* or this.
*/
toggleSearchForm () {
event.emit('search:toggle')
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#mainHeader {
height: $headerHeight;
background: $color2ndBgr;
display: flex;
border-bottom: 1px solid $colorMainBgr;
h1.brand {
flex: 1;
color: $colorMainText;
font-size: 1.7rem;
font-weight: $fontWeight_UltraThin;
opacity: 0;
line-height: $headerHeight;
text-align: center;
}
.hamburger, .magnifier {
font-size: 1.4rem;
flex: 0 0 48px;
order: -1;
line-height: $headerHeight;
text-align: center;
display: none;
}
@media only screen and (max-width: 667px) {
display: flex;
align-content: stretch;
justify-content: flext-start;
.hamburger, .magnifier {
display: inline-block;
}
h1.brand {
opacity: 1;
}
}
}
</style>

View file

@ -1,80 +0,0 @@
<template>
<div class="side search" id="searchForm" :class="{ showing: showing }">
<input type="search"
:class="{ dirty: q }"
@input="filter"
placeholder="Search"
v-model="q"
v-koel-focus
>
</div>
</template>
<script>
import isMobile from 'ismobilejs'
import { debounce } from 'lodash'
import { event } from '@/utils'
export default {
name: 'site-header--search-form',
data () {
return {
q: '',
showing: !isMobile.phone
}
},
methods: {
/**
* Limit the filter's execution rate using lodash's debounce.
*/
filter: debounce(function () {
event.emit('filter:changed', this.q)
}, 200)
},
created () {
event.on('search:toggle', () => {
this.showing = !this.showing
})
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#searchForm {
@include vertical-center();
flex: 0 0 256px;
order: -1;
background: $colorSearchFormBgr;
input[type="search"] {
width: 218px;
margin-top: 0;
}
@media only screen and (max-width : 667px) {
z-index: -1;
position: absolute;
left: 0;
background: rgba(0, 0, 0, .8);
width: 100%;
padding: 12px;
top: 0;
&.showing {
top: $headerHeight;
z-index: 100;
}
input[type="search"] {
width: 100%;
}
}
}
</style>

View file

@ -1,75 +0,0 @@
<template>
<span class="profile" id="userBadge">
<a class="view-profile control" href="#!/profile">
<img class="avatar" :src="state.current.avatar" alt="Avatar"/>
<span class="name">{{ state.current.name }}</span>
</a>
<a class="logout" @click.prevent="logout"><i class="fa fa-sign-out control"></i></a>
</span>
</template>
<script>
import { userStore } from '@/stores'
import { event } from '@/utils'
export default {
name: 'site-header--user-badge',
data () {
return {
state: userStore.state
}
},
methods: {
logout () {
event.emit('logout')
}
}
}
</script>
<style lang="scss">
@import "~#/partials/_vars.scss";
@import "~#/partials/_mixins.scss";
#userBadge {
@include vertical-center();
justify-content: flex-end;
flex: 0 0 $extraPanelWidth;
padding-right: 16px;
text-align: right;
.avatar {
width: 24px;
height: 24px;
border-radius: 50%;
margin-right: 8px;
}
.view-profile {
margin-right: 16px;
@include vertical-center();
}
@media only screen and (max-width : 667px) {
flex: 0 0 96px;
margin-right: 0;
padding-right: 0;
align-content: stretch;
.name {
display: none;
}
.view-profile, .logout {
flex: 0 0 40px;
font-size: 1.4rem;
margin-right: 0;
@include vertical-center();
}
}
}
</style>

View file

@ -1,4 +0,0 @@
export default {
unknownCover: (typeof window !== 'undefined' ? window.location.href.replace(window.location.hash, '') : '/') + 'public/img/covers/unknown-album.png',
appTitle: 'Koel'
}

View file

@ -1,15 +0,0 @@
/**
* A fork of https://github.com/simplesmiler/vue-clickaway.
* Trigger a function if the user clicks out of the bound element.
* @type {Object}
*/
export const clickawayDirective = {
bind (el, { value }) {
if (typeof value !== 'function') {
console.warn(`Expect a function, got ${value}`)
return
}
document.addEventListener('click', e => el.contains(e.target) || value())
}
}

View file

@ -1,8 +0,0 @@
/**
* A simple directive to set focus into an input field when it's shown.
*/
export const focusDirective = {
inserted (el) {
el.focus()
}
}

View file

@ -1,2 +0,0 @@
export * from './focus'
export * from './clickaway'

View file

@ -1,3 +0,0 @@
/*! modernizr 3.2.0 (Custom Build) | MIT *
* http://modernizr.com/download/?-touchevents-setclasses !*/
!function(e,n,t){function o(e,n){return typeof e===n}function s(){var e,n,t,s,a,i,r;for(var l in c)if(c.hasOwnProperty(l)){if(e=[],n=c[l],n.name&&(e.push(n.name.toLowerCase()),n.options&&n.options.aliases&&n.options.aliases.length))for(t=0;t<n.options.aliases.length;t++)e.push(n.options.aliases[t].toLowerCase());for(s=o(n.fn,"function")?n.fn():n.fn,a=0;a<e.length;a++)i=e[a],r=i.split("."),1===r.length?Modernizr[r[0]]=s:(!Modernizr[r[0]]||Modernizr[r[0]]instanceof Boolean||(Modernizr[r[0]]=new Boolean(Modernizr[r[0]])),Modernizr[r[0]][r[1]]=s),f.push((s?"":"no-")+r.join("-"))}}function a(e){var n=u.className,t=Modernizr._config.classPrefix||"";if(p&&(n=n.baseVal),Modernizr._config.enableJSClass){var o=new RegExp("(^|\\s)"+t+"no-js(\\s|$)");n=n.replace(o,"$1"+t+"js$2")}Modernizr._config.enableClasses&&(n+=" "+t+e.join(" "+t),p?u.className.baseVal=n:u.className=n)}function i(){return"function"!=typeof n.createElement?n.createElement(arguments[0]):p?n.createElementNS.call(n,"http://www.w3.org/2000/svg",arguments[0]):n.createElement.apply(n,arguments)}function r(){var e=n.body;return e||(e=i(p?"svg":"body"),e.fake=!0),e}function l(e,t,o,s){var a,l,f,c,d="modernizr",p=i("div"),h=r();if(parseInt(o,10))for(;o--;)f=i("div"),f.id=s?s[o]:d+(o+1),p.appendChild(f);return a=i("style"),a.type="text/css",a.id="s"+d,(h.fake?h:p).appendChild(a),h.appendChild(p),a.styleSheet?a.styleSheet.cssText=e:a.appendChild(n.createTextNode(e)),p.id=d,h.fake&&(h.style.background="",h.style.overflow="hidden",c=u.style.overflow,u.style.overflow="hidden",u.appendChild(h)),l=t(p,e),h.fake?(h.parentNode.removeChild(h),u.style.overflow=c,u.offsetHeight):p.parentNode.removeChild(p),!!l}var f=[],c=[],d={_version:"3.2.0",_config:{classPrefix:"",enableClasses:!0,enableJSClass:!0,usePrefixes:!0},_q:[],on:function(e,n){var t=this;setTimeout(function(){n(t[e])},0)},addTest:function(e,n,t){c.push({name:e,fn:n,options:t})},addAsyncTest:function(e){c.push({name:null,fn:e})}},Modernizr=function(){};Modernizr.prototype=d,Modernizr=new Modernizr;var u=n.documentElement,p="svg"===u.nodeName.toLowerCase(),h=d._config.usePrefixes?" -webkit- -moz- -o- -ms- ".split(" "):[];d._prefixes=h;var m=d.testStyles=l;Modernizr.addTest("touchevents",function(){var t;if("ontouchstart"in e||e.DocumentTouch&&n instanceof DocumentTouch)t=!0;else{var o=["@media (",h.join("touch-enabled),("),"heartz",")","{#modernizr{top:9px;position:absolute}}"].join("");m(o,function(e){t=9===e.offsetTop})}return t}),s(),a(f),delete d.addTest,delete d.addAsyncTest;for(var v=0;v<Modernizr._q.length;v++)Modernizr._q[v]();e.Modernizr=Modernizr}(window,document);

View file

@ -1,13 +0,0 @@
import { secondsToHis } from '@/utils'
export default {
computed: {
length () {
return this.album.songs.reduce((acc, song) => acc + song.length, 0)
},
fmtLength () {
return secondsToHis(this.length)
}
}
}

View file

@ -1,31 +0,0 @@
import { secondsToHis } from '@/utils'
import config from '@/config'
export default {
computed: {
length () {
return this.artist.songs.reduce((acc, song) => acc + song.length, 0)
},
fmtLength () {
return secondsToHis(this.length)
},
image () {
if (!this.artist.image) {
this.artist.image = config.unknownCover
this.artist.albums.every(album => {
// If there's a "real" cover, use it.
if (album.image !== config.unknownCover) {
this.artist.image = album.cover
// I want to break free.
return false
}
})
}
return this.artist.image
}
}
}

View file

@ -1,58 +0,0 @@
/**
* Add necessary functionalities into a view that contains a song-list component.
*/
import { assignIn } from 'lodash'
import isMobile from 'ismobilejs'
import { playback } from '@/services'
import songList from '@/components/shared/song-list.vue'
import songListControls from '@/components/shared/song-list-controls.vue'
import controlsToggler from '@/components/shared/song-list-controls-toggler.vue'
import { event } from '@/utils'
export default {
components: { songList, songListControls, controlsToggler },
data () {
return {
state: null,
meta: {
songCount: 0,
totalLength: '00:00'
},
selectedSongs: [],
showingControls: false,
songListControlConfig: {},
isPhone: isMobile.phone
}
},
methods: {
shuffleAll () {
playback.queueAndPlay(this.state.songs, true)
},
shuffleSelected () {
playback.queueAndPlay(this.selectedSongs, true)
},
toggleControls () {
this.showingControls = !this.showingControls
}
},
created () {
event.on({
updateMeta: (meta, target) => {
target === this && assignIn(this.meta, meta)
},
setSelectedSongs: (songs, target) => {
if (target === this) {
this.selectedSongs = songs
}
}
})
}
}

View file

@ -1,34 +0,0 @@
import toTopButton from '@/components/shared/to-top-button.vue'
/**
* Add a "infinite scroll" functionality to any component using this mixin.
* Such a component should have a `scrolling` method bound to `scroll` event on
* the wrapper element: @scroll="scrolling"
*/
export default {
components: { toTopButton },
data () {
return {
numOfItems: 30, // Number of currently loaded and displayed items
perPage: 30 // Number of items to be loaded per "page"
}
},
methods: {
scrolling ({ target: { scrollTop, clientHeight, scrollHeight }}) {
// Here we check if the user has scrolled to the end of the wrapper (or 32px to the end).
// If that's true, load more items.
if (scrollTop + clientHeight >= scrollHeight - 32) {
this.displayMore()
}
},
/**
* Load and display more items into the scrollable area.
*/
displayMore () {
this.numOfItems += this.perPage
}
}
}

View file

@ -1,73 +0,0 @@
import { queueStore, playlistStore, favoriteStore } from '@/stores'
/**
* Includes the methods triggerable on a song (context) menu.
* Each component including this mixin must have a `songs` array as either data, prop, or computed.
* Note that for some components, some of the methods here may not be applicable, or overridden,
* for example close() and open().
*/
export default {
data () {
return {
shown: false,
top: 0,
left: 0
}
},
methods: {
open () {},
/**
* Close all submenus.
*/
close () {
Array.from(this.$el.querySelectorAll('.submenu')).forEach(el => {
el.style.display = 'none'
})
this.shown = false
},
/**
* Queue select songs after the current song.
*/
queueSongsAfterCurrent () {
queueStore.queueAfterCurrent(this.songs)
this.close()
},
/**
* Queue selected songs to bottom of queue.
*/
queueSongsToBottom () {
queueStore.queue(this.songs)
this.close()
},
/**
* Queue selected songs to top of queue.
*/
queueSongsToTop () {
queueStore.queue(this.songs, false, true)
this.close()
},
/**
* Add the selected songs into Favorites.
*/
addSongsToFavorite () {
favoriteStore.like(this.songs)
this.close()
},
/**
* Add the selected songs into the chosen playlist.
*
* @param {Object} playlist The playlist.
*/
addSongsToExistingPlaylist (playlist) {
playlistStore.addSongs(playlist, this.songs)
this.close()
}
}
}

View file

@ -1,12 +0,0 @@
import './static-loader'
import Vue from 'vue'
import { http } from '@/services'
import App from './app.vue'
new Vue({
el: '#app',
render: h => h(App),
created () {
http.init()
}
})

View file

@ -1,499 +0,0 @@
<template>
<div id="app" :class="{ 'standalone' : inStandaloneMode }">
<template v-if="authenticated">
<div class="translucent" v-if="song" :style="{ backgroundImage: 'url('+song.album.cover+')' }">
</div>
<div id="main">
<template v-if="connected">
<div class="details" v-if="song">
<div class="cover" :style="{ backgroundImage: 'url('+song.album.cover+')' }"></div>
<div class="info">
<div class="wrap">
<p class="title text">{{ song.title }}</p>
<p class="artist text">{{ song.artist.name }}</p>
<p class="album text">{{ song.album.name }}</p>
</div>
</div>
</div>
<div class="none" v-else>
<p>No song is playing.</p>
</div>
<footer>
<a class="favorite" @click.prevent="toggleFavorite">
<i class="fa fa-heart yep" v-if="song && song.liked"></i>
<i class="fa fa-heart-o" v-else></i>
</a>
<a class="prev" @click="playPrev">
<i class="fa fa-step-backward"></i>
</a>
<a class="play-pause" @click.prevent="togglePlayback">
<i class="fa fa-pause" v-if="playing"></i>
<i class="fa fa-play" v-else></i>
</a>
<a class="next" @click.prevent="playNext">
<i class="fa fa-step-forward"></i>
</a>
<span class="volume">
<span id="volumeSlider" v-show="showingVolumeSlider"></span>
<span class="icon" @click.prevent="toggleVolumeSlider">
<i class="fa fa-volume-off" v-if="muted"></i>
<i class="fa fa-volume-up" v-else></i>
</span>
</span>
</footer>
</template>
<div v-else class="loader">
<div v-if="!maxRetriesReached">
<p><span>Searching for Koel</span></p>
<div class="signal"></div>
</div>
<div v-else>
<p>No active Koel instance found.
<a @click.prevent="rescan" class="rescan">Rescan</a>
</p>
</div>
</div>
</div>
</template>
<div class="login-wrapper" v-else>
<login-form @loggedin="onUserLoggedIn"/>
</div>
</div>
</template>
<script>
import nouislider from 'nouislider'
import { socket, ls } from '@/services'
import { userStore } from '@/stores'
import loginForm from '@/components/auth/login-form.vue'
let volumeSlider
const MAX_RETRIES = 10
export default {
components: { loginForm },
data () {
return {
authenticated: false,
song: null,
lastActiveTime: new Date().getTime(),
inStandaloneMode: false,
connected: false,
muted: false,
showingVolumeSlider: false,
retries: 0
}
},
watch: {
connected () {
this.$nextTick(() => {
volumeSlider = document.getElementById('volumeSlider')
nouislider.create(volumeSlider, {
orientation: 'vertical',
connect: [true, false],
start: this.volume,
range: { min: 0, max: 10 },
direction: 'rtl'
})
volumeSlider.noUiSlider.on('change', (values, handle) => {
const volume = parseFloat(values[handle])
this.muted = !volume
socket.broadcast('volume:set', { volume })
})
})
},
volume (value) {
volumeSlider.noUiSlider.set(value)
}
},
methods: {
onUserLoggedIn () {
this.authenticated = true
this.init()
},
async init () {
try {
const user = await userStore.getProfile()
userStore.init([], user)
await socket.init()
socket.listen('song', ({ song }) => {
this.song = song
}).listen('playback:stopped', () => {
if (this.song) {
this.song.playbackState = 'stopped'
}
}).listen('status', ({ song, volume }) => {
this.song = song
this.volume = volume
this.connected = true
}).listen('volume:changed', volume => {
volumeSlider.noUiSlider.set(volume)
})
this.scan()
} catch (e) {
this.authenticated = false
}
},
toggleVolumeSlider () {
this.showingVolumeSlider = !this.showingVolumeSlider
},
toggleFavorite () {
if (!this.song) {
return
}
this.song.liked = !this.song.liked
socket.broadcast('favorite:toggle')
},
togglePlayback () {
if (this.song) {
this.song.playbackState = this.song.playbackState === 'playing' ? 'paused' : 'playing'
}
socket.broadcast('playback:toggle')
},
playNext () {
socket.broadcast('playback:next')
},
playPrev () {
socket.broadcast('playback:prev')
},
getStatus () {
socket.broadcast('status:get')
},
/**
* As iOS will put a web app into standby/sleep mode (and halt all JS execution),
* this method will keep track of the last active time and keep the status always fresh.
*/
heartbeat () {
const now = new Date().getTime()
if (now - this.lastActiveTime > 2000) {
this.getStatus()
}
this.lastActiveTime = now
},
/**
* Scan for an active (desktop) Koel instance.
*/
scan () {
if (!this.connected) {
if (!this.maxRetriesReached) {
this.getStatus()
this.retries++
window.setTimeout(this.scan, 1000)
}
} else {
this.retries = 0
}
},
rescan () {
this.retries = 0
this.scan()
}
},
computed: {
playing () {
return this.song && this.song.playbackState === 'playing'
},
maxRetriesReached () {
return this.retries >= MAX_RETRIES
}
},
created () {
window.setInterval(this.heartbeat, 500)
this.inStandaloneMode = window.navigator.standalone
},
mounted () {
// The app has just been initialized, check if we can get the user data with an already existing token
const token = ls.get('jwt-token')
if (token) {
this.authenticated = true
this.init()
}
}
}
</script>
<style lang="scss">
@import "resources/assets/sass/partials/_vars.scss";
@import "resources/assets/sass/partials/_mixins.scss";
@import "resources/assets/sass/partials/_shared.scss";
#app {
height: 100%;
background: $colorMainBgr;
.login-wrapper {
display: flex;
min-height: 100vh;
flex-direction: column;
@include vertical-center();
}
.translucent {
position: absolute;
top: -20px;
left: -20px;
right: -20px;
bottom: -20px;
filter: blur(20px);
opacity: .3;
z-index: 0;
overflow: hidden;
background-size: cover;
background-position: center;
transform: translateZ(0);
backface-visibility: hidden;
perspective: 1000;
pointer-events: none;
}
.loader {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
position: relative;
p {
position: absolute;
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
top: 0;
left: 0;
padding-bottom: 40px;
}
.signal {
border: 1px solid $colorOrange;
border-radius: 50%;
height: 0;
opacity: 0;
width: 50vw;
animation: pulsate 1.5s ease-out;
animation-iteration-count: infinite;
transform: translate(-50%, -50%);
}
.rescan {
margin-left: 5px;
color: $colorOrange;
}
@keyframes pulsate {
0% {
transform:scale(.1);
opacity: 0.0;
}
50% {
opacity:1;
}
100% {
transform:scale(1.2);
opacity:0;
}
}
}
}
#main {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
text-align: center;
z-index: 1;
position: relative;
.none, .details {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
}
.details {
.info {
width: 100%;
display: flex;
flex-direction: column;
justify-content: space-around;
}
.cover {
margin: 0 auto;
width: calc(70vw + 4px);
height: calc(70vw + 4px);
border-radius: 50%;
border: 2px solid #fff;
background-position: center center;
background-size: cover;
background-color: #2d2f2f;
}
.text {
max-width: 90%;
margin: 0 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.3;
}
.title {
font-size: 6vmin;
font-weight: bold;
margin: 0 auto 10px;
}
.artist {
font-size: 5vmin;
margin: 0 auto 6px;
font-weight: 100;
opacity: .5;
}
.album {
font-size: 4vmin;
font-weight: 100;
opacity: .5;
}
}
footer {
height: 18vh;
display: flex;
justify-content: space-around;
align-items: center;
border-top: 1px solid rgba(255, 255, 255, .1);
font-size: 5vmin;
a {
color: #fff;
&:active {
opacity: .8;
}
}
.favorite {
.yep {
color: #bf2043;
}
}
.prev, .next {
font-size: 6vmin;
}
.play-pause {
display: inline-block;
width: 16vmin;
height: 16vmin;
border: 1px solid #fff;
border-radius: 50%;
line-height: 16vmin;
font-size: 7vmin;
&.fa-play {
margin-left: 4px;
}
}
}
}
#app.standalone {
padding-top: 20px;
#main {
.details {
.cover {
width: calc(80vw - 4px);
height: calc(80vw - 4px);
}
}
.footer {
height: 20vh;
}
}
}
.volume {
position: relative;
.icon {
width: 20px;
display: inline-block;
text-align: center;
}
}
#volumeSlider {
height: 80px;
position: absolute;
bottom: calc(50% + 26px);
}
.noUi-target {
background: #fff;
border-radius: 4px;
border: 0;
box-shadow: none;
left: 7px;
}
.noUi-base {
height: calc(100% - 16px);
border-radius: 4px;
}
.noUi-vertical {
width: 8px;
}
.noUi-vertical .noUi-handle {
width: 16px;
height: 16px;
border-radius: 50%;
border: 0;
left: -4px;
top: 0;
&::after, &::before {
display: none;
}
}
.noUi-connect {
background: transparent;
box-shadow: none;
}
</style>

View file

@ -1,5 +0,0 @@
import 'babel-polyfill/dist/polyfill.min.js'
import '@/libs/modernizr-custom.js'
import '@/../css/meyer-reset.min.css'
import 'nouislider/distribute/nouislider.min.css'
import 'font-awesome/css/font-awesome.min.css'

View file

@ -1,130 +0,0 @@
import isMobile from 'ismobilejs'
import { each } from 'lodash'
import { loadMainView } from './utils'
import { artistStore, albumStore, songStore, queueStore, playlistStore, userStore } from './stores'
import { playback } from './services'
export default {
routes: {
'/home' () {
loadMainView('home')
},
'/queue' () {
loadMainView('queue')
},
'/songs' () {
loadMainView('songs')
},
'/albums' () {
loadMainView('albums')
},
'/album/(\\d+)' (id) {
const album = albumStore.byId(~~id)
if (album) {
loadMainView('album', album)
}
},
'/artists' () {
loadMainView('artists')
},
'/artist/(\\d+)' (id) {
const artist = artistStore.byId(~~id)
if (artist) {
loadMainView('artist', artist)
}
},
'/favorites' () {
loadMainView('favorites')
},
'/playlist/(\\d+)' (id) {
const playlist = playlistStore.byId(~~id)
if (playlist) {
loadMainView('playlist', playlist)
}
},
'/settings' () {
userStore.current.is_admin && loadMainView('settings')
},
'/users' () {
userStore.current.is_admin && loadMainView('users')
},
'/profile' () {
loadMainView('profile')
},
'/login' () {
},
'/song/([a-z0-9]{32})' (id) {
const song = songStore.byId(id)
if (!song) return
if (isMobile.apple.device) {
// Mobile Safari doesn't allow autoplay, so we just queue.
queueStore.queue(song)
loadMainView('queue')
} else {
playback.queueAndPlay(song)
}
},
'/youtube' () {
loadMainView('youtubePlayer')
}
},
init () {
this.loadState()
window.addEventListener('popstate', () => this.loadState(), true)
},
loadState () {
if (!window.location.hash) {
return this.go('home')
}
each(Object.keys(this.routes), route => {
const matches = window.location.hash.match(new RegExp(`^#!${route}$`))
if (matches) {
const [, ...params] = matches
this.routes[route](...params)
return false
}
})
},
/**
* Navigate to a (relative, hashed) path.
*
* @param {String} path
*/
go (path) {
if (window.__UNIT_TESTING__) {
return
}
if (path[0] !== '/') {
path = `/${path}`
}
if (path.indexOf('/#!') !== 0) {
path = `/#!${path}`
}
path = path.substring(1, path.length)
document.location.href = `${document.location.origin}${document.location.pathname}${path}`
}
}

View file

@ -1,65 +0,0 @@
import { playlistStore, favoriteStore } from '@/stores'
import { ls } from '.'
export const download = {
/**
* Download individual song(s).
*
* @param {Array.<Object>|Object} songs
*/
fromSongs (songs) {
const query = [].concat(songs).reduce((q, song) => `songs[]=${song.id}&${q}`, '')
return this.trigger(`songs?${query}`)
},
/**
* Download all songs in an album.
*
* @param {Object} album
*/
fromAlbum (album) {
return this.trigger(`album/${album.id}`)
},
/**
* Download all songs performed by an artist.
*
* @param {Object} artist
*/
fromArtist (artist) {
// It's safe to assume an artist always has songs.
// After all, what's an artist without her songs?
// (See what I did there? Yes, I'm advocating for women's rights).
return this.trigger(`artist/${artist.id}`)
},
/**
* Download all songs in a playlist.
*
* @param {Object} playlist
*/
fromPlaylist (playlist) {
return playlistStore.getSongs(playlist).length ? this.trigger(`playlist/${playlist.id}`) : null
},
/**
* Download all favorite songs.
*/
fromFavorites () {
return favoriteStore.all.length ? this.trigger('favorites') : null
},
/**
* Build a download link using a segment and trigger it.
*
* @param {string} uri The uri segment, corresponding to the song(s),
* artist, playlist, or album.
*/
trigger (uri) {
const sep = uri.includes('?') ? '&' : '?'
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
iframe.setAttribute('src', `${window.BASE_URL}api/download/${uri}${sep}jwt-token=${ls.get('jwt-token')}`)
document.body.appendChild(iframe)
}
}

View file

@ -1,66 +0,0 @@
import axios from 'axios'
import NProgress from 'nprogress'
import { event } from '@/utils'
import { ls } from '@/services'
/**
* Responsible for all HTTP requests.
*/
export const http = {
request (method, url, data, successCb = null, errorCb = null) {
axios.request({ url, data, method: method.toLowerCase() }).then(successCb).catch(errorCb)
},
get (url, successCb = null, errorCb = null) {
return this.request('get', url, {}, successCb, errorCb)
},
post (url, data, successCb = null, errorCb = null) {
return this.request('post', url, data, successCb, errorCb)
},
put (url, data, successCb = null, errorCb = null) {
return this.request('put', url, data, successCb, errorCb)
},
delete (url, data = {}, successCb = null, errorCb = null) {
return this.request('delete', url, data, successCb, errorCb)
},
/**
* Init the service.
*/
init () {
axios.defaults.baseURL = `${window.BASE_URL}api`
// Intercept the request to make sure the token is injected into the header.
axios.interceptors.request.use(config => {
config.headers.Authorization = `Bearer ${ls.get('jwt-token')}`
return config
})
// Intercept the response and…
axios.interceptors.response.use(response => {
NProgress.done()
// …get the token from the header or response data if exists, and save it.
const token = response.headers['Authorization'] || response.data['token']
token && ls.set('jwt-token', token)
return response
}, error => {
NProgress.done()
// Also, if we receive a Bad Request / Unauthorized error
if (error.response.status === 400 || error.response.status === 401) {
// and we're not trying to login
if (!(error.config.method === 'post' && /\/api\/me\/?$/.test(error.config.url))) {
// the token must have expired. Log out.
event.emit('logout')
}
}
return Promise.reject(error)
})
}
}

View file

@ -1,7 +0,0 @@
export * from './info'
export * from './download'
export * from './http'
export * from './ls'
export * from './playback'
export * from './youtube'
export * from './socket'

View file

@ -1,48 +0,0 @@
import { secondsToHis } from '@/utils'
import { http } from '..'
export const albumInfo = {
/**
* Get extra album info (from Last.fm).
*
* @param {Object} album
*/
fetch (album) {
return new Promise((resolve, reject) => {
if (album.info) {
resolve(album)
return
}
http.get(`album/${album.id}/info`, ({ data }) => {
data && this.merge(album, data)
resolve(album)
}, error => reject(error))
})
},
/**
* Merge the (fetched) info into an album.
*
* @param {Object} album
* @param {Object} info
*/
merge (album, info) {
// Convert the duration into i:s
info.tracks && info.tracks.forEach(track => {
track.fmtLength = secondsToHis(track.length)
})
// If the album cover is not in a nice form, discard.
if (typeof info.image !== 'string') {
info.image = null
}
// Set the album cover on the client side to the retrieved image from server.
if (info.cover) {
album.cover = info.cover
}
album.info = info
}
}

View file

@ -1,42 +0,0 @@
import { http } from '..'
export const artistInfo = {
/**
* Get extra artist info (from Last.fm).
*
* @param {Object} artist
*/
fetch (artist) {
return new Promise((resolve, reject) => {
if (artist.info) {
resolve(artist)
return
}
http.get(`artist/${artist.id}/info`, ({ data }) => {
data && this.merge(artist, data)
resolve(artist)
}, error => reject(error))
})
},
/**
* Merge the (fetched) info into an artist.
*
* @param {Object} artist
* @param {Object} info
*/
merge (artist, info) {
// If the artist image is not in a nice form, discard.
if (typeof info.image !== 'string') {
info.image = null
}
// Set the artist image on the client side to the retrieved image from server.
if (info.image) {
artist.image = info.image
}
artist.info = info
}
}

View file

@ -1,3 +0,0 @@
export * from './album'
export * from './artist'
export * from './song'

View file

@ -1,28 +0,0 @@
/*eslint-disable camelcase*/
import { http, albumInfo, artistInfo } from '..'
export const songInfo = {
/**
* Get extra song information (lyrics, artist info, album info).
*
* @param {Object} song
*/
fetch (song) {
return new Promise((resolve, reject) => {
if (song.infoRetrieved) {
resolve(song)
return
}
http.get(`${song.id}/info`, ({ data: { artist_info, album_info, youtube, lyrics }}) => {
song.lyrics = lyrics
artist_info && artistInfo.merge(song.artist, artist_info)
album_info && albumInfo.merge(song.album, album_info)
song.youtube = youtube
song.infoRetrieved = true
resolve(song)
}, error => reject(error))
})
}
}

View file

@ -1,7 +0,0 @@
import localStore from 'local-storage'
export const ls = {
get: (key, defaultVal = null) => localStore(key) || defaultVal,
set: (key, val) => localStore(key, val),
remove: key => localStore.remove(key)
}

View file

@ -1,435 +0,0 @@
import { shuffle, orderBy } from 'lodash'
import plyr from 'plyr'
import Vue from 'vue'
import isMobile from 'ismobilejs'
import { event, isMediaSessionSupported } from '@/utils'
import { queueStore, sharedStore, userStore, songStore, preferenceStore as preferences } from '@/stores'
import { socket } from '@/services'
import config from '@/config'
import router from '@/router'
export const playback = {
player: null,
volumeInput: null,
repeatModes: ['NO_REPEAT', 'REPEAT_ALL', 'REPEAT_ONE'],
initialized: false,
/**
* Initialize the playback service for this whole Koel app.
*/
init () {
// We don't need to init this service twice, or the media events will be duplicated.
if (this.initialized) {
return
}
this.player = plyr.setup({
controls: []
})[0]
this.audio = document.querySelector('audio')
this.volumeInput = document.getElementById('volumeRange')
const player = document.querySelector('.plyr')
/**
* Listen to 'error' event on the audio player and play the next song if any.
*/
player.addEventListener('error', () => this.playNext(), true)
/**
* Listen to 'ended' event on the audio player and play the next song in the queue.
*/
player.addEventListener('ended', e => {
if (sharedStore.state.useLastfm && userStore.current.preferences.lastfm_session_key) {
songStore.scrobble(queueStore.current)
}
preferences.repeatMode === 'REPEAT_ONE' ? this.restart() : this.playNext()
})
/**
* Attempt to preload the next song.
*/
player.addEventListener('canplaythrough', e => {
const nextSong = queueStore.next
if (!nextSong || nextSong.preloaded || (isMobile.any && preferences.transcodeOnMobile)) {
// Don't preload if
// - there's no next song
// - next song has already been preloaded
// - we're on mobile and "transcode" option is checked
return
}
const audio = document.createElement('audio')
audio.setAttribute('src', songStore.getSourceUrl(nextSong))
audio.setAttribute('preload', 'auto')
audio.load()
nextSong.preloaded = true
})
player.addEventListener('timeupdate', e => {
const song = queueStore.current
if (this.player.media.currentTime > 10 && !song.registeredPlayCount) {
// After 10 seconds, register a play count and add it into "recently played" list
songStore.addRecentlyPlayed(song)
songStore.registerPlay(song)
song.registeredPlayCount = true
}
})
// On init, set the volume to the value found in the local storage.
this.setVolume(preferences.volume)
// Init the equalizer if supported.
event.emit('equalizer:init', this.player.media)
if (isMediaSessionSupported()) {
navigator.mediaSession.setActionHandler('play', () => this.resume())
navigator.mediaSession.setActionHandler('pause', () => this.pause())
navigator.mediaSession.setActionHandler('previoustrack', () => this.playPrev())
navigator.mediaSession.setActionHandler('nexttrack', () => this.playNext())
}
socket.listen('playback:toggle', () => this.toggle())
.listen('playback:next', () => this.playNext())
.listen('playback:prev', () => this.playPrev())
.listen('status:get', () => {
const data = queueStore.current ? songStore.generateDataToBroadcast(queueStore.current) : {}
data.volume = this.volumeInput.value
socket.broadcast('status', data)
})
.listen('song:getcurrent', () => {
socket.broadcast(
'song',
queueStore.current
? songStore.generateDataToBroadcast(queueStore.current)
: { song: null }
)
})
.listen('volume:set', ({ volume }) => this.setVolume(volume))
this.initialized = true
},
/**
* Play a song. Because
*
* So many adventures couldn't happen today,
* So many songs we forgot to play
* So many dreams swinging out of the blue
* We'll let them come true
*
* @param {Object} song The song to play
*/
play (song) {
if (!song) {
return
}
if (queueStore.current) {
queueStore.current.playbackState = 'stopped'
}
song.playbackState = 'playing'
// Set the song as the current song
queueStore.current = song
// Manually set the `src` attribute of the audio to prevent plyr from resetting
// the audio media object and cause our equalizer to malfunction.
this.player.media.src = songStore.getSourceUrl(song)
document.title = `${song.title}${config.appTitle}`
document.querySelector('.plyr audio').setAttribute('title', `${song.artist.name} - ${song.title}`)
// We'll just "restart" playing the song, which will handle notification, scrobbling etc.
this.restart()
},
/**
* Show the "now playing" notification for a song.
*
* @param {Object} song
*/
showNotification (song) {
// Show the notification if we're allowed to
if (!window.Notification || !preferences.notify) {
return
}
try {
const notif = new window.Notification(`${song.title}`, {
icon: song.album.cover,
body: `${song.album.name} ${song.artist.name}`
})
notif.onclick = () => window.focus()
// Close the notif after 5 secs.
window.setTimeout(() => notif.close(), 5000)
} catch (e) {
// Notification fails.
// @link https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification
}
if ('mediaSession' in navigator) {
/* global MediaMetadata */
navigator.mediaSession.metadata = new MediaMetadata({
title: song.title,
artist: song.artist.name,
album: song.album.name,
artwork: [
{ src: song.album.cover, sizes: '256x256', type: 'image/png' }
]
})
}
},
/**
* Restart playing a song.
*/
restart () {
const song = queueStore.current
this.showNotification(song)
// Record the UNIX timestamp the song start playing, for scrobbling purpose
song.playStartTime = Math.floor(Date.now() / 1000)
song.registeredPlayCount = false
event.emit('song:played', song)
socket.broadcast('song', songStore.generateDataToBroadcast(song))
this.player.restart()
this.player.play()
},
/**
* The next song in the queue.
* If we're in REPEAT_ALL mode and there's no next song, just get the first song.
*
* @return {Object} The song
*/
get next () {
if (queueStore.next) {
return queueStore.next
}
if (preferences.repeatMode === 'REPEAT_ALL') {
return queueStore.first
}
},
/**
* The previous song in the queue.
* If we're in REPEAT_ALL mode and there's no prev song, get the last song.
*
* @return {Object} The song
*/
get previous () {
if (queueStore.previous) {
return queueStore.previous
}
if (preferences.repeatMode === 'REPEAT_ALL') {
return queueStore.last
}
},
/**
* Circle through the repeat mode.
* The selected mode will be stored into local storage as well.
*/
changeRepeatMode () {
let index = this.repeatModes.indexOf(preferences.repeatMode) + 1
if (index >= this.repeatModes.length) {
index = 0
}
preferences.repeatMode = this.repeatModes[index]
},
/**
* Play the prev song in the queue, if one is found.
* If the prev song is not found and the current mode is NO_REPEAT, we stop completely.
*/
playPrev () {
// If the song's duration is greater than 5 seconds and we've passed 5 seconds into it,
// restart playing instead.
if (this.player.media.currentTime > 5 && queueStore.current.length > 5) {
this.player.restart()
return
}
const prev = this.previous
!prev && preferences.repeatMode === 'NO_REPEAT'
? this.stop()
: this.play(prev)
},
/**
* Play the next song in the queue, if one is found.
* If the next song is not found and the current mode is NO_REPEAT, we stop completely.
*/
playNext () {
const next = this.next
!next && preferences.repeatMode === 'NO_REPEAT'
? this.stop() // Nothing lasts forever, even cold November rain.
: this.play(next)
},
/**
* Set the volume level.
*
* @param {Number} volume 0-10
* @param {Boolean=true} persist Whether the volume should be saved into local storage
*/
setVolume (volume, persist = true) {
this.player.setVolume(volume)
if (persist) {
preferences.volume = volume
}
this.volumeInput.value = volume
},
/**
* Mute playback.
*/
mute () {
this.setVolume(0, false)
},
/**
* Unmute playback.
*/
unmute () {
// If the saved volume is 0, we unmute to the default level (7).
if (preferences.volume === '0' || preferences.volume === 0) {
preferences.volume = 7
}
this.setVolume(preferences.volume)
},
/**
* Completely stop playback.
*/
stop () {
document.title = config.appTitle
this.player.pause()
this.player.seek(0)
if (queueStore.current) {
queueStore.current.playbackState = 'stopped'
}
socket.broadcast('playback:stopped')
},
/**
* Pause playback.
*/
pause () {
this.player.pause()
queueStore.current.playbackState = 'paused'
socket.broadcast('song', songStore.generateDataToBroadcast(queueStore.current))
},
/**
* Resume playback.
*/
resume () {
this.player.play()
queueStore.current.playbackState = 'playing'
event.emit('song:played', queueStore.current)
socket.broadcast('song', songStore.generateDataToBroadcast(queueStore.current))
},
/**
* Toggle playback.
*/
toggle () {
if (!queueStore.current) {
this.playFirstInQueue()
return
}
if (queueStore.current.playbackState !== 'playing') {
this.resume()
return
}
this.pause()
},
/**
* Queue up songs (replace them into the queue) and start playing right away.
*
* @param {?Array.<Object>} songs An array of song objects. Defaults to all songs if null.
* @param {Boolean=false} shuffled Whether to shuffle the songs before playing.
*/
queueAndPlay (songs = null, shuffled = false) {
if (!songs) {
songs = songStore.all
}
if (!songs.length) {
return
}
if (shuffled) {
songs = shuffle(songs)
}
queueStore.queue(songs, true)
// Wrap this inside a nextTick() to wait for the DOM to complete updating
// and then play the first song in the queue.
Vue.nextTick(() => {
router.go('queue')
this.play(queueStore.first)
})
},
/**
* Play the first song in the queue.
* If the current queue is empty, try creating it by shuffling all songs.
*/
playFirstInQueue () {
queueStore.all.length ? this.play(queueStore.first) : this.queueAndPlay()
},
/**
* Play all songs by an artist.
*
* @param {Object} artist The artist object
* @param {Boolean=true} shuffled Whether to shuffle the songs
*/
playAllByArtist ({ songs }, shuffled = true) {
shuffled
? this.queueAndPlay(songs, true)
: this.queueAndPlay(orderBy(songs, ['album_id', 'disc', 'track']))
},
/**
* Play all songs in an album.
*
* @param {Object} album The album object
* @param {Boolean=true} shuffled Whether to shuffle the songs
*/
playAllInAlbum ({ songs }, shuffled = true) {
shuffled
? this.queueAndPlay(songs, true)
: this.queueAndPlay(orderBy(songs, ['disc', 'track']))
}
}

View file

@ -1,54 +0,0 @@
import Pusher from 'pusher-js'
import { userStore } from '@/stores'
import { ls } from '.'
export const socket = {
pusher: null,
channel: null,
async init () {
return new Promise((resolve, reject) => {
if (!window.PUSHER_APP_KEY) {
return resolve()
}
this.pusher = new Pusher(window.PUSHER_APP_KEY, {
authEndpoint: `${window.BASE_URL}api/broadcasting/auth`,
auth: {
headers: {
Authorization: `Bearer ${ls.get('jwt-token')}`
}
},
cluster: window.PUSHER_APP_CLUSTER,
encrypted: true
})
this.channel = this.pusher.subscribe('private-koel')
this.channel.bind('pusher:subscription_succeeded', () => resolve())
this.channel.bind('pusher:subscription_error', () => resolve())
})
},
/**
* Broadcast an event with Pusher.
* @param {string} eventName The event's name
* @param {Object} data The event's data
*/
broadcast (eventName, data = {}) {
this.channel && this.channel.trigger(`client-${eventName}.${userStore.current.id}`, data)
return this
},
/**
* Listen to an event.
* @param {string} eventName The event's name
* @param {Function} cb
*/
listen (eventName, cb) {
this.channel && this.channel.bind(`client-${eventName}.${userStore.current.id}`, data => cb(data))
return this
}
}

View file

@ -1,38 +0,0 @@
import { http } from '.'
import { event } from '@/utils'
import router from '@/router'
export const youtube = {
/**
* Search for YouTube videos related to a song.
*
* @param {Object} song
*/
searchVideosRelatedToSong (song) {
song.youtube = song.youtube || {}
const pageToken = song.youtube.nextPageToken || ''
return new Promise((resolve, reject) => {
http.get(`youtube/search/song/${song.id}?pageToken=${pageToken}`,
({ data: { nextPageToken, items }}) => {
song.youtube.nextPageToken = nextPageToken
song.youtube.items.push(...items)
resolve()
}, error => reject(error)
)
})
},
/**
* Play a YouTube video.
*
* @param {Object} vide The video object
*/
play (video) {
event.emit('youtube:play', {
id: video.id.videoId,
title: video.snippet.title
})
router.go('youtube')
}
}

View file

@ -1,7 +0,0 @@
import 'babel-polyfill/dist/polyfill.min.js'
import 'plyr/dist/plyr.js'
import './libs/modernizr-custom.js'
import '../css/meyer-reset.min.css'
import 'nouislider/distribute/nouislider.min.css'
import 'intersection-observer'
import 'font-awesome/css/font-awesome.min.css'

View file

@ -1,119 +0,0 @@
/*eslint camelcase: ["error", {properties: "never"}]*/
import Vue from 'vue'
import { union, difference, take, orderBy } from 'lodash'
import stub from '@/stubs/album'
import { artistStore } from '.'
export const albumStore = {
stub,
cache: [],
state: {
albums: [stub]
},
/**
* Init the store.
*
* @param {Array.<Object>} albums The array of album objects
*/
init (albums) {
// Traverse through the artists array and add their albums into our master album list.
this.all = albums
this.all.forEach(album => this.setupAlbum(album))
},
setupAlbum (album) {
const artist = artistStore.byId(album.artist_id)
artist.albums = union(artist.albums, [album])
Vue.set(album, 'artist', artist)
Vue.set(album, 'info', null)
Vue.set(album, 'songs', [])
Vue.set(album, 'playCount', 0)
this.cache[album.id] = album
return album
},
/**
* Get all albums in the store.
*
* @return {Array.<Object>}
*/
get all () {
return this.state.albums
},
/**
* Set all albums.
*
* @param {Array.<Object>} value
*/
set all (value) {
this.state.albums = value
},
byId (id) {
return this.cache[id]
},
/**
* Add new album/albums into the current collection.
*
* @param {Array.<Object>|Object} albums
*/
add (albums) {
[].concat(albums).forEach(album => {
this.setupAlbum(album, album.artist)
album.playCount = album.songs.reduce((count, song) => count + song.playCount, 0)
})
this.all = union(this.all, albums)
},
purify () {
this.compact()
},
/**
* Remove empty albums from the store.
*/
compact () {
const emptyAlbums = this.all.filter(album => album.songs.length === 0)
if (!emptyAlbums.length) {
return
}
this.all = difference(this.all, emptyAlbums)
emptyAlbums.forEach(album => delete this.cache[album.id])
},
/**
* Get top n most-played albums.
*
* @param {Number} n
*
* @return {Array.<Object>}
*/
getMostPlayed (n = 6) {
// Only non-unknown albums with actually play count are applicable.
const applicable = this.all.filter(album => album.playCount && album.id !== 1)
return take(orderBy(applicable, 'playCount', 'desc'), n)
},
/**
* Get n most recently added albums.
*
* @param {Number} n
*
* @return {Array.<Object>}
*/
getRecentlyAdded (n = 6) {
const applicable = this.all.filter(album => album.id !== 1)
return take(orderBy(applicable, 'created_at', 'desc'), n)
}
}

View file

@ -1,156 +0,0 @@
/*eslint camelcase: ["error", {properties: "never"}]*/
import Vue from 'vue'
import { union, difference, take, orderBy } from 'lodash'
import stub from '@/stubs/artist'
const UNKNOWN_ARTIST_ID = 1
const VARIOUS_ARTISTS_ID = 2
export const artistStore = {
stub,
cache: [],
state: {
artists: []
},
/**
* Init the store.
*
* @param {Array.<Object>} artists The array of artists we got from the server.
*/
init (artists) {
this.all = artists
// Traverse through artists array to get the cover and number of songs for each.
this.all.forEach(artist => this.setupArtist(artist))
},
/**
* Set up the (reactive) properties of an artist.
*
* @param {Object} artist
*/
setupArtist (artist) {
Vue.set(artist, 'playCount', 0)
Vue.set(artist, 'info', null)
Vue.set(artist, 'albums', [])
Vue.set(artist, 'songs', [])
this.cache[artist.id] = artist
return artist
},
/**
* Get all artists.
*
* @return {Array.<Object>}
*/
get all () {
return this.state.artists
},
/**
* Set all artists.
*
* @param {Array.<Object>} value
*/
set all (value) {
this.state.artists = value
},
/**
* Get an artist object by its ID.
*
* @param {Number} id
*/
byId (id) {
return this.cache[id]
},
/**
* Adds an artist/artists into the current collection.
*
* @param {Array.<Object>|Object} artists
*/
add (artists) {
[].concat(artists).forEach(artist => {
this.setupArtist(artist)
artist.playCount = artist.songs.reduce((count, song) => count + song.playCount, 0)
})
this.all = union(this.all, artists)
},
purify () {
this.compact()
},
/**
* Remove empty artists from the store.
*/
compact () {
const emptyArtists = this.all.filter(artist => artist.songs.length === 0)
if (!emptyArtists.length) {
return
}
this.all = difference(this.all, emptyArtists)
emptyArtists.forEach(artist => delete this.cache[artist.id])
},
/**
* Determine if the artist is the special "Various Artists".
*
* @param {Object} artist
*
* @return {Boolean}
*/
isVariousArtists (artist) {
return artist.id === VARIOUS_ARTISTS_ID
},
/**
* Determine if the artist is the special "Unknown Artist".
*
* @param {Object} artist [description]
*
* @return {Boolean}
*/
isUnknownArtist (artist) {
return artist.id === UNKNOWN_ARTIST_ID
},
/**
* Get all songs performed by an artist.
*
* @param {Object} artist
*
* @return {Array.<Object>}
*/
getSongsByArtist (artist) {
return artist.songs
},
/**
* Get top n most-played artists.
*
* @param {Number} n
*
* @return {Array.<Object>}
*/
getMostPlayed (n = 6) {
// Only non-unknown artists with actually play count are applicable.
// Also, "Various Artists" doesn't count.
const applicable = this.all.filter(artist => {
return artist.playCount &&
!this.isUnknownArtist(artist) &&
!this.isVariousArtists(artist)
})
return take(orderBy(applicable, 'playCount', 'desc'), n)
}
}

View file

@ -1,118 +0,0 @@
import { preferenceStore } from '.'
export const equalizerStore = {
presets: [
{
id: 0,
name: 'Default',
preamp: 0,
gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
},
{
id: 1,
name: 'Classical',
preamp: -1,
gains: [-1, -1, -1, -1, -1, -1, -7, -7, -7, -9]
},
{
id: 2,
name: 'Club',
preamp: -6.7,
gains: [-1, -1, 8, 5, 5, 5, 3, -1, -1, -1]
},
{
id: 3,
name: 'Dance',
preamp: -4.3,
gains: [9, 7, 2, -1, -1, -5, -7, -7, -1, -1]
},
{
id: 4,
name: 'Full Bass',
preamp: -7.2,
gains: [-8, 9, 9, 5, 1, -4, -8, -10, -11, -11]
},
{
id: 5,
name: 'Full Treble',
preamp: -12,
gains: [-9, -9, -9, -4, 2, 11, 16, 16, 16, 16]
},
{
id: 6,
name: 'Headphone',
preamp: -8,
gains: [4, 11, 5, -3, -2, 1, 4, 9, 12, 14]
},
{
id: 7,
name: 'Large Hall',
preamp: -7.2,
gains: [10, 10, 5, 5, -1, -4, -4, -4, -1, -1]
},
{
id: 8,
name: 'Live',
preamp: -5.3,
gains: [-4, -1, 4, 5, 5, 5, 4, 2, 2, 2]
},
{
id: 9,
name: 'Pop',
preamp: -6.2,
gains: [-1, 4, 7, 8, 5, -1, -2, -2, -1, -1]
},
{
id: 10,
name: 'Reggae',
preamp: -8.2,
gains: [-1, -1, -1, -5, -1, 6, 6, -1, -1, -1]
},
{
id: 11,
name: 'Rock',
preamp: -10,
gains: [8, 4, -5, -8, -3, 4, 8, 11, 11, 11]
},
{
id: 12,
name: 'Soft Rock',
preamp: -5.3,
gains: [4, 4, 2, -1, -4, -5, -3, -1, 2, 8]
},
{
id: 13,
name: 'Techno',
preamp: -7.7,
gains: [8, 5, -1, -5, -4, -1, 8, 9, 9, 8]
}
],
getPresetById (id) {
return this.presets.find(preset => preset.id === id)
},
/**
* Get the current equalizer config.
*
* @return {Object}
*/
get () {
if (!this.presets[preferenceStore.selectedPreset]) {
return preferenceStore.equalizer
}
// If the user chose a preset (instead of customizing one), just return it.
return this.getPresetById(preferenceStore.selectedPreset)
},
/**
* Save the current equalizer config.
*
* @param {Number} preamp The preamp value (dB)
* @param {Array.<Number>} gains The band's gain value (dB)
*/
set (preamp, gains) {
preferenceStore.equalizer = { preamp, gains }
}
}

View file

@ -1,118 +0,0 @@
import { difference, union } from 'lodash'
import NProgress from 'nprogress'
import { http } from '@/services'
import { alerts, pluralize } from '@/utils'
export const favoriteStore = {
state: {
songs: [],
length: 0,
fmtLength: ''
},
/**
* All songs favorite'd by the current user.
*
* @return {Array.<Object>}
*/
get all () {
return this.state.songs
},
/**
* Set all favorite'd songs.
*
* @param {Array.<Object>} value
*/
set all (value) {
this.state.songs = value
},
/**
* Toggle like/unlike a song.
* A request to the server will be made.
*
* @param {Object} song
*/
toggleOne (song) {
// Don't wait for the HTTP response to update the status, just toggle right away.
// This may cause a minor problem if the request fails somehow, but do we care?
song.liked = !song.liked
song.liked ? this.add(song) : this.remove(song)
NProgress.start()
return new Promise((resolve, reject) => {
http.post('interaction/like', { song: song.id }, ({ data }) => {
// We don't really need to notify just for one song.
resolve(data)
}, error => reject(error))
})
},
/**
* Add a song/songs into the store.
*
* @param {Array.<Object>|Object} songs
*/
add (songs) {
this.all = union(this.all, [].concat(songs))
},
/**
* Remove a song/songs from the store.
*
* @param {Array.<Object>|Object} songs
*/
remove (songs) {
this.all = difference(this.all, [].concat(songs))
},
/**
* Remove all favorites.
*/
clear () {
this.all = []
},
/**
* Like a bunch of songs.
*
* @param {Array.<Object>} songs
*/
like (songs) {
// Don't wait for the HTTP response to update the status, just set them to Liked right away.
// This may cause a minor problem if the request fails somehow, but do we care?
songs.forEach(song => { song.liked = true })
this.add(songs)
NProgress.start()
return new Promise((resolve, reject) => {
http.post('interaction/batch/like', { songs: songs.map(song => song.id) }, ({ data }) => {
alerts.success(`Added ${pluralize(songs.length, 'song')} into Favorites.`)
resolve(data)
}, error => reject(error))
})
},
/**
* Unlike a bunch of songs.
*
* @param {Array.<Object>} songs
*/
unlike (songs) {
songs.forEach(song => { song.liked = false })
this.remove(songs)
NProgress.start()
return new Promise((resolve, reject) => {
http.post('interaction/batch/unlike', { songs: songs.map(song => song.id) }, ({ data }) => {
alerts.success(`Removed ${pluralize(songs.length, 'song')} from Favorites.`)
resolve(data)
}, error => reject(error))
})
}
}

View file

@ -1,11 +0,0 @@
export * from './album'
export * from './artist'
export * from './equalizer'
export * from './favorite'
export * from './playlist'
export * from './preference'
export * from './queue'
export * from './setting'
export * from './shared'
export * from './song'
export * from './user'

View file

@ -1,207 +0,0 @@
import { difference, union } from 'lodash'
import NProgress from 'nprogress'
import stub from '@/stubs/playlist'
import { http } from '@/services'
import { alerts, pluralize } from '@/utils'
import { songStore } from '.'
export const playlistStore = {
stub,
state: {
playlists: []
},
init (playlists) {
this.all = playlists
},
/**
* All playlists of the current user.
*
* @return {Array.<Object>}
*/
get all () {
return this.state.playlists
},
/**
* Set all playlists.
*
* @param {Array.<Object>} value
*/
set all (value) {
this.state.playlists = value
},
/**
* Fetch the songs for a playlist.
*
* @param {Object} playlist
*/
fetchSongs (playlist) {
NProgress.start()
return new Promise((resolve, reject) => {
http.get(`playlist/${playlist.id}/songs`, ({ data }) => {
playlist.songs = songStore.byIds(data)
resolve(playlist)
}, error => reject(error))
})
},
/**
* Find a playlist by its ID
*
* @param {Number} id
*
* @return {Object}
*/
byId (id) {
return this.all.find(song => song.id === id)
},
/**
* Populate the playlist content by "objectifying" all songs in the playlist.
* (Initially, a playlist only contain the song IDs).
*
* @param {Object} playlist
*/
populateContent (playlist) {
playlist.songs = songStore.byIds(playlist.songs)
},
/**
* Get all songs in a playlist.
*
* @param {Object}
*
* return {Array.<Object>}
*/
getSongs (playlist) {
return playlist.songs
},
/**
* Add a playlist/playlists into the store.
*
* @param {Array.<Object>|Object} playlists
*/
add (playlists) {
this.all = union(this.all, [].concat(playlists))
},
/**
* Remove a playlist/playlists from the store.
*
* @param {Array.<Object>|Object} playlist
*/
remove (playlists) {
this.all = difference(this.all, [].concat(playlists))
},
/**
* Create a new playlist, optionally with its songs.
*
* @param {String} name Name of the playlist
* @param {Array.<Object>} songs An array of song objects
*/
store (name, songs = []) {
if (songs.length) {
// Extract the IDs from the song objects.
songs = songs.map(song => song.id)
}
NProgress.start()
return new Promise((resolve, reject) => {
http.post('playlist', { name, songs }, ({ data: playlist }) => {
playlist.songs = songs
this.populateContent(playlist)
this.add(playlist)
alerts.success(`Created playlist &quot;${playlist.name}&quot;.`)
resolve(playlist)
}, error => reject(error))
})
},
/**
* Delete a playlist.
*
* @param {Object} playlist
*/
delete (playlist) {
NProgress.start()
return new Promise((resolve, reject) => {
http.delete(`playlist/${playlist.id}`, {}, ({ data }) => {
this.remove(playlist)
alerts.success(`Deleted playlist &quot;${playlist.name}&quot;.`)
resolve(data)
}, error => reject(error))
})
},
/**
* Add songs into a playlist.
*
* @param {Object} playlist
* @param {Array.<Object>} songs
*/
addSongs (playlist, songs) {
return new Promise((resolve, reject) => {
const count = playlist.songs.length
playlist.songs = union(playlist.songs, songs)
if (count === playlist.songs.length) {
resolve(playlist)
return
}
NProgress.start()
http.put(`playlist/${playlist.id}/sync`, { songs: playlist.songs.map(song => song.id) }, () => {
alerts.success(`Added ${pluralize(songs.length, 'song')} into &quot;${playlist.name}&quot;.`)
resolve(playlist)
}, error => reject(error))
})
},
/**
* Remove songs from a playlist.
*
* @param {Object} playlist
* @param {Array.<Object>} songs
*/
removeSongs (playlist, songs) {
NProgress.start()
playlist.songs = difference(playlist.songs, songs)
return new Promise((resolve, reject) => {
http.put(`playlist/${playlist.id}/sync`, { songs: playlist.songs.map(song => song.id) }, () => {
alerts.success(`Removed ${pluralize(songs.length, 'song')} from &quot;${playlist.name}&quot;.`)
resolve(playlist)
}, error => reject(error))
})
},
/**
* Update a playlist (just change its name).
*
* @param {Object} playlist
*/
update (playlist) {
NProgress.start()
return new Promise((resolve, reject) => {
http.put(
`playlist/${playlist.id}`,
{ name: playlist.name },
() => resolve(playlist),
error => reject(error)
)
})
}
}

View file

@ -1,60 +0,0 @@
import { userStore } from '.'
import { ls } from '@/services'
export const preferenceStore = {
storeKey: '',
state: {
volume: 7,
notify: true,
repeatMode: 'NO_REPEAT',
showExtraPanel: true,
confirmClosing: false,
equalizer: {
preamp: 0,
gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
},
artistsViewMode: null,
albumsViewMode: null,
selectedPreset: -1,
transcodeOnMobile: false
},
/**
* Init the store.
*
* @param {Object} user The user whose preferences we are managing.
*/
init (user = null) {
user = user || userStore.current
this.storeKey = `preferences_${user.id}`
this.state = Object.assign(this.state, ls.get(this.storeKey, this.state))
this.setupProxy()
},
/**
* Proxy the state properties, so that each can be directly accessed using the key.
*/
setupProxy () {
Object.keys(this.state).forEach(key => {
Object.defineProperty(this, key, {
get: () => this.get(key),
set: value => this.set(key, value),
configurable: true
})
})
},
set (key, val) {
this.state[key] = val
this.save()
},
get (key) {
return this.state.hasOwnProperty(key) ? this.state[key] : null
},
save () {
ls.set(this.storeKey, this.state)
}
}

View file

@ -1,220 +0,0 @@
import { union, difference, shuffle } from 'lodash'
export const queueStore = {
state: {
songs: [],
current: null
},
init () {
// We don't have anything to do here yet.
// How about another song then?
//
// LITTLE WING
// -- by Jimi Fucking Hendrix
//
// Well she's walking
// Through the clouds
// With a circus mind
// That's running wild
// Butterflies and zebras and moonbeams and fairytales
// That's all she ever thinks about
// Riding with the wind
//
// When I'm sad
// She comes to me
// With a thousand smiles
// She gives to me free
// It's alright she said
// It's alright
// Take anything you want from me
// Anything...
},
/**
* Get all queued songs.
*
* @return {Array.<Object>}
*/
get all () {
return this.state.songs
},
/**
* Set all queued songs.
*
* @param {Array.<Object>}
*/
set all (songs) {
this.state.songs = songs
},
/**
* The first song in the queue.
*
* @return {?Object}
*/
get first () {
return this.all[0]
},
/**
* The last song in the queue.
*
* @return {?Object}
*/
get last () {
return this.all[this.all.length - 1]
},
/**
* Determine if the queue contains a song.
*
* @param {Object} song
*
* @return {Boolean}
*/
contains (song) {
return this.all.includes(song)
},
/**
* Add a list of songs to the end of the current queue,
* or replace the current queue as a whole if `replace` is true.
*
* @param {Object|Array.<Object>} songs The song, or an array of songs
* @param {Boolean} replace Whether to replace the current queue
* @param {Boolean} toTop Whether to prepend or append to the queue
*/
queue (songs, replace = false, toTop = false) {
songs = [].concat(songs)
if (replace) {
this.all = songs
} else {
this.all = toTop ? union(songs, this.all) : union(this.all, songs)
}
},
/**
* Queue song(s) to after the current song.
*
* @param {Array.<Object>|Object} songs
*/
queueAfterCurrent (songs) {
songs = [].concat(songs)
if (!this.current || !this.all.length) {
return this.queue(songs)
}
// First we unqueue the songs to make sure there are no duplicates.
this.unqueue(songs)
const head = this.all.splice(0, this.indexOf(this.current) + 1)
this.all = head.concat(songs, this.all)
},
/**
* Unqueue a song, or several songs at once.
*
* @param {Object|String|Array.<Object>} songs The song(s) to unqueue
*/
unqueue (songs) {
this.all = difference(this.all, [].concat(songs))
},
/**
* Move some songs to after a target.
*
* @param {Array.<Object>} songs Songs to move
* @param {Object} target The target song object
*/
move (songs, target) {
const targetIndex = this.indexOf(target)
songs.forEach(song => {
this.all.splice(this.indexOf(song), 1)
this.all.splice(targetIndex, 0, song)
})
},
/**
* Clear the current queue.
*/
clear () {
this.all = []
},
/**
* Get index of a song in the queue.
*
* @param {Object} song
*
* @return {?Integer}
*/
indexOf (song) {
return this.all.indexOf(song)
},
/**
* The next song in queue.
*
* @return {?Object}
*/
get next () {
if (!this.current) {
return this.first
}
const index = this.all.map(song => song.id).indexOf(this.current.id) + 1
return index >= this.all.length ? null : this.all[index]
},
/**
* The previous song in queue.
*
* @return {?Object}
*/
get previous () {
if (!this.current) {
return this.last
}
const index = this.all.map(song => song.id).indexOf(this.current.id) - 1
return index < 0 ? null : this.all[index]
},
/**
* The current song.
*
* @return {Object}
*/
get current () {
return this.state.current
},
/**
* Set a song as the current queued song.
*
* @param {Object} song
*
* @return {Object} The queued song.
*/
set current (song) {
this.state.current = song
return this.state.current
},
/**
* Shuffle the queue.
*
* @return {Array.<Object>} The shuffled array of song objects
*/
shuffle () {
this.all = shuffle(this.all)
return this.all
}
}

View file

@ -1,28 +0,0 @@
import { http } from '@/services'
import { alerts } from '@/utils'
import stub from '@/stubs/settings'
export const settingStore = {
stub,
state: {
settings: []
},
init (settings) {
this.state.settings = settings
},
get all () {
return this.state.settings
},
update () {
return new Promise((resolve, reject) => {
http.post('settings', this.all, ({ data }) => {
alerts.success('Settings saved.')
resolve(data)
}, error => reject(error))
})
}
}

View file

@ -1,58 +0,0 @@
import isMobile from 'ismobilejs'
import { http } from '@/services'
import { userStore, preferenceStore, artistStore, albumStore, songStore, playlistStore, queueStore, settingStore } from '.'
export const sharedStore = {
state: {
songs: [],
albums: [],
artists: [],
favorites: [],
queued: [],
interactions: [],
users: [],
settings: [],
currentUser: null,
playlists: [],
useLastfm: false,
useYouTube: false,
useiTunes: false,
allowDownload: false,
currentVersion: '',
latestVersion: '',
cdnUrl: '',
originalMediaPath: ''
},
init () {
return new Promise((resolve, reject) => {
http.get('data', ({ data }) => {
this.state = Object.assign(this.state, data)
// Don't allow downloading on mobile devices
this.state.allowDownload &= !isMobile.any
// Always disable YouTube integration on mobile.
this.state.useYouTube &= !isMobile.phone
// If this is a new user, initialize his preferences to be an empty object.
this.state.currentUser.preferences = this.state.currentUser.preferences || {}
userStore.init(this.state.users, this.state.currentUser)
preferenceStore.init(this.state.preferences)
artistStore.init(this.state.artists)
albumStore.init(this.state.albums)
songStore.init(this.state.songs)
songStore.initInteractions(this.state.interactions)
playlistStore.init(this.state.playlists)
queueStore.init()
settingStore.init(this.state.settings)
// Keep a copy of the media path. We'll need this to properly warn the user later.
this.state.originalMediaPath = this.state.settings.media_path
resolve(this.state)
}, error => reject(error))
})
}
}

Some files were not shown because too many files have changed in this diff Show more