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:
parent
70d6225186
commit
ffe90bd077
17 changed files with 1084 additions and 66 deletions
|
|
@ -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
26
assets/shaders/fade.frag
Normal 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;
|
||||||
|
}
|
||||||
BIN
assets/shaders/fade.frag.qsb
Normal file
BIN
assets/shaders/fade.frag.qsb
Normal file
Binary file not shown.
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,4 +19,5 @@ JsonObject {
|
||||||
"to": "YT Music"
|
"to": "YT Music"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
property bool showLyrics: true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
323
modules/dashboard/LyricMenu.qml
Normal file
323
modules/dashboard/LyricMenu.qml
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
modules/dashboard/LyricsView.qml
Normal file
114
modules/dashboard/LyricsView.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
13
modules/dashboard/MediaWrapper.qml
Normal file
13
modules/dashboard/MediaWrapper.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
340
services/LyricsService.qml
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
62
utils/scripts/lrcparser.js
Normal file
62
utils/scripts/lrcparser.js
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue