[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,33 +32,99 @@ 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) {
verseIndex() {
const currentTime = this.time
const { lyrics } = this
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 =
/\[(?<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)
if (match?.groups?.text) {
const { text, minutes, seconds, hundredths } = match.groups
const verse = {
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) => {
const nextTime = lyrics[index + 1]?.time ?? verse.time + 3
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)
@ -73,71 +137,29 @@ export default {
return { text, start, end }
})
})
}
return parsed
return verses
},
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) {
start = mid + 1
} else {
end = mid - 1
startTimer() {
if (this.timerId) {
return
}
}
return index
this.timerId = setInterval(this.tick, 100)
},
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 index >= 0 && index < total ? this.lyrics[index] : null
})
stopTimer() {
if (this.timerId) {
clearInterval(this.timerId)
this.timerId = null
}
},
watch: {
verseIndex(newIndex) {
this.lastIndex = newIndex
tick() {
this.time = this.lastProgress + Date.now() - this.lastUpdateTime
},
'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() {
const now = Date.now()
const elapsed = now - this.lastUpdateTime
if (this.playerStore.isPlaying) {
this.time = (this.lastProgressMs + elapsed) / 1000
this.startTimer()
} 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>
.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>