[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

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'