Files
qbz/crates/qbz-qt/qml/shell/LyricsLineRow.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

274 lines
11 KiB
QML

// One lyric line — QML port of the `LyricsLineRow` component inside
// crates/qbz-ui/ui/shell/LyricsLinesView.slint.
//
// Three stacked pieces, in Slint's order:
// 1. INACTIVE / unsynced: one wrapping Text on the dimming ladder.
// 2. ACTIVE: per-VISUAL-LINE karaoke highlight — the line is segmented at
// the view's content width (Qt font metrics, so the segments match what
// the Text actually draws), and each visual row renders a dim base plus
// a bright overlay clipped to that row's LOCAL share of the global
// progress. Row 1 therefore fills to 100% BEFORE row 2 starts, instead
// of every wrapped row lighting at once.
// 3. TRANSLATION (Qobuz v10): static text under the original, ~72% size and
// dimmer, never uppercased — it follows the line but the karaoke fill
// stays keyed on the ORIGINAL timings and text.
//
// PERFORMANCE CONTRACT: the row never binds the 30Hz `lineProgress` unless it
// is the active row. The karaoke Repeater's model is `isActive ? segments :
// []`, so on every other row zero delegates exist and zero bindings are
// re-evaluated per tick. Segmentation itself runs only when the line becomes
// active or when the width / font / size / uppercase inputs change — never
// per tick, never per frame.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Column {
id: row
// ---- data ---------------------------------------------------------
// Renamed from `index` so a Repeater delegate can carry the required
// `index` context property without shadowing it.
property int lineIndex: -1
property string lineText: ""
property string translationText: ""
// The sync engine (LyricsSyncEngine) — read ONLY from inside the active
// branch, so inactive rows never subscribe to its 30Hz outputs.
property var sync: null
// ---- state (pushed by the view; plain values, no geometry) ---------
property bool synced: false
property int activeIndex: -1
readonly property bool isActive: row.synced && row.lineIndex === row.activeIndex
readonly property bool isPast: row.synced && row.activeIndex >= 0
&& row.lineIndex < row.activeIndex
readonly property int distance: Math.abs(row.lineIndex - row.activeIndex)
// ---- display prefs -------------------------------------------------
property real contentWidth: 0
property int sizeInactive: 13
property int sizeActive: 15
// 0 off · 1 soft · 2 strong
property int dimmingMode: 2
property bool uppercase: false
property string fontFamily: ""
property color activeColor: theme.accent
// Lite fill: light the active line whole, no per-frame clip sweep.
property bool liteFill: false
property bool showTranslation: false
QbzTheme { id: theme }
// "" = the window default (Inter) — Qt resets an empty family to the
// application font, exactly Slint's empty font-family = "System" option.
readonly property string effFamily: row.fontFamily
width: row.contentWidth
// Gap between the original line and its translation (no effect while the
// translation is hidden — a single visible child has no spacing).
spacing: 3
// Slint transforms the string for display; the engine still matches on
// the raw text.
readonly property string displayText: row.uppercase ? row.lineText.toUpperCase() : row.lineText
// Dimming ladder (LyricsLinesView.slint `line-opacity`): off -> 1.0 all;
// soft -> 0.6 inactive; strong (default) is ASYMMETRIC — incoming lines
// fade much harder than already-sung ones, for a stronger timing cue.
// Unsynced lyrics render the whole list at 0.85. With no active line the
// ladder has no reference point: full brightness.
function lineOpacity() {
if (!row.synced)
return 0.85
if (row.isActive)
return 1.0
if (row.dimmingMode === 0 || row.activeIndex < 0)
return 1.0
if (row.dimmingMode === 1)
return 0.6
if (row.isPast) {
if (row.distance === 1) return 0.5
if (row.distance === 2) return 0.35
if (row.distance === 3) return 0.25
return 0.18
}
if (row.distance === 1) return 0.4
if (row.distance === 2) return 0.2
if (row.distance === 3) return 0.08
return 0.03
}
// ---- INACTIVE / unsynced ------------------------------------------
Text {
visible: !row.isActive
width: row.contentWidth
text: row.displayText
wrapMode: Text.WordWrap
opacity: row.lineOpacity()
// 1:1 with Slint's 220ms ease-out: upcoming lines fade IN as they
// brighten toward active, passed lines fade OUT to their dim tier,
// instead of snapping between dimming steps.
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.OutQuad } }
color: row.isPast ? theme.textMuted : theme.textSecondary
font.family: row.effFamily
font.pixelSize: row.sizeInactive
font.weight: theme.weightMedium
font.letterSpacing: 0.2
}
// ---- ACTIVE: per-visual-line karaoke -------------------------------
// Segments: [{ text, start, w }] — `start` is the cumulative width
// fraction of all PRIOR segments, `w` this segment's share of the line.
property var segments: []
TextMetrics {
id: metrics
font.family: row.effFamily
font.pixelSize: row.sizeActive
font.weight: theme.weightBold
font.letterSpacing: 0.2
}
// Greedy word wrap against the SAME metrics the active line is drawn
// with, so the segment boundaries match the rendered wrap. Runs on
// activation and on any input change — never per tick.
function computeSegments() {
if (!row.isActive) {
if (row.segments.length > 0)
row.segments = []
return
}
var text = row.displayText
var budget = row.contentWidth
if (text === "" || budget <= 0) {
row.segments = [{ "text": text, "start": 0.0, "w": 1.0 }]
return
}
var words = text.split(" ")
var visual = []
var cur = ""
for (var i = 0; i < words.length; i++) {
var cand = cur === "" ? words[i] : cur + " " + words[i]
metrics.text = cand
if (metrics.advanceWidth > budget && cur !== "") {
visual.push(cur)
cur = words[i]
} else {
cur = cand
}
}
if (cur !== "")
visual.push(cur)
var widths = []
var total = 0
for (var j = 0; j < visual.length; j++) {
metrics.text = visual[j]
widths.push(metrics.advanceWidth)
total += metrics.advanceWidth
}
var out = []
var acc = 0
for (var k = 0; k < visual.length; k++) {
var share = total > 0 ? widths[k] / total : 0
out.push({ "text": visual[k], "start": acc, "w": share })
acc += share
}
row.segments = out
}
onIsActiveChanged: row.computeSegments()
onDisplayTextChanged: row.computeSegments()
onContentWidthChanged: row.computeSegments()
onSizeActiveChanged: row.computeSegments()
onFontFamilyChanged: row.computeSegments()
Column {
visible: row.isActive
width: row.contentWidth
spacing: 0
Repeater {
// Empty model on every inactive row -> no delegates -> the 30Hz
// progress is bound exactly once, on the active row.
model: row.isActive ? row.segments : []
delegate: Item {
id: seg
required property var modelData
width: segBase.width
height: segBase.height
Text {
id: segBase
text: seg.modelData.text
wrapMode: Text.NoWrap
color: theme.textSecondary
font.family: row.effFamily
font.pixelSize: row.sizeActive
font.weight: theme.weightBold
font.letterSpacing: 0.2
}
// Bright (sung) overlay, clipped to this segment's LOCAL fill:
// local = clamp((progress - start) / w, 0, 1)
Item {
id: fillClip
x: 0
y: 0
height: segBase.height
clip: true
// Lite fill (pref): the active line is fully lit and
// STATIC — no per-frame clip animation, so the surface
// stops repainting while a line is held.
width: row.liteFill
? segBase.width
: segBase.width * Math.max(0, Math.min(1,
seg.modelData.w > 0
? ((row.sync ? row.sync.lineProgress : 0) - seg.modelData.start) / seg.modelData.w
: 0))
Behavior on width {
enabled: !row.liteFill
NumberAnimation {
duration: row.sync ? row.sync.fillAnimMs : 0
easing.type: Easing.Linear
}
}
Text {
x: 0
y: 0
width: segBase.width
text: seg.modelData.text
wrapMode: Text.NoWrap
color: row.activeColor
font.family: row.effFamily
font.pixelSize: row.sizeActive
font.weight: theme.weightBold
font.letterSpacing: 0.2
}
}
}
}
}
// ---- TRANSLATION ----------------------------------------------------
Text {
visible: row.showTranslation && row.translationText !== ""
width: row.contentWidth
text: row.translationText
wrapMode: Text.WordWrap
opacity: row.lineOpacity()
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.OutQuad } }
// Slint: text-muted when past, text-secondary @ 0.7 otherwise.
color: row.isPast
? theme.textMuted
: Qt.rgba(theme.textSecondary.r, theme.textSecondary.g,
theme.textSecondary.b, 0.7)
font.family: row.effFamily
font.pixelSize: Math.round((row.isActive ? row.sizeActive : row.sizeInactive) * 0.72)
font.weight: theme.weightRegular
font.letterSpacing: 0.2
}
}