dashboard: add synced lyrics to media tab (#1197)

* lyrics: rewrite model

* lyrics: cleanup code

* lyrics: some minor layouting

* lyrics: add fade shaders

* lyrics: use appearance values from config

* lyrics: formatting

* lyrics: update readme

* lyrics: fix jumping, redo netease backend (resused from another one of projects)

* lyrics: fix netease searches, remove credit lines

* lyrics: add RequestIds

* lyrics: formatting

* lyrics: lyricsDir path cleanup

* lyrics: make empty lines not take up much space

* lyrics: make empty lines not take up any space

* Lyrics: Use QNetworkAccessManager instead of XMLHttpRequest

also added ability to set request headers and ability to reset cookies for requests.cpp

* lyrics: cleanup old code

* lyrics: toggle lyrics by clicking the album art

* lyrics: sanitize non-breaking spaces

* lyrics: add a menu to select lyrics and manually search for them

* lyrics: remove LrcLib backend

* lyrics: change focus to when dashboard is opened instead of the lyricMenu

* lyrics: improve UI

* lyrics: improve UI more ig

* lyrics: extract LyricsView and LyricMenu into separate components

* lyrics: cleanup old files

* lyrics: refactor LyricsService

* lyrics: restore ComponentBehavior: Bound in lyrics components

* lyrics: change SongID to real to fix integer overflow

* lyrics: add animations to the lyricMenu

* lyrics: fix manual search fields not auto updating

* lyrics: fix jerks when switching tracks

* lyrics: create lyrics directory if it doesn't exist

* lyrics: silence logs, fix binding loops, fix index out of range errors

* lyrics: only request keyboard focus when lyric menu is open

* config: add option to enable/disable the lyrics

* format + silence fileview errors

* fix merge

* anim and lsp fixes + format

---------

Co-authored-by: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>
This commit is contained in:
八奈見 レイ 2026-03-19 17:08:36 +05:30 committed by GitHub
parent 70d6225186
commit ffe90bd077
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1084 additions and 66 deletions

View file

@ -598,7 +598,8 @@ default, you must create it manually.
"paths": { "paths": {
"mediaGif": "root:/assets/bongocat.gif", "mediaGif": "root:/assets/bongocat.gif",
"sessionGif": "root:/assets/kurukuru.gif", "sessionGif": "root:/assets/kurukuru.gif",
"wallpaperDir": "~/Pictures/Wallpapers" "wallpaperDir": "~/Pictures/Wallpapers",
"lyricsDir": "~/Music/lyrics"
}, },
"services": { "services": {
"audioIncrement": 0.1, "audioIncrement": 0.1,

26
assets/shaders/fade.frag Normal file
View file

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

Binary file not shown.

View file

@ -360,13 +360,15 @@ Singleton {
maxVolume: services.maxVolume, maxVolume: services.maxVolume,
smartScheme: services.smartScheme, smartScheme: services.smartScheme,
defaultPlayer: services.defaultPlayer, defaultPlayer: services.defaultPlayer,
playerAliases: services.playerAliases playerAliases: services.playerAliases,
showLyrics: services.showLyrics
}; };
} }
function serializePaths(): var { function serializePaths(): var {
return { return {
wallpaperDir: paths.wallpaperDir, wallpaperDir: paths.wallpaperDir,
lyricsDir: paths.lyricsDir,
sessionGif: paths.sessionGif, sessionGif: paths.sessionGif,
mediaGif: paths.mediaGif mediaGif: paths.mediaGif
}; };

View file

@ -19,4 +19,5 @@ JsonObject {
"to": "YT Music" "to": "YT Music"
} }
] ]
property bool showLyrics: true
} }

View file

@ -3,6 +3,7 @@ import Quickshell.Io
JsonObject { JsonObject {
property string wallpaperDir: `${Paths.pictures}/Wallpapers` property string wallpaperDir: `${Paths.pictures}/Wallpapers`
property string lyricsDir: `${Paths.home}/Music/lyrics/`
property string sessionGif: "root:/assets/kurukuru.gif" property string sessionGif: "root:/assets/kurukuru.gif"
property string mediaGif: "root:/assets/bongocat.gif" property string mediaGif: "root:/assets/bongocat.gif"
} }

View file

@ -12,6 +12,15 @@ Item {
id: root id: root
required property PersistentProperties visibilities 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 PersistentProperties state
required property FileDialog facePicker required property FileDialog facePicker
@ -160,7 +169,7 @@ Item {
Component { Component {
id: mediaComponent id: mediaComponent
Media { MediaWrapper {
visibilities: root.visibilities visibilities: root.visibilities
} }
} }

View file

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

View file

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

View file

@ -16,6 +16,14 @@ Item {
id: root id: root
required property PersistentProperties visibilities 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: { property real playerProgress: {
const active = Players.active; const active = Players.active;
@ -35,8 +43,21 @@ Item {
return `${mins}:${secs}`; 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 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 { Behavior on playerProgress {
Anim { Anim {
@ -49,7 +70,25 @@ Item {
interval: Config.dashboard.mediaUpdateInterval interval: Config.dashboard.mediaUpdateInterval
triggeredOnStart: true triggeredOnStart: true
repeat: 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 { ServiceRef {
@ -145,6 +184,13 @@ Item {
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
sourceSize.width: width sourceSize.width: width
sourceSize.height: height sourceSize.height: height
MouseArea {
anchors.fill: parent
onClicked: {
LyricsService.toggleVisibility();
}
}
} }
} }
@ -200,6 +246,12 @@ Item {
wrapMode: Players.active ? Text.NoWrap : Text.WordWrap wrapMode: Players.active ? Text.NoWrap : Text.WordWrap
} }
LyricsView {
id: lyricsViewInDetails
Layout.fillWidth: true
Layout.preferredHeight: 200
}
RowLayout { RowLayout {
id: controls id: controls
@ -209,6 +261,14 @@ Item {
spacing: Appearance.spacing.small 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 { PlayerControl {
type: IconButton.Text type: IconButton.Text
icon: "skip_previous" icon: "skip_previous"
@ -235,6 +295,13 @@ Item {
disabled: !Players.active?.canGoNext disabled: !Players.active?.canGoNext
onClicked: Players.active?.next() onClicked: Players.active?.next()
} }
PlayerControl {
type: IconButton.Text
icon: "lyrics"
font.pointSize: Math.round(Appearance.font.size.large)
onClicked: root.lyricMenuOpen = !root.lyricMenuOpen
}
} }
StyledSlider { StyledSlider {
@ -299,8 +366,70 @@ Item {
font.pointSize: Appearance.font.size.small font.pointSize: Appearance.font.size.small
} }
} }
}
ColumnLayout {
id: leftSection
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
}
}
Item {
id: bongocat
implicitWidth: visualiser.width
implicitHeight: visualiser.height
AnimatedImage {
anchors.centerIn: parent
width: visualiser.width * 0.75
height: visualiser.height * 0.75
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
}
}
}
LyricMenu {
id: lyricMenu
anchors.top: parent.top
anchors.left: details.right
anchors.right: parent.right
anchors.leftMargin: Appearance.spacing.normal
contentHeight: !root.lyricsShowingDebounced ? root.detailsHeightWithoutLyrics + Appearance.padding.large * 5 : root.detailsHeightWithoutLyrics + lyricsViewInDetails.implicitHeight
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
}
}
}
RowLayout { RowLayout {
id: playerChanger
parent: !root.lyricsShowingDebounced ? details : leftSection
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
spacing: Appearance.spacing.small spacing: Appearance.spacing.small
@ -353,31 +482,6 @@ Item {
onClicked: Players.active?.quit() onClicked: Players.active?.quit()
} }
} }
}
Item {
id: bongocat
anchors.verticalCenter: parent.verticalCenter
anchors.left: details.right
anchors.leftMargin: Appearance.spacing.normal
implicitWidth: visualiser.width
implicitHeight: visualiser.height
AnimatedImage {
anchors.centerIn: parent
width: visualiser.width * 0.75
height: visualiser.height * 0.75
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
}
}
component PlayerItem: MenuItem { component PlayerItem: MenuItem {
required property MprisPlayer modelData required property MprisPlayer modelData

View file

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

View file

@ -12,6 +12,7 @@ Item {
id: root id: root
required property PersistentProperties visibilities required property PersistentProperties visibilities
readonly property bool needsKeyboard: content.item?.needsKeyboard ?? false
readonly property PersistentProperties dashState: PersistentProperties { readonly property PersistentProperties dashState: PersistentProperties {
property int currentTab property int currentTab
property date currentDate: new Date() property date currentDate: new Date()

View file

@ -54,7 +54,7 @@ Variants {
screen: scope.modelData screen: scope.modelData
name: "drawers" name: "drawers"
WlrLayershell.exclusionMode: ExclusionMode.Ignore 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 { mask: Region {
x: bar.clampedWidth + win.dragMaskPadding x: bar.clampedWidth + win.dragMaskPadding

View file

@ -3,6 +3,8 @@
#include <qnetworkaccessmanager.h> #include <qnetworkaccessmanager.h>
#include <qnetworkreply.h> #include <qnetworkreply.h>
#include <qnetworkrequest.h> #include <qnetworkrequest.h>
#include <qjsvalueiterator.h>
#include <qnetworkcookiejar.h>
namespace caelestia { namespace caelestia {
@ -10,13 +12,27 @@ Requests::Requests(QObject* parent)
: QObject(parent) : QObject(parent)
, m_manager(new QNetworkAccessManager(this)) {} , 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()) { if (!onSuccess.isCallable()) {
qWarning() << "Requests::get: onSuccess is not callable"; qWarning() << "Requests::get: onSuccess is not callable";
return; return;
} }
QNetworkRequest request(url); 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); auto reply = m_manager->get(request);
QObject::connect(reply, &QNetworkReply::finished, [reply, onSuccess, onError]() { 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 } // namespace caelestia

View file

@ -14,7 +14,8 @@ class Requests : public QObject {
public: public:
explicit Requests(QObject* parent = nullptr); 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: private:
QNetworkAccessManager* m_manager; QNetworkAccessManager* m_manager;

340
services/LyricsService.qml Normal file
View file

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

View file

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