Merge pull request #917 from chme/web_next

Update player web interface (v0.7.0)
This commit is contained in:
Christian Meffert 2020-04-25 07:42:33 +02:00 committed by GitHub
commit d300ed2a40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
91 changed files with 6890 additions and 3939 deletions

View File

@ -100,7 +100,11 @@ dist_htdocsplayerjs_DATA = \
player/js/app.js \ player/js/app.js \
player/js/app.js.map \ player/js/app.js.map \
player/js/chunk-vendors.js \ player/js/chunk-vendors.js \
player/js/chunk-vendors.js.map player/js/chunk-vendors.js.map \
player/js/app-legacy.js \
player/js/app-legacy.js.map \
player/js/chunk-vendors-legacy.js \
player/js/chunk-vendors-legacy.js.map
htdocsplayerimgdir = $(datadir)/forked-daapd/htdocs/player/img htdocsplayerimgdir = $(datadir)/forked-daapd/htdocs/player/img

View File

@ -1 +1 @@
<!DOCTYPE html><html class="has-navbar-fixed-top has-navbar-fixed-bottom"><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>forked-daapd-web 2</title><link rel=apple-touch-icon sizes=120x120 href=/apple-touch-icon.png?ver1.1><link rel=icon type=image/png sizes=32x32 href=/favicon-32x32.png><link rel=icon type=image/png sizes=16x16 href=/favicon-16x16.png><link rel=manifest href=/site.webmanifest><link rel=mask-icon href=/safari-pinned-tab.svg color=#5bbad5><meta name=msapplication-TileColor content=#da532c><meta name=theme-color content=#ffffff><link href=/player/css/app.css rel=preload as=style><link href=/player/css/chunk-vendors.css rel=preload as=style><link href=/player/js/app.js rel=preload as=script><link href=/player/js/chunk-vendors.js rel=preload as=script><link href=/player/css/chunk-vendors.css rel=stylesheet><link href=/player/css/app.css rel=stylesheet></head><body><div id=app></div><script src=/player/js/chunk-vendors.js></script><script src=/player/js/app.js></script></body></html> <!DOCTYPE html><html class="has-navbar-fixed-top has-navbar-fixed-bottom"><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>forked-daapd-web 2</title><link rel=apple-touch-icon sizes=120x120 href=/apple-touch-icon.png?ver1.1><link rel=icon type=image/png sizes=32x32 href=/favicon-32x32.png><link rel=icon type=image/png sizes=16x16 href=/favicon-16x16.png><link rel=manifest href=/site.webmanifest><link rel=mask-icon href=/safari-pinned-tab.svg color=#5bbad5><meta name=msapplication-TileColor content=#da532c><meta name=theme-color content=#ffffff><link href=/player/css/app.css rel=preload as=style><link href=/player/css/chunk-vendors.css rel=preload as=style><link href=/player/js/app.js rel=modulepreload as=script><link href=/player/js/chunk-vendors.js rel=modulepreload as=script><link href=/player/css/chunk-vendors.css rel=stylesheet><link href=/player/css/app.css rel=stylesheet></head><body><div id=app></div><script type=module src=/player/js/chunk-vendors.js></script><script type=module src=/player/js/app.js></script><script>!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();</script><script src=/player/js/chunk-vendors-legacy.js nomodule></script><script src=/player/js/app-legacy.js nomodule></script></body></html>

View File

@ -1,2 +1,2 @@
.fd-notifications{position:fixed;bottom:60px;z-index:20000;width:100%}.fd-notifications .notification{margin-bottom:10px;margin-left:24px;margin-right:24px;box-shadow:0 4px 8px 0 rgba(0,0,0,.2),0 6px 20px 0 rgba(0,0,0,.19)}.slider{min-width:250px;width:100%}.range-slider-fill{background-color:#363636}.track-progress{margin:0;padding:0;min-width:250px;width:100%}.track-progress .range-slider-knob{visibility:hidden}.track-progress .range-slider-fill{background-color:#3273dc;height:2px}.track-progress .range-slider-rail{background-color:#fff}.media.with-progress h2:last-of-type{margin-bottom:6px}.media.with-progress{margin-top:0}a.navbar-item{outline:0;line-height:1.5;padding:.5rem 1rem}.fd-expanded{-webkit-box-flex:1;flex-grow:1;flex-shrink:1}.fd-margin-left-auto{margin-left:auto}.fd-has-action{cursor:pointer}.fd-is-movable{cursor:move}.fd-has-margin-top{margin-top:24px}.fd-has-margin-bottom{margin-bottom:24px}.fd-remove-padding-bottom{padding-bottom:0}.fd-has-padding-left-right{padding-left:24px;padding-right:24px}.fd-is-square .button{height:27px;width:27px}.fd-is-text-clipped{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.fd-is-fullheight{height:calc(100vh - 6.5rem)}.fd-is-fullheight-body{flex-shrink:1;overflow:hidden;height:100%}.fd-image-fullheight{height:100%;width:auto}.fd-tabs-section{padding-bottom:3px;padding-top:3px;background:#fff;top:3.25rem;z-index:20;position:fixed;width:100%}section.fd-tabs-section+section.fd-content{margin-top:24px}.fd-progress-bar{top:52px!important}.fd-has-shadow{box-shadow:0 4px 8px 0 rgba(0,0,0,.2),0 6px 20px 0 rgba(0,0,0,.19)}.sortable-chosen .media-right{visibility:hidden}.sortable-ghost h1,.sortable-ghost h2{color:#ff3860!important}.media:first-of-type{padding-top:17px;margin-top:16px}.fade-enter-active,.fade-leave-active{-webkit-transition:opacity .4s;transition:opacity .4s}.fade-enter,.fade-leave-to{opacity:0}.seek-slider{min-width:250px;max-width:500px;width:100%!important}.seek-slider .range-slider-fill{background-color:#00d1b2;box-shadow:0 4px 8px 0 rgba(0,0,0,.2),0 6px 20px 0 rgba(0,0,0,.19)}.seek-slider .range-slider-knob{width:10px;height:10px;background-color:#00d1b2;border-color:#00d1b2}.title:not(.is-spaced)+.subtitle,.title:not(.is-spaced)+.subtitle+.subtitle{margin-top:-1.3rem!important}.fd-modal-card{overflow:visible}.fd-modal-card .card-content{max-height:calc(100vh - 200px);overflow:auto}.fd-modal-card .card{margin-left:16px;margin-right:16px} .fd-notifications{position:fixed;bottom:60px;z-index:20000;width:100%}.fd-notifications .notification{margin-bottom:10px;margin-left:24px;margin-right:24px;box-shadow:0 4px 8px 0 rgba(0,0,0,.2),0 6px 20px 0 rgba(0,0,0,.19)}.slider{min-width:250px;width:100%}.range-slider-fill{background-color:#363636}.track-progress{margin:0;padding:0;min-width:250px;width:100%}.track-progress .range-slider-knob{visibility:hidden}.track-progress .range-slider-fill{background-color:#3273dc;height:2px}.track-progress .range-slider-rail{background-color:#fff}.media.with-progress h2:last-of-type{margin-bottom:6px}.media.with-progress{margin-top:0}a.navbar-item{outline:0;line-height:1.5;padding:.5rem 1rem}.fd-expanded{flex-grow:1;flex-shrink:1}.fd-margin-left-auto{margin-left:auto}.fd-has-action{cursor:pointer}.fd-is-movable{cursor:move}.fd-has-margin-top{margin-top:24px}.fd-has-margin-bottom{margin-bottom:24px}.fd-remove-padding-bottom{padding-bottom:0}.fd-has-padding-left-right{padding-left:24px;padding-right:24px}.fd-is-square .button{height:27px;width:27px}.fd-is-text-clipped{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.fd-tabs-section{padding-bottom:3px;padding-top:3px;background:#fff;top:3.25rem;z-index:20;position:fixed;width:100%}section.fd-tabs-section+section.fd-content{margin-top:24px}.fd-progress-bar{top:52px!important}.fd-has-shadow{box-shadow:0 4px 8px 0 rgba(0,0,0,.2),0 6px 20px 0 rgba(0,0,0,.19)}.fd-is-fullheight{height:calc(100vh - 6.5rem);display:flex;flex-direction:column;justify-content:center}.fd-is-fullheight .fd-is-expanded{max-height:calc(100vh - 25rem);padding:1.5rem;overflow:hidden;flex-grow:1;flex-shrink:1}.fd-cover-image{height:100%}.fd-cover-image img{width:100%;height:100%;object-fit:contain;object-position:bottom;-webkit-filter:drop-shadow(0 0 1px rgba(0,0,0,.3)) drop-shadow(0 0 10px rgba(0,0,0,.3));filter:drop-shadow(0 0 1px rgba(0,0,0,.3)) drop-shadow(0 0 10px rgba(0,0,0,.3))}.sortable-chosen .media-right{visibility:hidden}.sortable-ghost h1,.sortable-ghost h2{color:#ff3860!important}.media:first-of-type{padding-top:17px;margin-top:16px}.fade-enter-active,.fade-leave-active{transition:opacity .4s}.fade-enter,.fade-leave-to{opacity:0}.seek-slider{min-width:250px;max-width:500px;width:100%!important}.seek-slider .range-slider-fill{background-color:#00d1b2;box-shadow:0 4px 8px 0 rgba(0,0,0,.2),0 6px 20px 0 rgba(0,0,0,.19)}.seek-slider .range-slider-knob{width:10px;height:10px;background-color:#00d1b2;border-color:#00d1b2}.title:not(.is-spaced)+.subtitle,.title:not(.is-spaced)+.subtitle+.subtitle{margin-top:-1.3rem!important}.fd-modal-card{overflow:visible}.fd-modal-card .card-content{max-height:calc(100vh - 200px);overflow:auto}.fd-modal-card .card{margin-left:16px;margin-right:16px}.dropdown-item a{display:block}.dropdown-item:hover{background-color:#f5f5f5}
/*# sourceMappingURL=app.css.map */ /*# sourceMappingURL=app.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -3,7 +3,7 @@ module.exports = {
env: { env: {
node: true node: true
}, },
'extends': [ extends: [
'plugin:vue/essential', 'plugin:vue/essential',
'@vue/standard' '@vue/standard'
], ],

View File

@ -1,5 +1,5 @@
module.exports = { module.exports = {
presets: [ presets: [
'@vue/app' '@vue/cli-plugin-babel/preset'
] ]
} }

8337
web-src/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,39 +1,48 @@
{ {
"name": "forked-daapd-web", "name": "forked-daapd-web",
"version": "0.6.0", "version": "0.7.0",
"private": true,
"description": "forked-daapd web interface", "description": "forked-daapd web interface",
"author": "chme <christian.meffert@googlemail.com>", "author": "chme <christian.meffert@googlemail.com>",
"license": "GPL-2.0",
"private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"dev": "vue-cli-service serve", "build": "vue-cli-service build --no-clean --modern",
"build": "vue-cli-service build --no-clean", "lint": "vue-cli-service lint",
"lint": "vue-cli-service lint" "dev": "vue-cli-service serve"
}, },
"dependencies": { "dependencies": {
"axios": "^0.19.1", "axios": "^0.19.2",
"bulma": "^0.8.0", "bulma": "^0.8.2",
"core-js": "^3.6.5",
"mdi": "^2.2.43", "mdi": "^2.2.43",
"moment": "^2.24.0", "moment": "^2.24.0",
"moment-duration-format": "^2.3.2", "moment-duration-format": "^2.3.2",
"npm": "^6.13.6", "npm": "^6.14.4",
"reconnectingwebsocket": "^1.0.0", "reconnectingwebsocket": "^1.0.0",
"spotify-web-api-js": "^1.2.0", "spotify-web-api-js": "^1.2.0",
"v-click-outside": "^3.0.0", "string-to-color": "^2.1.3",
"v-click-outside": "^3.0.1",
"vue": "^2.6.11", "vue": "^2.6.11",
"vue-infinite-loading": "^2.4.4", "vue-infinite-loading": "^2.4.5",
"vue-progressbar": "^0.7.5", "vue-progressbar": "^0.7.5",
"vue-range-slider": "^0.6.0", "vue-range-slider": "^0.6.0",
"vue-router": "^3.1.3", "vue-router": "^3.1.6",
"vuedraggable": "^2.23.2", "vuedraggable": "^2.23.2",
"vuex": "^3.1.2" "vuex": "^3.1.3"
}, },
"devDependencies": { "devDependencies": {
"@vue/cli-plugin-babel": "^3.12.1", "@vue/cli-plugin-babel": "^4.3.1",
"@vue/cli-plugin-eslint": "^3.12.1", "@vue/cli-plugin-eslint": "^4.3.1",
"@vue/cli-service": "^3.12.1", "@vue/cli-service": "^4.3.1",
"@vue/eslint-config-standard": "^4.0.0", "@vue/eslint-config-standard": "^5.1.2",
"babel-eslint": "^10.1.0",
"eslint": "^6.8.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.1",
"eslint-plugin-vue": "^6.2.2",
"vue-template-compiler": "^2.6.11" "vue-template-compiler": "^2.6.11"
} },
"license": "GPL-2.0"
} }

View File

@ -6,8 +6,12 @@
<!-- Setting v-show to true on the router-view tag avoids jumpiness during transitions --> <!-- Setting v-show to true on the router-view tag avoids jumpiness during transitions -->
<router-view v-show="true" /> <router-view v-show="true" />
</transition> </transition>
<modal-dialog-remote-pairing :show="pairing_active" @close="pairing_active = false" />
<notifications v-show="!show_burger_menu" /> <notifications v-show="!show_burger_menu" />
<navbar-bottom v-show="!show_burger_menu" /> <navbar-bottom />
<div class="is-overlay" v-show="show_burger_menu || show_player_menu"
style="z-index:25; width: 100vw; height:100vh;background-color: rgba(10, 10, 10, 0.2);"
@click="show_burger_menu = show_player_menu = false"></div>
</div> </div>
</template> </template>
@ -15,25 +19,40 @@
import NavbarTop from '@/components/NavbarTop' import NavbarTop from '@/components/NavbarTop'
import NavbarBottom from '@/components/NavbarBottom' import NavbarBottom from '@/components/NavbarBottom'
import Notifications from '@/components/Notifications' import Notifications from '@/components/Notifications'
import ModalDialogRemotePairing from '@/components/ModalDialogRemotePairing'
import webapi from '@/webapi' import webapi from '@/webapi'
import * as types from '@/store/mutation_types' import * as types from '@/store/mutation_types'
import ReconnectingWebSocket from 'reconnectingwebsocket' import ReconnectingWebSocket from 'reconnectingwebsocket'
export default { export default {
name: 'App', name: 'App',
components: { NavbarTop, NavbarBottom, Notifications }, components: { NavbarTop, NavbarBottom, Notifications, ModalDialogRemotePairing },
template: '<App/>', template: '<App/>',
data () { data () {
return { return {
token_timer_id: 0, token_timer_id: 0,
reconnect_attempts: 0 reconnect_attempts: 0,
pairing_active: false
} }
}, },
computed: { computed: {
show_burger_menu () { show_burger_menu: {
return this.$store.state.show_burger_menu get () {
return this.$store.state.show_burger_menu
},
set (value) {
this.$store.commit(types.SHOW_BURGER_MENU, value)
}
},
show_player_menu: {
get () {
return this.$store.state.show_player_menu
},
set (value) {
this.$store.commit(types.SHOW_PLAYER_MENU, value)
}
} }
}, },
@ -47,7 +66,7 @@ export default {
this.$router.beforeEach((to, from, next) => { this.$router.beforeEach((to, from, next) => {
if (to.meta.show_progress) { if (to.meta.show_progress) {
if (to.meta.progress !== undefined) { if (to.meta.progress !== undefined) {
let meta = to.meta.progress const meta = to.meta.progress
this.$Progress.parseMeta(meta) this.$Progress.parseMeta(meta)
} }
this.$Progress.start() this.$Progress.start()
@ -204,17 +223,25 @@ export default {
update_pairing: function () { update_pairing: function () {
webapi.pairing().then(({ data }) => { webapi.pairing().then(({ data }) => {
this.$store.commit(types.UPDATE_PAIRING, data) this.$store.commit(types.UPDATE_PAIRING, data)
this.pairing_active = data.active
}) })
},
update_is_clipped: function () {
if (this.show_burger_menu || this.show_player_menu) {
document.querySelector('html').classList.add('is-clipped')
} else {
document.querySelector('html').classList.remove('is-clipped')
}
} }
}, },
watch: { watch: {
'show_burger_menu' () { 'show_burger_menu' () {
if (this.show_burger_menu) { this.update_is_clipped()
document.querySelector('html').classList.add('is-clipped') },
} else { 'show_player_menu' () {
document.querySelector('html').classList.remove('is-clipped') this.update_is_clipped()
}
} }
} }
} }

View File

@ -0,0 +1,110 @@
<template>
<figure>
<img
v-show="artwork_visible"
:src="artwork_url_with_size"
@load="artwork_loaded"
@error="artwork_error"
@click="$emit('click')">
<img
v-show="!artwork_visible"
:src="dataURI"
:alt="alt_text"
@click="$emit('click')">
</figure>
</template>
<script>
import webapi from '@/webapi'
import SVGRenderer from '@/lib/SVGRenderer'
import stringToColor from 'string-to-color'
export default {
name: 'CoverArtwork',
props: ['artist', 'album', 'artwork_url'],
data () {
return {
svg: new SVGRenderer(),
width: 600,
height: 600,
font_family: 'sans-serif',
font_size: 200,
font_weight: 600,
artwork_visible: false
}
},
computed: {
artwork_url_with_size: function () {
return webapi.artwork_url_append_size_params(this.artwork_url)
},
alt_text () {
return this.artist + ' - ' + this.album
},
caption () {
if (this.album) {
return this.album.substring(0, 2)
}
if (this.artist) {
return this.artist.substring(0, 2)
}
return ''
},
background_color () {
return stringToColor(this.alt_text)
},
is_background_light () {
// Based on https://stackoverflow.com/a/44615197
const hex = this.background_color.replace(/#/, '')
const r = parseInt(hex.substr(0, 2), 16)
const g = parseInt(hex.substr(2, 2), 16)
const b = parseInt(hex.substr(4, 2), 16)
const luma = [
0.299 * r,
0.587 * g,
0.114 * b
].reduce((a, b) => a + b) / 255
return luma > 0.5
},
text_color () {
return this.is_background_light ? '#000000' : '#ffffff'
},
rendererParams () {
return {
width: this.width,
height: this.height,
textColor: this.text_color,
backgroundColor: this.background_color,
caption: this.caption,
fontFamily: this.font_family,
fontSize: this.font_size,
fontWeight: this.font_weight
}
},
dataURI () {
return this.svg.render(this.rendererParams)
}
},
methods: {
artwork_loaded: function () {
this.artwork_visible = true
},
artwork_error: function () {
this.artwork_visible = false
}
}
}
</script>

View File

@ -18,7 +18,7 @@
<script> <script>
export default { export default {
name: 'ListItemDirectory', name: 'ListItemDirectory',
props: [ 'directory' ] props: ['directory']
} }
</script> </script>

View File

@ -12,7 +12,7 @@
<script> <script>
export default { export default {
name: 'ListItemGenre', name: 'ListItemGenre',
props: [ 'genre' ] props: ['genre']
} }
</script> </script>

View File

@ -34,7 +34,7 @@ export default {
methods: { methods: {
play: function () { play: function () {
webapi.player_play({ 'item_id': this.item.id }) webapi.player_play({ item_id: this.item.id })
} }
} }
} }

View File

@ -4,7 +4,25 @@
<div class="modal is-active" v-if="show"> <div class="modal is-active" v-if="show">
<div class="modal-background" @click="$emit('close')"></div> <div class="modal-background" @click="$emit('close')"></div>
<div class="modal-content fd-modal-card"> <div class="modal-content fd-modal-card">
<slot name="modal-content"></slot> <div class="card">
<div class="card-content">
<p class="title is-4" v-if="title">
{{ title }}
</p>
<slot name="modal-content"></slot>
</div>
<footer class="card-footer">
<a class="card-footer-item has-text-dark" @click="$emit('close')">
<span class="icon"><i class="mdi mdi-cancel"></i></span> <span class="is-size-7">Cancel</span>
</a>
<a v-if="delete_action" class="card-footer-item has-background-danger has-text-white has-text-weight-bold" @click="$emit('delete')">
<span class="icon"><i class="mdi mdi-delete"></i></span> <span class="is-size-7">{{ delete_action }}</span>
</a>
<a v-if="ok_action" class="card-footer-item has-background-info has-text-white has-text-weight-bold" @click="$emit('ok')">
<span class="icon"><i class="mdi mdi-check"></i></span> <span class="is-size-7">{{ ok_action }}</span>
</a>
</footer>
</div>
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button> <button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button>
</div> </div>
@ -15,7 +33,7 @@
<script> <script>
export default { export default {
name: 'ModalDialog', name: 'ModalDialog',
props: [ 'show' ] props: ['show', 'title', 'ok_action', 'delete_action']
} }
</script> </script>

View File

@ -0,0 +1,87 @@
<template>
<div>
<transition name="fade">
<div class="modal is-active" v-if="show">
<div class="modal-background" @click="$emit('close')"></div>
<div class="modal-content fd-modal-card">
<div class="card">
<div class="card-content">
<p class="title is-4">Add Podcast RSS feed URL</p>
<form @submit.prevent="add_stream">
<div class="field">
<p class="control is-expanded has-icons-left">
<input class="input is-shadowless" type="text" placeholder="http://url-to-rss" v-model="url" :disabled="loading" ref="url_field">
<span class="icon is-left">
<i class="mdi mdi-rss"></i>
</span>
</p>
<p class="help">Adding a podcast includes creating an RSS playlist, that will allow forked-daapd to manage the podcast subscription.
</p>
</div>
</form>
</div>
<footer class="card-footer" v-if="loading">
<a class="card-footer-item button is-loading">
<span class="icon"><i class="mdi mdi-web"></i></span> <span class="is-size-7">Processing ...</span>
</a>
</footer>
<footer class="card-footer" v-else>
<a class="card-footer-item has-text-danger" @click="$emit('close')">
<span class="icon"><i class="mdi mdi-cancel"></i></span> <span class="is-size-7">Cancel</span>
</a>
<a class="card-footer-item has-background-info has-text-white has-text-weight-bold" @click="add_stream">
<span class="icon"><i class="mdi mdi-playlist-plus"></i></span> <span class="is-size-7">Add</span>
</a>
</footer>
</div>
</div>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button>
</div>
</transition>
</div>
</template>
<script>
import webapi from '@/webapi'
export default {
name: 'ModalDialogAddRss',
props: ['show'],
data () {
return {
url: '',
loading: false
}
},
methods: {
add_stream: function () {
this.loading = true
webapi.library_add(this.url).then(() => {
this.$emit('close')
this.$emit('podcast_added')
this.url = ''
}).catch(() => {
this.loading = false
})
}
},
watch: {
'show' () {
if (this.show) {
this.loading = false
// We need to delay setting the focus to the input field until the field is part of the dom and visible
setTimeout(() => {
this.$refs.url_field.focus()
}, 10)
}
}
}
}
</script>
<style>
</style>

View File

@ -49,7 +49,7 @@ import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogAddUrlStream', name: 'ModalDialogAddUrlStream',
props: [ 'show' ], props: ['show'],
data () { data () {
return { return {

View File

@ -6,12 +6,18 @@
<div class="modal-content fd-modal-card"> <div class="modal-content fd-modal-card">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<figure class="image is-square fd-has-margin-bottom" v-show="artwork_visible"> <cover-artwork
<img :src="artwork_url" @load="artwork_loaded" @error="artwork_error" class="fd-has-shadow"> :artwork_url="album.artwork_url"
</figure> :artist="album.artist"
:album="album.name"
class="image is-square fd-has-margin-bottom fd-has-shadow" />
<p class="title is-4"> <p class="title is-4">
<a class="has-text-link" @click="open_album">{{ album.name }}</a> <a class="has-text-link" @click="open_album">{{ album.name }}</a>
</p> </p>
<div class="buttons" v-if="media_kind === 'podcast'">
<a class="button is-small" @click="mark_played">Mark as played</a>
<a class="button is-small" @click="$emit('remove_podcast')">Remove podcast</a>
</div>
<div class="content is-small"> <div class="content is-small">
<p v-if="album.artist && media_kind !== 'audiobook'"> <p v-if="album.artist && media_kind !== 'audiobook'">
<span class="heading">Album artist</span> <span class="heading">Album artist</span>
@ -47,11 +53,13 @@
</template> </template>
<script> <script>
import CoverArtwork from '@/components/CoverArtwork'
import webapi from '@/webapi' import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogAlbum', name: 'ModalDialogAlbum',
props: [ 'show', 'album', 'media_kind' ], components: { CoverArtwork },
props: ['show', 'album', 'media_kind', 'new_tracks'],
data () { data () {
return { return {
@ -95,6 +103,13 @@ export default {
this.$router.push({ path: '/music/artists/' + this.album.artist_id }) this.$router.push({ path: '/music/artists/' + this.album.artist_id })
}, },
mark_played: function () {
webapi.library_album_track_update(this.album.id, { play_count: 'played' }).then(({ data }) => {
this.$emit('play_count_changed')
this.$emit('close')
})
},
artwork_loaded: function () { artwork_loaded: function () {
this.artwork_visible = true this.artwork_visible = true
}, },

View File

@ -44,7 +44,7 @@ import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogArtist', name: 'ModalDialogArtist',
props: [ 'show', 'artist' ], props: ['show', 'artist'],
methods: { methods: {
play: function () { play: function () {

View File

@ -34,7 +34,7 @@ import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogDirectory', name: 'ModalDialogDirectory',
props: [ 'show', 'directory' ], props: ['show', 'directory'],
methods: { methods: {
play: function () { play: function () {

View File

@ -34,7 +34,7 @@ import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogGenre', name: 'ModalDialogGenre',
props: [ 'show', 'genre' ], props: ['show', 'genre'],
methods: { methods: {
play: function () { play: function () {

View File

@ -44,7 +44,7 @@ import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogPlaylist', name: 'ModalDialogPlaylist',
props: [ 'show', 'playlist' ], props: ['show', 'playlist'],
methods: { methods: {
play: function () { play: function () {

View File

@ -46,7 +46,7 @@ import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogPlaylistSave', name: 'ModalDialogPlaylistSave',
props: [ 'show' ], props: ['show'],
data () { data () {
return { return {

View File

@ -79,7 +79,7 @@ import SpotifyWebApi from 'spotify-web-api-js'
export default { export default {
name: 'ModalDialogQueueItem', name: 'ModalDialogQueueItem',
props: [ 'show', 'item' ], props: ['show', 'item'],
data () { data () {
return { return {
@ -95,7 +95,7 @@ export default {
play: function () { play: function () {
this.$emit('close') this.$emit('close')
webapi.player_play({ 'item_id': this.item.id }) webapi.player_play({ item_id: this.item.id })
}, },
open_album: function () { open_album: function () {

View File

@ -0,0 +1,82 @@
<template>
<div>
<transition name="fade">
<div class="modal is-active" v-if="show">
<div class="modal-background" @click="$emit('close')"></div>
<div class="modal-content fd-modal-card">
<div class="card">
<div class="card-content">
<p class="title is-4">
Remote pairing request
</p>
<form v-on:submit.prevent="kickoff_pairing">
<label class="label">
{{ pairing.remote }}
</label>
<div class="field">
<div class="control">
<input class="input" type="text" placeholder="Enter pairing code" v-model="pairing_req.pin" ref="pin_field">
</div>
</div>
</form>
</div>
<footer class="card-footer">
<a class="card-footer-item has-text-danger" @click="$emit('close')">
<span class="icon"><i class="mdi mdi-cancel"></i></span> <span class="is-size-7">Cancel</span>
</a>
<a class="card-footer-item has-background-info has-text-white has-text-weight-bold" @click="kickoff_pairing">
<span class="icon"><i class="mdi mdi-cellphone-iphone"></i></span> <span class="is-size-7">Pair Remote</span>
</a>
</footer>
</div>
</div>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button>
</div>
</transition>
</div>
</template>
<script>
import webapi from '@/webapi'
export default {
name: 'ModalDialogRemotePairing',
props: ['show'],
data () {
return {
pairing_req: { pin: '' }
}
},
computed: {
pairing () {
return this.$store.state.pairing
}
},
methods: {
kickoff_pairing () {
webapi.pairing_kickoff(this.pairing_req).then(() => {
this.pairing_req.pin = ''
})
}
},
watch: {
'show' () {
if (this.show) {
this.loading = false
// We need to delay setting the focus to the input field until the field is part of the dom and visible
setTimeout(() => {
this.$refs.pin_field.focus()
}, 10)
}
}
}
}
</script>
<style>
</style>

View File

@ -152,14 +152,14 @@ export default {
}, },
mark_new: function () { mark_new: function () {
webapi.library_track_update(this.track.id, { 'play_count': 'reset' }).then(() => { webapi.library_track_update(this.track.id, { play_count: 'reset' }).then(() => {
this.$emit('play_count_changed') this.$emit('play_count_changed')
this.$emit('close') this.$emit('close')
}) })
}, },
mark_played: function () { mark_played: function () {
webapi.library_track_update(this.track.id, { 'play_count': 'increment' }).then(() => { webapi.library_track_update(this.track.id, { play_count: 'increment' }).then(() => {
this.$emit('play_count_changed') this.$emit('play_count_changed')
this.$emit('close') this.$emit('close')
}) })

View File

@ -1,40 +1,377 @@
<template> <template>
<nav class="navbar is-dark is-fixed-bottom" role="navigation" aria-label="player controls"> <nav class="navbar is-white is-fixed-bottom" :style="zindex" :class="{ 'is-transparent': is_now_playing_page, 'is-dark': !is_now_playing_page }" role="navigation" aria-label="player controls">
<div class="navbar-brand fd-expanded"> <div class="navbar-brand fd-expanded">
<router-link to="/" class="navbar-item" active-class="is-active" exact>
<!-- Link to queue -->
<navbar-item-link to="/" exact>
<span class="icon"><i class="mdi mdi-24px mdi-playlist-play"></i></span> <span class="icon"><i class="mdi mdi-24px mdi-playlist-play"></i></span>
</router-link> </navbar-item-link>
<router-link to="/now-playing" class="navbar-item is-expanded is-clipped" active-class="is-active" exact>
<div> <!-- Now playing artist/title (not visible on "now playing" page) -->
<router-link to="/now-playing" v-if="!is_now_playing_page" class="navbar-item is-expanded is-clipped" active-class="is-active" exact>
<div class="is-clipped">
<p class="is-size-7 fd-is-text-clipped"> <p class="is-size-7 fd-is-text-clipped">
<strong>{{ now_playing.title }}</strong><br> <strong>{{ now_playing.title }}</strong><br>
{{ now_playing.artist }}<span v-if="now_playing.data_kind === 'url'"> - {{ now_playing.album }}</span> {{ now_playing.artist }}<span v-if="now_playing.data_kind === 'url'"> - {{ now_playing.album }}</span>
</p> </p>
</div> </div>
</router-link> </router-link>
<player-button-play-pause class="navbar-item fd-margin-left-auto" icon_style="mdi-36px" show_disabled_message></player-button-play-pause>
<!-- Skip previous (not visible on "now playing" page) -->
<player-button-previous v-if="is_now_playing_page" class="navbar-item fd-margin-left-auto" icon_style="mdi-24px"></player-button-previous>
<player-button-seek-back v-if="is_now_playing_page" seek_ms="10000" class="navbar-item" icon_style="mdi-24px"></player-button-seek-back>
<!-- Play/pause -->
<player-button-play-pause class="navbar-item" icon_style="mdi-36px" show_disabled_message></player-button-play-pause>
<player-button-seek-forward v-if="is_now_playing_page" seek_ms="30000" class="navbar-item" icon_style="mdi-24px"></player-button-seek-forward>
<!-- Skip next (not visible on "now playing" page) -->
<player-button-next v-if="is_now_playing_page" class="navbar-item" icon_style="mdi-24px"></player-button-next>
<!-- Player menu button (only visible on mobile and tablet) -->
<a class="navbar-item fd-margin-left-auto is-hidden-desktop" @click="show_player_menu = !show_player_menu">
<span class="icon"><i class="mdi mdi-18px" :class="{ 'mdi-chevron-up': !show_player_menu, 'mdi-chevron-down': show_player_menu }"></i></span>
</a>
<!-- Player menu dropup menu (only visible on desktop) -->
<div class="navbar-item has-dropdown has-dropdown-up fd-margin-left-auto is-hidden-touch"
:class="{ 'is-active': show_player_menu }">
<a class="navbar-link is-arrowless"
@click="show_player_menu = !show_player_menu">
<span class="icon"><i class="mdi mdi-18px"
:class="{ 'mdi-chevron-up': !show_player_menu, 'mdi-chevron-down': show_player_menu }"></i></span>
</a>
<div class="navbar-dropdown is-right is-boxed" style="margin-right: 6px; margin-bottom: 6px; border-radius: 6px;">
<div class="navbar-item">
<!-- Outputs: master volume -->
<div class="level is-mobile">
<div class="level-left fd-expanded">
<div class="level-item" style="flex-grow: 0;">
<a class="button is-white is-small" @click="toggle_mute_volume">
<span class="icon"><i class="mdi mdi-18px" :class="{ 'mdi-volume-off': player.volume <= 0, 'mdi-volume-high': player.volume > 0 }"></i></span>
</a>
</div>
<div class="level-item fd-expanded">
<div class="fd-expanded">
<p class="heading">Volume</p>
<range-slider
class="slider fd-has-action"
min="0"
max="100"
step="1"
:value="player.volume"
@change="set_volume">
</range-slider>
</div>
</div>
</div>
</div>
</div>
<!-- Outputs: master volume -->
<hr class="navbar-divider">
<navbar-item-output v-for="output in outputs" :key="output.id" :output="output"></navbar-item-output>
<!-- Outputs: stream volume -->
<hr class="navbar-divider">
<div class="navbar-item">
<div class="level is-mobile">
<div class="level-left fd-expanded">
<div class="level-item" style="flex-grow: 0;">
<a class="button is-white is-small" :class="{ 'is-loading': loading }"><span class="icon fd-has-action" :class="{ 'has-text-grey-light': !playing && !loading, 'is-loading': loading }" @click="togglePlay"><i class="mdi mdi-18px mdi-radio-tower"></i></span></a>
</div>
<div class="level-item fd-expanded">
<div class="fd-expanded">
<p class="heading" :class="{ 'has-text-grey-light': !playing }">HTTP stream <a href="/stream.mp3"><span class="is-lowercase">(stream.mp3)</span></a></p>
<range-slider
class="slider fd-has-action"
min="0"
max="100"
step="1"
:disabled="!playing"
:value="stream_volume"
@change="set_stream_volume">
</range-slider>
</div>
</div>
</div>
</div>
</div>
<!-- Playback controls -->
<hr class="navbar-divider">
<div class="navbar-item">
<div class="level is-mobile fd-expanded">
<div class="level-item">
<div class="buttons has-addons">
<player-button-repeat class="button"></player-button-repeat>
<player-button-shuffle class="button"></player-button-shuffle>
<player-button-consume class="button"></player-button-consume>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Player menu (only visible on mobile and tablet) -->
<div class="navbar-menu is-hidden-desktop" style="max-height: calc(100vh - 3.25rem); overflow: scroll"
:class="{ 'is-active': show_player_menu }">
<div class="navbar-start">
</div>
<div class="navbar-end">
<!-- Repeat/shuffle/consume -->
<div class="navbar-item">
<div class="buttons is-centered">
<player-button-repeat class="button" icon_style="mdi-18px"></player-button-repeat>
<player-button-shuffle class="button" icon_style="mdi-18px"></player-button-shuffle>
<player-button-consume class="button" icon_style="mdi-18px"></player-button-consume>
</div>
</div>
<hr style="margin: 12px 0;">
<!-- Outputs: master volume -->
<div class="navbar-item">
<div class="level is-mobile">
<div class="level-left fd-expanded">
<div class="level-item" style="flex-grow: 0;">
<a class="button is-white is-small" @click="toggle_mute_volume">
<span class="icon"><i class="mdi mdi-18px" :class="{ 'mdi-volume-off': player.volume <= 0, 'mdi-volume-high': player.volume > 0 }"></i></span>
</a>
</div>
<div class="level-item fd-expanded">
<div class="fd-expanded">
<p class="heading">Volume</p>
<range-slider
class="slider fd-has-action"
min="0"
max="100"
step="1"
:value="player.volume"
@change="set_volume">
</range-slider>
</div>
</div>
</div>
</div>
</div>
<!-- Outputs: speaker volumes -->
<navbar-item-output v-for="output in outputs" :key="output.id" :output="output"></navbar-item-output>
<!-- Outputs: stream volume -->
<hr class="navbar-divider">
<div class="navbar-item fd-has-margin-bottom">
<div class="level is-mobile">
<div class="level-left fd-expanded">
<div class="level-item" style="flex-grow: 0;">
<a class="button is-white is-small" :class="{ 'is-loading': loading }">
<span class="icon fd-has-action"
:class="{ 'has-text-grey-light': !playing && !loading, 'is-loading': loading }"
@click="togglePlay"><i class="mdi mdi-18px mdi-radio-tower"></i>
</span>
</a>
</div>
<div class="level-item fd-expanded">
<div class="fd-expanded">
<p class="heading" :class="{ 'has-text-grey-light': !playing }">HTTP stream <a href="/stream.mp3"><span class="is-lowercase">(stream.mp3)</span></a></p>
<range-slider
class="slider fd-has-action"
min="0"
max="100"
step="1"
:disabled="!playing"
:value="stream_volume"
@change="set_stream_volume">
</range-slider>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</nav> </nav>
</template> </template>
<script> <script>
import PlayerButtonPlayPause from './PlayerButtonPlayPause' import webapi from '@/webapi'
import _audio from '@/audio'
import NavbarItemLink from './NavbarItemLink'
import NavbarItemOutput from './NavbarItemOutput'
import PlayerButtonPlayPause from '@/components/PlayerButtonPlayPause'
import PlayerButtonNext from '@/components/PlayerButtonNext'
import PlayerButtonPrevious from '@/components/PlayerButtonPrevious'
import PlayerButtonShuffle from '@/components/PlayerButtonShuffle'
import PlayerButtonConsume from '@/components/PlayerButtonConsume'
import PlayerButtonRepeat from '@/components/PlayerButtonRepeat'
import PlayerButtonSeekBack from '@/components/PlayerButtonSeekBack'
import PlayerButtonSeekForward from '@/components/PlayerButtonSeekForward'
import RangeSlider from 'vue-range-slider'
import * as types from '@/store/mutation_types'
export default { export default {
name: 'NavbarBottom', name: 'NavbarBottom',
components: { PlayerButtonPlayPause }, components: {
NavbarItemLink,
NavbarItemOutput,
RangeSlider,
PlayerButtonPlayPause,
PlayerButtonNext,
PlayerButtonPrevious,
PlayerButtonShuffle,
PlayerButtonConsume,
PlayerButtonRepeat,
PlayerButtonSeekForward,
PlayerButtonSeekBack
},
data () { data () {
return { } return {
old_volume: 0,
playing: false,
loading: false,
stream_volume: 10,
show_outputs_menu: false,
show_desktop_outputs_menu: false
}
}, },
computed: { computed: {
show_player_menu: {
get () {
return this.$store.state.show_player_menu
},
set (value) {
this.$store.commit(types.SHOW_PLAYER_MENU, value)
}
},
show_burger_menu () {
return this.$store.state.show_burger_menu
},
zindex () {
if (this.show_burger_menu) {
return 'z-index: 20'
}
return ''
},
state () { state () {
return this.$store.state.player return this.$store.state.player
}, },
now_playing () { now_playing () {
return this.$store.getters.now_playing return this.$store.getters.now_playing
},
is_now_playing_page () {
return this.$route.path === '/now-playing'
},
outputs () {
return this.$store.state.outputs
},
player () {
return this.$store.state.player
},
config () {
return this.$store.state.config
} }
},
methods: {
on_click_outside_outputs () {
this.show_outputs_menu = false
},
set_volume: function (newVolume) {
webapi.player_volume(newVolume)
},
toggle_mute_volume: function () {
if (this.player.volume > 0) {
this.set_volume(0)
} else {
this.set_volume(this.old_volume)
}
},
setupAudio: function () {
const a = _audio.setupAudio()
a.addEventListener('waiting', e => {
this.playing = false
this.loading = true
})
a.addEventListener('playing', e => {
this.playing = true
this.loading = false
})
a.addEventListener('ended', e => {
this.playing = false
this.loading = false
})
a.addEventListener('error', e => {
this.closeAudio()
this.$store.dispatch('add_notification', { text: 'HTTP stream error: failed to load stream or stopped loading due to network problem', type: 'danger' })
this.playing = false
this.loading = false
})
},
// close active audio
closeAudio: function () {
_audio.stopAudio()
this.playing = false
},
playChannel: function () {
if (this.playing) {
return
}
const channel = '/stream.mp3'
this.loading = true
_audio.playSource(channel)
_audio.setVolume(this.stream_volume / 100)
},
togglePlay: function () {
if (this.loading) {
return
}
if (this.playing) {
return this.closeAudio()
}
return this.playChannel()
},
set_stream_volume: function (newVolume) {
this.stream_volume = newVolume
_audio.setVolume(this.stream_volume / 100)
}
},
watch: {
'$store.state.player.volume' () {
if (this.player.volume > 0) {
this.old_volume = this.player.volume
}
}
},
// on app mounted
mounted () {
this.setupAudio()
},
// on app destroyed
destroyed () {
this.closeAudio()
} }
} }
</script> </script>

View File

@ -1,5 +1,5 @@
<template> <template>
<a class="navbar-item" :class="{ 'is-active': is_active }" @click.prevent="open_link()" :href="full_path()"> <a class="navbar-item" :class="{ 'is-active': is_active }" @click.stop.prevent="open_link()" :href="full_path()">
<slot></slot> <slot></slot>
</a> </a>
</template> </template>
@ -9,17 +9,46 @@ import * as types from '@/store/mutation_types'
export default { export default {
name: 'NavbarItemLink', name: 'NavbarItemLink',
props: [ 'to' ], props: {
to: String,
exact: Boolean
},
computed: { computed: {
is_active () { is_active () {
if (this.exact) {
return this.$route.path === this.to
}
return this.$route.path.startsWith(this.to) return this.$route.path.startsWith(this.to)
},
show_player_menu: {
get () {
return this.$store.state.show_player_menu
},
set (value) {
this.$store.commit(types.SHOW_PLAYER_MENU, value)
}
},
show_burger_menu: {
get () {
return this.$store.state.show_burger_menu
},
set (value) {
this.$store.commit(types.SHOW_BURGER_MENU, value)
}
} }
}, },
methods: { methods: {
open_link: function () { open_link: function () {
this.$store.commit(types.SHOW_BURGER_MENU, false) if (this.show_burger_menu) {
this.$store.commit(types.SHOW_BURGER_MENU, false)
}
if (this.show_player_menu) {
this.$store.commit(types.SHOW_PLAYER_MENU, false)
}
this.$router.push({ path: this.to }) this.$router.push({ path: this.to })
}, },

View File

@ -32,7 +32,7 @@ export default {
name: 'NavbarItemOutput', name: 'NavbarItemOutput',
components: { RangeSlider }, components: { RangeSlider },
props: [ 'output' ], props: ['output'],
computed: { computed: {
type_class () { type_class () {
@ -61,7 +61,7 @@ export default {
set_enabled: function () { set_enabled: function () {
const values = { const values = {
'selected': !this.output.selected selected: !this.output.selected
} }
webapi.output_update(this.output.id, values) webapi.output_update(this.output.id, values)
} }

View File

@ -1,5 +1,5 @@
<template> <template>
<nav class="navbar is-light is-fixed-top" role="navigation" aria-label="main navigation"> <nav class="navbar is-light is-fixed-top" :style="zindex" role="navigation" aria-label="main navigation">
<div class="navbar-brand"> <div class="navbar-brand">
<navbar-item-link to="/playlists"> <navbar-item-link to="/playlists">
<span class="icon"><i class="mdi mdi-library-music"></i></span> <span class="icon"><i class="mdi mdi-library-music"></i></span>
@ -7,7 +7,7 @@
<navbar-item-link to="/music"> <navbar-item-link to="/music">
<span class="icon"><i class="mdi mdi-music"></i></span> <span class="icon"><i class="mdi mdi-music"></i></span>
</navbar-item-link> </navbar-item-link>
<navbar-item-link to="/podcasts" v-if="podcasts.tracks > 0"> <navbar-item-link to="/podcasts">
<span class="icon"><i class="mdi mdi-microphone"></i></span> <span class="icon"><i class="mdi mdi-microphone"></i></span>
</navbar-item-link> </navbar-item-link>
<navbar-item-link to="/audiobooks" v-if="audiobooks.tracks > 0"> <navbar-item-link to="/audiobooks" v-if="audiobooks.tracks > 0">
@ -20,7 +20,7 @@
<span class="icon"><i class="mdi mdi-magnify"></i></span> <span class="icon"><i class="mdi mdi-magnify"></i></span>
</navbar-item-link> </navbar-item-link>
<div class="navbar-burger" @click="update_show_burger_menu" :class="{ 'is-active': show_burger_menu }"> <div class="navbar-burger" @click="show_burger_menu = !show_burger_menu" :class="{ 'is-active': show_burger_menu }">
<span></span> <span></span>
<span></span> <span></span>
<span></span> <span></span>
@ -33,150 +33,59 @@
<div class="navbar-end"> <div class="navbar-end">
<!-- Outputs dropdown -->
<div class="navbar-item has-dropdown"
:class="{ 'is-active': show_outputs_menu, 'is-hoverable': !show_outputs_menu && !show_settings_menu }"
@click="show_outputs_menu = !show_outputs_menu"
v-click-outside="on_click_outside_outputs">
<a class="navbar-link is-arrowless"><span class="icon is-hidden-mobile is-hidden-tablet-only"><i class="mdi mdi-volume-high"></i></span> <span class="is-hidden-desktop has-text-weight-bold">Volume</span></a>
<div class="navbar-dropdown is-right">
<div class="navbar-item">
<!-- Outputs: master volume -->
<div class="level is-mobile">
<div class="level-left fd-expanded">
<div class="level-item" style="flex-grow: 0;">
<a class="button is-white is-small" @click="toggle_mute_volume">
<span class="icon"><i class="mdi mdi-18px" :class="{ 'mdi-volume-off': player.volume <= 0, 'mdi-volume-high': player.volume > 0 }"></i></span>
</a>
</div>
<div class="level-item fd-expanded">
<div class="fd-expanded">
<p class="heading">Volume</p>
<range-slider
class="slider fd-has-action"
min="0"
max="100"
step="1"
:value="player.volume"
@change="set_volume">
</range-slider>
</div>
</div>
</div>
</div>
</div>
<!-- Outputs: master volume -->
<hr class="navbar-divider">
<navbar-item-output v-for="output in outputs" :key="output.id" :output="output"></navbar-item-output>
<!-- Outputs: stream volume -->
<hr class="navbar-divider">
<div class="navbar-item">
<div class="level is-mobile">
<div class="level-left fd-expanded">
<div class="level-item" style="flex-grow: 0;">
<a class="button is-white is-small" :class="{ 'is-loading': loading }"><span class="icon fd-has-action" :class="{ 'has-text-grey-light': !playing && !loading, 'is-loading': loading }" @click="togglePlay"><i class="mdi mdi-18px mdi-radio-tower"></i></span></a>
</div>
<div class="level-item fd-expanded">
<div class="fd-expanded">
<p class="heading" :class="{ 'has-text-grey-light': !playing }">HTTP stream <a href="/stream.mp3"><span class="is-lowercase">(stream.mp3)</span></a></p>
<range-slider
class="slider fd-has-action"
min="0"
max="100"
step="1"
:disabled="!playing"
:value="stream_volume"
@change="set_stream_volume">
</range-slider>
</div>
</div>
</div>
</div>
</div>
<!-- Playback controls -->
<hr class="navbar-divider">
<div class="navbar-item">
<div class="level is-mobile">
<div class="level-left">
<div class="level-item">
<div class="buttons has-addons">
<player-button-previous class="button"></player-button-previous>
<player-button-play-pause class="button"></player-button-play-pause>
<player-button-next class="button"></player-button-next>
</div>
</div>
<div class="level-item">
<div class="buttons has-addons">
<player-button-repeat class="button is-light"></player-button-repeat>
<player-button-shuffle class="button is-light"></player-button-shuffle>
<player-button-consume class="button is-light"></player-button-consume>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Settings drop down --> <!-- Settings drop down -->
<div class="navbar-item has-dropdown" <div class="navbar-item has-dropdown is-hoverable"
:class="{ 'is-active': show_settings_menu, 'is-hoverable': !show_outputs_menu && !show_settings_menu }" :class="{ 'is-active': show_settings_menu }"
@click="show_settings_menu = !show_settings_menu" @click="on_click_outside_settings">
v-click-outside="on_click_outside_settings"> <a class="navbar-link is-arrowless">
<a class="navbar-link is-arrowless"><span class="icon is-hidden-mobile is-hidden-tablet-only"><i class="mdi mdi-settings"></i></span> <span class="is-hidden-desktop has-text-weight-bold">forked-daapd</span></a> <span class="icon is-hidden-touch"><i class="mdi mdi-24px mdi-menu"></i></span>
<span class="is-hidden-desktop has-text-weight-bold">forked-daapd</span>
</a>
<div class="navbar-dropdown is-right"> <div class="navbar-dropdown is-right">
<navbar-item-link to="/playlists"><span class="icon"><i class="mdi mdi-library-music"></i></span> <b>Playlists</b></navbar-item-link>
<navbar-item-link to="/music" exact><span class="icon"><i class="mdi mdi-music"></i></span> <b>Music</b></navbar-item-link>
<navbar-item-link to="/music/artists"><span style="padding-left: 1.5rem;">Artists</span></navbar-item-link>
<navbar-item-link to="/music/albums"><span style="padding-left: 1.5rem;">Albums</span></navbar-item-link>
<navbar-item-link to="/music/genres"><span style="padding-left: 1.5rem;">Genres</span></navbar-item-link>
<navbar-item-link to="/music/spotify" v-if="spotify_enabled"><span style="padding-left: 1.5rem;">Spotify</span></navbar-item-link>
<navbar-item-link to="/podcasts"><span class="icon"><i class="mdi mdi-microphone"></i></span> <b>Podcasts</b></navbar-item-link>
<navbar-item-link to="/audiobooks"><span class="icon"><i class="mdi mdi-book-open-variant"></i></span> <b>Audiobooks</b></navbar-item-link>
<navbar-item-link to="/files"><span class="icon"><i class="mdi mdi-folder-open"></i></span> <b>Files</b></navbar-item-link>
<navbar-item-link to="/search"><span class="icon"><i class="mdi mdi-magnify"></i></span> <b>Search</b></navbar-item-link>
<hr style="margin: 12px 0;">
<a class="navbar-item" href="/admin.html">Admin</a> <a class="navbar-item" href="/admin.html">Admin</a>
<hr class="navbar-divider"> <hr style="margin: 12px 0;">
<navbar-item-link to="/settings/webinterface">Settings</navbar-item-link> <navbar-item-link to="/settings/webinterface">Settings</navbar-item-link>
<navbar-item-link to="/about">About</navbar-item-link> <navbar-item-link to="/about">About</navbar-item-link>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="is-overlay" v-show="show_settings_menu"
style="z-index:10; width: 100vw; height:100vh;"
@click="show_settings_menu = false"></div>
</nav> </nav>
</template> </template>
<script> <script>
import webapi from '@/webapi'
import _audio from '@/audio'
import NavbarItemLink from './NavbarItemLink' import NavbarItemLink from './NavbarItemLink'
import NavbarItemOutput from './NavbarItemOutput'
import PlayerButtonPlayPause from './PlayerButtonPlayPause'
import PlayerButtonNext from './PlayerButtonNext'
import PlayerButtonPrevious from './PlayerButtonPrevious'
import PlayerButtonShuffle from './PlayerButtonShuffle'
import PlayerButtonConsume from './PlayerButtonConsume'
import PlayerButtonRepeat from './PlayerButtonRepeat'
import RangeSlider from 'vue-range-slider'
import * as types from '@/store/mutation_types' import * as types from '@/store/mutation_types'
export default { export default {
name: 'NavbarTop', name: 'NavbarTop',
components: { NavbarItemLink, NavbarItemOutput, PlayerButtonPlayPause, PlayerButtonNext, PlayerButtonPrevious, PlayerButtonShuffle, PlayerButtonConsume, PlayerButtonRepeat, RangeSlider }, components: { NavbarItemLink },
data () { data () {
return { return {
old_volume: 0,
playing: false,
loading: false,
stream_volume: 10,
show_outputs_menu: false,
show_settings_menu: false show_settings_menu: false
} }
}, },
computed: { computed: {
outputs () {
return this.$store.state.outputs
},
player () { player () {
return this.$store.state.player return this.$store.state.player
}, },
@ -197,108 +106,41 @@ export default {
return this.$store.state.podcasts_count return this.$store.state.podcasts_count
}, },
show_burger_menu () { spotify_enabled () {
return this.$store.state.show_burger_menu return this.$store.state.spotify.webapi_token_valid
},
show_burger_menu: {
get () {
return this.$store.state.show_burger_menu
},
set (value) {
this.$store.commit(types.SHOW_BURGER_MENU, value)
}
},
show_player_menu () {
return this.$store.state.show_player_menu
},
zindex () {
if (this.show_player_menu) {
return 'z-index: 20'
}
return ''
} }
}, },
methods: { methods: {
update_show_burger_menu: function () {
this.$store.commit(types.SHOW_BURGER_MENU, !this.show_burger_menu)
},
on_click_outside_outputs () {
this.show_outputs_menu = false
},
on_click_outside_settings () { on_click_outside_settings () {
this.show_settings_menu = false this.show_settings_menu = !this.show_settings_menu
},
set_volume: function (newVolume) {
webapi.player_volume(newVolume)
},
toggle_mute_volume: function () {
if (this.player.volume > 0) {
this.set_volume(0)
} else {
this.set_volume(this.old_volume)
}
},
setupAudio: function () {
const a = _audio.setupAudio()
a.addEventListener('waiting', e => {
this.playing = false
this.loading = true
})
a.addEventListener('playing', e => {
this.playing = true
this.loading = false
})
a.addEventListener('ended', e => {
this.playing = false
this.loading = false
})
a.addEventListener('error', e => {
this.closeAudio()
this.$store.dispatch('add_notification', { text: 'HTTP stream error: failed to load stream or stopped loading due to network problem', type: 'danger' })
this.playing = false
this.loading = false
})
},
// close active audio
closeAudio: function () {
_audio.stopAudio()
this.playing = false
},
playChannel: function () {
if (this.playing) {
return
}
const channel = '/stream.mp3'
this.loading = true
_audio.playSource(channel)
_audio.setVolume(this.stream_volume / 100)
},
togglePlay: function () {
if (this.loading) {
return
}
if (this.playing) {
return this.closeAudio()
}
return this.playChannel()
},
set_stream_volume: function (newVolume) {
this.stream_volume = newVolume
_audio.setVolume(this.stream_volume / 100)
} }
}, },
watch: { watch: {
'$store.state.player.volume' () { $route (to, from) {
if (this.player.volume > 0) { this.show_settings_menu = false
this.old_volume = this.player.volume
}
} }
},
// on app mounted
mounted () {
this.setupAudio()
},
// on app destroyed
destroyed () {
this.closeAudio()
} }
} }
</script> </script>

View File

@ -1,6 +1,6 @@
<template> <template>
<a v-on:click="toggle_consume_mode" v-bind:class="{ 'is-warning': is_consume }"> <a @click="toggle_consume_mode" :class="{ 'is-warning': is_consume }">
<span class="icon"><i class="mdi mdi-fire"></i></span> <span class="icon"><i class="mdi mdi-fire" :class="icon_style"></i></span>
</a> </a>
</template> </template>
@ -10,6 +10,10 @@ import webapi from '@/webapi'
export default { export default {
name: 'PlayerButtonConsume', name: 'PlayerButtonConsume',
props: {
icon_style: String
},
computed: { computed: {
is_consume () { is_consume () {
return this.$store.state.player.consume return this.$store.state.player.consume

View File

@ -1,6 +1,6 @@
<template> <template>
<a v-on:click="play_next" :disabled="disabled"> <a @click="play_next" :disabled="disabled">
<span class="icon"><i class="mdi mdi-skip-forward"></i></span> <span class="icon"><i class="mdi mdi-skip-forward" :class="icon_style"></i></span>
</a> </a>
</template> </template>
@ -10,6 +10,10 @@ import webapi from '@/webapi'
export default { export default {
name: 'PlayerButtonNext', name: 'PlayerButtonNext',
props: {
icon_style: String
},
computed: { computed: {
disabled () { disabled () {
return !this.$store.state.queue || this.$store.state.queue.count <= 0 return !this.$store.state.queue || this.$store.state.queue.count <= 0

View File

@ -1,6 +1,6 @@
<template> <template>
<a @click="toggle_play_pause" :disabled="disabled"> <a @click="toggle_play_pause" :disabled="disabled">
<span class="icon"><i class="mdi" v-bind:class="[icon_style, { 'mdi-play': !is_playing, 'mdi-pause': is_playing && is_pause_allowed, 'mdi-stop': is_playing && !is_pause_allowed }]"></i></span> <span class="icon"><i class="mdi" :class="[icon_style, { 'mdi-play': !is_playing, 'mdi-pause': is_playing && is_pause_allowed, 'mdi-stop': is_playing && !is_pause_allowed }]"></i></span>
</a> </a>
</template> </template>
@ -11,8 +11,8 @@ export default {
name: 'PlayerButtonPlayPause', name: 'PlayerButtonPlayPause',
props: { props: {
'icon_style': String, icon_style: String,
'show_disabled_message': Boolean show_disabled_message: Boolean
}, },
computed: { computed: {

View File

@ -1,6 +1,6 @@
<template> <template>
<a v-on:click="play_previous" :disabled="disabled"> <a @click="play_previous" :disabled="disabled">
<span class="icon"><i class="mdi mdi-skip-backward"></i></span> <span class="icon"><i class="mdi mdi-skip-backward" :class="icon_style"></i></span>
</a> </a>
</template> </template>
@ -10,6 +10,10 @@ import webapi from '@/webapi'
export default { export default {
name: 'PlayerButtonPrevious', name: 'PlayerButtonPrevious',
props: {
icon_style: String
},
computed: { computed: {
disabled () { disabled () {
return !this.$store.state.queue || this.$store.state.queue.count <= 0 return !this.$store.state.queue || this.$store.state.queue.count <= 0

View File

@ -1,6 +1,6 @@
<template> <template>
<a v-on:click="toggle_repeat_mode" v-bind:class="{ 'is-warning': !is_repeat_off }"> <a @click="toggle_repeat_mode" :class="{ 'is-warning': !is_repeat_off }">
<span class="icon"><i class="mdi" v-bind:class="{ 'mdi-repeat': is_repeat_all, 'mdi-repeat-once': is_repeat_single, 'mdi-repeat-off': is_repeat_off }"></i></span> <span class="icon"><i class="mdi" :class="[icon_style, { 'mdi-repeat': is_repeat_all, 'mdi-repeat-once': is_repeat_single, 'mdi-repeat-off': is_repeat_off }]"></i></span>
</a> </a>
</template> </template>
@ -10,8 +10,8 @@ import webapi from '@/webapi'
export default { export default {
name: 'PlayerButtonRepeat', name: 'PlayerButtonRepeat',
data () { props: {
return { } icon_style: String
}, },
computed: { computed: {

View File

@ -0,0 +1,38 @@
<template>
<a @click="seek" :disabled="disabled" v-if="visible">
<span class="icon"><i class="mdi mdi-rewind" :class="icon_style"></i></span>
</a>
</template>
<script>
import webapi from '@/webapi'
export default {
name: 'PlayerButtonSeekBack',
props: ['seek_ms', 'icon_style'],
computed: {
now_playing () {
return this.$store.getters.now_playing
},
is_stopped () {
return this.$store.state.player.state === 'stop'
},
disabled () {
return !this.$store.state.queue || this.$store.state.queue.count <= 0 || this.is_stopped ||
this.now_playing.data_kind === 'pipe'
},
visible () {
return ['podcast', 'audiobook'].includes(this.now_playing.media_kind)
}
},
methods: {
seek: function () {
if (!this.disabled) {
webapi.player_seek(this.seek_ms * -1)
}
}
}
}
</script>

View File

@ -0,0 +1,38 @@
<template>
<a @click="seek" :disabled="disabled" v-if="visible">
<span class="icon"><i class="mdi mdi-fast-forward" :class="icon_style"></i></span>
</a>
</template>
<script>
import webapi from '@/webapi'
export default {
name: 'PlayerButtonSeekForward',
props: ['seek_ms', 'icon_style'],
computed: {
now_playing () {
return this.$store.getters.now_playing
},
is_stopped () {
return this.$store.state.player.state === 'stop'
},
disabled () {
return !this.$store.state.queue || this.$store.state.queue.count <= 0 || this.is_stopped ||
this.now_playing.data_kind === 'pipe'
},
visible () {
return ['podcast', 'audiobook'].includes(this.now_playing.media_kind)
}
},
methods: {
seek: function () {
if (!this.disabled) {
webapi.player_seek(this.seek_ms)
}
}
}
}
</script>

View File

@ -1,6 +1,6 @@
<template> <template>
<a v-on:click="toggle_shuffle_mode" v-bind:class="{ 'is-warning': is_shuffle }"> <a @click="toggle_shuffle_mode" :class="{ 'is-warning': is_shuffle }">
<span class="icon"><i class="mdi" v-bind:class="{ 'mdi-shuffle': is_shuffle, 'mdi-shuffle-disabled': !is_shuffle }"></i></span> <span class="icon"><i class="mdi" :class="[icon_style, { 'mdi-shuffle': is_shuffle, 'mdi-shuffle-disabled': !is_shuffle }]"></i></span>
</a> </a>
</template> </template>
@ -10,6 +10,10 @@ import webapi from '@/webapi'
export default { export default {
name: 'PlayerButtonShuffle', name: 'PlayerButtonShuffle',
props: {
icon_style: String
},
computed: { computed: {
is_shuffle () { is_shuffle () {
return this.$store.state.player.shuffle return this.$store.state.player.shuffle

View File

@ -0,0 +1,113 @@
<template>
<div class="field">
<label class="checkbox">
<input type="checkbox"
:checked="value"
@change="set_update_timer"
ref="settings_checkbox">
<slot name="label"></slot>
<i class="is-size-7"
:class="{
'has-text-info': statusUpdate === 'success',
'has-text-danger': statusUpdate === 'error'
}"> {{ info }}</i>
</label>
<p class="help" v-if="$slots['info']">
<slot name="info"></slot>
</p>
</div>
</template>
<script>
import webapi from '@/webapi'
import * as types from '@/store/mutation_types'
export default {
name: 'SettingsCheckbox',
props: ['category_name', 'option_name'],
data () {
return {
timerDelay: 2000,
timerId: -1,
// <empty>: default/no changes, 'success': update succesful, 'error': update failed
statusUpdate: ''
}
},
computed: {
category () {
return this.$store.state.settings.categories.find(elem => elem.name === this.category_name)
},
option () {
if (!this.category) {
return {}
}
return this.category.options.find(elem => elem.name === this.option_name)
},
value () {
return this.option.value
},
info () {
if (this.statusUpdate === 'success') {
return '(setting saved)'
} else if (this.statusUpdate === 'error') {
return '(error saving setting)'
}
return ''
}
},
methods: {
set_update_timer () {
if (this.timerId > 0) {
window.clearTimeout(this.timerId)
this.timerId = -1
}
this.statusUpdate = ''
const newValue = this.$refs.settings_checkbox.checked
if (newValue !== this.value) {
this.timerId = window.setTimeout(this.update_setting, this.timerDelay)
}
},
update_setting () {
this.timerId = -1
const newValue = this.$refs.settings_checkbox.checked
if (newValue === this.value) {
this.statusUpdate = ''
return
}
const option = {
category: this.category.name,
name: this.option_name,
value: newValue
}
webapi.settings_update(this.category.name, option).then(() => {
this.$store.commit(types.UPDATE_SETTINGS_OPTION, option)
this.statusUpdate = 'success'
}).catch(() => {
this.statusUpdate = 'error'
this.$refs.settings_checkbox.checked = this.value
}).finally(() => {
this.timerId = window.setTimeout(this.clear_status, this.timerDelay)
})
},
clear_status: function () {
this.statusUpdate = ''
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,117 @@
<template>
<fieldset :disabled="disabled">
<div class="field">
<label class="label has-text-weight-normal">
<slot name="label"></slot>
<i class="is-size-7"
:class="{
'has-text-info': statusUpdate === 'success',
'has-text-danger': statusUpdate === 'error'
}"> {{ info }}</i>
</label>
<div class="control">
<input class="input" type="text" :placeholder="placeholder"
:value="value"
@input="set_update_timer"
ref="settings_text">
</div>
<p class="help" v-if="$slots['info']">
<slot name="info"></slot>
</p>
</div>
</fieldset>
</template>
<script>
import webapi from '@/webapi'
import * as types from '@/store/mutation_types'
export default {
name: 'SettingsTextfield',
props: ['category_name', 'option_name', 'placeholder', 'disabled'],
data () {
return {
timerDelay: 2000,
timerId: -1,
// <empty>: default/no changes, 'success': update succesful, 'error': update failed
statusUpdate: ''
}
},
computed: {
category () {
return this.$store.state.settings.categories.find(elem => elem.name === this.category_name)
},
option () {
if (!this.category) {
return {}
}
return this.category.options.find(elem => elem.name === this.option_name)
},
value () {
return this.option.value
},
info () {
if (this.statusUpdate === 'success') {
return '(setting saved)'
} else if (this.statusUpdate === 'error') {
return '(error saving setting)'
}
return ''
}
},
methods: {
set_update_timer () {
if (this.timerId > 0) {
window.clearTimeout(this.timerId)
this.timerId = -1
}
this.statusUpdate = ''
const newValue = this.$refs.settings_text.value
if (newValue !== this.value) {
this.timerId = window.setTimeout(this.update_setting, this.timerDelay)
}
},
update_setting () {
this.timerId = -1
const newValue = this.$refs.settings_text.value
if (newValue === this.value) {
this.statusUpdate = ''
return
}
const option = {
category: this.category.name,
name: this.option_name,
value: newValue
}
webapi.settings_update(this.category.name, option).then(() => {
this.$store.commit(types.UPDATE_SETTINGS_OPTION, option)
this.statusUpdate = 'success'
}).catch(() => {
this.statusUpdate = 'error'
this.$refs.settings_text.value = this.value
}).finally(() => {
this.timerId = window.setTimeout(this.clear_status, this.timerDelay)
})
},
clear_status: function () {
this.statusUpdate = ''
}
}
}
</script>
<style>
</style>

View File

@ -51,7 +51,7 @@ import webapi from '@/webapi'
export default { export default {
name: 'SpotifyModalDialogAlbum', name: 'SpotifyModalDialogAlbum',
props: [ 'show', 'album' ], props: ['show', 'album'],
data () { data () {
return { return {

View File

@ -44,7 +44,7 @@ import webapi from '@/webapi'
export default { export default {
name: 'SpotifyModalDialogArtist', name: 'SpotifyModalDialogArtist',
props: [ 'show', 'artist' ], props: ['show', 'artist'],
methods: { methods: {
play: function () { play: function () {

View File

@ -48,7 +48,7 @@ import webapi from '@/webapi'
export default { export default {
name: 'SpotifyModalDialogPlaylist', name: 'SpotifyModalDialogPlaylist',
props: [ 'show', 'playlist' ], props: ['show', 'playlist'],
methods: { methods: {
play: function () { play: function () {

View File

@ -63,7 +63,7 @@ import webapi from '@/webapi'
export default { export default {
name: 'SpotifyModalDialogTrack', name: 'SpotifyModalDialogTrack',
props: [ 'show', 'track', 'album' ], props: ['show', 'track', 'album'],
methods: { methods: {
play: function () { play: function () {

View File

@ -15,6 +15,11 @@
<span class="">Remotes &amp; Outputs</span> <span class="">Remotes &amp; Outputs</span>
</a> </a>
</router-link> </router-link>
<router-link tag="li" to="/settings/artwork" active-class="is-active">
<a>
<span class="">Artwork</span>
</a>
</router-link>
<router-link tag="li" to="/settings/online-services" active-class="is-active"> <router-link tag="li" to="/settings/online-services" active-class="is-active">
<a> <a>
<span class="">Online Services</span> <span class="">Online Services</span>

View File

@ -0,0 +1,31 @@
/*
* SVGRenderer taken from https://github.com/bendera/placeholder published under MIT License
* Copyright (c) 2017 Adam Bender
* https://github.com/bendera/placeholder/blob/master/LICENSE
*/
class SVGRenderer {
render (data) {
const svg = '<svg width="' + data.width + '" height="' + data.height + '" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + data.width + ' ' + data.height + '" preserveAspectRatio="none">' +
'<defs>' +
'<style type="text/css">' +
' #holder text {' +
' fill: ' + data.textColor + ';' +
' font-family: ' + data.fontFamily + ';' +
' font-size: ' + data.fontSize + 'px;' +
' font-weight: ' + data.fontWeight + ';' +
' }' +
' </style>' +
'</defs>' +
'<g id="holder">' +
' <rect width="100%" height="100%" fill="' + data.backgroundColor + '"></rect>' +
' <g>' +
' <text text-anchor="middle" x="50%" y="50%" dy=".3em">' + data.caption + '</text>' +
' </g>' +
'</g>' +
'</svg>'
return 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg)
}
}
export default SVGRenderer

View File

@ -86,21 +86,6 @@ a.navbar-item {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.fd-is-fullheight {
height: calc(100vh - 3.25rem - 3.25rem);
}
.fd-is-fullheight-body {
flex-shrink: 1;
overflow: hidden;
height: 100%
}
.fd-image-fullheight {
height: 100%;
width: auto;
}
.fd-tabs-section { .fd-tabs-section {
padding-bottom: 3px; padding-bottom: 3px;
padding-top: 3px; padding-top: 3px;
@ -123,6 +108,35 @@ section.fd-tabs-section + section.fd-content {
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
} }
/* Now playing page */
.fd-is-fullheight {
height: calc(100vh - 3.25rem - 3.25rem);
display: flex;
flex-direction: column;
justify-content: center;
}
.fd-is-fullheight .fd-is-expanded {
max-height: calc(100vh - 25rem);
padding: 1.5rem;
overflow: hidden;
flex-grow: 1;
flex-shrink: 1;
}
.fd-cover-image {
height: 100%;
}
.fd-cover-image img {
width: 100%;
height: 100%;
object-fit: contain;
object-position: bottom;
filter: drop-shadow(0px 0px 1px rgba(0,0,0,.3)) drop-shadow(0px 0px 10px rgba(0,0,0,.3));
}
.sortable-chosen .media-right { .sortable-chosen .media-right {
visibility: hidden; visibility: hidden;
} }
@ -180,3 +194,11 @@ section.fd-tabs-section + section.fd-content {
margin-left: 16px; margin-left: 16px;
margin-right: 16px; margin-right: 16px;
} }
.dropdown-item a {
display: block;
}
.dropdown-item:hover {
background-color: hsl(0, 0%, 96%)
}

View File

@ -24,9 +24,35 @@
</div> </div>
<!-- Right side --> <!-- Right side -->
<div class="level-right buttons"> <div class="level-right">
<a class="button is-small is-outlined is-link" :class="{ 'is-loading': library.updating }" @click="update">Update</a> <div v-if="library.updating"><a class="button is-small is-loading">Update</a></div>
<a class="button is-small is-outlined is-link" :class="{ 'is-loading': library.updating }" @click="update_meta">Force Meta Rescan</a> <div v-else class="dropdown is-right" :class="{ 'is-active': show_update_dropdown }">
<div class="dropdown-trigger">
<div class="buttons has-addons">
<a @click="update" class="button is-small">Update</a>
<a @click="show_update_dropdown = !show_update_dropdown" class="button is-small">
<span class="icon"><i class="mdi" :class="{ 'mdi-chevron-down': !show_update_dropdown, 'mdi-chevron-up': show_update_dropdown }"></i></span>
</a>
</div>
</div>
<div class="dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content">
<div class="dropdown-item">
<a @click="update" class="has-text-dark">
<strong>Update</strong><br>
<span class="is-size-7">Adds new, removes deleted and updates modified files.</span>
</a>
</div>
<hr class="dropdown-divider">
<div class="dropdown-item">
<a @click="update_meta" class="has-text-dark">
<strong>Rescan metadata</strong><br>
<span class="is-size-7">Same as update, but also rescans unmodified files.</span>
</a>
</div>
</div>
</div>
</div>
</div> </div>
</nav> </nav>
@ -84,6 +110,12 @@ import webapi from '@/webapi'
export default { export default {
name: 'PageAbout', name: 'PageAbout',
data () {
return {
show_update_dropdown: false
}
},
computed: { computed: {
config () { config () {
return this.$store.state.config return this.$store.state.config
@ -95,10 +127,12 @@ export default {
methods: { methods: {
update: function () { update: function () {
this.show_update_dropdown = false
webapi.library_update() webapi.library_update()
}, },
update_meta: function () { update_meta: function () {
this.show_update_dropdown = false
webapi.library_rescan() webapi.library_rescan()
} }
}, },

View File

@ -53,7 +53,7 @@ const albumData = {
export default { export default {
name: 'PageAlbum', name: 'PageAlbum',
mixins: [ LoadDataBeforeEnterMixin(albumData) ], mixins: [LoadDataBeforeEnterMixin(albumData)],
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, ModalDialogAlbum }, components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, ModalDialogAlbum },
data () { data () {

View File

@ -19,11 +19,10 @@
</a> </a>
</template> </template>
<template slot="content"> <template slot="content">
<list-item-album v-for="album in albums.items" <list-item-album v-for="album in albums_filtered"
:key="album.id" :key="album.id"
:album="album" :album="album"
@click="open_album(album)" @click="open_album(album)">
v-if="!hide_singles || album.track_count > 2">
<template slot="actions"> <template slot="actions">
<a @click="open_dialog(album)"> <a @click="open_dialog(album)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span> <span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
@ -61,7 +60,7 @@ const albumsData = {
export default { export default {
name: 'PageAlbums', name: 'PageAlbums',
mixins: [ LoadDataBeforeEnterMixin(albumsData) ], mixins: [LoadDataBeforeEnterMixin(albumsData)],
components: { ContentWithHeading, TabsMusic, IndexButtonList, ListItemAlbum, ModalDialogAlbum }, components: { ContentWithHeading, TabsMusic, IndexButtonList, ListItemAlbum, ModalDialogAlbum },
data () { data () {
@ -77,6 +76,10 @@ export default {
computed: { computed: {
hide_singles () { hide_singles () {
return this.$store.state.hide_singles return this.$store.state.hide_singles
},
albums_filtered () {
return this.albums.items.filter(album => !this.hide_singles || album.track_count > 2)
} }
}, },

View File

@ -52,7 +52,7 @@ const artistData = {
export default { export default {
name: 'PageArtist', name: 'PageArtist',
mixins: [ LoadDataBeforeEnterMixin(artistData) ], mixins: [LoadDataBeforeEnterMixin(artistData)],
components: { ContentWithHeading, ListItemAlbum, ModalDialogAlbum, ModalDialogArtist }, components: { ContentWithHeading, ListItemAlbum, ModalDialogAlbum, ModalDialogArtist },
data () { data () {

View File

@ -58,7 +58,7 @@ const tracksData = {
export default { export default {
name: 'PageArtistTracks', name: 'PageArtistTracks',
mixins: [ LoadDataBeforeEnterMixin(tracksData) ], mixins: [LoadDataBeforeEnterMixin(tracksData)],
components: { ContentWithHeading, ListItemTrack, IndexButtonList, ModalDialogTrack, ModalDialogArtist }, components: { ContentWithHeading, ListItemTrack, IndexButtonList, ModalDialogTrack, ModalDialogArtist },
data () { data () {

View File

@ -19,11 +19,10 @@
</a> </a>
</template> </template>
<template slot="content"> <template slot="content">
<list-item-artist v-for="artist in artists.items" <list-item-artist v-for="artist in artists_filtered"
:key="artist.id" :key="artist.id"
:artist="artist" :artist="artist"
@click="open_artist(artist)" @click="open_artist(artist)">
v-if="!hide_singles || artist.track_count > (artist.album_count * 2)">
<template slot="actions"> <template slot="actions">
<a @click="open_dialog(artist)"> <a @click="open_dialog(artist)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span> <span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
@ -58,7 +57,7 @@ const artistsData = {
export default { export default {
name: 'PageArtists', name: 'PageArtists',
mixins: [ LoadDataBeforeEnterMixin(artistsData) ], mixins: [LoadDataBeforeEnterMixin(artistsData)],
components: { ContentWithHeading, TabsMusic, IndexButtonList, ListItemArtist, ModalDialogArtist }, components: { ContentWithHeading, TabsMusic, IndexButtonList, ListItemArtist, ModalDialogArtist },
data () { data () {
@ -79,6 +78,10 @@ export default {
return [...new Set(this.artists.items return [...new Set(this.artists.items
.filter(artist => !this.$store.state.hide_singles || artist.track_count > (artist.album_count * 2)) .filter(artist => !this.$store.state.hide_singles || artist.track_count > (artist.album_count * 2))
.map(artist => artist.name_sort.charAt(0).toUpperCase()))] .map(artist => artist.name_sort.charAt(0).toUpperCase()))]
},
artists_filtered () {
return this.artists.items.filter(artist => !this.hide_singles || artist.track_count > (artist.album_count * 2))
} }
}, },

View File

@ -56,7 +56,7 @@ const albumData = {
export default { export default {
name: 'PageAudiobook', name: 'PageAudiobook',
mixins: [ LoadDataBeforeEnterMixin(albumData) ], mixins: [LoadDataBeforeEnterMixin(albumData)],
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, ModalDialogAlbum }, components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, ModalDialogAlbum },
data () { data () {

View File

@ -38,7 +38,7 @@ const albumsData = {
export default { export default {
name: 'PageAudiobooks', name: 'PageAudiobooks',
mixins: [ LoadDataBeforeEnterMixin(albumsData) ], mixins: [LoadDataBeforeEnterMixin(albumsData)],
components: { ContentWithHeading, ListItemAlbum, ModalDialogAlbum }, components: { ContentWithHeading, ListItemAlbum, ModalDialogAlbum },
data () { data () {

View File

@ -80,7 +80,7 @@ const browseData = {
export default { export default {
name: 'PageBrowse', name: 'PageBrowse',
mixins: [ LoadDataBeforeEnterMixin(browseData) ], mixins: [LoadDataBeforeEnterMixin(browseData)],
components: { ContentWithHeading, TabsMusic, ListItemAlbum, ListItemTrack, ModalDialogTrack, ModalDialogAlbum }, components: { ContentWithHeading, TabsMusic, ListItemAlbum, ListItemTrack, ModalDialogTrack, ModalDialogAlbum },
data () { data () {

View File

@ -45,7 +45,7 @@ const browseData = {
export default { export default {
name: 'PageBrowseType', name: 'PageBrowseType',
mixins: [ LoadDataBeforeEnterMixin(browseData) ], mixins: [LoadDataBeforeEnterMixin(browseData)],
components: { ContentWithHeading, TabsMusic, ListItemAlbum, ModalDialogAlbum }, components: { ContentWithHeading, TabsMusic, ListItemAlbum, ModalDialogAlbum },
data () { data () {

View File

@ -45,7 +45,7 @@ const browseData = {
export default { export default {
name: 'PageBrowseType', name: 'PageBrowseType',
mixins: [ LoadDataBeforeEnterMixin(browseData) ], mixins: [LoadDataBeforeEnterMixin(browseData)],
components: { ContentWithHeading, TabsMusic, ListItemTrack, ModalDialogTrack }, components: { ContentWithHeading, TabsMusic, ListItemTrack, ModalDialogTrack },
data () { data () {

View File

@ -106,7 +106,7 @@ const filesData = {
export default { export default {
name: 'PageFiles', name: 'PageFiles',
mixins: [ LoadDataBeforeEnterMixin(filesData) ], mixins: [LoadDataBeforeEnterMixin(filesData)],
components: { ContentWithHeading, ListItemDirectory, ListItemPlaylist, ListItemTrack, ModalDialogDirectory, ModalDialogPlaylist, ModalDialogTrack }, components: { ContentWithHeading, ListItemDirectory, ListItemPlaylist, ListItemTrack, ModalDialogDirectory, ModalDialogPlaylist, ModalDialogTrack },
data () { data () {

View File

@ -36,7 +36,6 @@
<script> <script>
import { LoadDataBeforeEnterMixin } from './mixin' import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic'
import IndexButtonList from '@/components/IndexButtonList' import IndexButtonList from '@/components/IndexButtonList'
import ListItemAlbums from '@/components/ListItemAlbum' import ListItemAlbums from '@/components/ListItemAlbum'
import ModalDialogAlbum from '@/components/ModalDialogAlbum' import ModalDialogAlbum from '@/components/ModalDialogAlbum'
@ -56,8 +55,8 @@ const genreData = {
export default { export default {
name: 'PageGenre', name: 'PageGenre',
mixins: [ LoadDataBeforeEnterMixin(genreData) ], mixins: [LoadDataBeforeEnterMixin(genreData)],
components: { ContentWithHeading, TabsMusic, IndexButtonList, ListItemAlbums, ModalDialogAlbum, ModalDialogGenre }, components: { ContentWithHeading, IndexButtonList, ListItemAlbums, ModalDialogAlbum, ModalDialogGenre },
data () { data () {
return { return {

View File

@ -55,7 +55,7 @@ const tracksData = {
export default { export default {
name: 'PageGenreTracks', name: 'PageGenreTracks',
mixins: [ LoadDataBeforeEnterMixin(tracksData) ], mixins: [LoadDataBeforeEnterMixin(tracksData)],
components: { ContentWithHeading, ListItemTrack, IndexButtonList, ModalDialogTrack, ModalDialogGenre }, components: { ContentWithHeading, ListItemTrack, IndexButtonList, ModalDialogTrack, ModalDialogGenre },
data () { data () {

View File

@ -45,7 +45,7 @@ const genresData = {
export default { export default {
name: 'PageGenres', name: 'PageGenres',
mixins: [ LoadDataBeforeEnterMixin(genresData) ], mixins: [LoadDataBeforeEnterMixin(genresData)],
components: { ContentWithHeading, TabsMusic, IndexButtonList, ListItemGenre, ModalDialogGenre }, components: { ContentWithHeading, TabsMusic, IndexButtonList, ListItemGenre, ModalDialogGenre },
data () { data () {

View File

@ -1,83 +1,77 @@
<template> <template>
<section class="hero fd-is-fullheight"> <section>
<div class="hero-head fd-has-padding-left-right"> <div v-if="now_playing.id > 0" class="fd-is-fullheight">
<div class="container has-text-centered fd-has-margin-top"> <div class="fd-is-expanded">
<h1 class="title is-4"> <cover-artwork @click="open_dialog(now_playing)"
{{ now_playing.title }} :artwork_url="now_playing.artwork_url"
</h1> :artist="now_playing.artist"
<h2 class="title is-6"> :album="now_playing.album"
{{ now_playing.artist }} class="fd-cover-image fd-has-action" />
</h2>
<h2 class="subtitle is-6 has-text-grey has-text-weight-bold" v-if="composer">
{{ composer }}
</h2>
<h3 class="subtitle is-6">
{{ now_playing.album }}
</h3>
</div> </div>
</div> <div class="fd-has-padding-left-right">
<div class="hero-body fd-is-fullheight-body has-text-centered" v-show="artwork_visible"> <div class="container has-text-centered">
<img :src="artwork_url" class="fd-has-shadow fd-image-fullheight fd-has-action" <p class="control has-text-centered fd-progress-now-playing">
@load="artwork_loaded" <range-slider
@error="artwork_error" class="seek-slider fd-has-action"
@click="open_dialog(now_playing)"> min="0"
</div> :max="state.item_length_ms"
<div class="hero-body fd-is-fullheight-body has-text-centered" v-show="!artwork_visible"> :value="item_progress_ms"
<a @click="open_dialog(now_playing)" class="button is-white is-medium"> :disabled="state.state === 'stop'"
<span class="icon has-text-grey-light"><i class="mdi mdi-information-outline"></i></span> step="1000"
</a> @change="seek" >
</div> </range-slider>
<div class="hero-foot fd-has-padding-left-right"> </p>
<div class="container has-text-centered fd-has-margin-bottom"> <p class="content">
<p class="control has-text-centered fd-progress-now-playing"> <span>{{ item_progress_ms | duration }} / {{ now_playing.length_ms | duration }}</span>
<range-slider </p>
class="seek-slider fd-has-action" </div>
min="0" </div>
:max="state.item_length_ms" <div class="fd-has-padding-left-right">
:value="item_progress_ms" <div class="container has-text-centered fd-has-margin-top">
:disabled="state.state === 'stop'" <h1 class="title is-5">
step="1000" {{ now_playing.title }}
@change="seek" > </h1>
</range-slider> <h2 class="title is-6">
</p> {{ now_playing.artist }}
<p class="content"> </h2>
<span>{{ item_progress_ms | duration }} / {{ now_playing.length_ms | duration }}</span> <h2 class="subtitle is-6 has-text-grey has-text-weight-bold" v-if="composer">
</p> {{ composer }}
<div class="buttons has-addons is-centered"> </h2>
<player-button-previous class="button is-medium"></player-button-previous> <h3 class="subtitle is-6">
<player-button-play-pause class="button is-medium" icon_style="mdi-36px"></player-button-play-pause> {{ now_playing.album }}
<player-button-next class="button is-medium"></player-button-next> </h3>
<player-button-repeat class="button is-medium is-light"></player-button-repeat>
<player-button-shuffle class="button is-medium is-light"></player-button-shuffle>
<player-button-consume class="button is-medium is-light"></player-button-consume>
</div> </div>
</div> </div>
<modal-dialog-queue-item :show="show_details_modal" :item="selected_item" @close="show_details_modal = false" />
</div> </div>
<div v-else class="fd-is-fullheight" style="justify-content: center;">
<div class="fd-is-expanded fd-has-padding-left-right has-text-centered">
<h1 class="title is-5">
You play queue is empty
</h1>
<p class="content">
Add some tracks by browsing your library
</p>
</div>
</div>
<modal-dialog-queue-item :show="show_details_modal" :item="selected_item" @close="show_details_modal = false" />
</section> </section>
</template> </template>
<script> <script>
import ModalDialogQueueItem from '@/components/ModalDialogQueueItem' import ModalDialogQueueItem from '@/components/ModalDialogQueueItem'
import PlayerButtonPlayPause from '@/components/PlayerButtonPlayPause'
import PlayerButtonNext from '@/components/PlayerButtonNext'
import PlayerButtonPrevious from '@/components/PlayerButtonPrevious'
import PlayerButtonShuffle from '@/components/PlayerButtonShuffle'
import PlayerButtonConsume from '@/components/PlayerButtonConsume'
import PlayerButtonRepeat from '@/components/PlayerButtonRepeat'
import RangeSlider from 'vue-range-slider' import RangeSlider from 'vue-range-slider'
import CoverArtwork from '@/components/CoverArtwork'
import webapi from '@/webapi' import webapi from '@/webapi'
import * as types from '@/store/mutation_types' import * as types from '@/store/mutation_types'
export default { export default {
name: 'PageNowPlaying', name: 'PageNowPlaying',
components: { ModalDialogQueueItem, PlayerButtonPlayPause, PlayerButtonNext, PlayerButtonPrevious, PlayerButtonShuffle, PlayerButtonConsume, PlayerButtonRepeat, RangeSlider }, components: { ModalDialogQueueItem, RangeSlider, CoverArtwork },
data () { data () {
return { return {
item_progress_ms: 0, item_progress_ms: 0,
interval_id: 0, interval_id: 0,
artwork_visible: false,
show_details_modal: false, show_details_modal: false,
selected_item: {} selected_item: {}
@ -110,10 +104,6 @@ export default {
return this.$store.getters.now_playing return this.$store.getters.now_playing
}, },
artwork_url: function () {
return webapi.artwork_url_append_size_params(this.now_playing.artwork_url)
},
settings_option_show_composer_now_playing () { settings_option_show_composer_now_playing () {
return this.$store.getters.settings_option_show_composer_now_playing return this.$store.getters.settings_option_show_composer_now_playing
}, },
@ -142,19 +132,11 @@ export default {
}, },
seek: function (newPosition) { seek: function (newPosition) {
webapi.player_seek(newPosition).catch(() => { webapi.player_seek_to_pos(newPosition).catch(() => {
this.item_progress_ms = this.state.item_progress_ms this.item_progress_ms = this.state.item_progress_ms
}) })
}, },
artwork_loaded: function () {
this.artwork_visible = true
},
artwork_error: function () {
this.artwork_visible = false
},
open_dialog: function (item) { open_dialog: function (item) {
this.selected_item = item this.selected_item = item
this.show_details_modal = true this.show_details_modal = true

View File

@ -52,7 +52,7 @@ const playlistData = {
export default { export default {
name: 'PagePlaylist', name: 'PagePlaylist',
mixins: [ LoadDataBeforeEnterMixin(playlistData) ], mixins: [LoadDataBeforeEnterMixin(playlistData)],
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, ModalDialogPlaylist }, components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, ModalDialogPlaylist },
data () { data () {

View File

@ -8,7 +8,7 @@
<list-item-playlist v-for="playlist in playlists.items" :key="playlist.id" :playlist="playlist" @click="open_playlist(playlist)"> <list-item-playlist v-for="playlist in playlists.items" :key="playlist.id" :playlist="playlist" @click="open_playlist(playlist)">
<template slot="icon"> <template slot="icon">
<span class="icon"> <span class="icon">
<i class="mdi" :class="{ 'mdi-library-music': playlist.type !== 'folder', 'mdi-folder': playlist.type === 'folder' }"></i> <i class="mdi" :class="{ 'mdi-library-music': playlist.type !== 'folder', 'mdi-rss': playlist.type === 'rss', 'mdi-folder': playlist.type === 'folder' }"></i>
</span> </span>
</template> </template>
<template slot="actions"> <template slot="actions">
@ -25,7 +25,6 @@
<script> <script>
import { LoadDataBeforeEnterMixin } from './mixin' import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic'
import ListItemPlaylist from '@/components/ListItemPlaylist' import ListItemPlaylist from '@/components/ListItemPlaylist'
import ModalDialogPlaylist from '@/components/ModalDialogPlaylist' import ModalDialogPlaylist from '@/components/ModalDialogPlaylist'
import webapi from '@/webapi' import webapi from '@/webapi'
@ -46,8 +45,8 @@ const playlistsData = {
export default { export default {
name: 'PagePlaylists', name: 'PagePlaylists',
mixins: [ LoadDataBeforeEnterMixin(playlistsData) ], mixins: [LoadDataBeforeEnterMixin(playlistsData)],
components: { ContentWithHeading, TabsMusic, ListItemPlaylist, ModalDialogPlaylist }, components: { ContentWithHeading, ListItemPlaylist, ModalDialogPlaylist },
data () { data () {
return { return {

View File

@ -1,8 +1,9 @@
<template> <template>
<content-with-heading> <content-with-heading>
<template slot="heading-left"> <template slot="heading-left">
<div class="title is-4">{{ album.name }}</div> <div class="title is-4">{{ album.name }}
</template> </div>
</template>
<template slot="heading-right"> <template slot="heading-right">
<div class="buttons is-centered"> <div class="buttons is-centered">
<a class="button is-small is-light is-rounded" @click="show_album_details_modal = true"> <a class="button is-small is-light is-rounded" @click="show_album_details_modal = true">
@ -35,8 +36,30 @@
</a> </a>
</template> </template>
</list-item-track> </list-item-track>
<modal-dialog-track :show="show_details_modal" :track="selected_track" @close="show_details_modal = false" @play_count_changed="reload_tracks" /> <modal-dialog-track
<modal-dialog-album :show="show_album_details_modal" :album="album" :media_kind="'podcast'" @close="show_album_details_modal = false" /> :show="show_details_modal"
:track="selected_track"
@close="show_details_modal = false"
@play_count_changed="reload_tracks" />
<modal-dialog-album
:show="show_album_details_modal"
:album="album"
:media_kind="'podcast'"
:new_tracks="new_tracks"
@close="show_album_details_modal = false"
@play_count_changed="reload_tracks"
@remove_podcast="open_remove_podcast_dialog" />
<modal-dialog
:show="show_remove_podcast_modal"
title="Remove podcast"
delete_action="Remove"
@close="show_remove_podcast_modal = false"
@delete="remove_podcast">
<template slot="modal-content">
<p>Permanently remove this podcast from your library?</p>
<p class="is-size-7">(This will also remove the RSS playlist <b>{{ rss_playlist_to_remove.name }}</b>.)</p>
</template>
</modal-dialog>
</template> </template>
</content-with-heading> </content-with-heading>
</template> </template>
@ -47,6 +70,7 @@ import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemTrack from '@/components/ListItemTrack' import ListItemTrack from '@/components/ListItemTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack' import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogAlbum from '@/components/ModalDialogAlbum' import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ModalDialog from '@/components/ModalDialog'
import RangeSlider from 'vue-range-slider' import RangeSlider from 'vue-range-slider'
import webapi from '@/webapi' import webapi from '@/webapi'
@ -66,8 +90,8 @@ const albumData = {
export default { export default {
name: 'PagePodcast', name: 'PagePodcast',
mixins: [ LoadDataBeforeEnterMixin(albumData) ], mixins: [LoadDataBeforeEnterMixin(albumData)],
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, RangeSlider, ModalDialogAlbum }, components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, RangeSlider, ModalDialogAlbum, ModalDialog },
data () { data () {
return { return {
@ -77,7 +101,16 @@ export default {
show_details_modal: false, show_details_modal: false,
selected_track: {}, selected_track: {},
show_album_details_modal: false show_album_details_modal: false,
show_remove_podcast_modal: false,
rss_playlist_to_remove: {}
}
},
computed: {
new_tracks () {
return this.tracks.filter(track => track.play_count === 0).length
} }
}, },
@ -95,6 +128,27 @@ export default {
this.show_details_modal = true this.show_details_modal = true
}, },
open_remove_podcast_dialog: function () {
this.show_album_details_modal = false
webapi.library_track_playlists(this.tracks[0].id).then(({ data }) => {
const rssPlaylists = data.items.filter(pl => pl.type === 'rss')
if (rssPlaylists.length !== 1) {
this.$store.dispatch('add_notification', { text: 'Podcast cannot be removed. Probably it was not added as an RSS playlist.', type: 'danger' })
return
}
this.rss_playlist_to_remove = rssPlaylists[0]
this.show_remove_podcast_modal = true
})
},
remove_podcast: function () {
this.show_remove_podcast_modal = false
webapi.library_playlist_delete(this.rss_playlist_to_remove.id).then(() => {
this.$router.replace({ path: '/podcasts' })
})
},
reload_tracks: function () { reload_tracks: function () {
webapi.library_podcast_episodes(this.album.id).then(({ data }) => { webapi.library_podcast_episodes(this.album.id).then(({ data }) => {
this.tracks = data.tracks.items this.tracks = data.tracks.items

View File

@ -4,7 +4,17 @@
<template slot="heading-left"> <template slot="heading-left">
<p class="title is-4">New episodes</p> <p class="title is-4">New episodes</p>
</template> </template>
<template slot="content"> <template slot="heading-right">
<div class="buttons is-centered">
<a class="button is-small" @click="mark_all_played">
<span class="icon">
<i class="mdi mdi-pencil"></i>
</span>
<span>Mark All Played</span>
</a>
</div>
</template>
<template slot="content">
<list-item-track v-for="track in new_episodes.items" :key="track.id" :track="track" @click="play_track(track)"> <list-item-track v-for="track in new_episodes.items" :key="track.id" :track="track" @click="play_track(track)">
<template slot="progress"> <template slot="progress">
<range-slider <range-slider
@ -31,6 +41,16 @@
<p class="title is-4">Podcasts</p> <p class="title is-4">Podcasts</p>
<p class="heading">{{ albums.total }} podcasts</p> <p class="heading">{{ albums.total }} podcasts</p>
</template> </template>
<template slot="heading-right">
<div class="buttons is-centered">
<a class="button is-small" @click="open_add_podcast_dialog">
<span class="icon">
<i class="mdi mdi-rss"></i>
</span>
<span>Add Podcast</span>
</a>
</div>
</template>
<template slot="content"> <template slot="content">
<list-item-album v-for="album in albums.items" :key="album.id" :album="album" :media_kind="'podcast'" @click="open_album(album)"> <list-item-album v-for="album in albums.items" :key="album.id" :album="album" :media_kind="'podcast'" @click="open_album(album)">
<template slot="actions"> <template slot="actions">
@ -39,7 +59,28 @@
</a> </a>
</template> </template>
</list-item-album> </list-item-album>
<modal-dialog-album :show="show_album_details_modal" :album="selected_album" :media_kind="'podcast'" @close="show_album_details_modal = false" /> <modal-dialog-album
:show="show_album_details_modal"
:album="selected_album"
:media_kind="'podcast'"
@close="show_album_details_modal = false"
@play_count_changed="reload_new_episodes"
@remove_podcast="open_remove_podcast_dialog" />
<modal-dialog
:show="show_remove_podcast_modal"
title="Remove podcast"
delete_action="Remove"
@close="show_remove_podcast_modal = false"
@delete="remove_podcast">
<template slot="modal-content">
<p>Permanently remove this podcast from your library?</p>
<p class="is-size-7">(This will also remove the RSS playlist <b>{{ rss_playlist_to_remove.name }}</b>.)</p>
</template>
</modal-dialog>
<modal-dialog-add-rss
:show="show_url_modal"
@close="show_url_modal = false"
@podcast_added="reload_podcasts" />
</template> </template>
</content-with-heading> </content-with-heading>
</div> </div>
@ -52,6 +93,8 @@ import ListItemTrack from '@/components/ListItemTrack'
import ListItemAlbum from '@/components/ListItemAlbum' import ListItemAlbum from '@/components/ListItemAlbum'
import ModalDialogTrack from '@/components/ModalDialogTrack' import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogAlbum from '@/components/ModalDialogAlbum' import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ModalDialogAddRss from '@/components/ModalDialogAddRss'
import ModalDialog from '@/components/ModalDialog'
import RangeSlider from 'vue-range-slider' import RangeSlider from 'vue-range-slider'
import webapi from '@/webapi' import webapi from '@/webapi'
@ -71,8 +114,8 @@ const albumsData = {
export default { export default {
name: 'PagePodcasts', name: 'PagePodcasts',
mixins: [ LoadDataBeforeEnterMixin(albumsData) ], mixins: [LoadDataBeforeEnterMixin(albumsData)],
components: { ContentWithHeading, ListItemTrack, ListItemAlbum, ModalDialogTrack, ModalDialogAlbum, RangeSlider }, components: { ContentWithHeading, ListItemTrack, ListItemAlbum, ModalDialogTrack, ModalDialogAlbum, ModalDialogAddRss, ModalDialog, RangeSlider },
data () { data () {
return { return {
@ -82,8 +125,13 @@ export default {
show_album_details_modal: false, show_album_details_modal: false,
selected_album: {}, selected_album: {},
show_url_modal: false,
show_track_details_modal: false, show_track_details_modal: false,
selected_track: {} selected_track: {},
show_remove_podcast_modal: false,
rss_playlist_to_remove: {}
} }
}, },
@ -106,10 +154,51 @@ export default {
this.show_album_details_modal = true this.show_album_details_modal = true
}, },
mark_all_played: function () {
this.new_episodes.items.forEach(ep => {
webapi.library_track_update(ep.id, { play_count: 'increment' })
})
this.new_episodes.items = { }
},
open_add_podcast_dialog: function (item) {
this.show_url_modal = true
},
open_remove_podcast_dialog: function () {
this.show_album_details_modal = false
webapi.library_album_tracks(this.selected_album.id, { limit: 1 }).then(({ data }) => {
webapi.library_track_playlists(data.items[0].id).then(({ data }) => {
const rssPlaylists = data.items.filter(pl => pl.type === 'rss')
if (rssPlaylists.length !== 1) {
this.$store.dispatch('add_notification', { text: 'Podcast cannot be removed. Probably it was not added as an RSS playlist.', type: 'danger' })
return
}
this.rss_playlist_to_remove = rssPlaylists[0]
this.show_remove_podcast_modal = true
})
})
},
remove_podcast: function () {
this.show_remove_podcast_modal = false
webapi.library_playlist_delete(this.rss_playlist_to_remove.id).then(() => {
this.reload_podcasts()
})
},
reload_new_episodes: function () { reload_new_episodes: function () {
webapi.library_podcasts_new_episodes().then(({ data }) => { webapi.library_podcasts_new_episodes().then(({ data }) => {
this.new_episodes = data.tracks this.new_episodes = data.tracks
}) })
},
reload_podcasts: function () {
webapi.library_podcasts().then(({ data }) => {
this.albums = data
this.reload_new_episodes()
})
} }
} }
} }

View File

@ -18,14 +18,6 @@
</span> </span>
<span>Add Stream</span> <span>Add Stream</span>
</a> </a>
<!--
<a class="button" :class="{ 'is-info': edit_mode }" @click="edit_mode = !edit_mode">
<span class="icon">
<i class="mdi mdi-content-save"></i>
</span>
<span>Save</span>
</a>
-->
<a class="button is-small" :class="{ 'is-info': edit_mode }" @click="edit_mode = !edit_mode"> <a class="button is-small" :class="{ 'is-info': edit_mode }" @click="edit_mode = !edit_mode">
<span class="icon"> <span class="icon">
<i class="mdi mdi-pencil"></i> <i class="mdi mdi-pencil"></i>
@ -150,7 +142,9 @@ export default {
}, },
save_dialog: function (item) { save_dialog: function (item) {
this.show_pls_save_modal = true if (this.queue_items.length > 0) {
this.show_pls_save_modal = true
}
} }
} }
} }

View File

@ -13,6 +13,9 @@
<i class="mdi mdi-magnify"></i> <i class="mdi mdi-magnify"></i>
</span> </span>
</p> </p>
<p class="help has-text-centered">Tip: you can search by a smart playlist query language <a href="https://github.com/ejurgensen/forked-daapd/blob/master/README_SMARTPL.md" target="_blank">expression</a> if you prefix it
with <code>query:</code>.
</p>
</div> </div>
</form> </form>
<div class="tags" style="margin-top: 16px;"> <div class="tags" style="margin-top: 16px;">
@ -148,6 +151,7 @@ export default {
data () { data () {
return { return {
search_query: '', search_query: '',
tracks: { items: [], total: 0 }, tracks: { items: [], total: 0 },
artists: { items: [], total: 0 }, artists: { items: [], total: 0 },
albums: { items: [], total: 0 }, albums: { items: [], total: 0 },
@ -210,9 +214,14 @@ export default {
} }
var searchParams = { var searchParams = {
'type': route.query.type, type: route.query.type,
'query': route.query.query, media_kind: 'music'
'media_kind': 'music' }
if (route.query.query.startsWith('query:')) {
searchParams.expression = route.query.query.replace(/^query:/, '').trim()
} else {
searchParams.query = route.query.query
} }
if (route.query.limit) { if (route.query.limit) {
@ -226,7 +235,7 @@ export default {
this.albums = data.albums ? data.albums : { items: [], total: 0 } this.albums = data.albums ? data.albums : { items: [], total: 0 }
this.playlists = data.playlists ? data.playlists : { items: [], total: 0 } this.playlists = data.playlists ? data.playlists : { items: [], total: 0 }
this.$store.commit(types.ADD_RECENT_SEARCH, searchParams.query) this.$store.commit(types.ADD_RECENT_SEARCH, route.query.query)
}) })
}, },
@ -235,7 +244,8 @@ export default {
return return
} }
this.$router.push({ path: '/search/library', this.$router.push({
path: '/search/library',
query: { query: {
type: 'track,artist,album,playlist', type: 'track,artist,album,playlist',
query: this.search_query, query: this.search_query,
@ -247,7 +257,8 @@ export default {
}, },
open_search_tracks: function () { open_search_tracks: function () {
this.$router.push({ path: '/search/library', this.$router.push({
path: '/search/library',
query: { query: {
type: 'track', type: 'track',
query: this.$route.query.query query: this.$route.query.query
@ -256,7 +267,8 @@ export default {
}, },
open_search_artists: function () { open_search_artists: function () {
this.$router.push({ path: '/search/library', this.$router.push({
path: '/search/library',
query: { query: {
type: 'artist', type: 'artist',
query: this.$route.query.query query: this.$route.query.query
@ -265,7 +277,8 @@ export default {
}, },
open_search_albums: function () { open_search_albums: function () {
this.$router.push({ path: '/search/library', this.$router.push({
path: '/search/library',
query: { query: {
type: 'album', type: 'album',
query: this.$route.query.query query: this.$route.query.query
@ -274,7 +287,8 @@ export default {
}, },
open_search_playlists: function () { open_search_playlists: function () {
this.$router.push({ path: '/search/library', this.$router.push({
path: '/search/library',
query: { query: {
type: 'playlist', type: 'playlist',
query: this.$route.query.query query: this.$route.query.query

View File

@ -0,0 +1,50 @@
<template>
<div>
<tabs-settings></tabs-settings>
<content-with-heading>
<template slot="heading-left">
<div class="title is-4">Artwork</div>
</template>
<template slot="content">
<div class="content">
<p>
forked-daapd supports PNG and JPEG artwork which is either placed as separate image files in the library,
embedded in the media files or made available online by radio stations.
</p>
<p>In addition to that, you can enable fetching artwork from the following artwork providers:</p>
</div>
<settings-checkbox category_name="artwork" option_name="use_artwork_source_spotify" v-if="spotify.enabled">
<template slot="label"> Spotify</template>
</settings-checkbox>
<settings-checkbox category_name="artwork" option_name="use_artwork_source_discogs">
<template slot="label"> Discogs (<a href="https://www.discogs.com/">https://www.discogs.com/</a>)</template>
</settings-checkbox>
<settings-checkbox category_name="artwork" option_name="use_artwork_source_coverartarchive">
<template slot="label"> Cover Art Archive (<a href="https://coverartarchive.org/">https://coverartarchive.org/</a>)</template>
</settings-checkbox>
</template>
</content-with-heading>
</div>
</template>
<script>
import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsSettings from '@/components/TabsSettings'
import SettingsCheckbox from '@/components/SettingsCheckbox'
export default {
name: 'SettingsPageArtwork',
components: { ContentWithHeading, TabsSettings, SettingsCheckbox },
computed: {
spotify () {
return this.$store.state.spotify
}
}
}
</script>
<style>
</style>

View File

@ -8,36 +8,15 @@
</template> </template>
<template slot="content"> <template slot="content">
<div class="field"> <settings-checkbox category_name="webinterface" option_name="show_composer_now_playing">
<label class="checkbox"> <template slot="label"> Show composer</template>
<input type="checkbox" :checked="settings_option_show_composer_now_playing" @change="set_timer_show_composer_now_playing" ref="checkbox_show_composer"> <template slot="info">If enabled the composer of the current playing track is shown on the &quot;now playing page&quot;</template>
Show composer </settings-checkbox>
<i class="is-size-7" <settings-textfield category_name="webinterface" option_name="show_composer_for_genre"
:class="{ :disabled="!settings_option_show_composer_now_playing"
'has-text-info': statusUpdateShowComposerNowPlaying === 'success', placeholder="Genres">
'has-text-danger': statusUpdateShowComposerNowPlaying === 'error' <template slot="label">Show composer only for listed genres</template>
}">{{ info_option_show_composer_now_playing }}</i> <template slot="info">
</label>
<p class="help has-text-justified">
If enabled the composer of the current playing track is shown on the &quot;now playing page&quot;
</p>
</div>
<fieldset :disabled="!settings_option_show_composer_now_playing">
<div class="field">
<label class="label has-text-weight-normal">
Show composer only for listed genres
<i class="is-size-7"
:class="{
'has-text-info': statusUpdateShowComposerForGenre === 'success',
'has-text-danger': statusUpdateShowComposerForGenre === 'error'
}">{{ info_option_show_composer_for_genre }}</i>
</label>
<div class="control">
<input class="input" type="text" placeholder="Genres"
:value="settings_option_show_composer_for_genre"
@input="set_timer_show_composer_for_genre"
ref="field_composer_for_genre">
</div>
<p class="help"> <p class="help">
Comma separated list of genres the composer should be displayed on the &quot;now playing page&quot;. Comma separated list of genres the composer should be displayed on the &quot;now playing page&quot;.
</p> </p>
@ -49,8 +28,8 @@
For example setting to <code>classical, soundtrack</code> will show the composer for tracks with For example setting to <code>classical, soundtrack</code> will show the composer for tracks with
a genre tag of &quot;Contemporary Classical&quot;.<br> a genre tag of &quot;Contemporary Classical&quot;.<br>
</p> </p>
</div> </template>
</fieldset> </settings-textfield>
</template> </template>
</content-with-heading> </content-with-heading>
</div> </div>
@ -59,140 +38,17 @@
<script> <script>
import ContentWithHeading from '@/templates/ContentWithHeading' import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsSettings from '@/components/TabsSettings' import TabsSettings from '@/components/TabsSettings'
import webapi from '@/webapi' import SettingsCheckbox from '@/components/SettingsCheckbox'
import * as types from '@/store/mutation_types' import SettingsTextfield from '@/components/SettingsTextfield'
export default { export default {
name: 'SettingsPageWebinterface', name: 'SettingsPageWebinterface',
components: { ContentWithHeading, TabsSettings }, components: { ContentWithHeading, TabsSettings, SettingsCheckbox, SettingsTextfield },
data () {
return {
timerDelay: 2000,
timerIdShowComposerNowPlaying: -1,
timerIdShowComposerForGenre: -1,
// <empty>: default/no changes, 'success': update succesful, 'error': update failed
statusUpdateShowComposerNowPlaying: '',
statusUpdateShowComposerForGenre: ''
}
},
computed: { computed: {
settings_category_webinterface () {
return this.$store.getters.settings_webinterface
},
settings_option_show_composer_now_playing () { settings_option_show_composer_now_playing () {
return this.$store.getters.settings_option_show_composer_now_playing return this.$store.getters.settings_option_show_composer_now_playing
},
settings_option_show_composer_for_genre () {
return this.$store.getters.settings_option_show_composer_for_genre
},
info_option_show_composer_for_genre () {
if (this.statusUpdateShowComposerForGenre === 'success') {
return '(setting saved)'
} else if (this.statusUpdateShowComposerForGenre === 'error') {
return '(error saving setting)'
}
return ''
},
info_option_show_composer_now_playing () {
if (this.statusUpdateShowComposerNowPlaying === 'success') {
return '(setting saved)'
} else if (this.statusUpdateShowComposerNowPlaying === 'error') {
return '(error saving setting)'
}
return ''
} }
},
methods: {
set_timer_show_composer_now_playing () {
if (this.timerIdShowComposerNowPlaying > 0) {
window.clearTimeout(this.timerIdShowComposerNowPlaying)
this.timerIdShowComposerNowPlaying = -1
}
this.statusUpdateShowComposerNowPlaying = ''
const newValue = this.$refs.checkbox_show_composer.checked
if (newValue !== this.settings_option_show_composer_now_playing) {
this.timerIdShowComposerNowPlaying = window.setTimeout(this.update_show_composer_now_playing, this.timerDelay)
}
},
update_show_composer_now_playing () {
this.timerIdShowComposerNowPlaying = -1
const newValue = this.$refs.checkbox_show_composer.checked
if (newValue === this.settings_option_show_composer_now_playing) {
this.statusUpdateShowComposerNowPlaying = ''
return
}
const option = {
category: this.settings_category_webinterface.name,
name: 'show_composer_now_playing',
value: newValue
}
webapi.settings_update(this.settings_category_webinterface.name, option).then(() => {
this.$store.commit(types.UPDATE_SETTINGS_OPTION, option)
this.statusUpdateShowComposerNowPlaying = 'success'
}).catch(() => {
this.statusUpdateShowComposerNowPlaying = 'error'
this.$refs.checkbox_show_composer.checked = this.settings_option_show_composer_now_playing
}).finally(() => {
this.timerIdShowComposerNowPlaying = window.setTimeout(this.clear_status_show_composer_now_playing, this.timerDelay)
})
},
set_timer_show_composer_for_genre () {
if (this.timerIdShowComposerForGenre > 0) {
window.clearTimeout(this.timerIdShowComposerForGenre)
this.timerIdShowComposerForGenre = -1
}
this.statusUpdateShowComposerForGenre = ''
const newValue = this.$refs.field_composer_for_genre.value
if (newValue !== this.settings_option_show_composer_for_genre) {
this.timerIdShowComposerForGenre = window.setTimeout(this.update_show_composer_for_genre, this.timerDelay)
}
},
update_show_composer_for_genre () {
this.timerIdShowComposerForGenre = -1
const newValue = this.$refs.field_composer_for_genre.value
if (newValue === this.settings_option_show_composer_for_genre) {
this.statusUpdateShowComposerForGenre = ''
return
}
const option = {
category: this.settings_category_webinterface.name,
name: 'show_composer_for_genre',
value: newValue
}
webapi.settings_update(this.settings_category_webinterface.name, option).then(() => {
this.$store.commit(types.UPDATE_SETTINGS_OPTION, option)
this.statusUpdateShowComposerForGenre = 'success'
}).catch(() => {
this.statusUpdateShowComposerForGenre = 'error'
this.$refs.field_composer_for_genre.value = this.settings_option_show_composer_for_genre
}).finally(() => {
this.timerIdShowComposerForGenre = window.setTimeout(this.clear_status_show_composer_for_genre, this.timerDelay)
})
},
clear_status_show_composer_for_genre () {
this.statusUpdateShowComposerForGenre = ''
},
clear_status_show_composer_now_playing () {
this.statusUpdateShowComposerNowPlaying = ''
}
},
filters: {
} }
} }
</script> </script>

View File

@ -53,7 +53,7 @@ const albumData = {
export default { export default {
name: 'PageAlbum', name: 'PageAlbum',
mixins: [ LoadDataBeforeEnterMixin(albumData) ], mixins: [LoadDataBeforeEnterMixin(albumData)],
components: { ContentWithHeading, SpotifyListItemTrack, SpotifyModalDialogTrack, SpotifyModalDialogAlbum }, components: { ContentWithHeading, SpotifyListItemTrack, SpotifyModalDialogTrack, SpotifyModalDialogAlbum },
data () { data () {

View File

@ -62,7 +62,7 @@ const artistData = {
export default { export default {
name: 'SpotifyPageArtist', name: 'SpotifyPageArtist',
mixins: [ LoadDataBeforeEnterMixin(artistData) ], mixins: [LoadDataBeforeEnterMixin(artistData)],
components: { ContentWithHeading, SpotifyListItemAlbum, SpotifyModalDialogAlbum, SpotifyModalDialogArtist, InfiniteLoading }, components: { ContentWithHeading, SpotifyListItemAlbum, SpotifyModalDialogAlbum, SpotifyModalDialogArtist, InfiniteLoading },
data () { data () {

View File

@ -92,7 +92,7 @@ const browseData = {
export default { export default {
name: 'SpotifyPageBrowse', name: 'SpotifyPageBrowse',
mixins: [ LoadDataBeforeEnterMixin(browseData) ], mixins: [LoadDataBeforeEnterMixin(browseData)],
components: { ContentWithHeading, TabsMusic, SpotifyListItemAlbum, SpotifyListItemPlaylist, SpotifyModalDialogAlbum, SpotifyModalDialogPlaylist }, components: { ContentWithHeading, TabsMusic, SpotifyListItemAlbum, SpotifyListItemPlaylist, SpotifyModalDialogAlbum, SpotifyModalDialogPlaylist },
data () { data () {

View File

@ -50,7 +50,7 @@ const browseData = {
export default { export default {
name: 'SpotifyPageBrowseFeaturedPlaylists', name: 'SpotifyPageBrowseFeaturedPlaylists',
mixins: [ LoadDataBeforeEnterMixin(browseData) ], mixins: [LoadDataBeforeEnterMixin(browseData)],
components: { ContentWithHeading, TabsMusic, SpotifyListItemPlaylist, SpotifyModalDialogPlaylist }, components: { ContentWithHeading, TabsMusic, SpotifyListItemPlaylist, SpotifyModalDialogPlaylist },
data () { data () {

View File

@ -50,7 +50,7 @@ const browseData = {
export default { export default {
name: 'SpotifyPageBrowseNewReleases', name: 'SpotifyPageBrowseNewReleases',
mixins: [ LoadDataBeforeEnterMixin(browseData) ], mixins: [LoadDataBeforeEnterMixin(browseData)],
components: { ContentWithHeading, TabsMusic, SpotifyListItemAlbum, SpotifyModalDialogAlbum }, components: { ContentWithHeading, TabsMusic, SpotifyListItemAlbum, SpotifyModalDialogAlbum },
data () { data () {

View File

@ -61,7 +61,7 @@ const playlistData = {
export default { export default {
name: 'SpotifyPagePlaylist', name: 'SpotifyPagePlaylist',
mixins: [ LoadDataBeforeEnterMixin(playlistData) ], mixins: [LoadDataBeforeEnterMixin(playlistData)],
components: { ContentWithHeading, SpotifyListItemTrack, SpotifyModalDialogTrack, SpotifyModalDialogPlaylist, InfiniteLoading }, components: { ContentWithHeading, SpotifyListItemTrack, SpotifyModalDialogTrack, SpotifyModalDialogPlaylist, InfiniteLoading },
data () { data () {

View File

@ -178,7 +178,7 @@ export default {
computed: { computed: {
recent_searches () { recent_searches () {
return this.$store.state.recent_searches return this.$store.state.recent_searches.filter(search => !search.startsWith('query:'))
}, },
show_tracks () { show_tracks () {
@ -222,7 +222,7 @@ export default {
this.reset() this.reset()
// If no search query present reset and focus search field // If no search query present reset and focus search field
if (!this.query.query || this.query.query === '') { if (!this.query.query || this.query.query === '' || this.query.query.startsWith('query:')) {
this.search_query = '' this.search_query = ''
this.$refs.search_field.focus() this.$refs.search_field.focus()
return return
@ -315,7 +315,8 @@ export default {
return return
} }
this.$router.push({ path: '/search/spotify', this.$router.push({
path: '/search/spotify',
query: { query: {
type: 'track,artist,album,playlist', type: 'track,artist,album,playlist',
query: this.search_query, query: this.search_query,
@ -327,7 +328,8 @@ export default {
}, },
open_search_tracks: function () { open_search_tracks: function () {
this.$router.push({ path: '/search/spotify', this.$router.push({
path: '/search/spotify',
query: { query: {
type: 'track', type: 'track',
query: this.$route.query.query query: this.$route.query.query
@ -336,7 +338,8 @@ export default {
}, },
open_search_artists: function () { open_search_artists: function () {
this.$router.push({ path: '/search/spotify', this.$router.push({
path: '/search/spotify',
query: { query: {
type: 'artist', type: 'artist',
query: this.$route.query.query query: this.$route.query.query
@ -345,7 +348,8 @@ export default {
}, },
open_search_albums: function () { open_search_albums: function () {
this.$router.push({ path: '/search/spotify', this.$router.push({
path: '/search/spotify',
query: { query: {
type: 'album', type: 'album',
query: this.$route.query.query query: this.$route.query.query
@ -354,7 +358,8 @@ export default {
}, },
open_search_playlists: function () { open_search_playlists: function () {
this.$router.push({ path: '/search/spotify', this.$router.push({
path: '/search/spotify',
query: { query: {
type: 'playlist', type: 'playlist',
query: this.$route.query.query query: this.$route.query.query

View File

@ -32,6 +32,7 @@ import SpotifyPageAlbum from '@/pages/SpotifyPageAlbum'
import SpotifyPagePlaylist from '@/pages/SpotifyPagePlaylist' import SpotifyPagePlaylist from '@/pages/SpotifyPagePlaylist'
import SpotifyPageSearch from '@/pages/SpotifyPageSearch' import SpotifyPageSearch from '@/pages/SpotifyPageSearch'
import SettingsPageWebinterface from '@/pages/SettingsPageWebinterface' import SettingsPageWebinterface from '@/pages/SettingsPageWebinterface'
import SettingsPageArtwork from '@/pages/SettingsPageArtwork'
import SettingsPageOnlineServices from '@/pages/SettingsPageOnlineServices' import SettingsPageOnlineServices from '@/pages/SettingsPageOnlineServices'
import SettingsPageRemotesOutputs from '@/pages/SettingsPageRemotesOutputs' import SettingsPageRemotesOutputs from '@/pages/SettingsPageRemotesOutputs'
@ -225,6 +226,11 @@ export const router = new VueRouter({
name: 'Settings Webinterface', name: 'Settings Webinterface',
component: SettingsPageWebinterface component: SettingsPageWebinterface
}, },
{
path: '/settings/artwork',
name: 'Settings Artwork',
component: SettingsPageArtwork
},
{ {
path: '/settings/online-services', path: '/settings/online-services',
name: 'Settings Online Services', name: 'Settings Online Services',
@ -269,9 +275,15 @@ export const router = new VueRouter({
}) })
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
const burgerMenuVisible = store.state.show_burger_menu if (store.state.show_burger_menu) {
if (burgerMenuVisible) {
store.commit(types.SHOW_BURGER_MENU, false) store.commit(types.SHOW_BURGER_MENU, false)
next(false)
return
} }
next(!burgerMenuVisible) if (store.state.show_player_menu) {
store.commit(types.SHOW_PLAYER_MENU, false)
next(false)
return
}
next(true)
}) })

View File

@ -7,37 +7,37 @@ Vue.use(Vuex)
export default new Vuex.Store({ export default new Vuex.Store({
state: { state: {
config: { config: {
'websocket_port': 0, websocket_port: 0,
'version': '', version: '',
'buildoptions': [ ] buildoptions: []
}, },
settings: { settings: {
'categories': [] categories: []
}, },
library: { library: {
'artists': 0, artists: 0,
'albums': 0, albums: 0,
'songs': 0, songs: 0,
'db_playtime': 0, db_playtime: 0,
'updating': false updating: false
}, },
audiobooks_count: { }, audiobooks_count: { },
podcasts_count: { }, podcasts_count: { },
outputs: [ ], outputs: [],
player: { player: {
'state': 'stop', state: 'stop',
'repeat': 'off', repeat: 'off',
'consume': false, consume: false,
'shuffle': false, shuffle: false,
'volume': 0, volume: 0,
'item_id': 0, item_id: 0,
'item_length_ms': 0, item_length_ms: 0,
'item_progress_ms': 0 item_progress_ms: 0
}, },
queue: { queue: {
'version': 0, version: 0,
'count': 0, count: 0,
'items': [ ] items: []
}, },
lastfm: {}, lastfm: {},
spotify: {}, spotify: {},
@ -47,14 +47,15 @@ export default new Vuex.Store({
spotify_featured_playlists: [], spotify_featured_playlists: [],
notifications: { notifications: {
'next_id': 1, next_id: 1,
'list': [] list: []
}, },
recent_searches: [], recent_searches: [],
hide_singles: false, hide_singles: false,
show_only_next_items: false, show_only_next_items: false,
show_burger_menu: false show_burger_menu: false,
show_player_menu: false
}, },
getters: { getters: {
@ -175,17 +176,20 @@ export default new Vuex.Store({
}, },
[types.SHOW_BURGER_MENU] (state, showBurgerMenu) { [types.SHOW_BURGER_MENU] (state, showBurgerMenu) {
state.show_burger_menu = showBurgerMenu state.show_burger_menu = showBurgerMenu
},
[types.SHOW_PLAYER_MENU] (state, showPlayerMenu) {
state.show_player_menu = showPlayerMenu
} }
}, },
actions: { actions: {
add_notification ({ commit, state }, notification) { add_notification ({ commit, state }, notification) {
const newNotification = { const newNotification = {
'id': state.notifications.next_id++, id: state.notifications.next_id++,
'type': notification.type, type: notification.type,
'text': notification.text, text: notification.text,
'topic': notification.topic, topic: notification.topic,
'timeout': notification.timeout timeout: notification.timeout
} }
commit(types.ADD_NOTIFICATION, newNotification) commit(types.ADD_NOTIFICATION, newNotification)

View File

@ -21,3 +21,4 @@ export const ADD_RECENT_SEARCH = 'ADD_RECENT_SEARCH'
export const HIDE_SINGLES = 'HIDE_SINGLES' export const HIDE_SINGLES = 'HIDE_SINGLES'
export const SHOW_ONLY_NEXT_ITEMS = 'SHOW_ONLY_NEXT_ITEMS' export const SHOW_ONLY_NEXT_ITEMS = 'SHOW_ONLY_NEXT_ITEMS'
export const SHOW_BURGER_MENU = 'SHOW_BURGER_MENU' export const SHOW_BURGER_MENU = 'SHOW_BURGER_MENU'
export const SHOW_PLAYER_MENU = 'SHOW_PLAYER_MENU'

View File

@ -4,7 +4,9 @@ import store from '@/store'
axios.interceptors.response.use(function (response) { axios.interceptors.response.use(function (response) {
return response return response
}, function (error) { }, function (error) {
store.dispatch('add_notification', { text: 'Request failed (status: ' + error.request.status + ' ' + error.request.statusText + ', url: ' + error.request.responseURL + ')', type: 'danger' }) if (error.request.status && error.request.responseURL) {
store.dispatch('add_notification', { text: 'Request failed (status: ' + error.request.status + ' ' + error.request.statusText + ', url: ' + error.request.responseURL + ')', type: 'danger' })
}
return Promise.reject(error) return Promise.reject(error)
}) })
@ -96,7 +98,7 @@ export default {
}, },
queue_save_playlist (name) { queue_save_playlist (name) {
return axios.post('/api/queue/save', undefined, { params: { 'name': name } }).then((response) => { return axios.post('/api/queue/save', undefined, { params: { name: name } }).then((response) => {
store.dispatch('add_notification', { text: 'Queue saved to playlist "' + name + '"', type: 'info', timeout: 2000 }) store.dispatch('add_notification', { text: 'Queue saved to playlist "' + name + '"', type: 'info', timeout: 2000 })
return Promise.resolve(response) return Promise.resolve(response)
}) })
@ -178,10 +180,14 @@ export default {
return axios.put('/api/player/volume?volume=' + outputVolume + '&output_id=' + outputId) return axios.put('/api/player/volume?volume=' + outputVolume + '&output_id=' + outputId)
}, },
player_seek (newPosition) { player_seek_to_pos (newPosition) {
return axios.put('/api/player/seek?position_ms=' + newPosition) return axios.put('/api/player/seek?position_ms=' + newPosition)
}, },
player_seek (seekMs) {
return axios.put('/api/player/seek?seek_ms=' + seekMs)
},
outputs () { outputs () {
return axios.get('/api/outputs') return axios.get('/api/outputs')
}, },
@ -213,8 +219,14 @@ export default {
return axios.get('/api/library/albums/' + albumId) return axios.get('/api/library/albums/' + albumId)
}, },
library_album_tracks (albumId) { library_album_tracks (albumId, filter = { limit: -1, offset: 0 }) {
return axios.get('/api/library/albums/' + albumId + '/tracks') return axios.get('/api/library/albums/' + albumId + '/tracks', {
params: filter
})
},
library_album_track_update (albumId, attributes) {
return axios.put('/api/library/albums/' + albumId + '/tracks', undefined, { params: attributes })
}, },
library_genres () { library_genres () {
@ -223,9 +235,9 @@ export default {
library_genre (genre) { library_genre (genre) {
var genreParams = { var genreParams = {
'type': 'albums', type: 'albums',
'media_kind': 'music', media_kind: 'music',
'expression': 'genre is "' + genre + '"' expression: 'genre is "' + genre + '"'
} }
return axios.get('/api/search', { return axios.get('/api/search', {
params: genreParams params: genreParams
@ -234,9 +246,9 @@ export default {
library_genre_tracks (genre) { library_genre_tracks (genre) {
var genreParams = { var genreParams = {
'type': 'tracks', type: 'tracks',
'media_kind': 'music', media_kind: 'music',
'expression': 'genre is "' + genre + '"' expression: 'genre is "' + genre + '"'
} }
return axios.get('/api/search', { return axios.get('/api/search', {
params: genreParams params: genreParams
@ -246,8 +258,8 @@ export default {
library_artist_tracks (artist) { library_artist_tracks (artist) {
if (artist) { if (artist) {
var artistParams = { var artistParams = {
'type': 'tracks', type: 'tracks',
'expression': 'songartistid is "' + artist + '"' expression: 'songartistid is "' + artist + '"'
} }
return axios.get('/api/search', { return axios.get('/api/search', {
params: artistParams params: artistParams
@ -261,8 +273,8 @@ export default {
library_podcasts_new_episodes () { library_podcasts_new_episodes () {
var episodesParams = { var episodesParams = {
'type': 'tracks', type: 'tracks',
'expression': 'media_kind is podcast and play_count = 0 ORDER BY time_added DESC' expression: 'media_kind is podcast and play_count = 0 ORDER BY time_added DESC'
} }
return axios.get('/api/search', { return axios.get('/api/search', {
params: episodesParams params: episodesParams
@ -271,14 +283,22 @@ export default {
library_podcast_episodes (albumId) { library_podcast_episodes (albumId) {
var episodesParams = { var episodesParams = {
'type': 'tracks', type: 'tracks',
'expression': 'media_kind is podcast and songalbumid is "' + albumId + '" ORDER BY time_added DESC' expression: 'media_kind is podcast and songalbumid is "' + albumId + '" ORDER BY date_released DESC'
} }
return axios.get('/api/search', { return axios.get('/api/search', {
params: episodesParams params: episodesParams
}) })
}, },
library_add (url) {
return axios.post('/api/library/add', undefined, { params: { url: url } })
},
library_playlist_delete (playlistId) {
return axios.delete('/api/library/playlists/' + playlistId, undefined)
},
library_audiobooks () { library_audiobooks () {
return axios.get('/api/library/albums?media_kind=audiobook') return axios.get('/api/library/albums?media_kind=audiobook')
}, },
@ -303,12 +323,16 @@ export default {
return axios.get('/api/library/tracks/' + trackId) return axios.get('/api/library/tracks/' + trackId)
}, },
library_track_playlists (trackId) {
return axios.get('/api/library/tracks/' + trackId + '/playlists')
},
library_track_update (trackId, attributes = {}) { library_track_update (trackId, attributes = {}) {
return axios.put('/api/library/tracks/' + trackId, undefined, { params: attributes }) return axios.put('/api/library/tracks/' + trackId, undefined, { params: attributes })
}, },
library_files (directory = undefined) { library_files (directory = undefined) {
var filesParams = { 'directory': directory } var filesParams = { directory: directory }
return axios.get('/api/library/files', { return axios.get('/api/library/files', {
params: filesParams params: filesParams
}) })