Merge pull request #875 from chme/web

Update player web interface v0.6.0
This commit is contained in:
Christian Meffert 2020-01-25 07:41:04 +01:00 committed by GitHub
commit aee916a63f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 2112 additions and 1077 deletions

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

2581
web-src/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "forked-daapd-web",
"version": "0.5.6",
"version": "0.6.0",
"description": "forked-daapd web interface",
"author": "chme <christian.meffert@googlemail.com>",
"license": "GPL-2.0",
@ -12,28 +12,28 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^0.19.0",
"bulma": "^0.7.5",
"axios": "^0.19.1",
"bulma": "^0.8.0",
"mdi": "^2.2.43",
"moment": "^2.24.0",
"moment-duration-format": "^2.3.2",
"npm": "^6.11.2",
"npm": "^6.13.6",
"reconnectingwebsocket": "^1.0.0",
"spotify-web-api-js": "^1.2.0",
"v-click-outside": "^2.1.4",
"vue": "^2.6.10",
"v-click-outside": "^3.0.0",
"vue": "^2.6.11",
"vue-infinite-loading": "^2.4.4",
"vue-progressbar": "^0.7.5",
"vue-range-slider": "^0.6.0",
"vue-router": "^3.1.3",
"vuedraggable": "^2.23.0",
"vuex": "^3.1.1"
"vuedraggable": "^2.23.2",
"vuex": "^3.1.2"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^3.11.0",
"@vue/cli-plugin-eslint": "^3.11.0",
"@vue/cli-service": "^3.11.0",
"@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-template-compiler": "^2.6.10"
"vue-template-compiler": "^2.6.11"
}
}

View File

@ -101,7 +101,7 @@ export default {
socket.onopen = function () {
vm.$store.dispatch('add_notification', { text: 'Connection to server established', type: 'primary', topic: 'connection', timeout: 2000 })
vm.reconnect_attempts = 0
socket.send(JSON.stringify({ notify: ['update', 'database', 'player', 'options', 'outputs', 'volume', 'spotify'] }))
socket.send(JSON.stringify({ notify: ['update', 'database', 'player', 'options', 'outputs', 'volume', 'spotify', 'lastfm', 'pairing'] }))
vm.update_outputs()
vm.update_player_status()
@ -109,6 +109,8 @@ export default {
vm.update_settings()
vm.update_queue()
vm.update_spotify()
vm.update_lastfm()
vm.update_pairing()
}
socket.onclose = function () {
// vm.$store.dispatch('add_notification', { text: 'Connection closed', type: 'danger', timeout: 2000 })
@ -134,6 +136,12 @@ export default {
if (data.notify.includes('spotify')) {
vm.update_spotify()
}
if (data.notify.includes('lastfm')) {
vm.update_lastfm()
}
if (data.notify.includes('pairing')) {
vm.update_pairing()
}
}
},
@ -173,6 +181,12 @@ export default {
})
},
update_lastfm: function () {
webapi.lastfm().then(({ data }) => {
this.$store.commit(types.UPDATE_LASTFM, data)
})
},
update_spotify: function () {
webapi.spotify().then(({ data }) => {
this.$store.commit(types.UPDATE_SPOTIFY, data)
@ -185,6 +199,12 @@ export default {
this.token_timer_id = window.setTimeout(this.update_spotify, 1000 * data.webapi_token_expires_in)
}
})
},
update_pairing: function () {
webapi.pairing().then(({ data }) => {
this.$store.commit(types.UPDATE_PAIRING, data)
})
}
},

View File

@ -3,6 +3,7 @@
<div class="media-content fd-has-action is-clipped" v-on:click="open_album">
<h1 class="title is-6">{{ album.name }}</h1>
<h2 class="subtitle is-7 has-text-grey"><b>{{ album.artists[0].name }}</b></h2>
<h2 class="subtitle is-7 has-text-grey has-text-weight-normal">({{ album.album_type }}, {{ album.release_date }})</h2>
</div>
<div class="media-right">
<slot name="actions"></slot>

View File

@ -0,0 +1,41 @@
<template>
<section class="section fd-tabs-section">
<div class="container">
<div class="columns is-centered">
<div class="column is-four-fifths">
<div class="tabs is-centered is-small">
<ul>
<router-link tag="li" to="/settings/webinterface" active-class="is-active">
<a>
<span class="">Webinterface</span>
</a>
</router-link>
<router-link tag="li" to="/settings/remotes-outputs" active-class="is-active">
<a>
<span class="">Remotes &amp; Outputs</span>
</a>
</router-link>
<router-link tag="li" to="/settings/online-services" active-class="is-active">
<a>
<span class="">Online Services</span>
</a>
</router-link>
</ul>
</div>
</div>
</div>
</div>
</section>
</template>
<script>
export default {
name: 'TabsSettings',
computed: {
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,205 @@
<template>
<div>
<tabs-settings></tabs-settings>
<content-with-heading>
<template slot="heading-left">
<div class="title is-4">Spotify</div>
</template>
<template slot="content">
<div class="notification is-size-7" v-if="!spotify.libspotify_installed">
<p>forked-daapd was either built without support for Spotify or libspotify is not installed.</p>
</div>
<div v-if="spotify.libspotify_installed">
<div class="notification is-size-7">
<b>You must have a Spotify premium account</b>. If you normally log into Spotify with your Facebook account you must first go to Spotify's web site where you can get the Spotify username and password that matches your account.
</div>
<div>
<p class="content">
<b>libspotify</b> - Login with your Spotify username and password
</p>
<p v-if="spotify.libspotify_logged_in" class="fd-has-margin-bottom">
Logged in as <b><code>{{ spotify.libspotify_user }}</code></b>
</p>
<form v-if="spotify.libspotify_installed && !spotify.libspotify_logged_in" @submit.prevent="login_libspotify">
<div class="field is-grouped">
<div class="control is-expanded">
<input class="input" type="text" placeholder="Username" v-model="libspotify.user">
<p class="help is-danger">{{ libspotify.errors.user }}</p>
</div>
<div class="control is-expanded">
<input class="input" type="password" placeholder="Password" v-model="libspotify.password">
<p class="help is-danger">{{ libspotify.errors.password }}</p>
</div>
<div class="control">
<button class="button is-info">Login</button>
</div>
</div>
</form>
<p class="help is-danger">{{ libspotify.errors.error }}</p>
<p class="help">
libspotify enables forked-daapd to play Spotify tracks.
</p>
<p class="help">
forked-daapd will not store your password, but will still be able to log you in automatically afterwards, because libspotify saves a login token.
</p>
</div>
<div class="fd-has-margin-top">
<p class="content">
<b>Spotify Web API</b> - Grant access to the Spotify Web API
</p>
<p v-if="spotify.webapi_token_valid">
Access granted for <b><code>{{ spotify.webapi_user }}</code></b>
</p>
<p class="help is-danger" v-if="spotify_missing_scope.length > 0">
Please reauthorize Web API access to grant forked-daapd the following additional access rights:
<b><code>{{ spotify_missing_scope | join }}</code></b>
</p>
<div class="field fd-has-margin-top ">
<div class="control">
<a class="button" :class="{ 'is-info': !spotify.webapi_token_valid || spotify_missing_scope.length > 0 }" :href="spotify.oauth_uri">Authorize Web API access</a>
</div>
</div>
<p class="help">
Access to the Spotify Web API enables scanning of your Spotify library. Required scopes are
<code>{{ spotify_required_scope | join }}</code>.
</p>
</div>
</div>
</template>
</content-with-heading>
<content-with-heading>
<template slot="heading-left">
<div class="title is-4">Last.fm</div>
</template>
<template slot="content">
<div class="notification is-size-7" v-if="!lastfm.enabled">
<p>forked-daapd was built without support for Last.fm.</p>
</div>
<div v-if="lastfm.enabled">
<p class="content">
<b>Last.fm</b> - Login with your Last.fm username and password to enable scrobbling
</p>
<div v-if="lastfm.scrobbling_enabled">
<a class="button" @click="logoutLastfm">Stop scrobbling</a>
</div>
<div v-if="!lastfm.scrobbling_enabled">
<form @submit.prevent="login_lastfm">
<div class="field is-grouped">
<div class="control is-expanded">
<input class="input" type="text" placeholder="Username" v-model="lastfm_login.user">
<p class="help is-danger">{{ lastfm_login.errors.user }}</p>
</div>
<div class="control is-expanded">
<input class="input" type="password" placeholder="Password" v-model="lastfm_login.password">
<p class="help is-danger">{{ lastfm_login.errors.password }}</p>
</div>
<div class="control">
<button class="button is-info" type="submit">Login</button>
</div>
</div>
<p class="help is-danger">{{ lastfm_login.errors.error }}</p>
<p class="help">
forked-daapd will not store your Last.fm username/password, only the session key. The session key does not expire.
</p>
</form>
</div>
</div>
</template>
</content-with-heading>
</div>
</template>
<script>
import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsSettings from '@/components/TabsSettings'
import webapi from '@/webapi'
export default {
name: 'SettingsPageOnlineServices',
components: { ContentWithHeading, TabsSettings },
data () {
return {
libspotify: { user: '', password: '', errors: { user: '', password: '', error: '' } },
lastfm_login: { user: '', password: '', errors: { user: '', password: '', error: '' } }
}
},
computed: {
lastfm () {
return this.$store.state.lastfm
},
spotify () {
return this.$store.state.spotify
},
spotify_required_scope () {
if (this.spotify.webapi_token_valid && this.spotify.webapi_granted_scope && this.spotify.webapi_required_scope) {
return this.spotify.webapi_required_scope.split(' ')
}
return []
},
spotify_missing_scope () {
if (this.spotify.webapi_token_valid && this.spotify.webapi_granted_scope && this.spotify.webapi_required_scope) {
return this.spotify.webapi_required_scope.split(' ').filter(scope => this.spotify.webapi_granted_scope.indexOf(scope) < 0)
}
return []
}
},
methods: {
login_libspotify () {
webapi.spotify_login(this.libspotify).then(response => {
this.libspotify.user = ''
this.libspotify.password = ''
this.libspotify.errors.user = ''
this.libspotify.errors.password = ''
this.libspotify.errors.error = ''
if (!response.data.success) {
this.libspotify.errors.user = response.data.errors.user
this.libspotify.errors.password = response.data.errors.password
this.libspotify.errors.error = response.data.errors.error
}
})
},
login_lastfm () {
webapi.lastfm_login(this.lastfm_login).then(response => {
this.lastfm_login.user = ''
this.lastfm_login.password = ''
this.lastfm_login.errors.user = ''
this.lastfm_login.errors.password = ''
this.lastfm_login.errors.error = ''
if (!response.data.success) {
this.lastfm_login.errors.user = response.data.errors.user
this.lastfm_login.errors.password = response.data.errors.password
this.lastfm_login.errors.error = response.data.errors.error
}
})
},
logoutLastfm () {
webapi.lastfm_logout()
}
},
filters: {
join (array) {
return array.join(', ')
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,114 @@
<template>
<div>
<tabs-settings></tabs-settings>
<content-with-heading>
<template slot="heading-left">
<div class="title is-4">Remote Pairing</div>
</template>
<template slot="content">
<!-- Paring request active -->
<div class="notification" v-if="pairing.active">
<form v-on:submit.prevent="kickoff_pairing">
<label class="label has-text-weight-normal">
Remote pairing request from <b>{{ pairing.remote }}</b>
</label>
<div class="field is-grouped">
<div class="control">
<input class="input" type="text" placeholder="Enter pairing code" v-model="pairing_req.pin">
</div>
<div class="control">
<button class="button is-info" type="submit">Send</button>
</div>
</div>
</form>
</div>
<!-- No pairing requests -->
<div class="content" v-if="!pairing.active">
<p>No active pairing request.</p>
</div>
</template>
</content-with-heading>
<content-with-heading>
<template slot="heading-left">
<div class="title is-4">Device Verification</div>
</template>
<template slot="content">
<p class="content">
If your Apple TV requires device verification then activate the device below and enter the PIN that the Apple TV displays.
</p>
<div v-for="output in outputs" :key="output.id">
<div class="field">
<div class="control">
<label class="checkbox">
<input type="checkbox" v-model="output.selected" @change="output_toggle(output.id)"> {{ output.name }}
</label>
</div>
</div>
<form @submit.prevent="kickoff_verification" v-if="output.needs_auth_key" class="fd-has-margin-bottom">
<div class="field is-grouped">
<div class="control">
<input class="input" type="text" placeholder="Enter verification code" v-model="verification_req.pin">
</div>
<div class="control">
<button class="button is-info" type="submit">Verify</button>
</div>
</div>
</form>
</div>
</template>
</content-with-heading>
</div>
</template>
<script>
import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsSettings from '@/components/TabsSettings'
import webapi from '@/webapi'
export default {
name: 'SettingsPageRemotesOutputs',
components: { ContentWithHeading, TabsSettings },
data () {
return {
pairing_req: { pin: '' },
verification_req: { pin: '' }
}
},
computed: {
pairing () {
return this.$store.state.pairing
},
outputs () {
return this.$store.state.outputs
}
},
methods: {
kickoff_pairing () {
webapi.pairing_kickoff(this.pairing_req)
},
output_toggle (outputId) {
webapi.output_toggle(outputId)
},
kickoff_verification () {
webapi.verification_kickoff(this.verification_req)
}
},
filters: {
}
}
</script>
<style>
</style>

View File

@ -1,71 +1,70 @@
<template>
<content-with-heading>
<template slot="heading-left">
<div class="title is-4">Settings</div>
</template>
<div>
<tabs-settings></tabs-settings>
<template slot="heading-right">
</template>
<content-with-heading>
<template slot="heading-left">
<div class="title is-4">Now playing page</div>
</template>
<template slot="content">
<div class="heading fd-has-margin-bottom">Now playing page</div>
<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">
<template slot="content">
<div class="field">
<label class="label has-text-weight-normal">
Show composer only for listed genres
<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': statusUpdateShowComposerForGenre === 'success',
'has-text-danger': statusUpdateShowComposerForGenre === 'error'
}">{{ info_option_show_composer_for_genre }}</i>
'has-text-info': statusUpdateShowComposerNowPlaying === 'success',
'has-text-danger': statusUpdateShowComposerNowPlaying === 'error'
}">{{ info_option_show_composer_now_playing }}</i>
</label>
<div class="control">
<input class="input" type="text" placeholder="Genres"
:value="settings_option_show_composer_for_genre"
@input="set_timer_show_composer_for_genre"
ref="field_composer_for_genre">
</div>
<p class="help">
Comma separated list of genres the composer should be displayed on the &quot;now playing page&quot;.
</p>
<p class="help">
Leave empty to always show the composer.
</p>
<p class="help">
The genre tag of the current track is matched by checking, if one of the defined genres are included.
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 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>
</template>
</content-with-heading>
<fieldset :disabled="!settings_option_show_composer_now_playing">
<div class="field">
<label class="label has-text-weight-normal">
Show composer only for listed genres
<i class="is-size-7"
:class="{
'has-text-info': statusUpdateShowComposerForGenre === 'success',
'has-text-danger': statusUpdateShowComposerForGenre === 'error'
}">{{ info_option_show_composer_for_genre }}</i>
</label>
<div class="control">
<input class="input" type="text" placeholder="Genres"
:value="settings_option_show_composer_for_genre"
@input="set_timer_show_composer_for_genre"
ref="field_composer_for_genre">
</div>
<p class="help">
Comma separated list of genres the composer should be displayed on the &quot;now playing page&quot;.
</p>
<p class="help">
Leave empty to always show the composer.
</p>
<p class="help">
The genre tag of the current track is matched by checking, if one of the defined genres are included.
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>
</content-with-heading>
</div>
</template>
<script>
import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsSettings from '@/components/TabsSettings'
import webapi from '@/webapi'
import * as types from '@/store/mutation_types'
export default {
name: 'SettingsPageWebinterface',
components: { ContentWithHeading },
components: { ContentWithHeading, TabsSettings },
data () {
return {

View File

@ -32,6 +32,8 @@ import SpotifyPageAlbum from '@/pages/SpotifyPageAlbum'
import SpotifyPagePlaylist from '@/pages/SpotifyPagePlaylist'
import SpotifyPageSearch from '@/pages/SpotifyPageSearch'
import SettingsPageWebinterface from '@/pages/SettingsPageWebinterface'
import SettingsPageOnlineServices from '@/pages/SettingsPageOnlineServices'
import SettingsPageRemotesOutputs from '@/pages/SettingsPageRemotesOutputs'
Vue.use(VueRouter)
@ -218,6 +220,16 @@ export const router = new VueRouter({
path: '/settings/webinterface',
name: 'Settings Webinterface',
component: SettingsPageWebinterface
},
{
path: '/settings/online-services',
name: 'Settings Online Services',
component: SettingsPageOnlineServices
},
{
path: '/settings/remotes-outputs',
name: 'Settings Remotes Outputs',
component: SettingsPageRemotesOutputs
}
],
scrollBehavior (to, from, savedPosition) {

View File

@ -39,7 +39,9 @@ export default new Vuex.Store({
'count': 0,
'items': [ ]
},
lastfm: {},
spotify: {},
pairing: {},
spotify_new_releases: [],
spotify_featured_playlists: [],
@ -121,9 +123,15 @@ export default new Vuex.Store({
[types.UPDATE_QUEUE] (state, queue) {
state.queue = queue
},
[types.UPDATE_LASTFM] (state, lastfm) {
state.lastfm = lastfm
},
[types.UPDATE_SPOTIFY] (state, spotify) {
state.spotify = spotify
},
[types.UPDATE_PAIRING] (state, pairing) {
state.pairing = pairing
},
[types.SPOTIFY_NEW_RELEASES] (state, newReleases) {
state.spotify_new_releases = newReleases
},

View File

@ -7,7 +7,9 @@ export const UPDATE_LIBRARY_PODCASTS_COUNT = 'UPDATE_LIBRARY_PODCASTS_COUNT'
export const UPDATE_OUTPUTS = 'UPDATE_OUTPUTS'
export const UPDATE_PLAYER_STATUS = 'UPDATE_PLAYER_STATUS'
export const UPDATE_QUEUE = 'UPDATE_QUEUE'
export const UPDATE_LASTFM = 'UPDATE_LASTFM'
export const UPDATE_SPOTIFY = 'UPDATE_SPOTIFY'
export const UPDATE_PAIRING = 'UPDATE_PAIRING'
export const SPOTIFY_NEW_RELEASES = 'SPOTIFY_NEW_RELEASES'
export const SPOTIFY_FEATURED_PLAYLISTS = 'SPOTIFY_FEATURED_PLAYLISTS'

View File

@ -190,6 +190,10 @@ export default {
return axios.put('/api/outputs/' + outputId, output)
},
output_toggle (outputId) {
return axios.put('/api/outputs/' + outputId + '/toggle')
},
library_artists () {
return axios.get('/api/library/artists?media_kind=music')
},
@ -316,6 +320,34 @@ export default {
return axios.get('/api/spotify')
},
spotify_login (credentials) {
return axios.post('/api/spotify-login', credentials)
},
lastfm () {
return axios.get('/api/lastfm')
},
lastfm_login (credentials) {
return axios.post('/api/lastfm-login', credentials)
},
lastfm_logout (credentials) {
return axios.get('/api/lastfm-logout')
},
pairing () {
return axios.get('/api/pairing')
},
pairing_kickoff (pairingReq) {
return axios.post('/api/pairing', pairingReq)
},
verification_kickoff (verificationReq) {
return axios.post('/api/verification', verificationReq)
},
artwork_url_append_size_params (artworkUrl, maxwidth = 600, maxheight = 600) {
if (artworkUrl && artworkUrl.startsWith('/')) {
if (artworkUrl.includes('?')) {