Files
qbz/crates/qbz-qt/qml/immersive/LyricsFocusPanel.qml
T

146 lines
6.0 KiB
QML

// LyricsFocusPanel — FOCUS mode 4, the giant-line lyrics variant (§6.6 of
// the 2026-08-02 immersive-port contract), port of
// crates/qbz-ui/ui/immersive/ImmersiveLyricsFocusPanel.slint:50-151.
//
// One giant centered CURRENT line, cross-faded on every line change; NO
// karaoke fill (whole-line swap only, exactly like the Slint/Svelte source
// which omits activeProgress). Data: the QbzLyrics doc (lines/status/synced,
// passed in by the mount) + the SHARED immersive LyricsSyncEngine instance
// from ImmersiveView (`sync`) — the Slint reads the one global LyricsState
// active-index; Qt has one engine per surface group, and the focus panel +
// the split lyrics share the immersive engine (trap 22: its gateOpen covers
// BOTH lyric surfaces and nothing else).
//
// Entry animation (:57-74,:116-125): fadeInUp-LITE (translate-up 24px +
// opacity; the blur is skipped per the recon Slint note) replayed WITHOUT
// remounting: a watch on sync.activeIndex flips `entering` (SNAP to the
// start pose — the Behaviors are DISABLED while entering so the pose jump
// does not animate), a 16ms one-shot releases it and the 500ms
// cubic-bezier(0.22,1,0.36,1) runs forward.
//
// The line takes QbzShell.ambientAccent — the album-art accent that is the
// COMPLEMENT of the cover's dominant hue (ImmersiveState.lyrics-accent),
// NOT the UI lyrics color, NOT plain white (:102-107).
import QtQuick
import com.blitzfc.qbz
Item {
id: root
// ---- data (supplied by the ImmersiveView mount) ----------------------
// The QbzLyrics document fields the panel reads.
property var lines: []
property bool synced: false
property int status: 0
// The SHARED immersive LyricsSyncEngine instance (see the header).
property var sync: null
readonly property int activeIndex: root.sync ? root.sync.activeIndex : -1
// Current line text (empty if no active line yet, :34-36).
readonly property string currentLine:
(root.activeIndex >= 0 && root.activeIndex < root.lines.length
&& root.lines[root.activeIndex].text !== undefined)
? root.lines[root.activeIndex].text : ""
readonly property bool hasCurrent: root.currentLine !== ""
// Synced docs with no current line yet (before the first lyric) show ♪
// (:40-42).
readonly property bool waiting: root.synced && root.status === 2
&& root.lines.length > 0 && !root.hasCurrent
// Truly empty: ready/no-lyrics with zero lines, or idle (:44-46).
readonly property bool noLyrics: root.status === 3
|| (root.status === 2 && root.lines.length === 0)
// Responsive font size (clamp(28px, 5vw, 56px), :50-51) — vw = the
// panel's own width (full-viewport in the focus placement).
readonly property real lineSize: Math.max(28, Math.min(root.width * 0.05, 56))
// --- Entry-animation retrigger (:57-74) --------------------------------
readonly property int watchedIndex: root.activeIndex
property bool entering: false
onWatchedIndexChanged: {
// Snap to the start pose, then release on the next tick so the
// Behaviors run forward (toggling within one evaluation would not
// produce a transition — the Behaviors are disabled while entering).
root.entering = true
enterTimer.restart()
}
Timer {
id: enterTimer
interval: 16
repeat: false
onTriggered: root.entering = false
}
// The line block, centered as a group in the viewport; padding mirrors
// the Svelte 80px 60px 140px (top / sides / bottom-player-clearance,
// :83-86).
Item {
anchors.fill: parent
anchors.topMargin: 80
anchors.leftMargin: 60
anchors.rightMargin: 60
anchors.bottomMargin: 140
// Current line — fadeInUp-lite (:100-126). Centered in the block.
Text {
visible: root.hasCurrent
anchors.centerIn: parent
// width min(parent, vw*0.9) (:114).
width: Math.min(parent.width, root.width * 0.9)
text: root.currentLine
color: QbzShell.ambientAccent
font.pixelSize: root.lineSize
font.weight: Font.Bold // 700
font.letterSpacing: 0.2
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
// The translate-up is applied via anchors.verticalCenterOffset so
// the centering anchor survives the offset.
anchors.verticalCenterOffset: root.entering ? 24 : 0
opacity: root.entering ? 0.0 : 1.0
Behavior on anchors.verticalCenterOffset {
// Disabled while entering -> the start pose SNAPS; the
// release (entering -> false) animates forward (:118-121).
enabled: !root.entering
NumberAnimation {
duration: 500
easing.type: Easing.Bezier
easing.bezierCurve: [0.22, 1.0, 0.36, 1.0]
}
}
Behavior on opacity {
enabled: !root.entering
NumberAnimation {
duration: 500
easing.type: Easing.Bezier
easing.bezierCurve: [0.22, 1.0, 0.36, 1.0]
}
}
}
// Synced-but-before-first-line: the ♪ glyph (:130-138).
Text {
visible: !root.hasCurrent && root.waiting
anchors.centerIn: parent
text: "♪"
color: "#ffffff"
opacity: 0.3
font.pixelSize: 48
}
// No lyrics at all — localized empty state (:142-150). Slint
// #ffffff80 (RRGGBBAA) -> Qt #AARRGGBB.
Text {
visible: !root.hasCurrent && !root.waiting && root.noLyrics
anchors.centerIn: parent
width: parent.width
text: QbzSession.tr("No lyrics", QbzSession.trRev)
color: "#80ffffff"
font.pixelSize: 20
font.italic: true
horizontalAlignment: Text.AlignHCenter
}
}
}