qs: better lyrics

This commit is contained in:
2026-03-25 06:20:10 +01:00
parent 768cdbdcfa
commit e6b928be7d
6 changed files with 284 additions and 151 deletions
@@ -25,7 +25,7 @@ Rectangle {
clip: true clip: true
UText { UText {
text: LyricsService.lyrics[LyricsService.currentIndex] || "" text: LyricsService.lyrics.get(LyricsService.currentIndex)?.line || ""
family: Fonts.sans family: Fonts.sans
pointSize: Style.fontSizeM pointSize: Style.fontSizeM
maximumLineCount: 1 maximumLineCount: 1
@@ -42,7 +42,7 @@ Rectangle {
baseSize: parent.height - Style.marginXS * 2 baseSize: parent.height - Style.marginXS * 2
iconSize: Style.fontSizeM iconSize: Style.fontSizeM
onClicked: { onClicked: {
LyricsService.increaseOffset(); LyricsService.decreaseOffset();
} }
} }
@@ -54,7 +54,7 @@ Rectangle {
baseSize: parent.height - Style.marginXS * 2 baseSize: parent.height - Style.marginXS * 2
iconSize: Style.fontSizeM iconSize: Style.fontSizeM
onClicked: { onClicked: {
LyricsService.decreaseOffset(); LyricsService.increaseOffset();
} }
} }
@@ -16,27 +16,45 @@ UBox {
LyricsService.unregisterComponent("LyricsCard"); LyricsService.unregisterComponent("LyricsCard");
} }
ColumnLayout { ListView {
id: lyricsColumn id: lyricsList
anchors.fill: parent anchors.fill: parent
anchors.margins: Style.marginS anchors.margins: Style.marginS
spacing: Style.marginM
Repeater {
model: LyricsService.lyrics model: LyricsService.lyrics
currentIndex: LyricsService.currentIndex
clip: true
onCurrentIndexChanged: {
if (currentIndex >= 0)
positionViewAtIndex(currentIndex, ListView.Center);
UText { }
Layout.fillWidth: true Component.onCompleted: {
text: modelData Qt.callLater(function() {
font.pointSize: index === LyricsService.currentIndex ? Style.fontSizeS : Style.fontSizeXS if (currentIndex >= 0)
font.weight: index === LyricsService.currentIndex ? Style.fontWeightBold : Style.fontWeightRegular positionViewAtIndex(currentIndex, ListView.Center);
});
}
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 font.family: Fonts.sans
color: index === LyricsService.currentIndex ? Colors.mOnSurface : Colors.mOnSurfaceVariant color: ListView.isCurrentItem ? Colors.mOnSurface : Colors.mOnSurfaceVariant
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight elide: Text.ElideRight
wrapMode: Text.WrapAnywhere wrapMode: Text.WrapAnywhere
maximumLineCount: 1
MouseArea {
anchors.fill: parent
onClicked: {
LyricsService.seekToLyric(index);
}
} }
} }
@@ -6,13 +6,27 @@ import qs.Constants
import qs.Services import qs.Services
ColumnLayout { ColumnLayout {
spacing: Style.marginS
UText { UText {
Layout.fillWidth: true Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
Layout.maximumWidth: buttonsGrid.width Layout.maximumWidth: buttonsGrid.width
Layout.bottomMargin: Style.marginS pointSize: Style.fontSizeS
horizontalAlignment: Text.AlignHCenter 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 { GridLayout {
@@ -29,7 +43,7 @@ ColumnLayout {
colorFg: Colors.mCyan colorFg: Colors.mCyan
iconName: "arrow-bar-up" iconName: "arrow-bar-up"
onClicked: { onClicked: {
LyricsService.increaseOffset(); LyricsService.decreaseOffset();
} }
} }
@@ -40,7 +54,7 @@ ColumnLayout {
colorFg: Colors.mPurple colorFg: Colors.mPurple
iconName: "arrow-bar-down" iconName: "arrow-bar-down"
onClicked: { onClicked: {
LyricsService.decreaseOffset(); LyricsService.increaseOffset();
} }
} }
@@ -55,17 +69,6 @@ ColumnLayout {
} }
} }
UIconButton {
id: fasterButton
baseSize: 32
colorFg: Colors.mRed
iconName: "trash"
onClicked: {
LyricsService.clearCache();
}
}
UIconButton { UIconButton {
id: barLyricsButton 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 { UIconButton {
baseSize: 32 baseSize: 32
colorFg: Colors.mOrange colorFg: Colors.mOrange
@@ -9,46 +9,25 @@ pragma Singleton
Singleton { Singleton {
id: root id: root
property int linesCount: 3 property bool isFetchingLyrics: false
property int linesAhead: linesCount / 2 // Lyrics state
readonly property int currentIndex: linesCount - linesAhead - 1 // { "time": 0, "line": "lyric line" }
readonly property string offsetFile: Paths.cacheDir + "/spotify-lyrics-offset.txt" // time is in ms
property int offset: 0 // in ms property ListModel lyrics
readonly property int offsetStep: 500 // in ms 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: ({ property var _registered: ({
}) })
readonly property int _registeredCount: Object.keys(_registered).length readonly property int _registeredCount: Object.keys(_registered).length
readonly property bool shouldRun: _registeredCount > 0 readonly property bool shouldRun: _registeredCount > 0
// with linesCount=3 and linesAhead=1, lyrics will be like: // Bar state
// line 1
// line 2 <- current line
// line 3
property var lyrics: Array(linesCount).fill(" ")
readonly property bool showLyricsBar: ShellState.lyricsState.showLyricsBar || false readonly property bool showLyricsBar: ShellState.lyricsState.showLyricsBar || false
function toggleLyricsBar() { signal lyricsUpdated()
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`]);
}
function registerComponent(componentId) { function registerComponent(componentId) {
root._registered[componentId] = true; root._registered[componentId] = true;
@@ -64,105 +43,232 @@ Singleton {
Logger.d("Lyrics", "Component unregistered:", componentId, "- total:", root._registeredCount); Logger.d("Lyrics", "Component unregistered:", componentId, "- total:", root._registeredCount);
} }
function writeOffset() { function _requestFetchLyrics() {
offsetFileView.setText(String(offset)); 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() { function increaseOffset() {
offset += offsetStep; root.lyricsOffset += 500;
saveState();
} }
function decreaseOffset() { function decreaseOffset() {
offset -= offsetStep; root.lyricsOffset -= 500;
saveState();
} }
function resetOffset() { function resetOffset() {
offset = 0; if (root.lyricsOffset !== 0)
saveState(); root.lyricsOffset = 0;
} }
function clearCache() { function toggleLyricsBar() {
action.command = ["sh", "-c", "spotify-lyrics clear $(spotify-lyrics trackid)"]; ShellState.lyricsState = {
action.startDetached(); "showLyricsBar": !ShellState.lyricsState.showLyricsBar
};
} }
function showLyricsText() { function seekToLyric(index) {
action.command = ["sh", "-c", "wezterm start -- sh -c 'spotify-lyrics fetch 2>/dev/null | less'"]; if (index < 0 || index >= root.lyrics.count)
action.startDetached(); 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: { onShouldRunChanged: {
if (shouldRun) Logger.d("Lyrics", "Should run changed:", root.shouldRun);
startSyncing(); 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: lyricsProcess
property string queuedPlayer: ""
function request(player) {
this.queuedPlayer = player;
if (this.running)
this.running = false;
else else
stopSyncing(); handleQueue();
} }
Process { function handleQueue() {
id: listenProcess 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 running: false
onExited: (exitCode, exitStatus) => {
if (this.queuedPlayer)
Qt.callLater(handleQueue);
else
root.isFetchingLyrics = false;
}
stdout: SplitParser { stdout: StdioCollector {
splitMarker: "" onStreamFinished: {
onRead: (data) => { root.parseLRC(this.text);
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] = " ";
}
}
} }
} }
} }
Process { Timer {
id: action id: updateInternalTimer
running: false interval: 50
repeat: true
running: shouldRun && MediaService.isPlaying
onTriggered: {
root.internalPosition += this.interval;
}
} }
FileView { lyrics: ListModel {
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.");
}
} }
} }
+18
View File
@@ -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"
+2 -2
View File
@@ -1,4 +1,4 @@
#!/bin/sh #!/usr/bin/env bash
lockfile=/tmp/fzfclip.lock lockfile=/tmp/fzfclip.lock
exec {LOCK_FD}>"$lockfile" exec {LOCK_FD}>"$lockfile"
@@ -7,4 +7,4 @@ flock -n "$LOCK_FD" || {
exit 1 exit 1
} }
fzfclip "$@" fzfclip "$@" {LOCK_FD}>&-