[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,110 +32,134 @@ 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() {
const raw = this.playerStore.lyricsContent
const parsed = []
if (raw.length > 0) {
const regex =
/\[(?<minutes>\d+):(?<seconds>\d+)(?:\.(?<hundredths>\d+))?\] ?(?<text>.*)/u
raw.split('\n').forEach((line) => {
const match = regex.exec(line)
if (match?.groups?.text) {
const { text, minutes, seconds, hundredths } = match.groups
const verse = {
text,
time: minutes * 60 + Number(`${seconds}.${hundredths ?? 0}`)
}
parsed.push(verse)
}
})
parsed.forEach((verse, index, lyrics) => {
const nextTime = lyrics[index + 1]?.time ?? verse.time + 3
const totalDuration = nextTime - verse.time
const words = verse.text.match(/\S+\s*/gu) || []
const totalLength = words.reduce((sum, word) => sum + word.length, 0)
let currentTime = verse.time
verse.words = words.map((text) => {
const duration = totalDuration * (text.length / totalLength)
const start = currentTime
const end = start + duration
currentTime = end
return { text, start, end }
})
})
}
return parsed
},
verseIndex() { verseIndex() {
const currentTime = this.time const currentTime = this.time
const { lyrics } = this const { lyrics } = this
let start = 0 let start = 0
let end = lyrics.length - 1 let end = lyrics.length - 1
let index = 0
while (start <= end) { while (start <= end) {
const mid = Math.floor((start + end) / 2) const mid = Math.floor((start + end) / 2)
const current = lyrics[mid].time const midTime = lyrics[mid].time
const next = lyrics[mid + 1]?.time const nextTime = lyrics[mid + 1]?.time
if (current <= currentTime && (!next || next > currentTime)) { if (midTime <= currentTime && (!nextTime || nextTime > currentTime)) {
index = mid return mid
break } else if (midTime < currentTime) {
} else if (current < currentTime) {
start = mid + 1 start = mid + 1
} else { } else {
end = mid - 1 end = mid - 1
} }
} }
return index return -1
}, },
visibleLyrics() { visibleLyrics() {
const VISIBLE_COUNT = 7
const HALF = Math.floor(VISIBLE_COUNT / 2)
const current = this.verseIndex const current = this.verseIndex
const total = this.lyrics.length const total = this.lyrics.length
return Array.from({ length: VISIBLE_COUNT }, (_, i) => { return Array.from({ length: VISIBLE_VERSES }, (_, i) => {
const index = current - HALF + i const index = current - MIDDLE_POSITION + i
return index >= 0 && index < total ? this.lyrics[index] : null return index >= 0 && index < total ? this.lyrics[index] : null
}) })
} }
}, },
watch: { watch: {
verseIndex(newIndex) { 'playerStore.isPlaying'(isPlaying) {
this.lastIndex = newIndex if (isPlaying) {
this.lastUpdateTime = Date.now()
this.startTimer()
} else {
this.stopTimer()
}
}, },
'playerStore.item_progress_ms'(newVal) { 'playerStore.item_progress_ms'(progress) {
this.lastProgressMs = newVal this.lastProgress = progress
this.lastUpdateTime = Date.now() this.lastUpdateTime = Date.now()
if (!this.playerStore.isPlaying) {
this.time = progress
}
},
'playerStore.lyricsContent'() {
this.lyrics = this.parseLyrics()
},
verseIndex(index) {
this.lastIndex = index
} }
}, },
mounted() { mounted() {
this.lastProgressMs = this.playerStore.item_progress_ms this.lastProgress = this.playerStore.item_progress_ms
this.lastUpdateTime = Date.now() this.lastUpdateTime = Date.now()
this.lyrics = this.parseLyrics()
this.updateTime() this.updateTime()
}, },
beforeUnmount() { beforeUnmount() {
clearTimeout(this.timerId) this.stopTimer()
}, },
methods: { methods: {
updateTime() {
const now = Date.now()
const elapsed = now - this.lastUpdateTime
if (this.playerStore.isPlaying) {
this.time = (this.lastProgressMs + elapsed) / 1000
} else {
this.time = this.lastProgressMs / 1000
}
this.timerId = setTimeout(this.updateTime, 50)
},
isWordHighlighted(word) { isWordHighlighted(word) {
return this.time >= word.start && this.time < word.end return this.time >= word.start && this.time < word.end
},
parseLyrics() {
const verses = []
const regex =
/\[(?<minutes>\d+):(?<seconds>\d+)(?:\.(?<hundredths>\d+))?\] ?(?<text>.*)/u
this.playerStore.lyricsContent.split('\n').forEach((line) => {
const match = regex.exec(line)
if (match?.groups?.text) {
const { text, minutes, seconds, hundredths } = match.groups
const verse = {
text,
time:
(Number(minutes) * 60 + Number(`${seconds}.${hundredths ?? 0}`)) *
1000
}
verses.push(verse)
} else {
verses.push({ text: line.trim() })
}
})
verses.forEach((verse, index, lyrics) => {
const nextTime = lyrics[index + 1]?.time ?? verse.time + 3000
const totalDuration = nextTime - verse.time
const words = verse.text.match(/\S+\s*/gu) || []
const totalLength = words.reduce((sum, word) => sum + word.length, 0)
let currentTime = verse.time
verse.words = words.map((text) => {
const duration = totalDuration * (text.length / totalLength)
const start = currentTime
const end = start + duration
currentTime = end
return { text, start, end }
})
})
return verses
},
startTimer() {
if (this.timerId) {
return
}
this.timerId = setInterval(this.tick, 100)
},
stopTimer() {
if (this.timerId) {
clearInterval(this.timerId)
this.timerId = null
}
},
tick() {
this.time = this.lastProgress + Date.now() - this.lastUpdateTime
},
updateTime() {
if (this.playerStore.isPlaying) {
this.startTimer()
} else {
this.time = this.lastProgress
}
} }
} }
} }
@ -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>