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.map \
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

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 */

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: {
node: true
},
'extends': [
extends: [
'plugin:vue/essential',
'@vue/standard'
],

View File

@ -1,5 +1,5 @@
module.exports = {
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",
"version": "0.6.0",
"version": "0.7.0",
"private": true,
"description": "forked-daapd web interface",
"author": "chme <christian.meffert@googlemail.com>",
"license": "GPL-2.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"dev": "vue-cli-service serve",
"build": "vue-cli-service build --no-clean",
"lint": "vue-cli-service lint"
"build": "vue-cli-service build --no-clean --modern",
"lint": "vue-cli-service lint",
"dev": "vue-cli-service serve"
},
"dependencies": {
"axios": "^0.19.1",
"bulma": "^0.8.0",
"axios": "^0.19.2",
"bulma": "^0.8.2",
"core-js": "^3.6.5",
"mdi": "^2.2.43",
"moment": "^2.24.0",
"moment-duration-format": "^2.3.2",
"npm": "^6.13.6",
"npm": "^6.14.4",
"reconnectingwebsocket": "^1.0.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-infinite-loading": "^2.4.4",
"vue-infinite-loading": "^2.4.5",
"vue-progressbar": "^0.7.5",
"vue-range-slider": "^0.6.0",
"vue-router": "^3.1.3",
"vue-router": "^3.1.6",
"vuedraggable": "^2.23.2",
"vuex": "^3.1.2"
"vuex": "^3.1.3"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^3.12.1",
"@vue/cli-plugin-eslint": "^3.12.1",
"@vue/cli-service": "^3.12.1",
"@vue/eslint-config-standard": "^4.0.0",
"@vue/cli-plugin-babel": "^4.3.1",
"@vue/cli-plugin-eslint": "^4.3.1",
"@vue/cli-service": "^4.3.1",
"@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"
}
},
"license": "GPL-2.0"
}

View File

@ -6,8 +6,12 @@
<!-- Setting v-show to true on the router-view tag avoids jumpiness during transitions -->
<router-view v-show="true" />
</transition>
<modal-dialog-remote-pairing :show="pairing_active" @close="pairing_active = false" />
<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>
</template>
@ -15,25 +19,40 @@
import NavbarTop from '@/components/NavbarTop'
import NavbarBottom from '@/components/NavbarBottom'
import Notifications from '@/components/Notifications'
import ModalDialogRemotePairing from '@/components/ModalDialogRemotePairing'
import webapi from '@/webapi'
import * as types from '@/store/mutation_types'
import ReconnectingWebSocket from 'reconnectingwebsocket'
export default {
name: 'App',
components: { NavbarTop, NavbarBottom, Notifications },
components: { NavbarTop, NavbarBottom, Notifications, ModalDialogRemotePairing },
template: '<App/>',
data () {
return {
token_timer_id: 0,
reconnect_attempts: 0
reconnect_attempts: 0,
pairing_active: false
}
},
computed: {
show_burger_menu () {
return this.$store.state.show_burger_menu
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) => {
if (to.meta.show_progress) {
if (to.meta.progress !== undefined) {
let meta = to.meta.progress
const meta = to.meta.progress
this.$Progress.parseMeta(meta)
}
this.$Progress.start()
@ -204,17 +223,25 @@ export default {
update_pairing: function () {
webapi.pairing().then(({ 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: {
'show_burger_menu' () {
if (this.show_burger_menu) {
document.querySelector('html').classList.add('is-clipped')
} else {
document.querySelector('html').classList.remove('is-clipped')
}
this.update_is_clipped()
},
'show_player_menu' () {
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>
export default {
name: 'ListItemDirectory',
props: [ 'directory' ]
props: ['directory']
}
</script>

View File

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

View File

@ -34,7 +34,7 @@ export default {
methods: {
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-background" @click="$emit('close')"></div>
<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>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button>
</div>
@ -15,7 +33,7 @@
<script>
export default {
name: 'ModalDialog',
props: [ 'show' ]
props: ['show', 'title', 'ok_action', 'delete_action']
}
</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 {
name: 'ModalDialogAddUrlStream',
props: [ 'show' ],
props: ['show'],
data () {
return {

View File

@ -6,12 +6,18 @@
<div class="modal-content fd-modal-card">
<div class="card">
<div class="card-content">
<figure class="image is-square fd-has-margin-bottom" v-show="artwork_visible">
<img :src="artwork_url" @load="artwork_loaded" @error="artwork_error" class="fd-has-shadow">
</figure>
<cover-artwork
:artwork_url="album.artwork_url"
:artist="album.artist"
:album="album.name"
class="image is-square fd-has-margin-bottom fd-has-shadow" />
<p class="title is-4">
<a class="has-text-link" @click="open_album">{{ album.name }}</a>
</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">
<p v-if="album.artist && media_kind !== 'audiobook'">
<span class="heading">Album artist</span>
@ -47,11 +53,13 @@
</template>
<script>
import CoverArtwork from '@/components/CoverArtwork'
import webapi from '@/webapi'
export default {
name: 'ModalDialogAlbum',
props: [ 'show', 'album', 'media_kind' ],
components: { CoverArtwork },
props: ['show', 'album', 'media_kind', 'new_tracks'],
data () {
return {
@ -95,6 +103,13 @@ export default {
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 () {
this.artwork_visible = true
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,40 +1,377 @@
<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">
<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>
</router-link>
<router-link to="/now-playing" class="navbar-item is-expanded is-clipped" active-class="is-active" exact>
<div>
</navbar-item-link>
<!-- 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">
<strong>{{ now_playing.title }}</strong><br>
{{ now_playing.artist }}<span v-if="now_playing.data_kind === 'url'"> - {{ now_playing.album }}</span>
</p>
</div>
</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>
</nav>
</template>
<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 {
name: 'NavbarBottom',
components: { PlayerButtonPlayPause },
components: {
NavbarItemLink,
NavbarItemOutput,
RangeSlider,
PlayerButtonPlayPause,
PlayerButtonNext,
PlayerButtonPrevious,
PlayerButtonShuffle,
PlayerButtonConsume,
PlayerButtonRepeat,
PlayerButtonSeekForward,
PlayerButtonSeekBack
},
data () {
return { }
return {
old_volume: 0,
playing: false,
loading: false,
stream_volume: 10,
show_outputs_menu: false,
show_desktop_outputs_menu: false
}
},
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 () {
return this.$store.state.player
},
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>

View File

@ -1,5 +1,5 @@
<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>
</a>
</template>
@ -9,17 +9,46 @@ import * as types from '@/store/mutation_types'
export default {
name: 'NavbarItemLink',
props: [ 'to' ],
props: {
to: String,
exact: Boolean
},
computed: {
is_active () {
if (this.exact) {
return this.$route.path === 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: {
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 })
},

View File

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

View File

@ -1,5 +1,5 @@
<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">
<navbar-item-link to="/playlists">
<span class="icon"><i class="mdi mdi-library-music"></i></span>
@ -7,7 +7,7 @@
<navbar-item-link to="/music">
<span class="icon"><i class="mdi mdi-music"></i></span>
</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>
</navbar-item-link>
<navbar-item-link to="/audiobooks" v-if="audiobooks.tracks > 0">
@ -20,7 +20,7 @@
<span class="icon"><i class="mdi mdi-magnify"></i></span>
</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>
@ -33,150 +33,59 @@
<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 -->
<div class="navbar-item has-dropdown"
:class="{ 'is-active': show_settings_menu, 'is-hoverable': !show_outputs_menu && !show_settings_menu }"
@click="show_settings_menu = !show_settings_menu"
v-click-outside="on_click_outside_settings">
<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>
<div class="navbar-item has-dropdown is-hoverable"
:class="{ 'is-active': show_settings_menu }"
@click="on_click_outside_settings">
<a class="navbar-link is-arrowless">
<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">
<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>
<hr class="navbar-divider">
<hr style="margin: 12px 0;">
<navbar-item-link to="/settings/webinterface">Settings</navbar-item-link>
<navbar-item-link to="/about">About</navbar-item-link>
</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>
</template>
<script>
import webapi from '@/webapi'
import _audio from '@/audio'
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'
export default {
name: 'NavbarTop',
components: { NavbarItemLink, NavbarItemOutput, PlayerButtonPlayPause, PlayerButtonNext, PlayerButtonPrevious, PlayerButtonShuffle, PlayerButtonConsume, PlayerButtonRepeat, RangeSlider },
components: { NavbarItemLink },
data () {
return {
old_volume: 0,
playing: false,
loading: false,
stream_volume: 10,
show_outputs_menu: false,
show_settings_menu: false
}
},
computed: {
outputs () {
return this.$store.state.outputs
},
player () {
return this.$store.state.player
},
@ -197,108 +106,41 @@ export default {
return this.$store.state.podcasts_count
},
show_burger_menu () {
return this.$store.state.show_burger_menu
spotify_enabled () {
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: {
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 () {
this.show_settings_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)
this.show_settings_menu = !this.show_settings_menu
}
},
watch: {
'$store.state.player.volume' () {
if (this.player.volume > 0) {
this.old_volume = this.player.volume
}
$route (to, from) {
this.show_settings_menu = false
}
},
// on app mounted
mounted () {
this.setupAudio()
},
// on app destroyed
destroyed () {
this.closeAudio()
}
}
</script>

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<template>
<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>
</template>
@ -11,8 +11,8 @@ export default {
name: 'PlayerButtonPlayPause',
props: {
'icon_style': String,
'show_disabled_message': Boolean
icon_style: String,
show_disabled_message: Boolean
},
computed: {

View File

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

View File

@ -1,6 +1,6 @@
<template>
<a v-on:click="toggle_repeat_mode" v-bind: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>
<a @click="toggle_repeat_mode" :class="{ 'is-warning': !is_repeat_off }">
<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>
</template>
@ -10,8 +10,8 @@ import webapi from '@/webapi'
export default {
name: 'PlayerButtonRepeat',
data () {
return { }
props: {
icon_style: String
},
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>
<a v-on:click="toggle_shuffle_mode" v-bind: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>
<a @click="toggle_shuffle_mode" :class="{ 'is-warning': is_shuffle }">
<span class="icon"><i class="mdi" :class="[icon_style, { 'mdi-shuffle': is_shuffle, 'mdi-shuffle-disabled': !is_shuffle }]"></i></span>
</a>
</template>
@ -10,6 +10,10 @@ import webapi from '@/webapi'
export default {
name: 'PlayerButtonShuffle',
props: {
icon_style: String
},
computed: {
is_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 {
name: 'SpotifyModalDialogAlbum',
props: [ 'show', 'album' ],
props: ['show', 'album'],
data () {
return {

View File

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

View File

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

View File

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

View File

@ -15,6 +15,11 @@
<span class="">Remotes &amp; Outputs</span>
</a>
</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">
<a>
<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;
}
.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 {
padding-bottom: 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);
}
/* 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 {
visibility: hidden;
}
@ -180,3 +194,11 @@ section.fd-tabs-section + section.fd-content {
margin-left: 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>
<!-- Right side -->
<div class="level-right buttons">
<a class="button is-small is-outlined is-link" :class="{ 'is-loading': library.updating }" @click="update">Update</a>
<a class="button is-small is-outlined is-link" :class="{ 'is-loading': library.updating }" @click="update_meta">Force Meta Rescan</a>
<div class="level-right">
<div v-if="library.updating"><a class="button is-small is-loading">Update</a></div>
<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>
</nav>
@ -84,6 +110,12 @@ import webapi from '@/webapi'
export default {
name: 'PageAbout',
data () {
return {
show_update_dropdown: false
}
},
computed: {
config () {
return this.$store.state.config
@ -95,10 +127,12 @@ export default {
methods: {
update: function () {
this.show_update_dropdown = false
webapi.library_update()
},
update_meta: function () {
this.show_update_dropdown = false
webapi.library_rescan()
}
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -52,7 +52,7 @@ const playlistData = {
export default {
name: 'PagePlaylist',
mixins: [ LoadDataBeforeEnterMixin(playlistData) ],
mixins: [LoadDataBeforeEnterMixin(playlistData)],
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, ModalDialogPlaylist },
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)">
<template slot="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>
</template>
<template slot="actions">
@ -25,7 +25,6 @@
<script>
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic'
import ListItemPlaylist from '@/components/ListItemPlaylist'
import ModalDialogPlaylist from '@/components/ModalDialogPlaylist'
import webapi from '@/webapi'
@ -46,8 +45,8 @@ const playlistsData = {
export default {
name: 'PagePlaylists',
mixins: [ LoadDataBeforeEnterMixin(playlistsData) ],
components: { ContentWithHeading, TabsMusic, ListItemPlaylist, ModalDialogPlaylist },
mixins: [LoadDataBeforeEnterMixin(playlistsData)],
components: { ContentWithHeading, ListItemPlaylist, ModalDialogPlaylist },
data () {
return {

View File

@ -1,8 +1,9 @@
<template>
<content-with-heading>
<template slot="heading-left">
<div class="title is-4">{{ album.name }}</div>
</template>
<div class="title is-4">{{ album.name }}
</div>
</template>
<template slot="heading-right">
<div class="buttons is-centered">
<a class="button is-small is-light is-rounded" @click="show_album_details_modal = true">
@ -35,8 +36,30 @@
</a>
</template>
</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-album :show="show_album_details_modal" :album="album" :media_kind="'podcast'" @close="show_album_details_modal = false" />
<modal-dialog-track
: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>
</content-with-heading>
</template>
@ -47,6 +70,7 @@ import ContentWithHeading from '@/templates/ContentWithHeading'
import ListItemTrack from '@/components/ListItemTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ModalDialog from '@/components/ModalDialog'
import RangeSlider from 'vue-range-slider'
import webapi from '@/webapi'
@ -66,8 +90,8 @@ const albumData = {
export default {
name: 'PagePodcast',
mixins: [ LoadDataBeforeEnterMixin(albumData) ],
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, RangeSlider, ModalDialogAlbum },
mixins: [LoadDataBeforeEnterMixin(albumData)],
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, RangeSlider, ModalDialogAlbum, ModalDialog },
data () {
return {
@ -77,7 +101,16 @@ export default {
show_details_modal: false,
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
},
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 () {
webapi.library_podcast_episodes(this.album.id).then(({ data }) => {
this.tracks = data.tracks.items

View File

@ -4,7 +4,17 @@
<template slot="heading-left">
<p class="title is-4">New episodes</p>
</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)">
<template slot="progress">
<range-slider
@ -31,6 +41,16 @@
<p class="title is-4">Podcasts</p>
<p class="heading">{{ albums.total }} podcasts</p>
</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">
<list-item-album v-for="album in albums.items" :key="album.id" :album="album" :media_kind="'podcast'" @click="open_album(album)">
<template slot="actions">
@ -39,7 +59,28 @@
</a>
</template>
</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>
</content-with-heading>
</div>
@ -52,6 +93,8 @@ import ListItemTrack from '@/components/ListItemTrack'
import ListItemAlbum from '@/components/ListItemAlbum'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ModalDialogAddRss from '@/components/ModalDialogAddRss'
import ModalDialog from '@/components/ModalDialog'
import RangeSlider from 'vue-range-slider'
import webapi from '@/webapi'
@ -71,8 +114,8 @@ const albumsData = {
export default {
name: 'PagePodcasts',
mixins: [ LoadDataBeforeEnterMixin(albumsData) ],
components: { ContentWithHeading, ListItemTrack, ListItemAlbum, ModalDialogTrack, ModalDialogAlbum, RangeSlider },
mixins: [LoadDataBeforeEnterMixin(albumsData)],
components: { ContentWithHeading, ListItemTrack, ListItemAlbum, ModalDialogTrack, ModalDialogAlbum, ModalDialogAddRss, ModalDialog, RangeSlider },
data () {
return {
@ -82,8 +125,13 @@ export default {
show_album_details_modal: false,
selected_album: {},
show_url_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
},
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 () {
webapi.library_podcasts_new_episodes().then(({ data }) => {
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>Add Stream</span>
</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">
<span class="icon">
<i class="mdi mdi-pencil"></i>
@ -150,7 +142,9 @@ export default {
},
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>
</span>
</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>
</form>
<div class="tags" style="margin-top: 16px;">
@ -148,6 +151,7 @@ export default {
data () {
return {
search_query: '',
tracks: { items: [], total: 0 },
artists: { items: [], total: 0 },
albums: { items: [], total: 0 },
@ -210,9 +214,14 @@ export default {
}
var searchParams = {
'type': route.query.type,
'query': route.query.query,
'media_kind': 'music'
type: route.query.type,
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) {
@ -226,7 +235,7 @@ export default {
this.albums = data.albums ? data.albums : { 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
}
this.$router.push({ path: '/search/library',
this.$router.push({
path: '/search/library',
query: {
type: 'track,artist,album,playlist',
query: this.search_query,
@ -247,7 +257,8 @@ export default {
},
open_search_tracks: function () {
this.$router.push({ path: '/search/library',
this.$router.push({
path: '/search/library',
query: {
type: 'track',
query: this.$route.query.query
@ -256,7 +267,8 @@ export default {
},
open_search_artists: function () {
this.$router.push({ path: '/search/library',
this.$router.push({
path: '/search/library',
query: {
type: 'artist',
query: this.$route.query.query
@ -265,7 +277,8 @@ export default {
},
open_search_albums: function () {
this.$router.push({ path: '/search/library',
this.$router.push({
path: '/search/library',
query: {
type: 'album',
query: this.$route.query.query
@ -274,7 +287,8 @@ export default {
},
open_search_playlists: function () {
this.$router.push({ path: '/search/library',
this.$router.push({
path: '/search/library',
query: {
type: 'playlist',
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 slot="content">
<div class="field">
<label class="checkbox">
<input type="checkbox" :checked="settings_option_show_composer_now_playing" @change="set_timer_show_composer_now_playing" ref="checkbox_show_composer">
Show composer
<i class="is-size-7"
:class="{
'has-text-info': statusUpdateShowComposerNowPlaying === 'success',
'has-text-danger': statusUpdateShowComposerNowPlaying === 'error'
}">{{ info_option_show_composer_now_playing }}</i>
</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>
<settings-checkbox category_name="webinterface" option_name="show_composer_now_playing">
<template slot="label"> Show composer</template>
<template slot="info">If enabled the composer of the current playing track is shown on the &quot;now playing page&quot;</template>
</settings-checkbox>
<settings-textfield category_name="webinterface" option_name="show_composer_for_genre"
:disabled="!settings_option_show_composer_now_playing"
placeholder="Genres">
<template slot="label">Show composer only for listed genres</template>
<template slot="info">
<p class="help">
Comma separated list of genres the composer should be displayed on the &quot;now playing page&quot;.
</p>
@ -49,8 +28,8 @@
For example setting to <code>classical, soundtrack</code> will show the composer for tracks with
a genre tag of &quot;Contemporary Classical&quot;.<br>
</p>
</div>
</fieldset>
</template>
</settings-textfield>
</template>
</content-with-heading>
</div>
@ -59,140 +38,17 @@
<script>
import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsSettings from '@/components/TabsSettings'
import webapi from '@/webapi'
import * as types from '@/store/mutation_types'
import SettingsCheckbox from '@/components/SettingsCheckbox'
import SettingsTextfield from '@/components/SettingsTextfield'
export default {
name: 'SettingsPageWebinterface',
components: { ContentWithHeading, TabsSettings },
data () {
return {
timerDelay: 2000,
timerIdShowComposerNowPlaying: -1,
timerIdShowComposerForGenre: -1,
// <empty>: default/no changes, 'success': update succesful, 'error': update failed
statusUpdateShowComposerNowPlaying: '',
statusUpdateShowComposerForGenre: ''
}
},
components: { ContentWithHeading, TabsSettings, SettingsCheckbox, SettingsTextfield },
computed: {
settings_category_webinterface () {
return this.$store.getters.settings_webinterface
},
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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,6 +32,7 @@ import SpotifyPageAlbum from '@/pages/SpotifyPageAlbum'
import SpotifyPagePlaylist from '@/pages/SpotifyPagePlaylist'
import SpotifyPageSearch from '@/pages/SpotifyPageSearch'
import SettingsPageWebinterface from '@/pages/SettingsPageWebinterface'
import SettingsPageArtwork from '@/pages/SettingsPageArtwork'
import SettingsPageOnlineServices from '@/pages/SettingsPageOnlineServices'
import SettingsPageRemotesOutputs from '@/pages/SettingsPageRemotesOutputs'
@ -225,6 +226,11 @@ export const router = new VueRouter({
name: 'Settings Webinterface',
component: SettingsPageWebinterface
},
{
path: '/settings/artwork',
name: 'Settings Artwork',
component: SettingsPageArtwork
},
{
path: '/settings/online-services',
name: 'Settings Online Services',
@ -269,9 +275,15 @@ export const router = new VueRouter({
})
router.beforeEach((to, from, next) => {
const burgerMenuVisible = store.state.show_burger_menu
if (burgerMenuVisible) {
if (store.state.show_burger_menu) {
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({
state: {
config: {
'websocket_port': 0,
'version': '',
'buildoptions': [ ]
websocket_port: 0,
version: '',
buildoptions: []
},
settings: {
'categories': []
categories: []
},
library: {
'artists': 0,
'albums': 0,
'songs': 0,
'db_playtime': 0,
'updating': false
artists: 0,
albums: 0,
songs: 0,
db_playtime: 0,
updating: false
},
audiobooks_count: { },
podcasts_count: { },
outputs: [ ],
outputs: [],
player: {
'state': 'stop',
'repeat': 'off',
'consume': false,
'shuffle': false,
'volume': 0,
'item_id': 0,
'item_length_ms': 0,
'item_progress_ms': 0
state: 'stop',
repeat: 'off',
consume: false,
shuffle: false,
volume: 0,
item_id: 0,
item_length_ms: 0,
item_progress_ms: 0
},
queue: {
'version': 0,
'count': 0,
'items': [ ]
version: 0,
count: 0,
items: []
},
lastfm: {},
spotify: {},
@ -47,14 +47,15 @@ export default new Vuex.Store({
spotify_featured_playlists: [],
notifications: {
'next_id': 1,
'list': []
next_id: 1,
list: []
},
recent_searches: [],
hide_singles: false,
show_only_next_items: false,
show_burger_menu: false
show_burger_menu: false,
show_player_menu: false
},
getters: {
@ -175,17 +176,20 @@ export default new Vuex.Store({
},
[types.SHOW_BURGER_MENU] (state, showBurgerMenu) {
state.show_burger_menu = showBurgerMenu
},
[types.SHOW_PLAYER_MENU] (state, showPlayerMenu) {
state.show_player_menu = showPlayerMenu
}
},
actions: {
add_notification ({ commit, state }, notification) {
const newNotification = {
'id': state.notifications.next_id++,
'type': notification.type,
'text': notification.text,
'topic': notification.topic,
'timeout': notification.timeout
id: state.notifications.next_id++,
type: notification.type,
text: notification.text,
topic: notification.topic,
timeout: notification.timeout
}
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 SHOW_ONLY_NEXT_ITEMS = 'SHOW_ONLY_NEXT_ITEMS'
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) {
return response
}, 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)
})
@ -96,7 +98,7 @@ export default {
},
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 })
return Promise.resolve(response)
})
@ -178,10 +180,14 @@ export default {
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)
},
player_seek (seekMs) {
return axios.put('/api/player/seek?seek_ms=' + seekMs)
},
outputs () {
return axios.get('/api/outputs')
},
@ -213,8 +219,14 @@ export default {
return axios.get('/api/library/albums/' + albumId)
},
library_album_tracks (albumId) {
return axios.get('/api/library/albums/' + albumId + '/tracks')
library_album_tracks (albumId, filter = { limit: -1, offset: 0 }) {
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 () {
@ -223,9 +235,9 @@ export default {
library_genre (genre) {
var genreParams = {
'type': 'albums',
'media_kind': 'music',
'expression': 'genre is "' + genre + '"'
type: 'albums',
media_kind: 'music',
expression: 'genre is "' + genre + '"'
}
return axios.get('/api/search', {
params: genreParams
@ -234,9 +246,9 @@ export default {
library_genre_tracks (genre) {
var genreParams = {
'type': 'tracks',
'media_kind': 'music',
'expression': 'genre is "' + genre + '"'
type: 'tracks',
media_kind: 'music',
expression: 'genre is "' + genre + '"'
}
return axios.get('/api/search', {
params: genreParams
@ -246,8 +258,8 @@ export default {
library_artist_tracks (artist) {
if (artist) {
var artistParams = {
'type': 'tracks',
'expression': 'songartistid is "' + artist + '"'
type: 'tracks',
expression: 'songartistid is "' + artist + '"'
}
return axios.get('/api/search', {
params: artistParams
@ -261,8 +273,8 @@ export default {
library_podcasts_new_episodes () {
var episodesParams = {
'type': 'tracks',
'expression': 'media_kind is podcast and play_count = 0 ORDER BY time_added DESC'
type: 'tracks',
expression: 'media_kind is podcast and play_count = 0 ORDER BY time_added DESC'
}
return axios.get('/api/search', {
params: episodesParams
@ -271,14 +283,22 @@ export default {
library_podcast_episodes (albumId) {
var episodesParams = {
'type': 'tracks',
'expression': 'media_kind is podcast and songalbumid is "' + albumId + '" ORDER BY time_added DESC'
type: 'tracks',
expression: 'media_kind is podcast and songalbumid is "' + albumId + '" ORDER BY date_released DESC'
}
return axios.get('/api/search', {
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 () {
return axios.get('/api/library/albums?media_kind=audiobook')
},
@ -303,12 +323,16 @@ export default {
return axios.get('/api/library/tracks/' + trackId)
},
library_track_playlists (trackId) {
return axios.get('/api/library/tracks/' + trackId + '/playlists')
},
library_track_update (trackId, attributes = {}) {
return axios.put('/api/library/tracks/' + trackId, undefined, { params: attributes })
},
library_files (directory = undefined) {
var filesParams = { 'directory': directory }
var filesParams = { directory: directory }
return axios.get('/api/library/files', {
params: filesParams
})