chore: remove old lyrics service & components
This commit is contained in:
parent
2aa87f7dd6
commit
8425e6f109
5 changed files with 1 additions and 994 deletions
|
|
@ -1,411 +0,0 @@
|
|||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Caelestia.Config
|
||||
import qs.components
|
||||
import qs.components.controls
|
||||
import qs.services
|
||||
|
||||
StyledRect {
|
||||
id: root
|
||||
|
||||
required property real contentHeight
|
||||
|
||||
function searchCandidates(title, artist) {
|
||||
LyricsService.currentRequestId++;
|
||||
LyricsService.fetchNetEaseCandidates(title, artist, LyricsService.currentRequestId);
|
||||
}
|
||||
|
||||
implicitHeight: contentHeight
|
||||
|
||||
radius: Tokens.rounding.extraLarge
|
||||
color: Colours.tPalette.m3surfaceContainer
|
||||
|
||||
Loader {
|
||||
asynchronous: true
|
||||
anchors.fill: parent
|
||||
active: root.height > 0
|
||||
|
||||
sourceComponent: ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.large
|
||||
spacing: Tokens.spacing.medium
|
||||
|
||||
// Header: icon, backend selector, refresh, toggle
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Tokens.padding.extraSmall
|
||||
|
||||
MaterialIcon {
|
||||
text: "lyrics"
|
||||
fill: 1
|
||||
color: Colours.palette.m3primary
|
||||
fontStyle: Tokens.font.icon.size(Tokens.spacing.large).build()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredHeight: 24
|
||||
Layout.preferredWidth: 80
|
||||
radius: Tokens.rounding.medium
|
||||
color: Qt.rgba(Colours.palette.m3primary.r, Colours.palette.m3primary.g, Colours.palette.m3primary.b, 0.15)
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
text: LyricsService.preferredBackend
|
||||
font: Tokens.font.body.small
|
||||
color: Colours.palette.m3primary
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
const backends = ["Auto", "Local", "NetEase"];
|
||||
const currentIndex = backends.indexOf(LyricsService.preferredBackend);
|
||||
const nextIndex = (currentIndex + 1) % backends.length;
|
||||
LyricsService.preferredBackend = backends[nextIndex];
|
||||
LyricsService.loadLyrics();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredHeight: 24
|
||||
Layout.preferredWidth: 60
|
||||
radius: Tokens.rounding.medium
|
||||
visible: LyricsService.preferredBackend === "Auto"
|
||||
color: LyricsService.backend === "Local" ? Qt.rgba(Colours.palette.m3tertiary.r, Colours.palette.m3tertiary.g, Colours.palette.m3tertiary.b, 0.15) : Qt.rgba(Colours.palette.m3secondary.r, Colours.palette.m3secondary.g, Colours.palette.m3secondary.b, 0.15)
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
text: LyricsService.backend
|
||||
font: Tokens.font.body.small
|
||||
color: LyricsService.backend === "Local" ? Colours.palette.m3tertiary : Colours.palette.m3secondary
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
IconButton {
|
||||
icon: "refresh"
|
||||
type: IconButton.Text
|
||||
onClicked: LyricsService.loadLyrics()
|
||||
}
|
||||
|
||||
StyledSwitch {
|
||||
checked: LyricsService.lyricsVisible
|
||||
onToggled: LyricsService.toggleVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: LyricsService.preferredBackend === "Local" ? "Loaded File:" : "Fetched Candidates:"
|
||||
color: Colours.palette.m3outline
|
||||
font: Tokens.font.body.small
|
||||
elide: Text.ElideRight
|
||||
visible: LyricsService.preferredBackend === "Local" ? LyricsService.loadedLocalFile.length > 0 : LyricsService.candidatesModel.count > 0
|
||||
}
|
||||
|
||||
// Local file info (shown in Local mode)
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 48
|
||||
visible: LyricsService.preferredBackend === "Local" && LyricsService.loadedLocalFile.length > 0
|
||||
radius: Tokens.rounding.medium
|
||||
color: Qt.rgba(Colours.palette.m3tertiary.r, Colours.palette.m3tertiary.g, Colours.palette.m3tertiary.b, 0.1)
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.extraSmall
|
||||
spacing: 0
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: {
|
||||
const path = LyricsService.loadedLocalFile;
|
||||
const parts = path.split('/');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
font: Tokens.font.body.small
|
||||
color: Colours.palette.m3tertiary
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: {
|
||||
const path = LyricsService.loadedLocalFile;
|
||||
const parts = path.split('/');
|
||||
if (parts.length > 2) {
|
||||
return parts.slice(-3, -1).join('/');
|
||||
}
|
||||
return "";
|
||||
}
|
||||
font: Tokens.font.body.small
|
||||
color: Colours.palette.m3outline
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Candidates list
|
||||
Loader {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
active: LyricsService.preferredBackend !== "Local"
|
||||
|
||||
sourceComponent: ListView {
|
||||
id: candidatesView
|
||||
|
||||
model: LyricsService.candidatesModel
|
||||
clip: true
|
||||
spacing: Tokens.spacing.small
|
||||
visible: LyricsService.candidatesModel.count > 0
|
||||
opacity: visible ? 1 : 0
|
||||
|
||||
delegate: Item {
|
||||
id: delegateRoot
|
||||
|
||||
required property real id
|
||||
required property string title
|
||||
required property string artist
|
||||
|
||||
property bool hovered: false
|
||||
property bool pressed: false
|
||||
|
||||
width: ListView.view.width * 0.98
|
||||
height: 70
|
||||
|
||||
anchors.horizontalCenter: parent?.horizontalCenter
|
||||
scale: hovered ? 1.02 : 1.0
|
||||
|
||||
Behavior on scale {
|
||||
NumberAnimation {
|
||||
duration: Tokens.anim.durations.small
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: background
|
||||
|
||||
anchors.fill: parent
|
||||
radius: Tokens.rounding.medium
|
||||
|
||||
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: Tokens.anim.durations.small
|
||||
}
|
||||
}
|
||||
Behavior on border.width {
|
||||
NumberAnimation {
|
||||
duration: Tokens.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: Tokens.padding.medium
|
||||
spacing: Tokens.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: Tokens.anim.durations.small
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - 30
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
text: delegateRoot.title
|
||||
font: Tokens.font.body.builders.medium.weight(Font.Bold).build()
|
||||
color: delegateRoot.hovered ? Colours.palette.m3primary : Colours.palette.m3onSurface
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Tokens.anim.durations.small
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: delegateRoot.artist
|
||||
font: Tokens.font.body.small
|
||||
color: Colours.palette.m3onSurfaceVariant
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
visible: LyricsService.candidatesModel.count == 0 && LyricsService.preferredBackend !== "Local"
|
||||
}
|
||||
|
||||
// Manual search
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Tokens.padding.extraSmall
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: "Manual Search"
|
||||
font: Tokens.font.body.small
|
||||
color: Colours.palette.m3onSurfaceVariant
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Tokens.padding.extraSmall
|
||||
|
||||
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: Tokens.padding.extraSmall
|
||||
|
||||
MaterialIcon {
|
||||
text: "contrast_square"
|
||||
fontStyle: Tokens.font.icon.large
|
||||
color: Colours.palette.m3secondary
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: "Offset"
|
||||
color: Colours.palette.m3outline
|
||||
font: Tokens.font.body.medium
|
||||
}
|
||||
|
||||
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: Tokens.font.body.medium
|
||||
selectByMouse: true
|
||||
text: (LyricsService.offset >= 0 ? "+" : "") + LyricsService.offset.toFixed(1) + "s"
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: offsetInput
|
||||
property: "text"
|
||||
value: (LyricsService.offset >= 0 ? "+" : "") + LyricsService.offset.toFixed(1) + "s"
|
||||
when: !offsetInput.activeFocus
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onCurrentRequestIdChanged() {
|
||||
offsetInput.focus = false;
|
||||
}
|
||||
|
||||
target: LyricsService
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
icon: "add"
|
||||
type: IconButton.Text
|
||||
onClicked: {
|
||||
LyricsService.offset = parseFloat((LyricsService.offset + 0.1).toFixed(1));
|
||||
LyricsService.savePrefs();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import Quickshell
|
||||
import Caelestia.Config
|
||||
import qs.components
|
||||
import qs.components.containers
|
||||
import qs.services
|
||||
|
||||
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
|
||||
preferredHighlightBegin: height / 2 - 30
|
||||
preferredHighlightEnd: height / 2 + 30
|
||||
highlightRangeMode: ListView.ApplyRange
|
||||
highlightFollowsCurrentItem: true
|
||||
highlightMoveDuration: LyricsService.isManualSeeking ? 0 : Tokens.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")
|
||||
}
|
||||
onLyricsActuallyVisibleChanged: {
|
||||
if (!lyricsActuallyVisible)
|
||||
hideTimer.restart();
|
||||
}
|
||||
onModelChanged: {
|
||||
if (model && model.count > 0) {
|
||||
Qt.callLater(() => positionViewAtIndex(currentIndex, ListView.Center));
|
||||
}
|
||||
}
|
||||
delegate: Item {
|
||||
id: delegateRoot
|
||||
|
||||
required property string lyricLine
|
||||
required property real time
|
||||
required property int index
|
||||
readonly property bool hasContent: lyricLine && lyricLine.trim().length > 0
|
||||
property bool isCurrent: ListView.isCurrentItem
|
||||
|
||||
width: ListView.view.width
|
||||
height: hasContent ? (lyricText.contentHeight + Tokens.spacing.largeIncreased) : 0
|
||||
|
||||
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: delegateRoot.isCurrent ? Tokens.font.body.builders.medium.weight(Font.Bold).build() : Tokens.font.body.medium
|
||||
color: delegateRoot.isCurrent ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant
|
||||
scale: delegateRoot.isCurrent ? 1.15 : 1.0
|
||||
|
||||
Behavior on color {
|
||||
CAnim {
|
||||
duration: Tokens.anim.durations.small
|
||||
}
|
||||
}
|
||||
Behavior on scale {
|
||||
Anim {
|
||||
type: Anim.StandardSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: hideTimer
|
||||
|
||||
interval: 300 // long enough to bridge the track switch gap
|
||||
running: false
|
||||
repeat: false
|
||||
}
|
||||
}
|
||||
|
|
@ -30,13 +30,7 @@ ColumnLayout {
|
|||
interval: GlobalConfig.dashboard.mediaUpdateInterval
|
||||
triggeredOnStart: true
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
if (!Players.active)
|
||||
return;
|
||||
|
||||
LyricsService.updatePosition();
|
||||
Players.active.positionChanged();
|
||||
}
|
||||
onTriggered: Players.active?.positionChanged()
|
||||
}
|
||||
|
||||
StyledText {
|
||||
|
|
|
|||
|
|
@ -1,403 +0,0 @@
|
|||
pragma Singleton
|
||||
|
||||
import "../utils/scripts/lrcparser.js" as Lrc
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Caelestia
|
||||
import Caelestia.Config
|
||||
import qs.utils
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var player: Players.active
|
||||
property int currentIndex: -1
|
||||
property bool loading: false
|
||||
property bool isManualSeeking: false
|
||||
property bool lyricsVisible: GlobalConfig.services.showLyrics
|
||||
property string backend: "Local"
|
||||
property string preferredBackend: GlobalConfig.services.lyricsBackend
|
||||
property real currentSongId: 0
|
||||
property string loadedLocalFile: ""
|
||||
property real offset
|
||||
property int currentRequestId: 0
|
||||
property var lyricsMap: ({})
|
||||
|
||||
readonly property string lyricsDir: Paths.absolutePath(GlobalConfig.paths.lyricsDir)
|
||||
readonly property string lyricsMapFile: lyricsDir + "/lyrics_map.json"
|
||||
readonly property alias model: lyricsModel
|
||||
readonly property alias candidatesModel: fetchedCandidatesModel
|
||||
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/"
|
||||
})
|
||||
|
||||
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() {
|
||||
GlobalConfig.services.showLyrics = !GlobalConfig.services.showLyrics;
|
||||
}
|
||||
|
||||
function loadLyrics() {
|
||||
loadDebounce.restart();
|
||||
}
|
||||
|
||||
function _doLoadLyrics() {
|
||||
const meta = getMetadata();
|
||||
if (!meta) {
|
||||
lyricsModel.clear();
|
||||
root.currentIndex = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
lyricsModel.clear();
|
||||
currentIndex = -1;
|
||||
root.currentSongId = 0;
|
||||
|
||||
root.currentRequestId++;
|
||||
let requestId = root.currentRequestId;
|
||||
|
||||
let key = _metaKey(meta);
|
||||
let saved = root.lyricsMap[key];
|
||||
root.offset = saved?.offset ?? 0.0;
|
||||
|
||||
if (root.preferredBackend === "NetEase") {
|
||||
root.backend = "NetEase";
|
||||
fetchNetEase(meta.title, meta.artist, requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.preferredBackend === "Local") {
|
||||
root.backend = "Local";
|
||||
let cleanDir = lyricsDir.replace(/\/$/, "");
|
||||
let flatPath = `${cleanDir}/${meta.artist} - ${meta.title}.lrc`;
|
||||
|
||||
// Search for files matching "Artist - Title.lrc" pattern
|
||||
const artistStr = Array.isArray(meta.artist) ? meta.artist.join(", ") : String(meta.artist || "");
|
||||
const titleStr = Array.isArray(meta.title) ? meta.title.join(", ") : String(meta.title || "");
|
||||
const escapedTitle = titleStr.replace(/'/g, "'\\''");
|
||||
const escapedArtist = artistStr.replace(/'/g, "'\\''");
|
||||
findLyricsInSubdirs.command = ["sh", "-c", `find "${cleanDir}" -type f -iname "*${escapedArtist}*${escapedTitle}*.lrc" | head -n 1`];
|
||||
findLyricsInSubdirs.requestId = requestId;
|
||||
findLyricsInSubdirs.running = true;
|
||||
|
||||
lrcFile.path = "";
|
||||
lrcFile.path = flatPath;
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto mode: try local first
|
||||
root.backend = "Local";
|
||||
let cleanDir = lyricsDir.replace(/\/$/, "");
|
||||
let flatPath = `${cleanDir}/${meta.artist} - ${meta.title}.lrc`;
|
||||
|
||||
const artistStr = Array.isArray(meta.artist) ? meta.artist.join(", ") : String(meta.artist || "");
|
||||
const titleStr = Array.isArray(meta.title) ? meta.title.join(", ") : String(meta.title || "");
|
||||
const escapedTitle = titleStr.replace(/'/g, "'\\''");
|
||||
const escapedArtist = artistStr.replace(/'/g, "'\\''");
|
||||
findLyricsInSubdirs.command = ["sh", "-c", `find "${cleanDir}" -type f -iname "*${escapedArtist}*${escapedTitle}*.lrc" | head -n 1`];
|
||||
findLyricsInSubdirs.requestId = requestId;
|
||||
findLyricsInSubdirs.running = true;
|
||||
|
||||
lrcFile.path = "";
|
||||
lrcFile.path = flatPath;
|
||||
fetchNetEaseCandidates(meta.title, meta.artist, requestId);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
onPreferredBackendChanged: {
|
||||
if (GlobalConfig.services.lyricsBackend !== preferredBackend) {
|
||||
GlobalConfig.services.lyricsBackend = preferredBackend;
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
root.loadedLocalFile = path;
|
||||
updateModel(parsed);
|
||||
loading = false;
|
||||
} else if (root.preferredBackend === "Local") {
|
||||
// Local mode only - fail immediately
|
||||
root.backend = "NetEase";
|
||||
fallbackToOnline();
|
||||
}
|
||||
// In Auto mode, let the Process onExited handle fallback
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onActiveChanged() {
|
||||
root.player = Players.active;
|
||||
loadLyrics();
|
||||
}
|
||||
|
||||
target: Players
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onMetadataChanged() {
|
||||
loadLyrics();
|
||||
}
|
||||
|
||||
target: root.player
|
||||
ignoreUnknownSignals: true
|
||||
}
|
||||
|
||||
Process {
|
||||
id: saveLyricsMap
|
||||
|
||||
command: ["sh", "-c", `mkdir -p "${root.lyricsDir}" && echo '${JSON.stringify(root.lyricsMap)}' > "${root.lyricsMapFile}"`]
|
||||
}
|
||||
|
||||
Process {
|
||||
id: findLyricsInSubdirs
|
||||
|
||||
property int requestId: -1
|
||||
property bool foundFile: false
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
if (findLyricsInSubdirs.requestId === root.currentRequestId) {
|
||||
const foundPath = data.trim();
|
||||
if (foundPath && foundPath.length > 0) {
|
||||
findLyricsInSubdirs.foundFile = true;
|
||||
fallbackTimer.stop();
|
||||
root.loadedLocalFile = foundPath;
|
||||
lrcFile.path = "";
|
||||
lrcFile.path = foundPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onExited: (exitCode, exitStatus) => { // qmllint disable signal-handler-parameters
|
||||
if (requestId === root.currentRequestId && !foundFile && root.preferredBackend === "Auto") {
|
||||
if (lyricsModel.count === 0) {
|
||||
fallbackTimer.restart();
|
||||
}
|
||||
}
|
||||
foundFile = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
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