397 lines
16 KiB
QML
397 lines
16 KiB
QML
// Right-side Lyrics panel — QML port of
|
|
// crates/qbz-ui/ui/shell/LyricsSidebar.slint.
|
|
//
|
|
// Header (LYRICS overline + "{title} - {artist}" meta + display-settings
|
|
// trigger + close X), body by status (loading / no-lyrics / offline-miss /
|
|
// error / the shared lines view), the floating translate toggle over the
|
|
// body's bottom-right corner, and the source-attribution footer.
|
|
//
|
|
// The sync is NOT the 1 Hz playback poll any more: LyricsSyncEngine ticks at
|
|
// ~30Hz off the player's millisecond position and pulls the active line +
|
|
// the karaoke fraction from Rust (word-anchored on Qobuz wsync documents).
|
|
// The 280px-adapted type scale from the reference is applied here (S 11/13 ·
|
|
// M 13/15 · L 16/19 · XL 20/24) and handed to the shared lines view together
|
|
// with the persisted display prefs.
|
|
//
|
|
// Translation failures and copy confirmation stay panel-local as an inline
|
|
// notice strip; the shared toast host is reserved for app-wide actions.
|
|
//
|
|
// LyricsLinesView and LyricsSyncEngine are host-agnostic: Immersive focus /
|
|
// split modes and the miniplayer mount the same document and sync math with
|
|
// their own layout knobs.
|
|
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import com.blitzfc.qbz
|
|
import "../theme"
|
|
|
|
Rectangle {
|
|
id: root
|
|
color: "transparent"
|
|
// Clipped ONLY when stacked with the other panel. The drag ghost really
|
|
// does leave this rect, but in the single-panel case the panel coincides
|
|
// exactly with queueColumn, which already clips (AppShell.qml) — so the
|
|
// scissor is pure duplication there, and duplication costs a batch root.
|
|
clip: QbzShell.queueOpen && QbzShell.lyricsOpen
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
// The document Rust publishes: {status, synced, provider, error, lines}.
|
|
readonly property var doc: JSON.parse(QbzLyrics.docJson)
|
|
readonly property var lines: doc.lines !== undefined && doc.lines !== null ? doc.lines : []
|
|
readonly property bool synced: doc.synced === true
|
|
readonly property bool ready: doc.status === 2 && root.lines.length > 0
|
|
|
|
// 280px-adapted type scale (LyricsSidebar.slint size matrix).
|
|
readonly property int sizeInactive: QbzLyrics.sizeIndex === 0 ? 11
|
|
: QbzLyrics.sizeIndex === 2 ? 16
|
|
: QbzLyrics.sizeIndex === 3 ? 20 : 13
|
|
readonly property int sizeActive: QbzLyrics.sizeIndex === 0 ? 13
|
|
: QbzLyrics.sizeIndex === 2 ? 19
|
|
: QbzLyrics.sizeIndex === 3 ? 24 : 15
|
|
|
|
Component.onCompleted: QbzLyrics.boot()
|
|
|
|
// ---- sync engine -------------------------------------------------
|
|
LyricsSyncEngine {
|
|
id: syncEngine
|
|
// Tick only while the panel is on screen and the doc can be followed.
|
|
gateOpen: root.visible && root.ready && root.synced
|
|
}
|
|
|
|
// Every doc commit resets the ladder and re-lands on the right line
|
|
// (the Slint `kick()` on commit / panel open).
|
|
Connections {
|
|
target: QbzLyrics
|
|
function onDocJsonChanged() {
|
|
syncEngine.reset()
|
|
syncEngine.kick()
|
|
}
|
|
}
|
|
|
|
Column {
|
|
anchors.fill: parent
|
|
spacing: 0
|
|
|
|
// --- Header: overline/meta + controls trigger + close ----------
|
|
Item {
|
|
width: parent.width
|
|
height: 56
|
|
|
|
Column {
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: theme.spacingSm
|
|
anchors.right: controlsRow.left
|
|
anchors.rightMargin: theme.spacingSm
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 2
|
|
Text {
|
|
text: QbzSession.tr("LYRICS", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 1.5
|
|
}
|
|
Text {
|
|
visible: QbzLyrics.trackTitle !== ""
|
|
width: parent.width
|
|
text: QbzLyrics.trackTitle + " - " + QbzLyrics.trackArtist
|
|
color: theme.textSecondary
|
|
font.pixelSize: 12
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
Row {
|
|
id: controlsRow
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: theme.spacingSm
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 8
|
|
// Display settings — opens the controls flyout.
|
|
Rectangle {
|
|
id: dsButton
|
|
width: 32
|
|
height: 32
|
|
radius: theme.radiusSm
|
|
color: dsArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
name: "sliders-horizontal"
|
|
width: 16
|
|
height: 16
|
|
tintName: dsArea.containsMouse ? "textPrimary" : "secondary"
|
|
}
|
|
MouseArea {
|
|
id: dsArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: dsFlyout.opened ? dsFlyout.close() : dsFlyout.open()
|
|
ToolTip.visible: containsMouse && !dsFlyout.opened
|
|
ToolTip.delay: 500
|
|
ToolTip.text: QbzSession.tr("Display settings", QbzSession.trRev)
|
|
}
|
|
}
|
|
// Close X (Slint-convention, homologated with Queue).
|
|
Rectangle {
|
|
width: 28
|
|
height: 28
|
|
radius: theme.radiusSm
|
|
color: closeArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
name: "x"
|
|
width: 15
|
|
height: 15
|
|
tintName: closeArea.containsMouse ? "textPrimary" : "secondary"
|
|
}
|
|
MouseArea {
|
|
id: closeArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: QbzShell.toggleLyrics()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 1
|
|
color: theme.borderSubtle
|
|
}
|
|
|
|
// --- Body by status ----------------------------------------------
|
|
Item {
|
|
id: body
|
|
width: parent.width
|
|
height: parent.height - 57 - sourceLine.height - noticeStrip.height
|
|
|
|
// Loading.
|
|
Column {
|
|
visible: root.doc.status === 1
|
|
anchors.centerIn: parent
|
|
spacing: 10
|
|
QbzSpinner { size: 24; anchors.horizontalCenter: parent.horizontalCenter }
|
|
Text {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: QbzSession.tr("Fetching lyrics...", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
}
|
|
}
|
|
|
|
// No lyrics (idle / not-found / ready-with-zero-lines).
|
|
Column {
|
|
visible: root.doc.status === 0 || root.doc.status === 3
|
|
|| (root.doc.status === 2 && root.lines.length === 0)
|
|
anchors.centerIn: parent
|
|
width: parent.width - 32
|
|
spacing: 12
|
|
Text {
|
|
width: parent.width
|
|
text: QbzSession.tr("No lyrics available", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
horizontalAlignment: Text.AlignHCenter
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
Column {
|
|
width: parent.width
|
|
spacing: 2
|
|
Text {
|
|
width: parent.width
|
|
text: QbzSession.tr("Missing lyrics? You can contribute them to LRCLIB:", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 10
|
|
horizontalAlignment: Text.AlignHCenter
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
Text {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: "lrclibup.boidu.dev"
|
|
color: lrcArea.containsMouse ? theme.accentHover : theme.accent
|
|
font.pixelSize: 10
|
|
MouseArea {
|
|
id: lrcArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: QbzShell.openExternalUrl("https://lrclibup.boidu.dev/")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Offline with nothing cached.
|
|
Text {
|
|
visible: root.doc.status === 5
|
|
anchors.centerIn: parent
|
|
width: parent.width - 32
|
|
text: QbzSession.tr("Lyrics unavailable offline", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
horizontalAlignment: Text.AlignHCenter
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
// Error.
|
|
Text {
|
|
visible: root.doc.status === 4
|
|
anchors.centerIn: parent
|
|
width: parent.width - 32
|
|
text: root.doc.error !== undefined ? root.doc.error : ""
|
|
color: theme.danger
|
|
font.pixelSize: theme.fontLegal
|
|
horizontalAlignment: Text.AlignHCenter
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
// The lines (status 2, lines > 0).
|
|
LyricsLinesView {
|
|
id: linesView
|
|
anchors.fill: parent
|
|
visible: root.ready
|
|
lines: root.lines
|
|
synced: root.synced
|
|
showTranslation: QbzLyrics.showTranslation
|
|
sync: syncEngine
|
|
sizeInactive: root.sizeInactive
|
|
sizeActive: root.sizeActive
|
|
dimmingMode: QbzLyrics.dimmingMode
|
|
autoFollow: QbzLyrics.autoFollow
|
|
uppercase: QbzLyrics.uppercase
|
|
fontIndex: QbzLyrics.fontIndex
|
|
liteFill: QbzLyrics.liteFill
|
|
activeColor: QbzLyrics.useCustomColor ? QbzLyrics.customColor : theme.accent
|
|
onSeekRequested: function (timeMs) {
|
|
if (QbzPlayer.npDurationSecs > 0)
|
|
QbzPlayer.seek(timeMs / 1000 / QbzPlayer.npDurationSecs)
|
|
}
|
|
}
|
|
|
|
// Floating translate toggle — bottom-right overlay of the body,
|
|
// mounted only while the current doc advertises translations.
|
|
Rectangle {
|
|
visible: QbzLyrics.translationAvailable && root.ready
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
anchors.rightMargin: 12
|
|
anchors.bottomMargin: 12
|
|
width: 32
|
|
height: 32
|
|
radius: 4
|
|
color: trArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
opacity: QbzLyrics.translationBusy ? 0.5 : 1.0
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
name: "translate"
|
|
width: 20
|
|
height: 20
|
|
tintName: QbzLyrics.showTranslation
|
|
? "accent" : (trArea.containsMouse ? "textPrimary" : "muted")
|
|
}
|
|
MouseArea {
|
|
id: trArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: QbzLyrics.toggleTranslation()
|
|
ToolTip.visible: containsMouse
|
|
ToolTip.delay: 500
|
|
ToolTip.text: QbzLyrics.showTranslation
|
|
? QbzSession.tr("Hide lyrics translation", QbzSession.trRev)
|
|
: QbzSession.tr("Show lyrics translation", QbzSession.trRev)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Panel-local inline notice ------------------------------------
|
|
Rectangle {
|
|
id: noticeStrip
|
|
width: parent.width
|
|
height: QbzLyrics.notice !== "" ? noticeText.implicitHeight + 14 : 0
|
|
visible: height > 0
|
|
color: theme.surfaceElevated
|
|
Text {
|
|
id: noticeText
|
|
anchors.left: parent.left
|
|
anchors.right: dismiss.left
|
|
anchors.leftMargin: theme.spacingSm
|
|
anchors.rightMargin: 4
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzLyrics.notice
|
|
color: theme.textSecondary
|
|
font.pixelSize: 11
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
QbzIcon {
|
|
id: dismiss
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: theme.spacingSm
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
name: "x"
|
|
width: 12
|
|
height: 12
|
|
tintName: "muted"
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: QbzLyrics.clearNotice()
|
|
}
|
|
}
|
|
// Auto-dismiss, toast-style.
|
|
Timer {
|
|
interval: 5000
|
|
repeat: false
|
|
running: QbzLyrics.notice !== ""
|
|
onTriggered: QbzLyrics.clearNotice()
|
|
}
|
|
}
|
|
|
|
// --- Source attribution ------------------------------------------
|
|
Row {
|
|
id: sourceLine
|
|
visible: root.ready
|
|
width: parent.width
|
|
leftPadding: theme.spacingSm
|
|
rightPadding: theme.spacingSm
|
|
topPadding: 3
|
|
bottomPadding: 5
|
|
spacing: 3
|
|
Text {
|
|
text: QbzSession.tr("Lyrics source", QbzSession.trRev) + ":"
|
|
color: theme.textMuted
|
|
font.pixelSize: 9
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
Text {
|
|
text: root.doc.provider === "lrclib" ? "LRCLIB"
|
|
: root.doc.provider === "ovh" ? "lyrics.ovh" : "Qobuz"
|
|
color: theme.accent
|
|
font.pixelSize: 9
|
|
font.weight: theme.weightSemibold
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Display-settings flyout -----------------------------------------
|
|
Popup {
|
|
id: dsFlyout
|
|
// Right edge aligned with the close button's right edge, 8px below
|
|
// the trigger (the reference's anchor math).
|
|
x: root.width - theme.spacingSm - 260
|
|
y: 8 + 32 + 8
|
|
width: 260
|
|
height: flyoutBody.implicitHeight
|
|
padding: 0
|
|
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
|
|
background: null
|
|
contentItem: LyricsControlsFlyout {
|
|
id: flyoutBody
|
|
canCopy: root.ready
|
|
}
|
|
}
|
|
}
|