nosignal-shell/modules/dashboard/LyricMenu.qml
八奈見 レイ ffe90bd077
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>
2026-03-19 22:38:36 +11:00

323 lines
12 KiB
QML

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