[web] Improve lyrics pane component

This commit is contained in:
Alain Nussbaumer 2025-05-10 13:43:23 +02:00
parent 858e49bdb3
commit 2c517ae8a6
5 changed files with 158 additions and 143 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -27,9 +27,9 @@
}, },
"devDependencies": { "devDependencies": {
"@intlify/unplugin-vue-i18n": "^6.0.8", "@intlify/unplugin-vue-i18n": "^6.0.8",
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.4",
"eslint": "^9.26.0", "eslint": "^9.26.0",
"eslint-config-prettier": "^10.1.3", "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.87.0", "sass": "^1.87.0",
@ -1780,9 +1780,9 @@
} }
}, },
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "5.2.3", "version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.3.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
"integrity": "sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg==", "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -2632,14 +2632,17 @@
} }
}, },
"node_modules/eslint-config-prettier": { "node_modules/eslint-config-prettier": {
"version": "10.1.3", "version": "10.1.5",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.3.tgz", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz",
"integrity": "sha512-vDo4d9yQE+cS2tdIT4J02H/16veRvkHgiLDRpej+WL67oCfbOb97itZXn8wMPJ/GsiEBVjrjs//AVNw2Cp1EcA==", "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"eslint-config-prettier": "bin/cli.js" "eslint-config-prettier": "bin/cli.js"
}, },
"funding": {
"url": "https://opencollective.com/eslint-config-prettier"
},
"peerDependencies": { "peerDependencies": {
"eslint": ">=7.0.0" "eslint": ">=7.0.0"
} }
@ -2841,9 +2844,9 @@
} }
}, },
"node_modules/eventsource": { "node_modules/eventsource": {
"version": "3.0.6", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -31,9 +31,9 @@
}, },
"devDependencies": { "devDependencies": {
"@intlify/unplugin-vue-i18n": "^6.0.8", "@intlify/unplugin-vue-i18n": "^6.0.8",
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.4",
"eslint": "^9.26.0", "eslint": "^9.26.0",
"eslint-config-prettier": "^10.1.3", "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.87.0", "sass": "^1.87.0",

View File

@ -1,24 +1,19 @@
<template> <template>
<div ref="lyrics" class="lyrics is-overlay"> <div class="lyrics is-overlay">
<template v-for="(verse, index) in visibleLyrics" :key="index"> <div v-for="(verse, index) in visibleLyrics" :key="index">
<template v-if="verse"> <div v-if="verse">
<div <div v-if="index === 3" class="title is-5 my-5 lh-2">
v-if="index === 3"
class="my-5 has-line-height-2 title is-5"
:class="{ 'is-highlighted': playerStore.isPlaying }"
>
<span <span
v-for="(word, wordIndex) in verse.words" v-for="(word, wordIndex) in verse.words"
:key="wordIndex" :key="wordIndex"
class="word" :class="{ 'is-highlighted': isWordHighlighted(word) }"
:class="{ 'is-word-highlighted': isWordHighlighted(word) }"
v-text="word.text" v-text="word.text"
/> />
</div> </div>
<div v-else class="has-line-height-2" v-text="verse.text" /> <div v-else class="lh-2" v-text="verse.text" />
</template> </div>
<div v-else v-text="'&nbsp;'" /> <div v-else v-text="'\u00A0'" />
</template> </div>
</div> </div>
</template> </template>
@ -26,6 +21,9 @@
import { usePlayerStore } from '@/stores/player' import { usePlayerStore } from '@/stores/player'
import { useQueueStore } from '@/stores/queue' import { useQueueStore } from '@/stores/queue'
const VISIBLE_VERSES = 7
const MIDDLE_POSITION = Math.floor(VISIBLE_VERSES / 2)
export default { export default {
name: 'LyricsPane', name: 'LyricsPane',
setup() { setup() {
@ -34,33 +32,99 @@ export default {
data() { data() {
return { return {
lastIndex: -1, lastIndex: -1,
lastItemId: -1,
time: 0, time: 0,
timerId: null, timerId: null,
lastProgressMs: 0, lastProgress: 0,
lastUpdateTime: 0 lastUpdateTime: 0,
lyrics: []
} }
}, },
computed: { computed: {
lyrics() { verseIndex() {
const raw = this.playerStore.lyricsContent const currentTime = this.time
const parsed = [] const { lyrics } = this
if (raw.length > 0) { let start = 0
let end = lyrics.length - 1
while (start <= end) {
const mid = Math.floor((start + end) / 2)
const midTime = lyrics[mid].time
const nextTime = lyrics[mid + 1]?.time
if (midTime <= currentTime && (!nextTime || nextTime > currentTime)) {
return mid
} else if (midTime < currentTime) {
start = mid + 1
} else {
end = mid - 1
}
}
return -1
},
visibleLyrics() {
const current = this.verseIndex
const total = this.lyrics.length
return Array.from({ length: VISIBLE_VERSES }, (_, i) => {
const index = current - MIDDLE_POSITION + i
return index >= 0 && index < total ? this.lyrics[index] : null
})
}
},
watch: {
'playerStore.isPlaying'(isPlaying) {
if (isPlaying) {
this.lastUpdateTime = Date.now()
this.startTimer()
} else {
this.stopTimer()
}
},
'playerStore.item_progress_ms'(progress) {
this.lastProgress = progress
this.lastUpdateTime = Date.now()
if (!this.playerStore.isPlaying) {
this.time = progress
}
},
'playerStore.lyricsContent'() {
this.lyrics = this.parseLyrics()
},
verseIndex(index) {
this.lastIndex = index
}
},
mounted() {
this.lastProgress = this.playerStore.item_progress_ms
this.lastUpdateTime = Date.now()
this.lyrics = this.parseLyrics()
this.updateTime()
},
beforeUnmount() {
this.stopTimer()
},
methods: {
isWordHighlighted(word) {
return this.time >= word.start && this.time < word.end
},
parseLyrics() {
const verses = []
const regex = const regex =
/\[(?<minutes>\d+):(?<seconds>\d+)(?:\.(?<hundredths>\d+))?\] ?(?<text>.*)/u /\[(?<minutes>\d+):(?<seconds>\d+)(?:\.(?<hundredths>\d+))?\] ?(?<text>.*)/u
raw.split('\n').forEach((line) => { this.playerStore.lyricsContent.split('\n').forEach((line) => {
const match = regex.exec(line) const match = regex.exec(line)
if (match?.groups?.text) { if (match?.groups?.text) {
const { text, minutes, seconds, hundredths } = match.groups const { text, minutes, seconds, hundredths } = match.groups
const verse = { const verse = {
text, text,
time: minutes * 60 + Number(`${seconds}.${hundredths ?? 0}`) time:
(Number(minutes) * 60 + Number(`${seconds}.${hundredths ?? 0}`)) *
1000
} }
parsed.push(verse) verses.push(verse)
} else {
verses.push({ text: line.trim() })
} }
}) })
parsed.forEach((verse, index, lyrics) => { verses.forEach((verse, index, lyrics) => {
const nextTime = lyrics[index + 1]?.time ?? verse.time + 3 const nextTime = lyrics[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)
@ -73,71 +137,29 @@ export default {
return { text, start, end } return { text, start, end }
}) })
}) })
} return verses
return parsed
}, },
verseIndex() { startTimer() {
const currentTime = this.time if (this.timerId) {
const { lyrics } = this return
let start = 0
let end = lyrics.length - 1
let index = 0
while (start <= end) {
const mid = Math.floor((start + end) / 2)
const current = lyrics[mid].time
const next = lyrics[mid + 1]?.time
if (current <= currentTime && (!next || next > currentTime)) {
index = mid
break
} else if (current < currentTime) {
start = mid + 1
} else {
end = mid - 1
} }
} this.timerId = setInterval(this.tick, 100)
return index
}, },
visibleLyrics() { stopTimer() {
const VISIBLE_COUNT = 7 if (this.timerId) {
const HALF = Math.floor(VISIBLE_COUNT / 2) clearInterval(this.timerId)
const current = this.verseIndex this.timerId = null
const total = this.lyrics.length
return Array.from({ length: VISIBLE_COUNT }, (_, i) => {
const index = current - HALF + i
return index >= 0 && index < total ? this.lyrics[index] : null
})
} }
}, },
watch: { tick() {
verseIndex(newIndex) { this.time = this.lastProgress + Date.now() - this.lastUpdateTime
this.lastIndex = newIndex
}, },
'playerStore.item_progress_ms'(newVal) {
this.lastProgressMs = newVal
this.lastUpdateTime = Date.now()
}
},
mounted() {
this.lastProgressMs = this.playerStore.item_progress_ms
this.lastUpdateTime = Date.now()
this.updateTime()
},
beforeUnmount() {
clearTimeout(this.timerId)
},
methods: {
updateTime() { updateTime() {
const now = Date.now()
const elapsed = now - this.lastUpdateTime
if (this.playerStore.isPlaying) { if (this.playerStore.isPlaying) {
this.time = (this.lastProgressMs + elapsed) / 1000 this.startTimer()
} else { } else {
this.time = this.lastProgressMs / 1000 this.time = this.lastProgress
} }
this.timerId = setTimeout(this.updateTime, 50)
},
isWordHighlighted(word) {
return this.time >= word.start && this.time < word.end
} }
} }
} }
@ -146,11 +168,7 @@ export default {
<style scoped> <style scoped>
.lyrics { .lyrics {
position: absolute; position: absolute;
top: 0; inset: 0;
bottom: 0;
left: 0;
right: 0;
overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@ -158,24 +176,18 @@ export default {
--mask: linear-gradient( --mask: linear-gradient(
180deg, 180deg,
transparent 0%, transparent 0%,
rgba(0, 0, 0, 1) 15%, black 15%,
rgba(0, 0, 0, 1) 85%, black 85%,
transparent 100% transparent 100%
); );
-webkit-mask: var(--mask); -webkit-mask: var(--mask);
mask: var(--mask); mask: var(--mask);
} }
.lyrics div.has-line-height-2 { .lyrics div.lh-2 {
line-height: 2rem; line-height: 2rem;
} }
.lyrics div { .is-highlighted {
line-height: 3rem; color: var(--bulma-success);
text-align: center;
}
.word {
transition: color 0.2s; transition: color 0.2s;
} }
.is-word-highlighted {
color: var(--bulma-success);
}
</style> </style>