[web] Add lyrics player to the webinterface

Update icons.js
Add icons in alphabetical order.
Change comment to remove reference to external website
Remove extra line feeds

Co-Authored-by: Alain Nussbaumer <alain.nussbaumer@alleluia.ch>
This commit is contained in:
X-Ryl669 2023-09-21 18:53:20 +02:00
parent 83ac327d7f
commit 5e370e479a
14 changed files with 340 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,193 @@
<template>
<div
ref="lyricsWrapper"
class="lyrics-wrapper"
@touchstart="autoScroll = false"
@touchend="autoScroll = true"
@scroll.passive="startedScroll"
@wheel.passive="startedScroll"
>
<div class="lyrics">
<p
v-for="(item, key) in lyricsArr"
:key="item"
:class="key == lyricIndex && is_sync && 'gradient'"
>
{{ item[0] }}
</p>
</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: {
player() {
return this.$store.state.player
},
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
}
},
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-wrapper {
position: absolute;
top: -1rem;
left: calc(50% - 40vw);
right: calc(50% - 40vw);
bottom: 0;
max-height: calc(100% - 9rem);
overflow: auto;
/* Glass effect */
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
border: 1px solid rgba(0, 0, 0, 0.3);
}
.lyrics-wrapper .lyrics {
display: flex;
align-items: center;
flex-direction: column;
}
.lyrics-wrapper .lyrics .gradient {
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: bold;
font-size: 120%;
animation: slide-right 1 linear;
background-size: 200% 100%;
background-image: -webkit-linear-gradient(left, #080 50%, #000 50%);
}
@keyframes slide-right {
0% {
background-position: 100% 0%;
}
100% {
background-position: 0% 0%;
}
}
.lyrics-wrapper .lyrics p {
line-height: 3rem;
text-align: center;
font-size: 1rem;
color: #000;
}
</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

@ -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'