mirror of
https://github.com/owntone/owntone-server.git
synced 2025-07-20 05:58:56 -04:00
[web] Improve lyrics pane component
This commit is contained in:
parent
858e49bdb3
commit
2c517ae8a6
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
25
web-src/package-lock.json
generated
25
web-src/package-lock.json
generated
@ -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": {
|
||||||
|
@ -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",
|
||||||
|
@ -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="' '" />
|
<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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user