Files
qbz/crates/qbz-qt/qml/miniplayer/MiniLyricsSurface.qml
T
vicrodh 684fffd7f8 feat(qt): the mini queue and lyrics surfaces
Block B4, the last code block of the miniplayer/tray contract.

MiniQueueSurface — the current track first, then the FULL upcoming list:
uncapped, unpaginated, a position number where the main list has artwork,
and hover beating active on the row background. A recycling ListView, since
a 2,000-track queue in a 380 px card is exactly the case the track's perf
rule forbids materialising.

Its document is applied IMPERATIVELY, and that is the perf rule, not style.
A bound model hands the view a new array on every publish that differs,
which is a full model reset: a user scrolled to row 120 gets yanked to the
top every time the track advances. The upstream guard only suppresses
IDENTICAL republishes — a real change is precisely what it lets through. The
cure is the port's own, from Cortinilla: latch contentY, apply, restore
under Qt.callLater so a burst costs one restore.

MiniLyricsSurface — the empty test is on the line COUNT, not on status;
there is no loading, error or offline state in the mini. It mounts its own
LyricsSyncEngine, which is what makes the reference's ensure_lyrics_engine
hack unnecessary: the karaoke advances with the sidebar panel closed.

Both surfaces are gated on QbzMini.open, not on the surface alone. The mini
window is created once and never destroyed, so a surface-only gate would
leave them mounted and re-parsing a whole JSON document on every queue or
lyrics edit for the rest of the session with the mini nowhere on screen.

The lyrics pump additionally reads the MINI WINDOW's visibility. Section 4.5
spells the gate as open + surface + synced, but QbzMini.open is a logical
flag that stays true when the window is minimised, and the general rule says
that gate must read the mini's own window. The window term is added, not
substituted.

queue_play routes remote-first and RETURNS on Err — an error must never fall
through to local playback — and its row-to-index mapping calls the tested
helper rather than restating it. Row 0 resumes only when a current track
exists; with none it plays upcoming[0], which is the reference's off-by-one
this port declines to inherit.

mini_qt::clamp_to_implemented is DELETED. It was the temporary guard that
kept an unimplemented surface from stranding the user on a blank card, and
its doc said the last block to land would remove it.

Driven and seen: ten rows with their numbers, titles, artists, durations,
the E badges on the two explicit tracks, and no artwork. What is NOT seen,
because it needs sound: a row click actually playing, the off-by-one on live
behaviour, and the karaoke advancing.

cc
2026-08-04 09:41:37 -06:00

169 lines
7.6 KiB
QML

// MiniLyricsSurface — surface 4 (2026-08-03 miniplayer/tray contract §4.5),
// port of `crates/qbz-ui/ui/miniplayer/MiniLyricsSurface.slint` (46 L, read
// whole).
//
// THE EMPTY TEST IS ON THE LINE COUNT, NOT ON `status`
// (`MiniLyricsSurface.slint:11`). The mini has no loading state, no error
// state and no offline-miss state — only "lines" and "no lines" (§15 trap 13).
// The sidebar panel's `status === 2 && lines.length > 0` is deliberately NOT
// reproduced here.
//
// THE SLINT VIEW TAKES ITS DATA FROM A GLOBAL; THE QT COMPONENT TAKES IT AS
// PROPS. `LyricsLinesView.qml` declares `lines: []`, `synced: false`,
// `showTranslation: false` and `sync: null` as ordinary properties with inert
// defaults (`:35-39`), so "pass only the sizing overrides" would mount a
// permanently empty, permanently monolingual view. The mount below is §4.5's
// complete nine-binding list and nothing else — the four sizing numbers are
// the only DIVERGENCE from the component's defaults; the five data bindings
// are the mount.
//
// Everything §4.5 leaves out is left out on purpose: `dimmingMode` (2),
// `autoFollow` (true), `uppercase` (false), `fontIndex` (0) and `activeColor`
// (theme.accent) stay at the component's defaults. The SIDEBAR passes those
// five from the user's prefs (`LyricsPanel.qml:255-263`); the Slint mini
// renders with the fixed values baked into its own view instance
// (`miniplayer.rs:519-534`), so leaving the Qt defaults in place is what
// reproduces it.
//
// `seekRequested` stays UNCONNECTED. The signal exists and the sidebar honours
// it, but the Slint mini has no click-to-seek and its own view header records
// the parity reason. An inert handler that does nothing would be a deferral
// artifact, so there is none.
//
// `show-attribution: false` (`LyricsLinesView.slint:298`) is DEAD in the
// reference — declared and read nowhere, the attribution having moved to a
// fixed footer in the sidebar. The Qt component has no such property and MUST
// NOT grow one (§12-P9).
//
// NO COLOUR LITERALS: every colour on this surface is a theme token, so the
// #RRGGBBAA -> #AARRGGBB conversion the port owes Slint literals has nothing
// to convert here.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../shell"
import "../theme"
Item {
id: root
QbzTheme { id: theme }
// {status, synced, provider, error, translationAvailable, lines[]}. The
// property's Rust default is a full-shape document, which is why the
// sidebar parses it unguarded (`LyricsPanel.qml:36`).
readonly property var doc: JSON.parse(QbzLyrics.docJson)
readonly property var lines: root.doc.lines !== undefined && root.doc.lines !== null
? root.doc.lines : []
readonly property bool synced: root.doc.synced === true
// THE gate — count only (see the header).
readonly property bool hasLines: root.lines.length > 0
// --- The mini's OWN sync engine (§4.5, §3.2) ---------------------------
// A SECOND instance, not a share of the sidebar's. It is stateless per
// instance (`_index`/`_progress`/`_animMs` are file-local) and calls only
// read-only invokables (`QbzLyrics.activeIndexAt`, `fillFractionAt`), so
// two instances cannot fight.
//
// The gate is the MINI's own open+surface — never the sidebar's
// `QbzShell.lyricsOpen`, which nothing here writes — and it carries the
// panel's `synced` term (`LyricsPanel.qml:55`) so the 33/250 ms pump never
// ticks on an unsynced document. Gating on the mini's own state is exactly
// what makes the reference's `ensure_lyrics_engine` hack unnecessary: the
// karaoke advances with the sidebar panel CLOSED.
// §4.5 spells this gate as open + surface + synced. The window term is
// ADDED, not substituted, because §7-M8 is a general rule the mini cannot
// opt out of: "the windowShowing gate must read THE MINI'S OWN WINDOW",
// called there the single most likely copy-paste bug in this port.
// `QbzMini.open` is a LOGICAL flag — it stays true when the compositor or
// the taskbar minimises the mini, and the 33 ms pump would then pull three
// invokables thirty times a second, animating a window nobody can see.
// The blessed shape is `AmbientField.qml:42`; reading it off the MINI is
// the whole point, so `root.Window.window` here is the mini, never the
// shell.
readonly property bool windowShowing: root.Window.window !== null
&& root.Window.window.visibility !== Window.Hidden
&& root.Window.window.visibility !== Window.Minimized
LyricsSyncEngine {
id: syncEngine
gateOpen: QbzMini.open && QbzMini.surface === 4
&& root.windowShowing && root.hasLines && root.synced
}
// Every doc commit resets the ladder and re-lands on the right line — the
// Slint `kick()` on commit, and the same two calls the sidebar makes
// (`LyricsPanel.qml:58-66`).
Connections {
target: QbzLyrics
function onDocJsonChanged() {
syncEngine.reset()
syncEngine.kick()
}
}
// --- Empty state (MiniLyricsSurface.slint:13-35) -----------------------
// VerticalLayout { alignment: center; spacing: 10; padding: 24 }: a
// centred 30x30 mic-vocal glyph in text-muted, then the 13 px label. The
// 24 px padding is uniform on all four sides, so the column's width is the
// surface minus 48.
Column {
anchors.centerIn: parent
width: Math.max(0, root.width - 48)
visible: !root.hasLines
spacing: 10
// The icon's own centring wrapper (`:20-22` is a
// HorizontalLayout { alignment: center }).
Item {
width: parent.width
height: 30
QbzIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: "mic-vocal"
tintName: "muted"
width: 30
height: 30
}
}
Text {
width: parent.width
text: QbzSession.tr("No lyrics", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
// --- Populated (:37-45) ------------------------------------------------
// The nine bindings of §4.5, in its order: the five that are the mount,
// then the four sizing overrides. The 280 px scale the sidebar computes is
// the SIDEBAR's; this card is 340-380 px wide and owns its own numbers,
// which is what `sidePadding` and `lineGap` exist for.
LyricsLinesView {
anchors.fill: parent
visible: root.hasLines
lines: root.lines
synced: root.synced
// No toggle of its own: the mini FOLLOWS the sidebar's bilingual state
// through the shared model, exactly as `mirror_lyrics_gated` copies it
// (`crates/qbz/src/miniplayer.rs:610-612`).
showTranslation: QbzLyrics.showTranslation
sync: syncEngine
// §13-D5b, a divergence in the port's favour: the reference's mini
// cannot honour lite-fill (its window's LyricsState instance is never
// mirrored, so it always pays the animated karaoke sweep). Here the
// singleton is shared. Do not "fix" it back.
liteFill: QbzLyrics.liteFill
sidePadding: 18 // component default 6
lineGap: 10 // component default 12
sizeInactive: 13 // == the component default, passed because the
sizeActive: 15 // reference states both explicitly
}
}