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

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

View file

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

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

@ -2,6 +2,7 @@ pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import Caelestia
import Caelestia.Services
@ -67,6 +68,15 @@ Singleton {
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 {
if (stream?.ready && stream?.audio) {
stream.audio.muted = false;
@ -162,4 +172,12 @@ Singleton {
BeatTracker {
id: beatTracker
}
IpcHandler {
function cycleOutput(): void {
root.cycleNextAudioOutput();
}
target: "audio"
}
}

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

View file

@ -128,7 +128,7 @@ Singleton {
const forecastList = [];
for (let i = 0; i < json.daily.time.length; i++)
forecastList.push({
date: json.daily.time[i],
date: json.daily.time[i].replace(/-/g, "/"),
maxTempC: Math.round(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]),
@ -141,7 +141,8 @@ Singleton {
const hourlyList = [];
const now = new Date();
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)
continue;