From e6b928be7db0673dcbd67b807cf511df15c8a908 Mon Sep 17 00:00:00 2001 From: Uyanide Date: Wed, 25 Mar 2026 06:20:10 +0100 Subject: [PATCH] qs: better lyrics --- .../Modules/Bar/Modules/LyricsBar.qml | 6 +- .../Modules/Sidebar/Modules/LyricsCard.qml | 50 ++- .../Modules/Sidebar/Modules/LyricsControl.qml | 45 +-- .../quickshell/Services/LyricsService.qml | 312 ++++++++++++------ config/scripts/.local/scripts/elisa-lyrics | 18 + config/scripts/.local/scripts/fzfclip-wrap | 4 +- 6 files changed, 284 insertions(+), 151 deletions(-) create mode 100755 config/scripts/.local/scripts/elisa-lyrics diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Modules/LyricsBar.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/LyricsBar.qml index 66756fe..fe21e56 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Modules/LyricsBar.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/LyricsBar.qml @@ -25,7 +25,7 @@ Rectangle { clip: true UText { - text: LyricsService.lyrics[LyricsService.currentIndex] || "" + text: LyricsService.lyrics.get(LyricsService.currentIndex)?.line || "" family: Fonts.sans pointSize: Style.fontSizeM maximumLineCount: 1 @@ -42,7 +42,7 @@ Rectangle { baseSize: parent.height - Style.marginXS * 2 iconSize: Style.fontSizeM onClicked: { - LyricsService.increaseOffset(); + LyricsService.decreaseOffset(); } } @@ -54,7 +54,7 @@ Rectangle { baseSize: parent.height - Style.marginXS * 2 iconSize: Style.fontSizeM onClicked: { - LyricsService.decreaseOffset(); + LyricsService.increaseOffset(); } } diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsCard.qml index ef10500..c619757 100644 --- a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsCard.qml +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsCard.qml @@ -16,27 +16,45 @@ UBox { LyricsService.unregisterComponent("LyricsCard"); } - ColumnLayout { - id: lyricsColumn + ListView { + id: lyricsList anchors.fill: parent anchors.margins: Style.marginS + spacing: Style.marginM + model: LyricsService.lyrics + currentIndex: LyricsService.currentIndex + clip: true + onCurrentIndexChanged: { + if (currentIndex >= 0) + positionViewAtIndex(currentIndex, ListView.Center); - Repeater { - model: LyricsService.lyrics + } + Component.onCompleted: { + Qt.callLater(function() { + if (currentIndex >= 0) + positionViewAtIndex(currentIndex, ListView.Center); - UText { - Layout.fillWidth: true - text: modelData - font.pointSize: index === LyricsService.currentIndex ? Style.fontSizeS : Style.fontSizeXS - font.weight: index === LyricsService.currentIndex ? Style.fontWeightBold : Style.fontWeightRegular - font.family: Fonts.sans - color: index === LyricsService.currentIndex ? Colors.mOnSurface : Colors.mOnSurfaceVariant - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - wrapMode: Text.WrapAnywhere - maximumLineCount: 1 + }); + } + + delegate: UText { + width: ListView.view.width + text: model.line || "" + font.pointSize: ListView.isCurrentItem ? Style.fontSizeS : Style.fontSizeXS + font.weight: ListView.isCurrentItem ? Style.fontWeightBold : Style.fontWeightRegular + font.family: Fonts.sans + color: ListView.isCurrentItem ? Colors.mOnSurface : Colors.mOnSurfaceVariant + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + wrapMode: Text.WrapAnywhere + + MouseArea { + anchors.fill: parent + onClicked: { + LyricsService.seekToLyric(index); + } } } diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsControl.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsControl.qml index 6be971e..9645911 100644 --- a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsControl.qml +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsControl.qml @@ -6,13 +6,27 @@ import qs.Constants import qs.Services ColumnLayout { + spacing: Style.marginS + UText { Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter Layout.maximumWidth: buttonsGrid.width - Layout.bottomMargin: Style.marginS + pointSize: Style.fontSizeS horizontalAlignment: Text.AlignHCenter - text: (LyricsService.offset > 0 ? "+" + LyricsService.offset : LyricsService.offset) + " ms" + text: "Offset (ms)" + } + + UText { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + Layout.maximumWidth: buttonsGrid.width + horizontalAlignment: Text.AlignHCenter + text: (LyricsService.lyricsOffset > 0 ? "+" + LyricsService.lyricsOffset : LyricsService.lyricsOffset) + } + + UDivider { + implicitWidth: buttonsGrid.implicitWidth } GridLayout { @@ -29,7 +43,7 @@ ColumnLayout { colorFg: Colors.mCyan iconName: "arrow-bar-up" onClicked: { - LyricsService.increaseOffset(); + LyricsService.decreaseOffset(); } } @@ -40,7 +54,7 @@ ColumnLayout { colorFg: Colors.mPurple iconName: "arrow-bar-down" onClicked: { - LyricsService.decreaseOffset(); + LyricsService.increaseOffset(); } } @@ -55,17 +69,6 @@ ColumnLayout { } } - UIconButton { - id: fasterButton - - baseSize: 32 - colorFg: Colors.mRed - iconName: "trash" - onClicked: { - LyricsService.clearCache(); - } - } - UIconButton { id: barLyricsButton @@ -78,18 +81,6 @@ ColumnLayout { } } - UIconButton { - id: textButton - - baseSize: 32 - colorFg: Colors.mYellow - iconName: "align-box-left-bottom" - onClicked: { - LyricsService.showLyricsText(); - controlCenterPanel.close(); - } - } - UIconButton { baseSize: 32 colorFg: Colors.mOrange diff --git a/config/quickshell/.config/quickshell/Services/LyricsService.qml b/config/quickshell/.config/quickshell/Services/LyricsService.qml index fecf325..0620fd1 100644 --- a/config/quickshell/.config/quickshell/Services/LyricsService.qml +++ b/config/quickshell/.config/quickshell/Services/LyricsService.qml @@ -9,46 +9,25 @@ pragma Singleton Singleton { id: root - property int linesCount: 3 - property int linesAhead: linesCount / 2 - readonly property int currentIndex: linesCount - linesAhead - 1 - readonly property string offsetFile: Paths.cacheDir + "/spotify-lyrics-offset.txt" - property int offset: 0 // in ms - readonly property int offsetStep: 500 // in ms + property bool isFetchingLyrics: false + // Lyrics state + // { "time": 0, "line": "lyric line" } + // time is in ms + property ListModel lyrics + property int lyricsOffset: 0 // in ms + property int currentIndex: -1 + // Player state + // this property will be updated regardless of shouldRun for simplicity + property int internalPosition: 0 + // Reference counting property var _registered: ({ }) readonly property int _registeredCount: Object.keys(_registered).length readonly property bool shouldRun: _registeredCount > 0 - // with linesCount=3 and linesAhead=1, lyrics will be like: - // line 1 - // line 2 <- current line - // line 3 - property var lyrics: Array(linesCount).fill(" ") + // Bar state readonly property bool showLyricsBar: ShellState.lyricsState.showLyricsBar || false - function toggleLyricsBar() { - ShellState.lyricsState = { - "showLyricsBar": !root.showLyricsBar - }; - } - - function startSyncing() { - Logger.d("Lyrics", "Starting lyrics syncing"); - // fill lyrics with empty lines - lyrics = Array(linesCount).fill(" "); - listenProcess.exec(["sh", "-c", `pkill -x spotify-lyrics -u $USER; spotify-lyrics listen -l ${linesCount} -a ${linesAhead} -f ${offsetFile}`]); - } - - function stopSyncing() { - Logger.d("Lyrics", "Stopping lyrics syncing"); - // kinda ugly but works, meanwhile: - // listenProcess.signal(9) - // listenProcess.signal(15) - // listenProcess.running = false - // counting on exec() to terminate previous exec() - // all don't work - Quickshell.execDetached(["sh", "-c", `pkill -x spotify-lyrics -u $USER`]); - } + signal lyricsUpdated() function registerComponent(componentId) { root._registered[componentId] = true; @@ -64,105 +43,232 @@ Singleton { Logger.d("Lyrics", "Component unregistered:", componentId, "- total:", root._registeredCount); } - function writeOffset() { - offsetFileView.setText(String(offset)); + function _requestFetchLyrics() { + if (!root.shouldRun) + return ; + + fetchLyricsTimer.restart(); + } + + function fetchLyrics() { + root.lyrics.clear(); + root.lyricsUpdated(); + root.currentIndex = -1; + if (!root.shouldRun) + return ; + + root.isFetchingLyrics = true; + if (!MediaService.currentPlayer) { + root.isFetchingLyrics = false; + return ; + } else if (MediaService.currentPlayer.identity.toLowerCase() === "spotify") + lyricsProcess.request("spotify"); + else if (MediaService.currentPlayer.identity.toLowerCase() === "elisa") + lyricsProcess.request("elisa"); + else + root.isFetchingLyrics = false; + } + + function parseLRC(text) { + if (!root.shouldRun) + return ; + + const lines = text.split("\n"); + let newLyrics = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line.length === 0) + continue; + + const lyricLine = line.replace(/\[.*?]/g, "").trim(); + const regex = /\[(\d{2}):(\d{2})(?:\.(\d{2,3}))?]/g; + let match; + while ((match = regex.exec(line)) !== null) { + const minutes = parseInt(match[1], 10); + const seconds = parseInt(match[2], 10); + const milliseconds = match[3] ? parseInt(match[3].padEnd(3, "0"), 10) : 0; + const time = minutes * 60 * 1000 + seconds * 1000 + milliseconds; + newLyrics.push({ + "time": time, + "line": lyricLine + }); + } + } + newLyrics.sort((a, b) => { + return a.time - b.time; + }); + root.lyrics.clear(); + root.lyrics.append(newLyrics); + root.isFetchingLyrics = false; + root.lyricsUpdated(); + updateIndex(); + } + + function updateIndex() { + if (!root.shouldRun) + return ; + + if (root.lyrics.count === 0) { + if (root.currentIndex !== -1) + root.currentIndex = -1; + + return ; + } + const effectiveTime = root.internalPosition + root.lyricsOffset; + let low = 0; + let high = root.lyrics.count - 1; + let bestMatch = -1; + while (low <= high) { + let mid = (low + high) >> 1; + let midTime = root.lyrics.get(mid).time; + if (midTime <= effectiveTime) { + bestMatch = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + if (root.currentIndex !== bestMatch) + root.currentIndex = bestMatch; + } function increaseOffset() { - offset += offsetStep; - saveState(); + root.lyricsOffset += 500; } function decreaseOffset() { - offset -= offsetStep; - saveState(); + root.lyricsOffset -= 500; } function resetOffset() { - offset = 0; - saveState(); + if (root.lyricsOffset !== 0) + root.lyricsOffset = 0; + } - function clearCache() { - action.command = ["sh", "-c", "spotify-lyrics clear $(spotify-lyrics trackid)"]; - action.startDetached(); + function toggleLyricsBar() { + ShellState.lyricsState = { + "showLyricsBar": !ShellState.lyricsState.showLyricsBar + }; } - function showLyricsText() { - action.command = ["sh", "-c", "wezterm start -- sh -c 'spotify-lyrics fetch 2>/dev/null | less'"]; - action.startDetached(); + function seekToLyric(index) { + if (index < 0 || index >= root.lyrics.count) + return ; + + const time = root.lyrics.get(index).time - root.lyricsOffset; + MediaService.seek(time / 1000); } - onOffsetChanged: { - if (root.showLyricsBar) - SendNotification.show("Lyrics Offset Changed", `Current offset: ${offset} ms`); - - writeOffset(); - } onShouldRunChanged: { - if (shouldRun) - startSyncing(); - else - stopSyncing(); + Logger.d("Lyrics", "Should run changed:", root.shouldRun); + if (!root.shouldRun) { + root.lyrics.clear(); + root.isFetchingLyrics = false; + } else { + root._requestFetchLyrics(); + } + } + onInternalPositionChanged: updateIndex() + onLyricsOffsetChanged: updateIndex() + + Connections { + function onCurrentPlayerChanged() { + _requestFetchLyrics(); + } + + function onTrackTitleChanged() { + _requestFetchLyrics(); + } + + function onTrackArtistChanged() { + _requestFetchLyrics(); + } + + function onTrackAlbumChanged() { + _requestFetchLyrics(); + } + + function onCurrentPositionChanged() { + root.internalPosition = MediaService.currentPosition * 1000; + if (shouldRun && MediaService.isPlaying) + updateInternalTimer.restart(); + + } + + target: MediaService + } + + Timer { + id: fetchLyricsTimer + + interval: 100 + repeat: false + running: false + onTriggered: fetchLyrics() } Process { - id: listenProcess + id: lyricsProcess + + property string queuedPlayer: "" + + function request(player) { + this.queuedPlayer = player; + if (this.running) + this.running = false; + else + handleQueue(); + } + + function handleQueue() { + if (!this.queuedPlayer) { + root.isFetchingLyrics = false; + return ; + } + const player = this.queuedPlayer.toLowerCase(); + this.queuedPlayer = ""; + if (player === "spotify") { + this.command = ["lrcfetch", "fetch", "--player", "spotify"]; + } else if (player === "elisa") { + this.command = ["lrcfetch", "fetch", "--player", "elisa"]; + } else { + root.isFetchingLyrics = false; + root.parseLRC(""); + return ; + } + this.running = true; + } running: false + onExited: (exitCode, exitStatus) => { + if (this.queuedPlayer) + Qt.callLater(handleQueue); + else + root.isFetchingLyrics = false; + } - stdout: SplitParser { - splitMarker: "" - onRead: (data) => { - lyrics = data.split("\n").slice(0, linesCount); - if (lyrics.length < linesCount) { - // fill with empty lines if not enough - for (let i = lyrics.length; i < linesCount; i++) { - lyrics[i] = " "; - } - } + stdout: StdioCollector { + onStreamFinished: { + root.parseLRC(this.text); } } } - Process { - id: action + Timer { + id: updateInternalTimer - running: false + interval: 50 + repeat: true + running: shouldRun && MediaService.isPlaying + onTriggered: { + root.internalPosition += this.interval; + } } - FileView { - id: offsetFileView - - path: offsetFile - watchChanges: false - onLoaded: { - try { - const fileContents = text(); - if (fileContents.length > 0) { - const val = parseInt(fileContents); - if (!isNaN(val)) { - offset = val; - Logger.d("Lyrics", "Loaded offset:", offset); - } else { - offset = 0; - writeOffset(); - } - } else { - offset = 0; - writeOffset(); - } - } catch (e) { - Logger.e("Lyrics", "Error reading offset file:", e); - } - } - onLoadFailed: { - Logger.e("Lyrics", "Error loading offset file."); - } - onSaveFailed: { - Logger.e("Lyrics", "Error saving offset file."); - } + lyrics: ListModel { } } diff --git a/config/scripts/.local/scripts/elisa-lyrics b/config/scripts/.local/scripts/elisa-lyrics new file mode 100755 index 0000000..7eaaa84 --- /dev/null +++ b/config/scripts/.local/scripts/elisa-lyrics @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +set -euo pipefail + +url=$(playerctl --player=elisa metadata xesam:url) + +if [[ "$url" != file://* ]]; then + exit 1 +fi + +path="${url#file://}" +lyrics_path="${path%.*}.lrc" + +if [[ ! -e "$lyrics_path" ]]; then + exit 1 +fi + +cat "$lyrics_path" diff --git a/config/scripts/.local/scripts/fzfclip-wrap b/config/scripts/.local/scripts/fzfclip-wrap index 26df3e4..7199676 100755 --- a/config/scripts/.local/scripts/fzfclip-wrap +++ b/config/scripts/.local/scripts/fzfclip-wrap @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash lockfile=/tmp/fzfclip.lock exec {LOCK_FD}>"$lockfile" @@ -7,4 +7,4 @@ flock -n "$LOCK_FD" || { exit 1 } -fzfclip "$@" +fzfclip "$@" {LOCK_FD}>&-