Merge branch 'main' into sdfs

This commit is contained in:
2 * r + 2 * t 2026-03-27 21:24:17 +11:00
commit 5a4dbf1300
8 changed files with 293 additions and 119 deletions

View file

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

View file

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

View file

@ -280,6 +280,11 @@ ColumnLayout {
forceActiveFocus(); forceActiveFocus();
} }
if (event.key === Qt.Key_Escape) {
event.accepted = false;
closeDialog();
}
// Clear error when user starts typing // Clear error when user starts typing
if (connectButton.hasError && event.text && event.text.length > 0) { if (connectButton.hasError && event.text && event.text.length > 0) {
connectButton.hasError = false; connectButton.hasError = false;
@ -298,6 +303,10 @@ ColumnLayout {
} }
event.accepted = true; event.accepted = true;
} else if (event.text && event.text.length > 0) { } else if (event.text && event.text.length > 0) {
if (event.key === Qt.Key_Tab) {
event.accepted = false;
return;
}
passwordBuffer += event.text; passwordBuffer += event.text;
event.accepted = true; event.accepted = true;
} }

View file

@ -202,6 +202,11 @@ Item {
forceActiveFocus(); forceActiveFocus();
} }
if (event.key === Qt.Key_Escape) {
event.accepted = false;
closeDialog();
}
if (connectButton.hasError && event.text && event.text.length > 0) { if (connectButton.hasError && event.text && event.text.length > 0) {
connectButton.hasError = false; connectButton.hasError = false;
} }
@ -219,6 +224,10 @@ Item {
} }
event.accepted = true; event.accepted = true;
} else if (event.text && event.text.length > 0) { } else if (event.text && event.text.length > 0) {
if (event.key === Qt.Key_Tab) {
event.accepted = false;
return;
}
passwordBuffer += event.text; passwordBuffer += event.text;
event.accepted = true; event.accepted = true;
} }

View file

@ -32,7 +32,7 @@ StyledRect {
anchors.margins: Appearance.padding.large anchors.margins: Appearance.padding.large
spacing: Appearance.spacing.normal spacing: Appearance.spacing.normal
// Header: icon, backend name, refresh, toggle // Header: icon, backend selector, refresh, toggle
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: Appearance.padding.small spacing: Appearance.padding.small
@ -44,12 +44,49 @@ StyledRect {
font.pointSize: Appearance.spacing.large 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 Layout.fillWidth: true
text: LyricsService.backend
font.pointSize: Appearance.font.size.normal
color: Colours.palette.m3secondary
elide: Text.ElideRight
} }
IconButton { IconButton {
@ -66,117 +103,141 @@ StyledRect {
StyledText { StyledText {
Layout.fillWidth: true Layout.fillWidth: true
text: "Fetched Candidates:" text: LyricsService.preferredBackend === "Local" ? "Loaded File:" : "Fetched Candidates:"
color: Colours.palette.m3outline color: Colours.palette.m3outline
font.pointSize: Appearance.font.size.small font.pointSize: Appearance.font.size.small
elide: Text.ElideRight 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 // Candidates list
ListView { Loader {
id: candidatesView
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
visible: LyricsService.candidatesModel.count > 0 active: LyricsService.preferredBackend !== "Local"
model: LyricsService.candidatesModel
clip: true
spacing: Appearance.spacing.small
opacity: visible ? 1 : 0 sourceComponent: ListView {
// Behavior on opacity { id: candidatesView
// NumberAnimation { duration: Appearance.anim.durations.normal }
// }
delegate: Item { model: LyricsService.candidatesModel
id: delegateRoot clip: true
spacing: Appearance.spacing.small
visible: LyricsService.candidatesModel.count > 0
opacity: visible ? 1 : 0
required property real id delegate: Item {
required property string title id: delegateRoot
required property string artist
property bool hovered: false
property bool pressed: false
width: ListView.view.width * 0.98 required property real id
height: 70 required property string title
anchors.horizontalCenter: parent?.horizontalCenter required property string artist
scale: hovered ? 1.02 : 1.0
Behavior on scale { property bool hovered: false
NumberAnimation { property bool pressed: false
duration: Appearance.anim.durations.small
easing.type: Easing.OutCubic
}
}
Rectangle { width: ListView.view.width * 0.98
id: background height: 70
anchors.fill: parent anchors.horizontalCenter: parent?.horizontalCenter
radius: Appearance.rounding.small 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) Behavior on scale {
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 { NumberAnimation {
duration: Appearance.anim.durations.small 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 { Rectangle {
width: 4 id: background
height: parent.height * 0.6
radius: 2 anchors.fill: parent
anchors.verticalCenter: parent.verticalCenter radius: Appearance.rounding.small
color: LyricsService.currentSongId === delegateRoot.id ? Colours.palette.m3primary : "transparent"
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 { Behavior on color {
ColorAnimation { ColorAnimation {
duration: Appearance.anim.durations.small duration: Appearance.anim.durations.small
} }
} }
Behavior on border.width {
NumberAnimation {
duration: Appearance.anim.durations.small
}
}
} }
Column { MouseArea {
anchors.verticalCenter: parent.verticalCenter anchors.fill: parent
width: parent.width - 30 hoverEnabled: true
spacing: 4 cursorShape: Qt.PointingHandCursor
Text { onEntered: delegateRoot.hovered = true
text: delegateRoot.title onExited: delegateRoot.hovered = false
font.pointSize: Appearance.font.size.normal onPressed: delegateRoot.pressed = true
font.bold: true onReleased: delegateRoot.pressed = false
color: delegateRoot.hovered ? Colours.palette.m3primary : Colours.palette.m3onSurface onClicked: LyricsService.selectCandidate(delegateRoot.id)
width: parent.width }
elide: Text.ElideRight
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 { Behavior on color {
ColorAnimation { ColorAnimation {
@ -185,11 +246,32 @@ StyledRect {
} }
} }
Text { Column {
text: delegateRoot.artist anchors.verticalCenter: parent.verticalCenter
font.pointSize: Appearance.font.size.small width: parent.width - 30
color: Colours.palette.m3onSurfaceVariant spacing: 4
elide: Text.ElideRight
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 { Item {
Layout.fillHeight: true Layout.fillHeight: true
visible: LyricsService.candidatesModel.count == 0 visible: LyricsService.candidatesModel.count == 0 && LyricsService.preferredBackend !== "Local"
} }
// Manual search // Manual search

View file

@ -2,6 +2,7 @@ pragma Singleton
import QtQuick import QtQuick
import Quickshell import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire import Quickshell.Services.Pipewire
import Caelestia import Caelestia
import Caelestia.Services import Caelestia.Services
@ -67,6 +68,15 @@ Singleton {
Pipewire.preferredDefaultAudioSource = newSource; Pipewire.preferredDefaultAudioSource = newSource;
} }
function cycleNextAudioOutput(): void {
if (sinks.length === 0)
return;
const currentIndex = sinks.findIndex(s => s === sink);
const nextIndex = (currentIndex + 1) % sinks.length;
setAudioSink(sinks[nextIndex]);
}
function setStreamVolume(stream: PwNode, newVolume: real): void { function setStreamVolume(stream: PwNode, newVolume: real): void {
if (stream?.ready && stream?.audio) { if (stream?.ready && stream?.audio) {
stream.audio.muted = false; stream.audio.muted = false;
@ -162,4 +172,12 @@ Singleton {
BeatTracker { BeatTracker {
id: beatTracker id: beatTracker
} }
IpcHandler {
function cycleOutput(): void {
root.cycleNextAudioOutput();
}
target: "audio"
}
} }

View file

@ -17,22 +17,17 @@ Singleton {
property bool isManualSeeking: false property bool isManualSeeking: false
property bool lyricsVisible: Config.services.showLyrics property bool lyricsVisible: Config.services.showLyrics
property string backend: "Local" property string backend: "Local"
property string preferredBackend: Config.services.lyricsBackend
property real currentSongId: 0 property real currentSongId: 0
property string loadedLocalFile: ""
property real offset property real offset
property int currentRequestId: 0
property var lyricsMap: ({})
readonly property string lyricsDir: Paths.absolutePath(Config.paths.lyricsDir) readonly property string lyricsDir: Paths.absolutePath(Config.paths.lyricsDir)
readonly property string lyricsMapFile: Paths.absolutePath(Config.paths.lyricsDir) + "/lyrics_map.json" 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 model: lyricsModel
readonly property alias candidatesModel: fetchedCandidatesModel readonly property alias candidatesModel: fetchedCandidatesModel
property var lyricsMap: ({})
// shared headers for all NetEase requests
readonly property var _netEaseHeaders: ({ readonly property var _netEaseHeaders: ({
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0",
"Referer": "https://music.163.com/" "Referer": "https://music.163.com/"
@ -90,7 +85,6 @@ Singleton {
lyricsModel.clear(); lyricsModel.clear();
currentIndex = -1; currentIndex = -1;
root.currentSongId = 0; root.currentSongId = 0;
root.backend = "Local";
root.currentRequestId++; root.currentRequestId++;
let requestId = root.currentRequestId; let requestId = root.currentRequestId;
@ -99,29 +93,47 @@ Singleton {
let saved = root.lyricsMap[key]; let saved = root.lyricsMap[key];
root.offset = saved?.offset ?? 0.0; root.offset = saved?.offset ?? 0.0;
if (saved?.neteaseId && saved?.backend === "NetEase") { if (root.preferredBackend === "NetEase") {
root.backend = "NetEase"; root.backend = "NetEase";
root.currentSongId = saved.neteaseId; fetchNetEase(meta.title, meta.artist, requestId);
fetchNetEaseLyrics(saved.neteaseId, requestId);
fetchNetEaseCandidates(meta.title, meta.artist, requestId);
return; return;
} }
if (saved?.backend === "NetEase") { if (root.preferredBackend === "Local") {
fallbackTimer.restart(); 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; return;
} }
// Auto mode: try local first
root.backend = "Local";
let cleanDir = lyricsDir.replace(/\/$/, ""); 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 = "";
lrcFile.path = fullPath; lrcFile.path = flatPath;
fetchNetEaseCandidates(meta.title, meta.artist, requestId); //to populate the list regardless fetchNetEaseCandidates(meta.title, meta.artist, requestId);
// 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();
} }
function updateModel(parsedArray) { function updateModel(parsedArray) {
@ -256,6 +268,13 @@ Singleton {
seekTimer.restart(); seekTimer.restart();
} }
onPreferredBackendChanged: {
if (Config.services.lyricsBackend !== preferredBackend) {
Config.services.lyricsBackend = preferredBackend;
Config.save();
}
}
ListModel { ListModel {
id: lyricsModel id: lyricsModel
} }
@ -314,12 +333,15 @@ Singleton {
let parsed = Lrc.parseLrc(text()); let parsed = Lrc.parseLrc(text());
if (parsed.length > 0) { if (parsed.length > 0) {
root.backend = "Local"; root.backend = "Local";
root.loadedLocalFile = path;
updateModel(parsed); updateModel(parsed);
loading = false; loading = false;
} else { } else if (root.preferredBackend === "Local") {
// Local mode only - fail immediately
root.backend = "NetEase"; root.backend = "NetEase";
fallbackToOnline(); 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}"`] 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;
}
}
} }

View file

@ -128,7 +128,7 @@ Singleton {
const forecastList = []; const forecastList = [];
for (let i = 0; i < json.daily.time.length; i++) for (let i = 0; i < json.daily.time.length; i++)
forecastList.push({ forecastList.push({
date: json.daily.time[i], date: json.daily.time[i].replace(/-/g, "/"),
maxTempC: Math.round(json.daily.temperature_2m_max[i]), maxTempC: Math.round(json.daily.temperature_2m_max[i]),
maxTempF: Math.round(toFahrenheit(json.daily.temperature_2m_max[i])), maxTempF: Math.round(toFahrenheit(json.daily.temperature_2m_max[i])),
minTempC: Math.round(json.daily.temperature_2m_min[i]), minTempC: Math.round(json.daily.temperature_2m_min[i]),
@ -141,7 +141,8 @@ Singleton {
const hourlyList = []; const hourlyList = [];
const now = new Date(); const now = new Date();
for (let i = 0; i < json.hourly.time.length; i++) { for (let i = 0; i < json.hourly.time.length; i++) {
const time = new Date(json.hourly.time[i]); const time = new Date(json.hourly.time[i].replace("T", " "));
if (time < now) if (time < now)
continue; continue;