Files
qbz/crates/qbz-qt/qml/shell/LyricsPanel.qml
T
vicrodh f3a33245b2 feat(qbz-qt): staged loading, skeletons everywhere, lyrics, gestures, richer menus
**Deferred external rows.** The album page awaited the Last.fm row before
publishing anything, so the tracklist waited on an external service plus
MusicBrainz. It publishes in stages now — header and tracks first, then each
Qobuz rail as it lands, then Last.fm — with every patch merged under a
generation counter so a late reply for album A cannot paint onto album B. The
artist page was already staged this way; its views were the part still showing
bare "Loading…" text.

**Skeletons as the standard.** One control, new variants rather than new
components, mounted across the views. Its second job is the one the owner
named: cleanup. A cover that never resolves settles the placeholder once and
reveals the card's own empty state instead of shimmering forever — local albums
with no embedded art produce no artwork entry at all, so a placeholder gated
only on "not arrived yet" would never stop.

**Local artwork streams.** Fifteen seconds to first cover on a 1274-album grid
was one batch emitted after the whole window resolved. Each cover emits as it
resolves now, visible band before prefetch margin.

**Lyrics migrated.** Smooth sync instead of a 1 Hz tick, word-level karaoke
where the document carries it, the surrounding-line dimming, the controls
flyout and the translation layer the v10 backend already supported. Exactly one
row subscribes to the 30 Hz output; the timer drops to 250ms when paused or
hidden.

**Navigation by mouse and touchpad.** Side buttons and a two-finger horizontal
swipe, matching #653 — including the part that matters, which is that a swipe
over a carousel scrolls the carousel. The layer is declared first in the shell
so everything else gets first refusal.

**Context menus.** Brought to the Slint inventory, Play later included, with
right-click opening the same menu as the button everywhere. Entries whose seam
does not exist are hidden rather than shipped inert.

Two controls that rendered and no-opped are now honestly disabled: the My QBZ
nav row, which routes to a view the shell has no case for, and three sidebar
menu entries with no backing call.

The sidebar caret was never a condition bug. The row is a positioner and the
name cell's width ignored the count label, so a folder WITH content pushed the
chevron past the clip — which is why it vanished exactly when it mattered.

cc
2026-07-28 18:12:31 -06:00

393 lines
15 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.
//
// The Qt port has no toast host, so translation failures and the copy
// confirmation surface as an inline notice strip instead of vanishing.
//
// NOT hosted here (no such surface exists in the Qt port yet): the immersive
// lyrics-focus panel and the miniplayer lyrics surface. LyricsLinesView and
// LyricsSyncEngine are written host-agnostic so both can mount them later.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
color: "transparent"
clip: true
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 ? "primary" : "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 ? "primary" : "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 ? "primary" : "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)
}
}
}
// --- Inline notice (this shell has no toast host) -----------------
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
}
}
}