diff --git a/README.md b/README.md index e3bebcd7..385ded16 100644 --- a/README.md +++ b/README.md @@ -598,7 +598,8 @@ default, you must create it manually. "paths": { "mediaGif": "root:/assets/bongocat.gif", "sessionGif": "root:/assets/kurukuru.gif", - "wallpaperDir": "~/Pictures/Wallpapers" + "wallpaperDir": "~/Pictures/Wallpapers", + "lyricsDir": "~/Music/lyrics" }, "services": { "audioIncrement": 0.1, diff --git a/assets/shaders/fade.frag b/assets/shaders/fade.frag new file mode 100644 index 00000000..a6cdf70d --- /dev/null +++ b/assets/shaders/fade.frag @@ -0,0 +1,26 @@ +#version 440 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float fadeMargin; +}; + +layout(binding = 1) uniform sampler2D source; + +void main() { + vec4 tex = texture(source, qt_TexCoord0); + float factor = 1.0; + float margin = 0.1; + + if (qt_TexCoord0.y < margin) { + factor = qt_TexCoord0.y / margin; + } else if (qt_TexCoord0.y > (1.0 - margin)) { + factor = (1.0 - qt_TexCoord0.y) / margin; + } + + fragColor = tex * factor * qt_Opacity; +} diff --git a/assets/shaders/fade.frag.qsb b/assets/shaders/fade.frag.qsb new file mode 100644 index 00000000..888e4c10 Binary files /dev/null and b/assets/shaders/fade.frag.qsb differ diff --git a/config/Config.qml b/config/Config.qml index 2fd8c436..1fdfa4c0 100644 --- a/config/Config.qml +++ b/config/Config.qml @@ -360,13 +360,15 @@ Singleton { maxVolume: services.maxVolume, smartScheme: services.smartScheme, defaultPlayer: services.defaultPlayer, - playerAliases: services.playerAliases + playerAliases: services.playerAliases, + showLyrics: services.showLyrics }; } function serializePaths(): var { return { wallpaperDir: paths.wallpaperDir, + lyricsDir: paths.lyricsDir, sessionGif: paths.sessionGif, mediaGif: paths.mediaGif }; diff --git a/config/ServiceConfig.qml b/config/ServiceConfig.qml index 29600cc5..5294fb69 100644 --- a/config/ServiceConfig.qml +++ b/config/ServiceConfig.qml @@ -19,4 +19,5 @@ JsonObject { "to": "YT Music" } ] + property bool showLyrics: true } diff --git a/config/UserPaths.qml b/config/UserPaths.qml index f8de2678..ea4bf459 100644 --- a/config/UserPaths.qml +++ b/config/UserPaths.qml @@ -3,6 +3,7 @@ import Quickshell.Io JsonObject { property string wallpaperDir: `${Paths.pictures}/Wallpapers` + property string lyricsDir: `${Paths.home}/Music/lyrics/` property string sessionGif: "root:/assets/kurukuru.gif" property string mediaGif: "root:/assets/bongocat.gif" } diff --git a/modules/dashboard/Content.qml b/modules/dashboard/Content.qml index d0386b70..34e4f77a 100644 --- a/modules/dashboard/Content.qml +++ b/modules/dashboard/Content.qml @@ -12,6 +12,15 @@ Item { id: root required property PersistentProperties visibilities + readonly property bool needsKeyboard: { + const count = repeater.count; + for (let i = 0; i < count; i++) { + const item = repeater.itemAt(i) as Loader; + if (item?.sourceComponent === mediaComponent && (item?.item as MediaWrapper)?.needsKeyboard) + return true; + } + return false; + } required property PersistentProperties state required property FileDialog facePicker @@ -160,7 +169,7 @@ Item { Component { id: mediaComponent - Media { + MediaWrapper { visibilities: root.visibilities } } diff --git a/modules/dashboard/LyricMenu.qml b/modules/dashboard/LyricMenu.qml new file mode 100644 index 00000000..0add06bb --- /dev/null +++ b/modules/dashboard/LyricMenu.qml @@ -0,0 +1,323 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property real contentHeight + + implicitHeight: contentHeight + + radius: Appearance.rounding.large + color: Colours.tPalette.m3surfaceContainer + + function searchCandidates(title, artist) { + LyricsService.currentRequestId++; + LyricsService.fetchNetEaseCandidates(title, artist, LyricsService.currentRequestId); + } + + Loader { + anchors.fill: parent + active: root.height > 0 + + sourceComponent: ColumnLayout { + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.normal + + // Header: icon, backend name, refresh, toggle + RowLayout { + Layout.fillWidth: true + spacing: Appearance.padding.small + + MaterialIcon { + text: "lyrics" + fill: 1 + color: Colours.palette.m3primary + font.pointSize: Appearance.spacing.large + } + + StyledText { + Layout.fillWidth: true + text: LyricsService.backend + font.pointSize: Appearance.font.size.normal + color: Colours.palette.m3secondary + elide: Text.ElideRight + } + + IconButton { + icon: "refresh" + type: IconButton.Text + onClicked: LyricsService.loadLyrics() + } + + StyledSwitch { + checked: LyricsService.lyricsVisible + onToggled: LyricsService.toggleVisibility() + } + } + + StyledText { + Layout.fillWidth: true + text: "Fetched Candidates:" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.small + elide: Text.ElideRight + } + + // Candidates list + ListView { + id: candidatesView + + Layout.fillWidth: true + Layout.fillHeight: true + + visible: LyricsService.candidatesModel.count > 0 + model: LyricsService.candidatesModel + clip: true + spacing: Appearance.spacing.small + + opacity: visible ? 1 : 0 + // Behavior on opacity { + // NumberAnimation { duration: Appearance.anim.durations.normal } + // } + + delegate: Item { + id: delegateRoot + width: ListView.view.width * 0.98 + height: 70 + anchors.horizontalCenter: parent?.horizontalCenter + + required property real id + required property string title + required property string artist + + property bool hovered: false + property bool pressed: false + + scale: hovered ? 1.02 : 1.0 + Behavior on scale { + NumberAnimation { + duration: Appearance.anim.durations.small + easing.type: Easing.OutCubic + } + } + + Rectangle { + 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 + } + } + } + + 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" + Behavior on color { + ColorAnimation { + duration: Appearance.anim.durations.small + } + } + } + + 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 + } + } + } + } + } + + Item { + Layout.fillHeight: true + visible: LyricsService.candidatesModel.count == 0 + } + + // Manual search + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.padding.small + + StyledText { + Layout.fillWidth: true + text: "Manual Search" + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + elide: Text.ElideRight + } + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.padding.small + + StyledInputField { + id: searchTitle + Layout.fillWidth: true + horizontalAlignment: TextInput.AlignLeft + + Binding { + target: searchTitle + property: "text" + value: (Players.active?.trackTitle ?? qsTr("title")) || qsTr("title") + } + } + + StyledInputField { + id: searchArtist + Layout.fillWidth: true + horizontalAlignment: TextInput.AlignLeft + + Binding { + target: searchArtist + property: "text" + value: (Players.active?.trackArtist ?? qsTr("artist")) || qsTr("artist") + } + } + + IconButton { + icon: "search" + onClicked: root.searchCandidates(searchTitle.text, searchArtist.text) + } + } + } + + // Offset controls + RowLayout { + Layout.fillWidth: true + spacing: Appearance.padding.small + + MaterialIcon { + text: "contrast_square" + font.pointSize: Appearance.font.size.large + color: Colours.palette.m3secondary + } + + StyledText { + text: "Offset" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + IconButton { + icon: "remove" + type: IconButton.Text + onClicked: { + LyricsService.offset = parseFloat((LyricsService.offset - 0.1).toFixed(1)); + LyricsService.savePrefs(); + } + } + + TextInput { + id: offsetInput + horizontalAlignment: TextInput.AlignHCenter + color: Colours.palette.m3secondary + font.pointSize: Appearance.font.size.normal + selectByMouse: true + text: (LyricsService.offset >= 0 ? "+" : "") + LyricsService.offset.toFixed(1) + "s" + + Binding { + target: offsetInput + property: "text" + value: (LyricsService.offset >= 0 ? "+" : "") + LyricsService.offset.toFixed(1) + "s" + when: !offsetInput.activeFocus + } + + Connections { + target: LyricsService + function onCurrentRequestIdChanged() { + offsetInput.focus = false; + } + } + + onEditingFinished: { + let cleaned = offsetInput.text.replace(/[+s]/g, "").trim(); + let val = parseFloat(cleaned); + if (!isNaN(val)) { + LyricsService.offset = parseFloat(val.toFixed(1)); + LyricsService.savePrefs(); + } else { + offsetInput.text = (LyricsService.offset >= 0 ? "+" : "") + LyricsService.offset.toFixed(1) + "s"; + } + } + } + + IconButton { + icon: "add" + type: IconButton.Text + onClicked: { + LyricsService.offset = parseFloat((LyricsService.offset + 0.1).toFixed(1)); + LyricsService.savePrefs(); + } + } + } + } + } +} diff --git a/modules/dashboard/LyricsView.qml b/modules/dashboard/LyricsView.qml new file mode 100644 index 00000000..bce1b23a --- /dev/null +++ b/modules/dashboard/LyricsView.qml @@ -0,0 +1,114 @@ +import qs.components +import qs.components.containers +import qs.services +import qs.config +import Quickshell +import QtQuick +import QtQuick.Effects + +StyledListView { + id: root + + readonly property bool lyricsActuallyVisible: LyricsService.lyricsVisible && LyricsService.model.count != 0 + + clip: true + model: LyricsService.model + currentIndex: LyricsService.currentIndex + + visible: lyricsActuallyVisible || hideTimer.running + + onLyricsActuallyVisibleChanged: { + if (!lyricsActuallyVisible) + hideTimer.restart(); + } + + Timer { + id: hideTimer + interval: 300 // long enough to bridge the track switch gap + running: false + repeat: false + } + + preferredHighlightBegin: height / 2 - 30 + preferredHighlightEnd: height / 2 + 30 + highlightRangeMode: ListView.ApplyRange + highlightFollowsCurrentItem: true + highlightMoveDuration: LyricsService.isManualSeeking ? 0 : Appearance.anim.durations.normal + + layer.enabled: true + layer.effect: ShaderEffect { + required property Item source + property real fadeMargin: 0.5 + fragmentShader: Quickshell.shellPath("assets/shaders/fade.frag.qsb") + } + + onModelChanged: { + if (model && model.count > 0) { + Qt.callLater(() => positionViewAtIndex(currentIndex, ListView.Center)); + } + } + + delegate: Item { + id: delegateRoot + width: ListView.view.width + + required property string lyricLine + required property real time + required property int index + + readonly property bool hasContent: lyricLine && lyricLine.trim().length > 0 + height: hasContent ? (lyricText.contentHeight + Appearance.spacing.large) : 0 + + property bool isCurrent: ListView.isCurrentItem + + MultiEffect { + id: effect + anchors.fill: lyricText + source: lyricText + scale: lyricText.scale + enabled: delegateRoot.isCurrent + visible: delegateRoot.isCurrent + + blurEnabled: true + blur: 0.4 + + shadowEnabled: true + shadowColor: Colours.palette.m3primary + shadowOpacity: 0.5 + shadowBlur: 0.6 + shadowHorizontalOffset: 0 + shadowVerticalOffset: 0 + + autoPaddingEnabled: true + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: LyricsService.jumpTo(delegateRoot.index, delegateRoot.time) + } + + Text { + id: lyricText + text: delegateRoot.lyricLine ? delegateRoot.lyricLine.replace(/\u00A0/g, " ") : "" + width: parent.width * 0.85 + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + font.pointSize: Appearance.font.size.normal + color: delegateRoot.isCurrent ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + font.bold: delegateRoot.isCurrent + scale: delegateRoot.isCurrent ? 1.15 : 1.0 + Behavior on color { + CAnim { + duration: Appearance.anim.durations.small + } + } + Behavior on scale { + Anim { + duration: Appearance.anim.durations.small + } + } + } + } +} diff --git a/modules/dashboard/Media.qml b/modules/dashboard/Media.qml index 722bc933..ec92743c 100644 --- a/modules/dashboard/Media.qml +++ b/modules/dashboard/Media.qml @@ -16,6 +16,14 @@ Item { id: root required property PersistentProperties visibilities + readonly property bool needsKeyboard: lyricMenuOpen + + readonly property real nonAnimHeight: Math.max(cover.implicitHeight + Config.dashboard.sizes.mediaVisualiserSize * 2, lyricMenuOpen ? lyricMenu.implicitHeight : details.implicitHeight, bongocat.implicitHeight) + Appearance.padding.large * 2 + readonly property real detailsHeightWithoutLyrics: details.implicitHeight - lyricsViewInDetails.implicitHeight + + property bool lyricMenuOpen: false + property bool lyricsShowing: LyricsService.lyricsVisible && LyricsService.model.count != 0 + property bool lyricsShowingDebounced: false property real playerProgress: { const active = Players.active; @@ -35,8 +43,21 @@ Item { return `${mins}:${secs}`; } + onLyricsShowingChanged: { + if (lyricsShowing) { + lyricsHideDelay.stop(); + lyricsShowingDebounced = true; + } else { + lyricsHideDelay.restart(); + } + } + implicitWidth: cover.implicitWidth + Config.dashboard.sizes.mediaVisualiserSize * 2 + details.implicitWidth + details.anchors.leftMargin + bongocat.implicitWidth + bongocat.anchors.leftMargin * 2 + Appearance.padding.large * 2 - implicitHeight: Math.max(cover.implicitHeight + Config.dashboard.sizes.mediaVisualiserSize * 2, details.implicitHeight, bongocat.implicitHeight) + Appearance.padding.large * 2 + implicitHeight: nonAnimHeight + + Behavior on implicitHeight { + Anim {} + } Behavior on playerProgress { Anim { @@ -49,7 +70,25 @@ Item { interval: Config.dashboard.mediaUpdateInterval triggeredOnStart: true repeat: true - onTriggered: Players.active?.positionChanged() + onTriggered: { + if (!Players.active) + return; + LyricsService.updatePosition(); + Players.active?.positionChanged(); + } + } + + Timer { + id: lyricsHideDelay + interval: 300 + repeat: false + } + + Connections { + target: lyricsHideDelay + function onTriggered() { + root.lyricsShowingDebounced = false; + } } ServiceRef { @@ -145,6 +184,13 @@ Item { fillMode: Image.PreserveAspectCrop sourceSize.width: width sourceSize.height: height + + MouseArea { + anchors.fill: parent + onClicked: { + LyricsService.toggleVisibility(); + } + } } } @@ -200,6 +246,12 @@ Item { wrapMode: Players.active ? Text.NoWrap : Text.WordWrap } + LyricsView { + id: lyricsViewInDetails + Layout.fillWidth: true + Layout.preferredHeight: 200 + } + RowLayout { id: controls @@ -209,6 +261,14 @@ Item { spacing: Appearance.spacing.small + PlayerControl { + type: IconButton.Text + icon: Players.active?.shuffle ? "shuffle_on" : "shuffle" + font.pointSize: Math.round(Appearance.font.size.large) + disabled: !Players.active?.shuffleSupported + onClicked: Players.active.shuffle = !Players.active?.shuffle + } + PlayerControl { type: IconButton.Text icon: "skip_previous" @@ -235,6 +295,13 @@ Item { disabled: !Players.active?.canGoNext onClicked: Players.active?.next() } + + PlayerControl { + type: IconButton.Text + icon: "lyrics" + font.pointSize: Math.round(Appearance.font.size.large) + onClicked: root.lyricMenuOpen = !root.lyricMenuOpen + } } StyledSlider { @@ -299,83 +366,120 @@ Item { font.pointSize: Appearance.font.size.small } } + } - RowLayout { - Layout.alignment: Qt.AlignHCenter - spacing: Appearance.spacing.small + ColumnLayout { + id: leftSection - PlayerControl { - type: IconButton.Text - icon: "move_up" - inactiveOnColour: Colours.palette.m3secondary - padding: Appearance.padding.small - font.pointSize: Appearance.font.size.large - disabled: !Players.active?.canRaise - onClicked: { - Players.active?.raise(); - root.visibilities.dashboard = false; - } + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: playerChanger.parent == leftSection ? -playerChanger.height : 0 + anchors.left: details.right + anchors.leftMargin: Appearance.spacing.normal + + visible: lyricMenu.height === 0 || opacity > 0 + opacity: lyricMenu.height === 0 ? 1 : 0 + Behavior on opacity { + NumberAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.OutCubic } + } - SplitButton { - id: playerSelector + Item { + id: bongocat - disabled: !Players.list.length - active: menuItems.find(m => m.modelData === Players.active) ?? menuItems[0] ?? null - menu.onItemSelected: item => Players.manualActive = (item as PlayerItem).modelData + implicitWidth: visualiser.width + implicitHeight: visualiser.height - menuItems: playerList.instances - fallbackIcon: "music_off" - fallbackText: qsTr("No players") + AnimatedImage { + anchors.centerIn: parent - label.Layout.maximumWidth: slider.implicitWidth * 0.28 - label.elide: Text.ElideRight + width: visualiser.width * 0.75 + height: visualiser.height * 0.75 - stateLayer.disabled: true - menuOnTop: true - - Variants { - id: playerList - - model: Players.list - - PlayerItem {} - } - } - - PlayerControl { - type: IconButton.Text - icon: "delete" - inactiveOnColour: Colours.palette.m3error - padding: Appearance.padding.small - font.pointSize: Appearance.font.size.large - disabled: !Players.active?.canQuit - onClicked: Players.active?.quit() + playing: Players.active?.isPlaying ?? false + speed: Audio.beatTracker.bpm / Appearance.anim.mediaGifSpeedAdjustment // qmllint disable unresolved-type + source: Paths.absolutePath(Config.paths.mediaGif) + asynchronous: true + fillMode: AnimatedImage.PreserveAspectFit } } } - Item { - id: bongocat + LyricMenu { + id: lyricMenu - anchors.verticalCenter: parent.verticalCenter + anchors.top: parent.top anchors.left: details.right + anchors.right: parent.right anchors.leftMargin: Appearance.spacing.normal - implicitWidth: visualiser.width - implicitHeight: visualiser.height + contentHeight: !root.lyricsShowingDebounced ? root.detailsHeightWithoutLyrics + Appearance.padding.large * 5 : root.detailsHeightWithoutLyrics + lyricsViewInDetails.implicitHeight - AnimatedImage { - anchors.centerIn: parent + visible: root.lyricMenuOpen || height > 0 + height: root.lyricMenuOpen ? implicitHeight : 0 + clip: true + Behavior on height { + NumberAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.OutCubic + } + } + } - width: visualiser.width * 0.75 - height: visualiser.height * 0.75 + RowLayout { + id: playerChanger + parent: !root.lyricsShowingDebounced ? details : leftSection + Layout.alignment: Qt.AlignHCenter + spacing: Appearance.spacing.small - playing: Players.active?.isPlaying ?? false - speed: Audio.beatTracker.bpm / Appearance.anim.mediaGifSpeedAdjustment // qmllint disable unresolved-type - source: Paths.absolutePath(Config.paths.mediaGif) - asynchronous: true - fillMode: AnimatedImage.PreserveAspectFit + PlayerControl { + type: IconButton.Text + icon: "move_up" + inactiveOnColour: Colours.palette.m3secondary + padding: Appearance.padding.small + font.pointSize: Appearance.font.size.large + disabled: !Players.active?.canRaise + onClicked: { + Players.active?.raise(); + root.visibilities.dashboard = false; + } + } + + SplitButton { + id: playerSelector + + disabled: !Players.list.length + active: menuItems.find(m => m.modelData === Players.active) ?? menuItems[0] ?? null + menu.onItemSelected: item => Players.manualActive = (item as PlayerItem).modelData + + menuItems: playerList.instances + fallbackIcon: "music_off" + fallbackText: qsTr("No players") + + label.Layout.maximumWidth: slider.implicitWidth * 0.28 + label.elide: Text.ElideRight + + stateLayer.disabled: true + menuOnTop: true + + Variants { + id: playerList + + model: Players.list + + PlayerItem {} + } + } + + PlayerControl { + type: IconButton.Text + icon: "delete" + inactiveOnColour: Colours.palette.m3error + padding: Appearance.padding.small + font.pointSize: Appearance.font.size.large + disabled: !Players.active?.canQuit + onClicked: Players.active?.quit() } } diff --git a/modules/dashboard/MediaWrapper.qml b/modules/dashboard/MediaWrapper.qml new file mode 100644 index 00000000..b03a11ee --- /dev/null +++ b/modules/dashboard/MediaWrapper.qml @@ -0,0 +1,13 @@ +import QtQuick + +Item { + property alias visibilities: media.visibilities + readonly property alias needsKeyboard: media.needsKeyboard + + implicitWidth: media.implicitWidth + implicitHeight: media.nonAnimHeight + + Media { + id: media + } +} diff --git a/modules/dashboard/Wrapper.qml b/modules/dashboard/Wrapper.qml index 0e37909e..01eddcc3 100644 --- a/modules/dashboard/Wrapper.qml +++ b/modules/dashboard/Wrapper.qml @@ -12,6 +12,7 @@ Item { id: root required property PersistentProperties visibilities + readonly property bool needsKeyboard: content.item?.needsKeyboard ?? false readonly property PersistentProperties dashState: PersistentProperties { property int currentTab property date currentDate: new Date() diff --git a/modules/drawers/Drawers.qml b/modules/drawers/Drawers.qml index 302353be..1423cd21 100644 --- a/modules/drawers/Drawers.qml +++ b/modules/drawers/Drawers.qml @@ -54,7 +54,7 @@ Variants { screen: scope.modelData name: "drawers" WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.session ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.session || panels.dashboard.needsKeyboard ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None mask: Region { x: bar.clampedWidth + win.dragMaskPadding diff --git a/plugin/src/Caelestia/requests.cpp b/plugin/src/Caelestia/requests.cpp index 2ceddb35..4818507e 100644 --- a/plugin/src/Caelestia/requests.cpp +++ b/plugin/src/Caelestia/requests.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include namespace caelestia { @@ -10,13 +12,27 @@ Requests::Requests(QObject* parent) : QObject(parent) , m_manager(new QNetworkAccessManager(this)) {} -void Requests::get(const QUrl& url, QJSValue onSuccess, QJSValue onError) const { +void Requests::get(const QUrl& url, QJSValue onSuccess, QJSValue onError, QJSValue headers) const { if (!onSuccess.isCallable()) { qWarning() << "Requests::get: onSuccess is not callable"; return; } QNetworkRequest request(url); + request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::AlwaysNetwork); + request.setAttribute(QNetworkRequest::CookieSaveControlAttribute, QNetworkRequest::Manual); + request.setRawHeader("Cache-Control", "no-cache, no-store"); + request.setRawHeader("Pragma", "no-cache"); + request.setRawHeader("Connection", "close"); + + if (headers.isObject()) { + QJSValueIterator it(headers); + while (it.hasNext()) { + it.next(); + request.setRawHeader(it.name().toUtf8(), it.value().toString().toUtf8()); + } + } + auto reply = m_manager->get(request); QObject::connect(reply, &QNetworkReply::finished, [reply, onSuccess, onError]() { @@ -32,4 +48,8 @@ void Requests::get(const QUrl& url, QJSValue onSuccess, QJSValue onError) const }); } +void Requests::resetCookies() const { + m_manager->setCookieJar(new QNetworkCookieJar(m_manager)); +} + } // namespace caelestia diff --git a/plugin/src/Caelestia/requests.hpp b/plugin/src/Caelestia/requests.hpp index 1db2f4cf..03c8d723 100644 --- a/plugin/src/Caelestia/requests.hpp +++ b/plugin/src/Caelestia/requests.hpp @@ -14,7 +14,8 @@ class Requests : public QObject { public: explicit Requests(QObject* parent = nullptr); - Q_INVOKABLE void get(const QUrl& url, QJSValue callback, QJSValue onError = QJSValue()) const; + Q_INVOKABLE void get(const QUrl& url, QJSValue callback, QJSValue onError = QJSValue(), QJSValue headers = QJSValue()) const; + Q_INVOKABLE void resetCookies() const; private: QNetworkAccessManager* m_manager; diff --git a/services/LyricsService.qml b/services/LyricsService.qml new file mode 100644 index 00000000..35dba468 --- /dev/null +++ b/services/LyricsService.qml @@ -0,0 +1,340 @@ +pragma Singleton + +import qs.config +import qs.utils +import Caelestia +import QtQuick +import Quickshell +import Quickshell.Io +import "../utils/scripts/lrcparser.js" as Lrc + +Singleton { + id: root + + property var player: Players.active + property int currentIndex: -1 + property bool loading: false + property bool isManualSeeking: false + property bool lyricsVisible: Config.services.showLyrics + property string backend: "Local" + property real currentSongId: 0 + + property real offset + + 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: ({}) + + ListModel { + id: lyricsModel + } + ListModel { + id: fetchedCandidatesModel + } + + Timer { + id: seekTimer + interval: 500 + onTriggered: root.isManualSeeking = false + } + + // If no local lyrics were loaded within the interval, fall back to NetEase + Timer { + id: fallbackTimer + interval: 200 + onTriggered: { + if (lyricsModel.count === 0) { + root.backend = "NetEase"; + fallbackToOnline(); + } + } + } + + Timer { + id: loadDebounce + interval: 50 + onTriggered: root._doLoadLyrics() + } + + FileView { + id: lyricsMapFileView + path: root.lyricsMapFile + printErrors: false + onLoaded: { + try { + root.lyricsMap = JSON.parse(text()); + } catch (e) { + root.lyricsMap = {}; + } + } + } + + FileView { + id: lrcFile + printErrors: false + onLoaded: { + fallbackTimer.stop(); + let parsed = Lrc.parseLrc(text()); + if (parsed.length > 0) { + root.backend = "Local"; + updateModel(parsed); + loading = false; + } else { + root.backend = "NetEase"; + fallbackToOnline(); + } + } + } + + Connections { + target: Players + function onActiveChanged() { + root.player = Players.active; + loadLyrics(); + } + } + + Connections { + target: root.player + ignoreUnknownSignals: true + function onMetadataChanged() { + loadLyrics(); + } + } + + Process { + id: saveLyricsMap + command: ["sh", "-c", `mkdir -p "${root.lyricsDir}" && echo '${JSON.stringify(root.lyricsMap)}' > "${root.lyricsMapFile}"`] + } + + function getMetadata() { + if (!player || !player.metadata) + return null; + let artist = player.metadata["xesam:artist"]; + const title = player.metadata["xesam:title"]; + if (Array.isArray(artist)) + artist = artist.join(", "); + return { + artist: artist || "Unknown", + title: title || "Unknown" + }; + } + + function _metaKey(meta) { + return `${meta.artist} - ${meta.title}`; + } + + function savePrefs() { + let meta = getMetadata(); + if (!meta) + return; + let key = _metaKey(meta); + let existing = root.lyricsMap[key] ?? {}; + root.lyricsMap[key] = { + offset: root.offset, + backend: root.backend, + neteaseId: existing.neteaseId ?? null + }; + // reassign to notify QML bindings of the map change + root.lyricsMap = root.lyricsMap; + saveLyricsMap.command = ["sh", "-c", `mkdir -p "${root.lyricsDir}" && echo '${JSON.stringify(root.lyricsMap).replace(/'/g, "'\\''")}' > "${root.lyricsMapFile}"`]; + saveLyricsMap.running = true; + } + + function toggleVisibility() { + Config.services.showLyrics = !Config.services.showLyrics; + Config.save(); + } + + function loadLyrics() { + loadDebounce.restart(); + } + + function _doLoadLyrics() { + const meta = getMetadata(); + if (!meta) + return; + + loading = true; + lyricsModel.clear(); + currentIndex = -1; + root.currentSongId = 0; + root.backend = "Local"; + + root.currentRequestId++; + let requestId = root.currentRequestId; + + let key = _metaKey(meta); + let saved = root.lyricsMap[key]; + root.offset = saved?.offset ?? 0.0; + + if (saved?.neteaseId && saved?.backend === "NetEase") { + root.backend = "NetEase"; + root.currentSongId = saved.neteaseId; + fetchNetEaseLyrics(saved.neteaseId, requestId); + fetchNetEaseCandidates(meta.title, meta.artist, requestId); + return; + } + + if (saved?.backend === "NetEase") { + fallbackTimer.restart(); + return; + } + + let cleanDir = lyricsDir.replace(/\/$/, ""); + let fullPath = `${cleanDir}/${meta.artist} - ${meta.title}.lrc`; + + 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(); + } + + function updateModel(parsedArray) { + root.currentIndex = -1; + lyricsModel.clear(); + for (let line of parsedArray) { + lyricsModel.append({ + time: line.time, + lyricLine: line.text + }); + } + } + + function fallbackToOnline() { + let meta = getMetadata(); + if (!meta) + return; + fetchNetEase(meta.title, meta.artist, root.currentRequestId); + } + + // NetEase + + // 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/" + }) + + // searches NetEase and populates the candidates model. returns the result array via the onResults callback + function _searchNetEase(title, artist, reqId, onResults) { + Requests.resetCookies(); + const query = encodeURIComponent(`${title} ${artist}`); + const url = `https://music.163.com/api/search/get?s=${query}&type=1&limit=5`; + + Requests.get(url, text => { + if (reqId !== root.currentRequestId) + return; + const res = JSON.parse(text); + const songs = res.result?.songs || []; + + fetchedCandidatesModel.clear(); + for (let s of songs) { + fetchedCandidatesModel.append({ + id: s.id, + title: s.name || "Unknown Title", + artist: s.artists?.map(a => a.name).join(", ") || "Unknown Artist" + }); + } + + onResults(songs); + }, err => {}, root._netEaseHeaders); + } + + // populates the candidates model only. used when a saved NetEase ID already exists and we just want to refresh the picker list. + function fetchNetEaseCandidates(title, artist, reqId) { + _searchNetEase(title, artist, reqId, _songs => {}); + } + + // searches NetEase, populates candidates, then auto-selects the best match and fetches its lyrics. + function fetchNetEase(title, artist, reqId) { + _searchNetEase(title, artist, reqId, songs => { + const bestMatch = songs.find(s => { + const inputArtist = String(artist || "").toLowerCase(); + const sArtist = String(s.artists?.[0]?.name || "").toLowerCase(); + return inputArtist.includes(sArtist) || sArtist.includes(inputArtist); + }); + + if (!bestMatch) { + return; // No reliable lyrics found + } + + let key = `${artist} - ${title}`; + root.lyricsMap[key] = { + offset: root.lyricsMap[key]?.offset ?? 0.0, + backend: "NetEase", + neteaseId: bestMatch.id + }; + root.currentSongId = bestMatch.id; + savePrefs(); + fetchNetEaseLyrics(bestMatch.id, reqId); + }); + } + + function fetchNetEaseLyrics(id, reqId) { + const url = `https://music.163.com/api/song/lyric?id=${id}&lv=1&kv=1&tv=-1`; + Requests.get(url, text => { + if (reqId !== root.currentRequestId) + return; + const res = JSON.parse(text); + if (res.lrc?.lyric) { + updateModel(Lrc.parseLrc(res.lrc.lyric)); + loading = false; + } + }); + } + + function selectCandidate(songId) { + let meta = getMetadata(); + if (!meta) + return; + root.backend = "NetEase"; + root.currentSongId = songId; + let key = _metaKey(meta); + root.lyricsMap[key] = { + offset: root.lyricsMap[key]?.offset ?? 0.0, + neteaseId: songId + }; + savePrefs(); + fetchNetEaseLyrics(songId, currentRequestId); + } + + function updatePosition() { + if (isManualSeeking || loading || !player || lyricsModel.count === 0) + return; + + let pos = player.position - root.offset; + let newIdx = -1; + for (let i = lyricsModel.count - 1; i >= 0; i--) { + if (pos >= lyricsModel.get(i).time - 0.1) { // 100ms fudge factor + newIdx = i; + break; + } + } + + if (newIdx !== currentIndex) { + root.currentIndex = newIdx; + } + } + + function jumpTo(index, time) { + root.isManualSeeking = true; + root.currentIndex = index; + + if (player) { + player.position = time + root.offset + 0.01; // compensate for rounding + } + + seekTimer.restart(); + } +} diff --git a/utils/scripts/lrcparser.js b/utils/scripts/lrcparser.js new file mode 100644 index 00000000..847779ed --- /dev/null +++ b/utils/scripts/lrcparser.js @@ -0,0 +1,62 @@ +function parseLrc(text) { + if (!text) return []; + let lines = text.split("\n"); + let result = []; + + let timeRegex = /\[(\d+):(\d+\.\d+|\d+)\]/g; + + // Blacklist for credits/metadata often found in NetEase lyrics + const creditKeywords = [ + "作词", "作曲", "编曲", "制作", "收录", "演奏", "词:", "曲:", "Lyricist", "Composer", "Arranger", "Producer", "Mixing", "Mastering" + ]; + + for (let line of lines) { + + timeRegex.lastIndex = 0; + let matches = []; + let match; + + while ((match = timeRegex.exec(line)) !== null) { + matches.push(match); + } + + if (matches.length === 0) continue; + + let lyric = line.replace(timeRegex, "").trim(); + + let min = parseInt(matches[0][1]); + let sec = parseFloat(matches[0][2]); + let totalTime = min * 60 + sec; + + // Only filter credits if they appear in the first 20 seconds + if (totalTime < 20) { + let isCreditFormat = creditKeywords.some(k => lyric.includes(k)); + if (isCreditFormat && (lyric.includes(":") || lyric.includes(":") || lyric.length < 25)) { + continue; + } + } + + for (let match of matches) { + let min = parseInt(match[1]); + let sec = parseFloat(match[2]); + + result.push({ + time: min * 60 + sec, + text: lyric + }); + } + } + + result.sort((a, b) => a.time - b.time); + return result; +} + +function getCurrentLine(lyrics, position) { + const epsilon = 0.1; // 100ms tolerance + for (let i = lyrics.length - 1; i >= 0; i--) { + if ((position + epsilon) >= lyrics[i].time) { + return i; + } + } + return -1; +}