From 6fa80bd85778cff5a9f7c716a9bff4648884919f Mon Sep 17 00:00:00 2001 From: Robin Seger Date: Thu, 26 Mar 2026 07:13:57 +0100 Subject: [PATCH] dashboard: lyrics enhancements (#1321) * dual lrc file search strategies, UI adjustments * qml conventions & order * format --------- Co-authored-by: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> --- config/Config.qml | 3 +- config/ServiceConfig.qml | 3 +- modules/dashboard/LyricMenu.qml | 264 +++++++++++++++++++++----------- services/LyricsService.qml | 101 +++++++++--- 4 files changed, 254 insertions(+), 117 deletions(-) diff --git a/config/Config.qml b/config/Config.qml index cb8eb9f7..91454b59 100644 --- a/config/Config.qml +++ b/config/Config.qml @@ -363,7 +363,8 @@ Singleton { smartScheme: services.smartScheme, defaultPlayer: services.defaultPlayer, playerAliases: services.playerAliases, - showLyrics: services.showLyrics + showLyrics: services.showLyrics, + lyricsBackend: services.lyricsBackend }; } diff --git a/config/ServiceConfig.qml b/config/ServiceConfig.qml index 5f3c24fb..a9d7c3dc 100644 --- a/config/ServiceConfig.qml +++ b/config/ServiceConfig.qml @@ -19,5 +19,6 @@ JsonObject { "to": "YT Music" } ] - property bool showLyrics: true + property bool showLyrics: false + property string lyricsBackend: "Auto" } diff --git a/modules/dashboard/LyricMenu.qml b/modules/dashboard/LyricMenu.qml index 0dae6246..54b43eec 100644 --- a/modules/dashboard/LyricMenu.qml +++ b/modules/dashboard/LyricMenu.qml @@ -32,7 +32,7 @@ StyledRect { anchors.margins: Appearance.padding.large spacing: Appearance.spacing.normal - // Header: icon, backend name, refresh, toggle + // Header: icon, backend selector, refresh, toggle RowLayout { Layout.fillWidth: true spacing: Appearance.padding.small @@ -44,12 +44,49 @@ StyledRect { font.pointSize: Appearance.spacing.large } - StyledText { + Rectangle { + Layout.preferredHeight: 24 + Layout.preferredWidth: 80 + radius: Appearance.rounding.small + color: Qt.rgba(Colours.palette.m3primary.r, Colours.palette.m3primary.g, Colours.palette.m3primary.b, 0.15) + + StyledText { + anchors.centerIn: parent + text: LyricsService.preferredBackend + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3primary + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + const backends = ["Auto", "Local", "NetEase"]; + const currentIndex = backends.indexOf(LyricsService.preferredBackend); + const nextIndex = (currentIndex + 1) % backends.length; + LyricsService.preferredBackend = backends[nextIndex]; + LyricsService.loadLyrics(); + } + } + } + + Rectangle { + Layout.preferredHeight: 24 + Layout.preferredWidth: 60 + radius: Appearance.rounding.small + visible: LyricsService.preferredBackend === "Auto" + color: LyricsService.backend === "Local" ? Qt.rgba(Colours.palette.m3tertiary.r, Colours.palette.m3tertiary.g, Colours.palette.m3tertiary.b, 0.15) : Qt.rgba(Colours.palette.m3secondary.r, Colours.palette.m3secondary.g, Colours.palette.m3secondary.b, 0.15) + + StyledText { + anchors.centerIn: parent + text: LyricsService.backend + font.pointSize: Appearance.font.size.small + color: LyricsService.backend === "Local" ? Colours.palette.m3tertiary : Colours.palette.m3secondary + } + } + + Item { Layout.fillWidth: true - text: LyricsService.backend - font.pointSize: Appearance.font.size.normal - color: Colours.palette.m3secondary - elide: Text.ElideRight } IconButton { @@ -66,117 +103,141 @@ StyledRect { StyledText { Layout.fillWidth: true - text: "Fetched Candidates:" + text: LyricsService.preferredBackend === "Local" ? "Loaded File:" : "Fetched Candidates:" color: Colours.palette.m3outline font.pointSize: Appearance.font.size.small elide: Text.ElideRight + visible: LyricsService.preferredBackend === "Local" ? LyricsService.loadedLocalFile.length > 0 : LyricsService.candidatesModel.count > 0 + } + + // Local file info (shown in Local mode) + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 48 + visible: LyricsService.preferredBackend === "Local" && LyricsService.loadedLocalFile.length > 0 + radius: Appearance.rounding.small + color: Qt.rgba(Colours.palette.m3tertiary.r, Colours.palette.m3tertiary.g, Colours.palette.m3tertiary.b, 0.1) + + ColumnLayout { + anchors.fill: parent + anchors.margins: Appearance.padding.small + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: { + const path = LyricsService.loadedLocalFile; + const parts = path.split('/'); + return parts[parts.length - 1]; + } + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3tertiary + elide: Text.ElideMiddle + } + + StyledText { + Layout.fillWidth: true + text: { + const path = LyricsService.loadedLocalFile; + const parts = path.split('/'); + if (parts.length > 2) { + return parts.slice(-3, -1).join('/'); + } + return ""; + } + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3outline + elide: Text.ElideMiddle + } + } } // Candidates list - ListView { - id: candidatesView - + Loader { Layout.fillWidth: true Layout.fillHeight: true - visible: LyricsService.candidatesModel.count > 0 - model: LyricsService.candidatesModel - clip: true - spacing: Appearance.spacing.small + active: LyricsService.preferredBackend !== "Local" - opacity: visible ? 1 : 0 - // Behavior on opacity { - // NumberAnimation { duration: Appearance.anim.durations.normal } - // } + sourceComponent: ListView { + id: candidatesView - delegate: Item { - id: delegateRoot + model: LyricsService.candidatesModel + clip: true + spacing: Appearance.spacing.small + visible: LyricsService.candidatesModel.count > 0 + opacity: visible ? 1 : 0 - required property real id - required property string title - required property string artist - property bool hovered: false - property bool pressed: false + delegate: Item { + id: delegateRoot - width: ListView.view.width * 0.98 - height: 70 - anchors.horizontalCenter: parent?.horizontalCenter - scale: hovered ? 1.02 : 1.0 + required property real id + required property string title + required property string artist - Behavior on scale { - NumberAnimation { - duration: Appearance.anim.durations.small - easing.type: Easing.OutCubic - } - } + property bool hovered: false + property bool pressed: false - Rectangle { - id: background + width: ListView.view.width * 0.98 + height: 70 - anchors.fill: parent - radius: Appearance.rounding.small + anchors.horizontalCenter: parent?.horizontalCenter + scale: hovered ? 1.02 : 1.0 - color: delegateRoot.pressed ? Qt.rgba(Colours.palette.m3primary.r, Colours.palette.m3primary.g, Colours.palette.m3primary.b, 0.25) : delegateRoot.hovered ? Qt.rgba(Colours.palette.m3primary.r, Colours.palette.m3primary.g, Colours.palette.m3primary.b, 0.06) : Qt.rgba(Colours.palette.m3primary.r, Colours.palette.m3primary.g, Colours.palette.m3primary.b, 0.03) - - border.width: delegateRoot.hovered ? 1 : 0 - border.color: Colours.palette.m3primary - - Behavior on color { - ColorAnimation { - duration: Appearance.anim.durations.small - } - } - Behavior on border.width { + Behavior on scale { NumberAnimation { duration: Appearance.anim.durations.small + easing.type: Easing.OutCubic } } - } - MouseArea { - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - - onEntered: delegateRoot.hovered = true - onExited: delegateRoot.hovered = false - onPressed: delegateRoot.pressed = true - onReleased: delegateRoot.pressed = false - onClicked: LyricsService.selectCandidate(delegateRoot.id) - } - - Row { - anchors.fill: parent - anchors.margins: Appearance.padding.normal - spacing: Appearance.spacing.small - - // Active indicator bar Rectangle { - width: 4 - height: parent.height * 0.6 - radius: 2 - anchors.verticalCenter: parent.verticalCenter - color: LyricsService.currentSongId === delegateRoot.id ? Colours.palette.m3primary : "transparent" + id: background + + anchors.fill: parent + radius: Appearance.rounding.small + + color: delegateRoot.pressed ? Qt.rgba(Colours.palette.m3primary.r, Colours.palette.m3primary.g, Colours.palette.m3primary.b, 0.25) : delegateRoot.hovered ? Qt.rgba(Colours.palette.m3primary.r, Colours.palette.m3primary.g, Colours.palette.m3primary.b, 0.06) : Qt.rgba(Colours.palette.m3primary.r, Colours.palette.m3primary.g, Colours.palette.m3primary.b, 0.03) + + border.width: delegateRoot.hovered ? 1 : 0 + border.color: Colours.palette.m3primary Behavior on color { ColorAnimation { duration: Appearance.anim.durations.small } } + Behavior on border.width { + NumberAnimation { + duration: Appearance.anim.durations.small + } + } } - Column { - anchors.verticalCenter: parent.verticalCenter - width: parent.width - 30 - spacing: 4 + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor - Text { - text: delegateRoot.title - font.pointSize: Appearance.font.size.normal - font.bold: true - color: delegateRoot.hovered ? Colours.palette.m3primary : Colours.palette.m3onSurface - width: parent.width - elide: Text.ElideRight + onEntered: delegateRoot.hovered = true + onExited: delegateRoot.hovered = false + onPressed: delegateRoot.pressed = true + onReleased: delegateRoot.pressed = false + onClicked: LyricsService.selectCandidate(delegateRoot.id) + } + + Row { + anchors.fill: parent + anchors.margins: Appearance.padding.normal + spacing: Appearance.spacing.small + + // Active indicator bar + Rectangle { + width: 4 + height: parent.height * 0.6 + radius: 2 + anchors.verticalCenter: parent.verticalCenter + color: LyricsService.currentSongId === delegateRoot.id ? Colours.palette.m3primary : "transparent" Behavior on color { ColorAnimation { @@ -185,11 +246,32 @@ StyledRect { } } - Text { - text: delegateRoot.artist - font.pointSize: Appearance.font.size.small - color: Colours.palette.m3onSurfaceVariant - elide: Text.ElideRight + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 30 + spacing: 4 + + Text { + text: delegateRoot.title + font.pointSize: Appearance.font.size.normal + font.bold: true + color: delegateRoot.hovered ? Colours.palette.m3primary : Colours.palette.m3onSurface + width: parent.width + elide: Text.ElideRight + + Behavior on color { + ColorAnimation { + duration: Appearance.anim.durations.small + } + } + } + + Text { + text: delegateRoot.artist + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + elide: Text.ElideRight + } } } } @@ -198,7 +280,7 @@ StyledRect { Item { Layout.fillHeight: true - visible: LyricsService.candidatesModel.count == 0 + visible: LyricsService.candidatesModel.count == 0 && LyricsService.preferredBackend !== "Local" } // Manual search diff --git a/services/LyricsService.qml b/services/LyricsService.qml index 53be9f90..d41b93b4 100644 --- a/services/LyricsService.qml +++ b/services/LyricsService.qml @@ -17,22 +17,17 @@ Singleton { property bool isManualSeeking: false property bool lyricsVisible: Config.services.showLyrics property string backend: "Local" + property string preferredBackend: Config.services.lyricsBackend property real currentSongId: 0 - + property string loadedLocalFile: "" property real offset + property int currentRequestId: 0 + property var lyricsMap: ({}) readonly property string lyricsDir: Paths.absolutePath(Config.paths.lyricsDir) readonly property string lyricsMapFile: Paths.absolutePath(Config.paths.lyricsDir) + "/lyrics_map.json" - - property int currentRequestId: 0 - - // The data source for the UI readonly property alias model: lyricsModel readonly property alias candidatesModel: fetchedCandidatesModel - - property var lyricsMap: ({}) - - // shared headers for all NetEase requests readonly property var _netEaseHeaders: ({ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0", "Referer": "https://music.163.com/" @@ -90,7 +85,6 @@ Singleton { lyricsModel.clear(); currentIndex = -1; root.currentSongId = 0; - root.backend = "Local"; root.currentRequestId++; let requestId = root.currentRequestId; @@ -99,29 +93,47 @@ Singleton { let saved = root.lyricsMap[key]; root.offset = saved?.offset ?? 0.0; - if (saved?.neteaseId && saved?.backend === "NetEase") { + if (root.preferredBackend === "NetEase") { root.backend = "NetEase"; - root.currentSongId = saved.neteaseId; - fetchNetEaseLyrics(saved.neteaseId, requestId); - fetchNetEaseCandidates(meta.title, meta.artist, requestId); + fetchNetEase(meta.title, meta.artist, requestId); return; } - if (saved?.backend === "NetEase") { - fallbackTimer.restart(); + if (root.preferredBackend === "Local") { + root.backend = "Local"; + let cleanDir = lyricsDir.replace(/\/$/, ""); + let flatPath = `${cleanDir}/${meta.artist} - ${meta.title}.lrc`; + + // Search for files matching "Artist - Title.lrc" pattern + const artistStr = Array.isArray(meta.artist) ? meta.artist.join(", ") : String(meta.artist || ""); + const titleStr = Array.isArray(meta.title) ? meta.title.join(", ") : String(meta.title || ""); + const escapedTitle = titleStr.replace(/'/g, "'\\''"); + const escapedArtist = artistStr.replace(/'/g, "'\\''"); + findLyricsInSubdirs.command = ["sh", "-c", `find "${cleanDir}" -type f -iname "*${escapedArtist}*${escapedTitle}*.lrc" | head -n 1`]; + findLyricsInSubdirs.requestId = requestId; + findLyricsInSubdirs.running = true; + + lrcFile.path = ""; + lrcFile.path = flatPath; return; } + // Auto mode: try local first + root.backend = "Local"; let cleanDir = lyricsDir.replace(/\/$/, ""); - let fullPath = `${cleanDir}/${meta.artist} - ${meta.title}.lrc`; + let flatPath = `${cleanDir}/${meta.artist} - ${meta.title}.lrc`; + + const artistStr = Array.isArray(meta.artist) ? meta.artist.join(", ") : String(meta.artist || ""); + const titleStr = Array.isArray(meta.title) ? meta.title.join(", ") : String(meta.title || ""); + const escapedTitle = titleStr.replace(/'/g, "'\\''"); + const escapedArtist = artistStr.replace(/'/g, "'\\''"); + findLyricsInSubdirs.command = ["sh", "-c", `find "${cleanDir}" -type f -iname "*${escapedArtist}*${escapedTitle}*.lrc" | head -n 1`]; + findLyricsInSubdirs.requestId = requestId; + findLyricsInSubdirs.running = true; lrcFile.path = ""; - lrcFile.path = fullPath; - fetchNetEaseCandidates(meta.title, meta.artist, requestId); //to populate the list regardless - - // if the file is missing, FileView will not fire onLoaded, so we arm the fallback timer here as a safety net. It is cancelled in onLoaded if the file loads successfully. - if (saved?.backend !== "Local") - fallbackTimer.restart(); + lrcFile.path = flatPath; + fetchNetEaseCandidates(meta.title, meta.artist, requestId); } function updateModel(parsedArray) { @@ -256,6 +268,13 @@ Singleton { seekTimer.restart(); } + onPreferredBackendChanged: { + if (Config.services.lyricsBackend !== preferredBackend) { + Config.services.lyricsBackend = preferredBackend; + Config.save(); + } + } + ListModel { id: lyricsModel } @@ -314,12 +333,15 @@ Singleton { let parsed = Lrc.parseLrc(text()); if (parsed.length > 0) { root.backend = "Local"; + root.loadedLocalFile = path; updateModel(parsed); loading = false; - } else { + } else if (root.preferredBackend === "Local") { + // Local mode only - fail immediately root.backend = "NetEase"; fallbackToOnline(); } + // In Auto mode, let the Process onExited handle fallback } } @@ -346,4 +368,35 @@ Singleton { command: ["sh", "-c", `mkdir -p "${root.lyricsDir}" && echo '${JSON.stringify(root.lyricsMap)}' > "${root.lyricsMapFile}"`] } + + Process { + id: findLyricsInSubdirs + + property int requestId: -1 + property bool foundFile: false + + stdout: SplitParser { + onRead: data => { + if (findLyricsInSubdirs.requestId === root.currentRequestId) { + const foundPath = data.trim(); + if (foundPath && foundPath.length > 0) { + findLyricsInSubdirs.foundFile = true; + fallbackTimer.stop(); + root.loadedLocalFile = foundPath; + lrcFile.path = ""; + lrcFile.path = foundPath; + } + } + } + } + + onExited: (exitCode, exitStatus) => { // qmllint disable signal-handler-parameters + if (requestId === root.currentRequestId && !foundFile && root.preferredBackend === "Auto") { + if (lyricsModel.count === 0) { + fallbackTimer.restart(); + } + } + foundFile = false; + } + } }