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>
This commit is contained in:
Robin Seger 2026-03-26 07:13:57 +01:00 committed by GitHub
parent 9c34c232e4
commit 6fa80bd857
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 254 additions and 117 deletions

View file

@ -363,7 +363,8 @@ Singleton {
smartScheme: services.smartScheme,
defaultPlayer: services.defaultPlayer,
playerAliases: services.playerAliases,
showLyrics: services.showLyrics
showLyrics: services.showLyrics,
lyricsBackend: services.lyricsBackend
};
}

View file

@ -19,5 +19,6 @@ JsonObject {
"to": "YT Music"
}
]
property bool showLyrics: true
property bool showLyrics: false
property string lyricsBackend: "Auto"
}

View file

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

View file

@ -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;
}
}
}