Files
qbz/crates/qbz-qt/qml/shell/LyricsPanel.qml
T
2026-08-26 11:11:50 -06:00

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