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

View File

@ -31,9 +31,9 @@
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^6.0.8",
"@vitejs/plugin-vue": "^5.2.3",
"@vitejs/plugin-vue": "^5.2.4",
"eslint": "^9.26.0",
"eslint-config-prettier": "^10.1.3",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-vue": "^10.1.0",
"prettier": "^3.5.3",
"sass": "^1.87.0",

View File

@ -1,24 +1,19 @@
<template>
<div ref="lyrics" class="lyrics is-overlay">
<template v-for="(verse, index) in visibleLyrics" :key="index">
<template v-if="verse">
<div
v-if="index === 3"
class="my-5 has-line-height-2 title is-5"
:class="{ 'is-highlighted': playerStore.isPlaying }"
>
<div class="lyrics is-overlay">
<div v-for="(verse, index) in visibleLyrics" :key="index">
<div v-if="verse">
<div v-if="index === 3" class="title is-5 my-5 lh-2">
<span
v-for="(word, wordIndex) in verse.words"
:key="wordIndex"
class="word"
:class="{ 'is-word-highlighted': isWordHighlighted(word) }"
:class="{ 'is-highlighted': isWordHighlighted(word) }"
v-text="word.text"
/>
</div>
<div v-else class="has-line-height-2" v-text="verse.text" />
</template>
<div v-else v-text="'&nbsp;'" />
</template>
<div v-else class="lh-2" v-text="verse.text" />
</div>
<div v-else v-text="'\u00A0'" />
</div>
</div>
</template>
@ -26,6 +21,9 @@
import { usePlayerStore } from '@/stores/player'
import { useQueueStore } from '@/stores/queue'
const VISIBLE_VERSES = 7
const MIDDLE_POSITION = Math.floor(VISIBLE_VERSES / 2)
export default {
name: 'LyricsPane',
setup() {
@ -34,110 +32,134 @@ export default {
data() {
return {
lastIndex: -1,
lastItemId: -1,
time: 0,
timerId: null,
lastProgressMs: 0,
lastUpdateTime: 0
lastProgress: 0,
lastUpdateTime: 0,
lyrics: []
}
},
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() {
const currentTime = this.time
const { lyrics } = this
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) {
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 index
return -1
},
visibleLyrics() {
const VISIBLE_COUNT = 7
const HALF = Math.floor(VISIBLE_COUNT / 2)
const current = this.verseIndex
const total = this.lyrics.length
return Array.from({ length: VISIBLE_COUNT }, (_, i) => {
const index = current - HALF + i
return Array.from({ length: VISIBLE_VERSES }, (_, i) => {
const index = current - MIDDLE_POSITION + i
return index >= 0 && index < total ? this.lyrics[index] : null
})
}
},
watch: {
verseIndex(newIndex) {
this.lastIndex = newIndex
'playerStore.isPlaying'(isPlaying) {
if (isPlaying) {
this.lastUpdateTime = Date.now()
this.startTimer()
} else {
this.stopTimer()
}
},
'playerStore.item_progress_ms'(newVal) {
this.lastProgressMs = newVal
'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.lastProgressMs = this.playerStore.item_progress_ms
this.lastProgress = this.playerStore.item_progress_ms
this.lastUpdateTime = Date.now()
this.lyrics = this.parseLyrics()
this.updateTime()
},
beforeUnmount() {
clearTimeout(this.timerId)
this.stopTimer()
},
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) {
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>
.lyrics {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
overflow: hidden;
inset: 0;
display: flex;
flex-direction: column;
justify-content: center;
@ -158,24 +176,18 @@ export default {
--mask: linear-gradient(
180deg,
transparent 0%,
rgba(0, 0, 0, 1) 15%,
rgba(0, 0, 0, 1) 85%,
black 15%,
black 85%,
transparent 100%
);
-webkit-mask: var(--mask);
mask: var(--mask);
}
.lyrics div.has-line-height-2 {
.lyrics div.lh-2 {
line-height: 2rem;
}
.lyrics div {
line-height: 3rem;
text-align: center;
}
.word {
.is-highlighted {
color: var(--bulma-success);
transition: color 0.2s;
}
.is-word-highlighted {
color: var(--bulma-success);
}
</style>