Merge pull request #1679 from X-Ryl669/LyricsWeb

Add lyrics player to the web interface.
This commit is contained in:
Alain Nussbaumer 2023-11-17 20:14:58 +01:00 committed by GitHub
commit 47b0eef3bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 377 additions and 28 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

@ -297,15 +297,27 @@ export default {
update_player_status() {
webapi.player_status().then(({ data }) => {
this.$store.commit(types.UPDATE_PLAYER_STATUS, data)
this.update_lyrics()
})
},
update_queue() {
webapi.queue().then(({ data }) => {
this.$store.commit(types.UPDATE_QUEUE, data)
this.update_lyrics()
})
},
update_lyrics() {
let track = this.$store.state.queue.items.filter(
(e) => e.id == this.$store.state.player.item_id
)
if (track.length >= 1)
webapi.library_track(track[0].track_id).then(({ data }) => {
this.$store.commit(types.UPDATE_LYRICS, data)
})
},
update_settings() {
webapi.settings().then(({ data }) => {
this.$store.commit(types.UPDATE_SETTINGS, data)

View File

@ -0,0 +1,212 @@
<template>
<div class="lyrics-overlay"></div>
<div
ref="lyricsWrapper"
class="lyrics-wrapper"
@touchstart="autoScroll = false"
@touchend="autoScroll = true"
@scroll.passive="startedScroll"
@wheel.passive="startedScroll"
>
<div class="lyrics">
<div
v-for="(item, key) in lyricsArr"
:key="item"
:class="key == lyricIndex && is_sync && 'gradient'"
>
<ul v-if="key == lyricIndex && is_sync">
<template v-for="timedWord in splitLyric" :key="timedWord.delay">
<li :style="{ animationDuration: timedWord.delay + 's' }">
{{ timedWord.text }}
</li>
</template>
</ul>
<template v-if="key != lyricIndex || !is_sync">
{{ item[0] }}
</template>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'LyricsPane',
data() {
// Non reactive
// Used as a cache to speed up finding the lyric index in the array for the current time
this.lastIndex = 0
// Fired upon scrolling, that's disabling the auto scrolling for 5s
this.scrollTimer = null
this.lastItemId = -1
// Reactive
return {
scroll: {},
// lineHeight: 42,
autoScroll: true // stop scroll to element when touch
}
},
computed: {
is_sync() {
return this.lyricsArr.length && this.lyricsArr[0].length > 1
},
lyricIndex() {
// We have to perform a dichotomic search in the time array to find the index that's matching
const curTime = this.player.item_progress_ms / 1000
const la = this.lyricsArr
if (la.length && la[0].length === 1) return 0 // Bail out for non synchronized lyrics
if (
this.player.item_id != this.lastItemId ||
(this.lastIndex < la.length && la[this.lastIndex][1] > curTime)
) {
// Song changed or time scrolled back, let's reset the cache
this.resetPosCache()
}
// Check the cached value to avoid searching the times
if (this.lastIndex < la.length - 1 && la[this.lastIndex + 1][1] > curTime)
return this.lastIndex
if (this.lastIndex < la.length - 2 && la[this.lastIndex + 2][1] > curTime)
return this.lastIndex + 1
// Not found in the next 2 items, so start dichotomic search for the best time
let i
let start = 0,
end = la.length - 1
while (start <= end) {
i = ((end + start) / 2) | 0
if (la[i][1] <= curTime && la.length > i + 1 && la[i + 1][1] > curTime)
break
if (la[i][1] < curTime) start = i + 1
else end = i - 1
}
return i
},
lyricDuration() {
// Ignore unsynchronized lyrics.
if (!this.lyricsArr.length || this.lyricsArr[0].length < 2) return 3600
// The index is 0 before the first lyric until the end of the first lyric
if (
!this.lyricIndex &&
this.player.item_progress_ms / 1000 < this.lyricsArr[0][1]
)
return this.lyricsArr[0][1]
return this.lyricIndex < this.lyricsArr.length - 1
? this.lyricsArr[this.lyricIndex + 1][1] -
this.lyricsArr[this.lyricIndex][1]
: 3600
},
lyricsArr() {
return this.$store.getters.lyrics
},
player() {
return this.$store.state.player
},
splitLyric() {
if (this.lyricIndex == 0 || !this.lyricsArr.length) return {}
// Need to split evenly the transition in the lyrics's word (based on the word size / lyrics size)
const lyric = this.lyricsArr[this.lyricIndex][0]
const lyricDur = this.lyricDuration / lyric.length
// Split lyrics in words
const parsedWords = lyric.match(/\S+\s*/g)
let duration = 0
return parsedWords.map((w) => {
let d = duration
duration += (w.length + 1) * lyricDur
return { delay: d, text: w }
})
}
},
watch: {
lyricIndex() {
// Scroll current lyric in the center of the view unless user manipulated
this.autoScroll && this._scrollToElement()
this.lastIndex = this.lyricIndex
}
},
methods: {
resetPosCache() {
this.lastItemId = this.player.item_id
this.lastIndex = 0
},
startedScroll(e) {
// Ugly trick to check if a scroll event comes from the user or from JS
if (!e.screenX || e.screenX == 0 || !e.screenY || e.screenY == 0) return // Programmatically triggered event are ignored here
this.autoScroll = false
if (this.scrollTimer) clearTimeout(this.scrollTimer)
let t = this
// Re-enable automatic scrolling after 5s
this.scrollTimer = setTimeout(function () {
t.autoScroll = true
}, 5000)
},
_scrollToElement() {
let scrollTouch = this.$refs.lyricsWrapper,
currentLyric = scrollTouch.children[0].children[this.lyricIndex],
offsetToCenter = scrollTouch.offsetHeight >> 1
if (!this.lyricsArr || !currentLyric) return
let currOff = scrollTouch.scrollTop,
destOff = currentLyric.offsetTop - offsetToCenter
// Using scrollBy ensure that scrolling will happen
// even if the element is visible before scrolling
scrollTouch.scrollBy({
top: destOff - currOff,
left: 0,
behavior: 'smooth'
})
// Then prepare the animated gradient too
// currentLyric.style.animationDuration = this.lyricDuration + 's'
}
}
}
</script>
<style scoped>
.lyrics-overlay {
position: absolute;
top: -1rem;
left: calc(50% - 50vw);
width: 100vw;
height: calc(100% - 9rem);
z-index: 3;
pointer-events: none;
}
.lyrics-wrapper {
position: absolute;
top: -1rem;
left: calc(50% - 50vw);
width: 100vw;
height: calc(100% - 9rem);
z-index: 1;
overflow: auto;
/* Glass effect */
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
.lyrics-wrapper .lyrics {
display: flex;
align-items: center;
flex-direction: column;
}
.lyrics-wrapper .lyrics .gradient ul li {
display: inline;
font-weight: bold;
font-size: 120%;
animation: pop-color 0s linear forwards;
}
.lyrics-wrapper .lyrics div {
line-height: 3rem;
text-align: center;
font-size: 1rem;
}
</style>

View File

@ -100,6 +100,7 @@
<player-button-repeat class="button" />
<player-button-shuffle class="button" />
<player-button-consume class="button" />
<player-button-lyrics class="button" />
</div>
</div>
</div>
@ -176,6 +177,7 @@
<player-button-repeat class="button" />
<player-button-shuffle class="button" />
<player-button-consume class="button" />
<player-button-lyrics class="button" />
</div>
</div>
<hr class="my-3" />
@ -268,6 +270,7 @@ import { mdiCancel } from '@mdi/js'
import NavbarItemLink from './NavbarItemLink.vue'
import NavbarItemOutput from './NavbarItemOutput.vue'
import PlayerButtonConsume from '@/components/PlayerButtonConsume.vue'
import PlayerButtonLyrics from '@/components/PlayerButtonLyrics.vue'
import PlayerButtonNext from '@/components/PlayerButtonNext.vue'
import PlayerButtonPlayPause from '@/components/PlayerButtonPlayPause.vue'
import PlayerButtonPrevious from '@/components/PlayerButtonPrevious.vue'
@ -284,6 +287,7 @@ export default {
NavbarItemLink,
NavbarItemOutput,
PlayerButtonConsume,
PlayerButtonLyrics,
PlayerButtonNext,
PlayerButtonPlayPause,
PlayerButtonPrevious,

View File

@ -0,0 +1,46 @@
<template>
<a :class="{ 'is-active': is_active }" @click="toggle_lyrics">
<mdicon
v-if="!is_active"
class="icon"
name="script-text-outline"
:size="icon_size"
:title="$t('player.button.toggle-lyrics')"
/>
<mdicon
v-if="is_active"
class="icon"
name="script-text-play"
:size="icon_size"
:title="$t('player.button.toggle-lyrics')"
/>
</a>
</template>
<script>
export default {
name: 'PlayerButtonLyrics',
props: {
icon_size: {
type: Number,
default: 16
}
},
computed: {
is_active() {
return this.$store.getters.lyrics_pane
}
},
methods: {
toggle_lyrics() {
this.$store.state.lyrics.lyrics_pane =
!this.$store.state.lyrics.lyrics_pane
}
}
}
</script>
<style></style>

View File

@ -45,6 +45,8 @@ import {
mdiRepeatOnce,
mdiRewind10,
mdiRss,
mdiScriptTextOutline,
mdiScriptTextPlay,
mdiServer,
mdiShuffle,
mdiShuffleDisabled,
@ -107,6 +109,8 @@ export const icons = {
mdiRewind10,
mdiRss,
mdiServer,
mdiScriptTextOutline,
mdiScriptTextPlay,
mdiShuffle,
mdiShuffleDisabled,
mdiSkipBackward,

View File

@ -578,7 +578,8 @@
"shuffle-disabled": "Tracks in Reihenfolge wiedergeben",
"skip-backward": "Zum vorherigen Track springen",
"skip-forward": "Zum nächsten Track springen",
"stop": "Wiedergabe stoppen"
"stop": "Wiedergabe stoppen",
"toggle-lyrics": "Liedtexte anzeigen/verbergen"
}
},
"setting": {

View File

@ -578,7 +578,8 @@
"shuffle-disabled": "Play tracks in order",
"skip-backward": "Skip to previous track",
"skip-forward": "Skip to next track",
"stop": "Stop"
"stop": "Stop",
"toggle-lyrics": "Toggle lyrics"
}
},
"setting": {

View File

@ -578,7 +578,8 @@
"shuffle-disabled": "Lire les pistes dans lordre",
"skip-backward": "Reculer à la piste précédente",
"skip-forward": "Avancer à la piste suivante",
"stop": "Arrêter la lecture"
"stop": "Arrêter la lecture",
"toggle-lyrics": "Voir/Cacher les paroles"
}
},
"setting": {

View File

@ -578,7 +578,8 @@
"shuffle-disabled": "按顺序播放曲目",
"skip-backward": "播放上一首",
"skip-forward": "播放下一首",
"stop": "停止"
"stop": "停止",
"toggle-lyrics": "显示/隐藏歌词"
}
},
"setting": {

View File

@ -3,6 +3,24 @@
@import 'bulma/bulma.sass';
@import 'bulma-switch';
/* Lyrics animation */
@keyframes pop-color {
0% {
color: $black;
}
100% {
color: $success;
}
}
.lyrics-wrapper .lyrics div {
color: $black;
}
.lyrics-overlay {
box-shadow: 0px 100px 100px 0px $white inset,
0px -100px 100px 0px $white inset;
}
.progress-bar {
background-color: $info;
border-radius: 2px;

View File

@ -9,6 +9,7 @@
class="is-clickable fd-has-shadow fd-cover-big-image"
@click="open_dialog(track)"
/>
<lyrics-pane v-if="lyrics_visible" />
<control-slider
v-model:value="track_progress"
class="mt-5"
@ -56,6 +57,7 @@
import * as types from '@/store/mutation_types'
import ControlSlider from '@/components/ControlSlider.vue'
import CoverArtwork from '@/components/CoverArtwork.vue'
import LyricsPane from '@/components/LyricsPane.vue'
import { mdiCancel } from '@mdi/js'
import ModalDialogQueueItem from '@/components/ModalDialogQueueItem.vue'
import webapi from '@/webapi'
@ -67,6 +69,7 @@ export default {
components: {
ControlSlider,
CoverArtwork,
LyricsPane,
ModalDialogQueueItem
},
@ -86,13 +89,8 @@ export default {
return this.track.length_ms === 0
},
track_progress: {
get() {
return Math.floor(this.player.item_progress_ms / INTERVAL)
},
set(value) {
this.player.item_progress_ms = value * INTERVAL
}
lyrics_visible() {
return this.$store.getters.lyrics_pane
},
player() {
@ -103,6 +101,15 @@ export default {
return this.$store.getters.now_playing
},
track_progress: {
get() {
return Math.floor(this.player.item_progress_ms / INTERVAL)
},
set(value) {
this.player.item_progress_ms = value * INTERVAL
}
},
track_progress_max() {
return this.is_live ? 1 : Math.floor(this.track.length_ms / INTERVAL)
},

View File

@ -33,6 +33,12 @@ export default createStore({
item_length_ms: 0,
item_progress_ms: 0
},
lyrics: {
found: false,
lyrics_id: -1,
lyrics_pane: false,
lyrics: []
},
queue: {
version: 0,
count: 0,
@ -70,6 +76,18 @@ export default createStore({
},
getters: {
lyrics: (state) => {
return state.lyrics.lyrics
},
lyrics_found: (state) => {
return state.lyrics.found
},
lyrics_pane: (state) => {
return state.lyrics.lyrics_pane
},
now_playing: (state) => {
const item = state.queue.items.find(function (item) {
return item.id === state.player.item_id
@ -188,6 +206,28 @@ export default createStore({
[types.UPDATE_QUEUE](state, queue) {
state.queue = queue
},
[types.UPDATE_LYRICS](state, lyrics) {
// Parse from .LRC or text format to synchronized lyrics
function parse(lyrics) {
let lyricsObj = []
let tempArr = lyrics.split('\n')
const regex = /(\[(\d+):(\d+)(?:\.\d+)?\] ?)?(.*)/
tempArr.forEach((item) => {
let matches = regex.exec(item)
if (matches !== null && matches[4].length) {
let obj = [matches[4]]
if (matches[2] != null && matches[3] != null)
obj.push(parseInt(matches[2], 10) * 60 + parseInt(matches[3], 10))
lyricsObj.push(obj)
}
})
return lyricsObj
}
state.lyrics.lyrics = 'lyrics' in lyrics ? parse(lyrics.lyrics) : ''
if (!state.lyrics.found)
state.lyrics.found = state.lyrics.lyrics.length > 0
},
[types.UPDATE_LASTFM](state, lastfm) {
state.lastfm = lastfm
},

View File

@ -8,6 +8,7 @@ export const UPDATE_LIBRARY_RSS_COUNT = 'UPDATE_LIBRARY_RSS_COUNT'
export const UPDATE_OUTPUTS = 'UPDATE_OUTPUTS'
export const UPDATE_PLAYER_STATUS = 'UPDATE_PLAYER_STATUS'
export const UPDATE_QUEUE = 'UPDATE_QUEUE'
export const UPDATE_LYRICS = 'UPDATE_LYRICS'
export const UPDATE_LASTFM = 'UPDATE_LASTFM'
export const UPDATE_SPOTIFY = 'UPDATE_SPOTIFY'
export const UPDATE_PAIRING = 'UPDATE_PAIRING'