[web] Improve the initialisation of stores

This commit is contained in:
Alain Nussbaumer 2025-05-17 00:24:58 +02:00
parent 6b5f4ff4d7
commit f91189d93b
11 changed files with 289 additions and 1094 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 it is too large Load Diff

View File

@ -22,7 +22,7 @@
"pinia": "^3.0.2", "pinia": "^3.0.2",
"reconnectingwebsocket": "^1.0.0", "reconnectingwebsocket": "^1.0.0",
"spotify-web-api-js": "^1.5.2", "spotify-web-api-js": "^1.5.2",
"vue": "^3.5.13", "vue": "^3.5.14",
"vue-i18n": "^11.1.3", "vue-i18n": "^11.1.3",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"vue3-click-away": "^1.2.4", "vue3-click-away": "^1.2.4",
@ -32,11 +32,11 @@
"devDependencies": { "devDependencies": {
"@intlify/unplugin-vue-i18n": "^6.0.8", "@intlify/unplugin-vue-i18n": "^6.0.8",
"@vitejs/plugin-vue": "^5.2.4", "@vitejs/plugin-vue": "^5.2.4",
"eslint": "^9.26.0", "eslint": "^9.27.0",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.5",
"eslint-plugin-vue": "^10.1.0", "eslint-plugin-vue": "^10.1.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"sass": "^1.88.0", "sass": "^1.89.0",
"vite": "^6.3.5" "vite": "^6.3.5"
} }
} }

View File

@ -29,7 +29,6 @@ import NavbarBottom from '@/components/NavbarBottom.vue'
import NavbarTop from '@/components/NavbarTop.vue' import NavbarTop from '@/components/NavbarTop.vue'
import ReconnectingWebSocket from 'reconnectingwebsocket' import ReconnectingWebSocket from 'reconnectingwebsocket'
import configuration from '@/api/configuration' import configuration from '@/api/configuration'
import services from '@/api/services'
import { useConfigurationStore } from '@/stores/configuration' import { useConfigurationStore } from '@/stores/configuration'
import { useLibraryStore } from '@/stores/library' import { useLibraryStore } from '@/stores/library'
import { useNotificationsStore } from '@/stores/notifications' import { useNotificationsStore } from '@/stores/notifications'
@ -66,7 +65,7 @@ export default {
}, },
data() { data() {
return { return {
timerId: 0 updateThrottled: false
} }
}, },
watch: { watch: {
@ -114,79 +113,33 @@ export default {
}, },
openWebsocket() { openWebsocket() {
const socket = this.createWebsocket() const socket = this.createWebsocket()
const events = [
'database',
'lastfm',
'options',
'outputs',
'pairing',
'player',
'queue',
'settings',
'spotify',
'update',
'volume'
]
socket.onopen = () => { socket.onopen = () => {
socket.send( socket.send(JSON.stringify({ notify: events }))
JSON.stringify({ this.handleEvents(events)
notify: [
'update',
'database',
'player',
'options',
'outputs',
'volume',
'queue',
'spotify',
'lastfm',
'pairing'
]
})
)
this.updateOutputs()
this.updatePlayer()
this.updateLibrary()
this.updateSettings()
this.updateQueue()
this.updateSpotify()
this.updateLastfm()
this.updateRemotes()
} }
window.addEventListener('focus', () => {
let updateThrottled = false this.handleEvents(events)
const updateInfo = () => { })
if (updateThrottled) {
return
}
this.updateOutputs()
this.updatePlayer()
this.updateLibrary()
this.updateSettings()
this.updateQueue()
this.updateSpotify()
this.updateLastfm()
this.updateRemotes()
updateThrottled = true
setTimeout(() => {
updateThrottled = false
}, 500)
}
window.addEventListener('focus', updateInfo)
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
updateInfo() this.handleEvents(events)
} }
}) })
socket.onmessage = (response) => { socket.onmessage = (response) => {
const data = JSON.parse(response.data) this.handleEvents(JSON.parse(response.data).notify)
const notify = new Set(data.notify || [])
const handlers = [
{ events: ['update', 'database'], handler: this.updateLibrary },
{
events: ['player', 'options', 'volume'],
handler: this.updatePlayer
},
{ events: ['outputs', 'volume'], handler: this.updateOutputs },
{ events: ['queue'], handler: this.updateQueue },
{ events: ['spotify'], handler: this.updateSpotify },
{ events: ['lastfm'], handler: this.updateLastfm },
{ events: ['pairing'], handler: this.updateRemotes }
]
handlers.forEach(({ handler, events }) => {
if (events.some((key) => notify.has(key))) {
handler.call(this)
}
})
} }
}, },
createWebsocket() { createWebsocket() {
@ -203,50 +156,46 @@ export default {
reconnectInterval: 1000 reconnectInterval: 1000
}) })
}, },
handleEvents(notifications = []) {
if (this.updateThrottled) {
return
}
const handlers = [
{
events: ['update', 'database'],
handler: this.libraryStore.initialise
},
{
events: ['player', 'options', 'volume'],
handler: this.playerStore.initialise
},
{
events: ['outputs', 'volume'],
handler: this.outputsStore.initialise
},
{ events: ['queue'], handler: this.queueStore.initialise },
{ events: ['settings'], handler: this.settingsStore.initialise },
{ events: ['spotify'], handler: this.servicesStore.initialiseSpotify },
{ events: ['lastfm'], handler: this.servicesStore.initialiseLastfm },
{ events: ['pairing'], handler: this.remotesStore.initialise }
]
const notificationSet = new Set(notifications)
handlers.forEach(({ handler, events }) => {
if (events.some((key) => notificationSet.has(key))) {
handler.call(this)
}
})
this.updateThrottled = true
setTimeout(() => {
this.updateThrottled = false
}, 500)
},
updateClipping() { updateClipping() {
if (this.uiStore.showBurgerMenu || this.uiStore.showPlayerMenu) { if (this.uiStore.showBurgerMenu || this.uiStore.showPlayerMenu) {
document.querySelector('html').classList.add('is-clipped') document.querySelector('html').classList.add('is-clipped')
} else { } else {
document.querySelector('html').classList.remove('is-clipped') document.querySelector('html').classList.remove('is-clipped')
} }
},
updateLastfm() {
services.lastfm().then((data) => {
this.servicesStore.lastfm = data
})
},
updateLibrary() {
this.libraryStore.initialise()
},
updateOutputs() {
this.outputsStore.initialise()
},
updateRemotes() {
this.remotesStore.initialise()
},
updatePlayer() {
this.playerStore.initialise()
},
updateQueue() {
this.queueStore.initialise()
},
updateSettings() {
this.settingsStore.initialise()
},
updateSpotify() {
services.spotify().then((data) => {
this.servicesStore.spotify = data
if (this.timerId > 0) {
window.clearTimeout(this.timerId)
this.timerId = 0
}
if (data.webapi_token_expires_in > 0 && data.webapi_token) {
this.timerId = window.setTimeout(
this.updateSpotify,
1000 * data.webapi_token_expires_in
)
}
})
} }
} }
} }

View File

@ -1,5 +1,5 @@
<template> <template>
<button :class="{ 'is-dark': playerStore.lyrics }" @click="toggle"> <button :class="{ 'is-dark': playerStore.showLyrics }" @click="toggle">
<mdicon <mdicon
class="icon" class="icon"
:name="icon" :name="icon"
@ -21,14 +21,14 @@ export default {
}, },
computed: { computed: {
icon() { icon() {
return this.playerStore.lyrics return this.playerStore.showLyrics
? 'script-text-play' ? 'script-text-play'
: 'script-text-outline' : 'script-text-outline'
} }
}, },
methods: { methods: {
toggle() { toggle() {
this.playerStore.lyrics = !this.playerStore.lyrics this.playerStore.showLyrics = !this.playerStore.showLyrics
} }
} }
} }

View File

@ -1,8 +1,12 @@
<template> <template>
<div class="lyrics is-overlay"> <div
<div v-for="(verse, index) in visibleLyrics" :key="index"> class="lyrics is-overlay"
@wheel.prevent="onScroll"
@touchmove.prevent="onScroll"
>
<div v-for="(verse, index) in visibleVerses" :key="index">
<div v-if="verse"> <div v-if="verse">
<div v-if="index === 3" class="title is-5 my-5 lh-2"> <div v-if="index === MIDDLE_POSITION" class="title is-5 my-5 lh-2">
<span <span
v-for="(word, wordIndex) in verse.words" v-for="(word, wordIndex) in verse.words"
:key="wordIndex" :key="wordIndex"
@ -19,36 +23,40 @@
<script> <script>
import { usePlayerStore } from '@/stores/player' import { usePlayerStore } from '@/stores/player'
import { useQueueStore } from '@/stores/queue'
const VISIBLE_VERSES = 7
const MIDDLE_POSITION = Math.floor(VISIBLE_VERSES / 2)
export default { export default {
name: 'PaneLyrics', name: 'PaneLyrics',
setup() { setup() {
return { playerStore: usePlayerStore(), queueStore: useQueueStore() } const VISIBLE_VERSES = 7
const MIDDLE_POSITION = Math.floor(VISIBLE_VERSES / 2)
const SCROLL_THRESHOLD = 10
return {
MIDDLE_POSITION,
playerStore: usePlayerStore(),
SCROLL_THRESHOLD,
VISIBLE_VERSES
}
}, },
data() { data() {
return { return {
lastIndex: -1,
time: 0,
timerId: null,
lastProgress: 0,
lastUpdateTime: 0, lastUpdateTime: 0,
lyrics: [] lyrics: { synchronised: false, verses: [] },
scrollIndex: 0,
scrollDelta: 0,
time: 0,
timerId: null
} }
}, },
computed: { computed: {
verseIndex() { verseIndex() {
const currentTime = this.time const currentTime = this.time
const { lyrics } = this const { verses } = this.lyrics
let start = 0 let start = 0
let end = lyrics.length - 1 let end = verses.length - 1
while (start <= end) { while (start <= end) {
const mid = Math.floor((start + end) / 2) const mid = Math.floor((start + end) / 2)
const midTime = lyrics[mid].time const midTime = verses[mid].time
const nextTime = lyrics[mid + 1]?.time const nextTime = verses[mid + 1]?.time
if (midTime <= currentTime && (!nextTime || nextTime > currentTime)) { if (midTime <= currentTime && (!nextTime || nextTime > currentTime)) {
return mid return mid
} else if (midTime < currentTime) { } else if (midTime < currentTime) {
@ -59,13 +67,13 @@ export default {
} }
return -1 return -1
}, },
visibleLyrics() { visibleVerses() {
const current = this.verseIndex const { verses, synchronised } = this.lyrics
const total = this.lyrics.length const index = synchronised ? this.verseIndex : this.scrollIndex
return Array.from({ length: VISIBLE_VERSES }, (_, i) => { return Array.from(
const index = current - MIDDLE_POSITION + i { length: this.VISIBLE_VERSES },
return index >= 0 && index < total ? this.lyrics[index] : null (_, i) => verses[index - this.MIDDLE_POSITION + i] ?? null
}) )
} }
}, },
watch: { watch: {
@ -78,7 +86,6 @@ export default {
} }
}, },
'playerStore.item_progress_ms'(progress) { 'playerStore.item_progress_ms'(progress) {
this.lastProgress = progress
this.lastUpdateTime = Date.now() this.lastUpdateTime = Date.now()
if (!this.playerStore.isPlaying) { if (!this.playerStore.isPlaying) {
this.time = progress this.time = progress
@ -86,13 +93,10 @@ export default {
}, },
'playerStore.lyricsContent'() { 'playerStore.lyricsContent'() {
this.lyrics = this.parseLyrics() this.lyrics = this.parseLyrics()
},
verseIndex(index) {
this.lastIndex = index
} }
}, },
mounted() { mounted() {
this.lastProgress = this.playerStore.item_progress_ms this.playerStore.initialise()
this.lastUpdateTime = Date.now() this.lastUpdateTime = Date.now()
this.lyrics = this.parseLyrics() this.lyrics = this.parseLyrics()
this.updateTime() this.updateTime()
@ -101,11 +105,24 @@ export default {
this.stopTimer() this.stopTimer()
}, },
methods: { methods: {
onScroll(event) {
if (this.verseIndex >= 0) {
return
}
this.scrollDelta += event.deltaY
if (Math.abs(this.scrollDelta) >= this.SCROLL_THRESHOLD) {
const newIndex = this.scrollIndex + Math.sign(this.scrollDelta)
if (newIndex >= -1 && newIndex < this.lyrics.verses.length) {
this.scrollIndex = newIndex
}
this.scrollDelta = 0
}
},
isWordHighlighted(word) { isWordHighlighted(word) {
return this.time >= word.start && this.time < word.end return this.time >= word.start && this.time < word.end
}, },
parseLyrics() { parseLyrics() {
const verses = [] const lyrics = { synchronised: false, verses: [] }
const regex = const regex =
/(?:\[(?<minutes>\d+):(?<seconds>\d+)(?:\.(?<hundredths>\d+))?\])?\s*(?<text>\S.*\S)?\s*/u /(?:\[(?<minutes>\d+):(?<seconds>\d+)(?:\.(?<hundredths>\d+))?\])?\s*(?<text>\S.*\S)?\s*/u
this.playerStore.lyricsContent.split('\n').forEach((line) => { this.playerStore.lyricsContent.split('\n').forEach((line) => {
@ -117,25 +134,25 @@ export default {
const time = const time =
(Number(minutes) * 60 + Number(`${seconds}.${hundredths ?? 0}`)) * (Number(minutes) * 60 + Number(`${seconds}.${hundredths ?? 0}`)) *
1000 1000
verses.push({ text: verse, time }) lyrics.synchronised = !isNaN(time)
lyrics.verses.push({ text: verse, time })
} }
} }
}) })
verses.forEach((verse, index, lyrics) => { lyrics.verses.forEach((verse, index, verses) => {
const nextTime = lyrics[index + 1]?.time ?? verse.time + 3000 const nextTime = verses[index + 1]?.time ?? verse.time + 3000
const totalDuration = nextTime - verse.time const totalDuration = nextTime - verse.time
const words = verse.text.match(/\S+\s*/gu) || [] const words = verse.text.match(/\S+\s*/gu) || []
const totalLength = words.reduce((sum, word) => sum + word.length, 0) const totalLength = words.reduce((sum, word) => sum + word.length, 0)
let currentTime = verse.time let currentTime = verse.time
verse.words = words.map((text) => { verse.words = words.map((text) => {
const duration = totalDuration * (text.length / totalLength)
const start = currentTime const start = currentTime
const end = start + duration const end = start + totalDuration * (text.length / totalLength)
currentTime = end currentTime = end
return { text, start, end } return { text, start, end }
}) })
}) })
return verses return lyrics
}, },
startTimer() { startTimer() {
if (this.timerId) { if (this.timerId) {
@ -150,13 +167,15 @@ export default {
} }
}, },
tick() { tick() {
this.time = this.lastProgress + Date.now() - this.lastUpdateTime this.time =
this.playerStore.item_progress_ms + Date.now() - this.lastUpdateTime
}, },
updateTime() { updateTime() {
this.lastUpdateTime = Date.now()
if (this.playerStore.isPlaying) { if (this.playerStore.isPlaying) {
this.startTimer() this.startTimer()
} else { } else {
this.time = this.lastProgress this.time = this.playerStore.item_progress_ms
} }
} }
} }

View File

@ -8,10 +8,10 @@
:url="track.artwork_url" :url="track.artwork_url"
:caption="track.album" :caption="track.album"
class="is-clickable is-big" class="is-clickable is-big"
:class="{ 'is-masked': playerStore.lyrics }" :class="{ 'is-masked': playerStore.showLyrics }"
@click="openDetails(track)" @click="openDetails(track)"
/> />
<pane-lyrics v-if="playerStore.lyrics" /> <pane-lyrics v-if="playerStore.showLyrics" />
</div> </div>
<control-slider <control-slider
v-model:value="trackProgress" v-model:value="trackProgress"

View File

@ -123,7 +123,7 @@
class="help" class="help"
scope="global" scope="global"
> >
<br /> <slot><br /></slot>
</i18n-t> </i18n-t>
</template> </template>
</control-setting-text-field> </control-setting-text-field>

View File

@ -27,9 +27,9 @@ export const usePlayerStore = defineStore('PlayerStore', {
item_id: 0, item_id: 0,
item_length_ms: 0, item_length_ms: 0,
item_progress_ms: 0, item_progress_ms: 0,
lyrics: false,
lyricsContent: '', lyricsContent: '',
repeat: 'off', repeat: 'off',
showLyrics: false,
shuffle: false, shuffle: false,
state: 'stop', state: 'stop',
volume: 0 volume: 0

View File

@ -1,6 +1,27 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import services from '@/api/services'
export const useServicesStore = defineStore('ServicesStore', { export const useServicesStore = defineStore('ServicesStore', {
actions: {
async initialiseLastfm() {
this.lastfm = await services.lastfm()
},
initialiseSpotify() {
services.spotify().then((data) => {
this.spotify = data
if (this.spotifyTimerId > 0) {
clearTimeout(this.spotifyTimerId)
this.spotifyTimerId = 0
}
if (data.webapi_token_expires_in > 0 && data.webapi_token) {
this.spotifyTimerId = setTimeout(
() => this.updateSpotify(),
1000 * data.webapi_token_expires_in
)
}
})
}
},
getters: { getters: {
hasMissingSpotifyScopes: (state) => state.missingSpotifyScopes.length > 0, hasMissingSpotifyScopes: (state) => state.missingSpotifyScopes.length > 0,
isAuthorizationRequired: (state) => isAuthorizationRequired: (state) =>
@ -22,6 +43,7 @@ export const useServicesStore = defineStore('ServicesStore', {
}, },
state: () => ({ state: () => ({
lastfm: {}, lastfm: {},
spotify: {} spotify: {},
spotifyTimerId: 0
}) })
}) })