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
This commit is contained in:
vicrodh committed 2026-07-28 18:12:31 -06:00
1 parent 2b31d3866c
commit f3a33245b2
52 files changed
+4962 -719

No files matched your search

+10
View File
@@ -31,6 +31,16 @@ Rectangle {
// so every chrome surface paints above it): the ambient album-colored
// field + the dark dim scrim that keeps chrome text legible
// (QBZ_BG_DIM-tunable, default 0.35).
// Window-level history navigation: mouse side buttons + two-finger
// horizontal touchpad swipe (#653 parity). Declared FIRST so the whole UI
// hit-tests on top of it — a scroll reaches it only when nothing above
// consumed it, which is what keeps horizontal carousels scrolling instead
// of navigating. Never consumes a wheel event; accepts only the two side
// buttons.
NavGestureLayer {
anchors.fill: parent
}
AmbientField {
anchors.fill: parent
visible: root.ambientOn
@@ -0,0 +1,370 @@
// Lyrics display-settings flyout — QML port of
// crates/qbz-ui/ui/shell/LyricsControlsFlyout.slint (`LyricsControlsPanel`).
//
// Row order is the reference's exactly: Auto-follow / Font / Size /
// Translation language / Active color / Uppercase / Lite fill / Dimming /
// footer (Copy lyrics + Reset). Selects instead of segmented pill rows
// (ADR-008), and the active color is a fixed swatch palette because there is
// no native color input (deviation D6) — the "Theme" reset link shows only
// while a custom color is set.
//
// Every mutation writes the QbzLyrics pref property directly (live preview)
// and fires `prefsChanged()` so Rust persists it into the SAME per-user
// lyrics_prefs.json the Slint build reads. Changing the translation language
// while the translation is ON re-requests the track in the new language
// (Rust side) — the row is never a dead control.
//
// This component is the popup CONTENT; the Popup shell lives at the anchor
// site in LyricsPanel.
import QtQuick
import com.blitzfc.qbz
import "../theme"
import "../controls"
Rectangle {
id: panel
// Copy gate — ready && lines > 0 (the reference's `can-copy`).
property bool canCopy: false
readonly property int rowWidth: 232 // 260 - 2 * 14 padding
readonly property var swatches: [
"#8b5cf6", "#ec4899", "#ef4444", "#f97316",
"#eab308", "#22c55e", "#06b6d4", "#3b82f6"
]
implicitWidth: 260
implicitHeight: column.implicitHeight
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
QbzTheme { id: theme }
// Clipboard seam: this shell has no Rust clipboard bridge, so the copy
// goes through the standard QML route (a hidden TextEdit's copy()).
TextEdit {
id: clipboard
visible: false
width: 0
height: 0
}
function copyLyrics() {
var text = QbzLyrics.plainText()
if (text === "")
return
clipboard.text = text
clipboard.selectAll()
clipboard.copy()
clipboard.text = ""
QbzLyrics.notice = QbzSession.tr("Lyrics copied", QbzSession.trRev)
}
Column {
id: column
width: parent.width
padding: 14
spacing: 12
// --- Auto-follow ------------------------------------------------
Item {
width: panel.rowWidth
height: 22
Text {
anchors.left: parent.left
anchors.right: afToggle.left
anchors.rightMargin: 8
height: parent.height
text: QbzSession.tr("Auto-follow", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
QbzToggle {
id: afToggle
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: QbzLyrics.autoFollow
onToggled: function (value) {
QbzLyrics.autoFollow = value
QbzLyrics.prefsChanged()
}
}
}
// --- Font --------------------------------------------------------
Item {
width: panel.rowWidth
height: 34
Text {
anchors.left: parent.left
anchors.right: fontSelect.left
anchors.rightMargin: 8
height: parent.height
text: QbzSession.tr("Font", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
QbzSelect {
id: fontSelect
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
menuWidth: 150
options: [
QbzSession.tr("System", QbzSession.trRev),
"LINE Seed JP",
"Montserrat",
"Noto Sans",
"Source Sans 3"
]
currentIndex: QbzLyrics.fontIndex
onSelected: function (i) {
QbzLyrics.fontIndex = i
QbzLyrics.prefsChanged()
}
}
}
// --- Size --------------------------------------------------------
Item {
width: panel.rowWidth
height: 34
Text {
anchors.left: parent.left
anchors.right: sizeSelect.left
anchors.rightMargin: 8
height: parent.height
text: QbzSession.tr("Size", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
QbzSelect {
id: sizeSelect
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
menuWidth: 110
options: ["S", "M", "L", "XL"]
currentIndex: QbzLyrics.sizeIndex
onSelected: function (i) {
QbzLyrics.sizeIndex = i
QbzLyrics.prefsChanged()
}
}
}
// --- Translation language (Qobuz v10) ----------------------------
Item {
width: panel.rowWidth
height: 34
Text {
anchors.left: parent.left
anchors.right: langSelect.left
anchors.rightMargin: 8
height: parent.height
text: QbzSession.tr("Translation language", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
QbzSelect {
id: langSelect
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
menuWidth: 118
popupWidth: 178
options: [
QbzSession.tr("Auto (account language)", QbzSession.trRev),
"English", "Español", "Français", "Deutsch", "Italiano",
"Português", "Nederlands", "日本語", "Русский"
]
currentIndex: QbzLyrics.translationLanguageIndex
onSelected: function (i) {
QbzLyrics.translationLanguageIndex = i
QbzLyrics.prefsChanged()
}
}
}
// --- Active color -------------------------------------------------
Column {
width: panel.rowWidth
spacing: 8
Item {
width: parent.width
height: 18
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Active color", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 12
}
Text {
visible: QbzLyrics.useCustomColor
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Theme", QbzSession.trRev)
color: themeLinkArea.containsMouse ? theme.accentHover : theme.accent
font.pixelSize: 12
MouseArea {
id: themeLinkArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
QbzLyrics.useCustomColor = false
QbzLyrics.prefsChanged()
}
}
}
}
Row {
anchors.right: parent.right
spacing: 6
Repeater {
model: panel.swatches
delegate: Rectangle {
id: sw
required property var modelData
readonly property bool picked: QbzLyrics.useCustomColor
&& Qt.colorEqual(QbzLyrics.customColor, sw.modelData)
width: 18
height: 18
radius: 9
color: sw.modelData
border.width: sw.picked ? 2 : (swArea.containsMouse ? 1 : 0)
border.color: sw.picked ? theme.textPrimary : theme.borderMuted
MouseArea {
id: swArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
QbzLyrics.useCustomColor = true
QbzLyrics.customColor = sw.modelData
QbzLyrics.prefsChanged()
}
}
}
}
}
}
// --- Uppercase -----------------------------------------------------
Item {
width: panel.rowWidth
height: 22
Text {
anchors.left: parent.left
anchors.right: upToggle.left
anchors.rightMargin: 8
height: parent.height
text: QbzSession.tr("Uppercase", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
QbzToggle {
id: upToggle
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: QbzLyrics.uppercase
onToggled: function (value) {
QbzLyrics.uppercase = value
QbzLyrics.prefsChanged()
}
}
}
// --- Lite fill (perf) -----------------------------------------------
Item {
width: panel.rowWidth
height: 22
Text {
anchors.left: parent.left
anchors.right: liteToggle.left
anchors.rightMargin: 8
height: parent.height
text: QbzSession.tr("Lite fill (saves CPU)", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
QbzToggle {
id: liteToggle
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: QbzLyrics.liteFill
onToggled: function (value) {
QbzLyrics.liteFill = value
QbzLyrics.prefsChanged()
}
}
}
// --- Dimming --------------------------------------------------------
Item {
width: panel.rowWidth
height: 34
Text {
anchors.left: parent.left
anchors.right: dimSelect.left
anchors.rightMargin: 8
height: parent.height
text: QbzSession.tr("Dimming", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
QbzSelect {
id: dimSelect
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
menuWidth: 120
options: [
QbzSession.tr("Off", QbzSession.trRev),
QbzSession.tr("Soft", QbzSession.trRev),
QbzSession.tr("Strong", QbzSession.trRev)
]
currentIndex: QbzLyrics.dimmingMode
onSelected: function (i) {
QbzLyrics.dimmingMode = i
QbzLyrics.prefsChanged()
}
}
}
// --- Footer ----------------------------------------------------------
Item {
width: panel.rowWidth
height: 34
Row {
anchors.right: parent.right
spacing: 8
SettingsButton {
width: 112
text: QbzSession.tr("Copy lyrics", QbzSession.trRev)
enabled: panel.canCopy
onClicked: panel.copyLyrics()
}
SettingsButton {
width: 84
text: QbzSession.tr("Reset", QbzSession.trRev)
onClicked: QbzLyrics.resetPrefs()
}
}
}
}
}
+273
View File
@@ -0,0 +1,273 @@
// 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
}
}
+221
View File
@@ -0,0 +1,221 @@
// Shared lyrics lines renderer — QML port of
// crates/qbz-ui/ui/shell/LyricsLinesView.slint.
//
// Everything arrives through properties; nothing here references the sidebar
// or window geometry, so a future miniplayer / immersive host can mount it
// with its own padding, gap and type scale (neither host exists in the Qt
// port yet).
//
// What it owns:
// - the scrolling body: top/bottom spacers (so the first/last SYNCED line
// can center), one LyricsLineRow per line, the shared scrollbar;
// - AUTO-CENTER: on every active-line change the row's LAYOUT-relative
// geometry is read one frame later (a 16ms one-shot, the same trick the
// Slint view uses for its recenter epoch — the row's font-size flip
// resizes it, so measuring in the same pass would read the OLD height)
// and the viewport is moved so the row sits in the middle. SMOOTH for
// moves of <= 2 lines, INSTANT for bigger jumps and the initial sync,
// 1:1 with Slint. The animation drives `contentY` only on the auto-follow
// path, so user wheel input is never animated.
// - the four embedded lyric font families (lazy: a FontLoader only gets a
// source once its option is selected).
//
// Unsynced/plain documents render as a static top-aligned list: no spacers,
// no active line, no scroll-follow.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: view
// ---- data ----------------------------------------------------------
// Array of { timeMs, endMs, text, translation }.
property var lines: []
property bool synced: false
property bool showTranslation: false
// The LyricsSyncEngine instance driving the highlight.
property var sync: null
readonly property int activeIndex: view.sync ? view.sync.activeIndex : -1
// ---- layout knobs (a mini/immersive host overrides these) -----------
property int sidePadding: 6
property int lineGap: 12
// ---- display prefs --------------------------------------------------
property int sizeInactive: 13
property int sizeActive: 15
property int dimmingMode: 2
property bool autoFollow: true
property bool uppercase: false
// 0 System · 1 LINE Seed JP · 2 Montserrat · 3 Noto Sans · 4 Source Sans 3
property int fontIndex: 0
property color activeColor: theme.accent
property bool liteFill: false
// Seek request from a line click (the host decides whether to honour it).
signal seekRequested(int timeMs)
QbzTheme { id: theme }
readonly property real contentWidth: Math.max(0, width - 2 * sidePadding)
// ---- embedded lyric fonts (lazy) -------------------------------------
readonly property string fontDir: "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/fonts/"
FontLoader { id: fLineSeed; source: view.fontIndex === 1 ? view.fontDir + "LINESeedJP-Regular.ttf" : "" }
FontLoader { id: fMontserrat; source: view.fontIndex === 2 ? view.fontDir + "Montserrat-VariableFont_wght.ttf" : "" }
FontLoader { id: fNoto; source: view.fontIndex === 3 ? view.fontDir + "NotoSans-VariableFont_wdth,wght.ttf" : "" }
FontLoader { id: fSource; source: view.fontIndex === 4 ? view.fontDir + "SourceSans3-VariableFont_wght.ttf" : "" }
// "" = the window default (Inter) = Slint's "System" option. A family
// that has not finished loading falls back to the default rather than
// rendering in a wrong face.
readonly property string fontFamily: {
if (view.fontIndex === 1) return fLineSeed.status === FontLoader.Ready ? fLineSeed.name : ""
if (view.fontIndex === 2) return fMontserrat.status === FontLoader.Ready ? fMontserrat.name : ""
if (view.fontIndex === 3) return fNoto.status === FontLoader.Ready ? fNoto.name : ""
if (view.fontIndex === 4) return fSource.status === FontLoader.Ready ? fSource.name : ""
return ""
}
// ---- auto-center scroll ----------------------------------------------
// Last index centered on; -1 = nothing yet -> the next center is INSTANT.
property int lastCentered: -1
readonly property real spacerHeight: Math.max(0, flick.height / 2 - sizeActive * 1.5)
onActiveIndexChanged: {
if (view.activeIndex < 0)
view.lastCentered = -1
centerTimer.restart()
}
onLinesChanged: {
view.lastCentered = -1
flick.contentY = 0
}
Timer {
id: centerTimer
interval: 16
repeat: false
onTriggered: view.centerActive()
}
function centerActive() {
if (!view.autoFollow || !view.synced)
return
var idx = view.activeIndex
if (idx < 0)
return
var item = rows.itemAt(idx)
if (!item)
return
var max = Math.max(0, flick.contentHeight - flick.height)
var target = Math.max(0, Math.min(
item.y + item.height / 2 - flick.height / 2, max))
// Smooth for adjacent moves (<= 2 lines), INSTANT for bigger jumps
// and the initial sync.
var smooth = view.lastCentered >= 0 && Math.abs(idx - view.lastCentered) <= 2
view.lastCentered = idx
scrollAnim.stop()
if (smooth) {
scrollAnim.to = target
scrollAnim.start()
} else {
flick.contentY = target
}
}
NumberAnimation {
id: scrollAnim
target: flick
property: "contentY"
duration: 260
easing.type: Easing.InOutQuad
}
Flickable {
id: flick
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: body.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: body
width: flick.width
leftPadding: view.sidePadding
rightPadding: view.sidePadding
spacing: view.lineGap
// Top spacer — synced docs only (so line 0 can center).
Item {
width: 1
height: view.synced ? view.spacerHeight : 10
}
Repeater {
id: rows
model: view.lines
// The delegate root is a plain Item so the click target can
// anchors.fill it — anchoring a child INSIDE the row's Column
// would fight the positioner.
delegate: Item {
id: rowSlot
required property var modelData
required property int index
width: view.contentWidth
height: lineRow.height
LyricsLineRow {
id: lineRow
contentWidth: view.contentWidth
lineText: rowSlot.modelData.text !== undefined ? rowSlot.modelData.text : ""
translationText: rowSlot.modelData.translation !== undefined
? rowSlot.modelData.translation : ""
lineIndex: rowSlot.index
sync: view.sync
synced: view.synced
activeIndex: view.activeIndex
sizeInactive: view.sizeInactive
sizeActive: view.sizeActive
dimmingMode: view.dimmingMode
uppercase: view.uppercase
fontFamily: view.fontFamily
activeColor: view.activeColor
liteFill: view.liteFill
showTranslation: view.showTranslation
}
// Click-to-seek on stamped lines. NOT in the Slint
// reference (which has neither click-to-seek nor a
// user-scroll pause) — kept from the existing Qt panel
// rather than removing working behaviour; see the report.
MouseArea {
anchors.fill: parent
enabled: rowSlot.modelData.timeMs !== null
&& rowSlot.modelData.timeMs !== undefined
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: view.seekRequested(rowSlot.modelData.timeMs)
}
}
}
// Bottom spacer — synced docs only (so the last line can center).
Item {
width: 1
height: view.synced ? view.spacerHeight : 10
}
}
}
QbzScrollBar {
target: flick
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
+173 -119
View File
@@ -2,18 +2,23 @@
// 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 lines view), source attribution line. Synced rendering:
// LINE-level highlight + auto-follow driven by the 1 Hz position feed
// (QbzPlayer.npElapsedSecs; the Slint engine ticks ~30 Hz and can fill
// word-level karaoke — both noted POC-NOTEs). Lines with timestamps are
// click-to-seek (LyricsLinesView behavior).
// 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.
//
// POC-NOTEs: display settings flyout (size/dimming/uppercase/font/active
// color — Slint defaults hardcoded here: 13/15px, accent, auto-follow
// ON, no dimming of surrounding lines), the translate toggle, word-level
// karaoke, the LRCLIB contribute link is rendered but opens via the
// system browser like upstream.
// 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
@@ -25,33 +30,38 @@ Rectangle {
color: "transparent"
clip: true
readonly property var doc: JSON.parse(QbzShell.lyricsJson)
readonly property var lines: doc.lines || []
readonly property bool synced: doc.synced === true
// Active line (line-level sync, 1 Hz cadence).
readonly property int activeIndex: {
if (!synced) return -1
var nowMs = (QbzPlayer.npElapsedSecs || 0) * 1000
var idx = -1
for (var i = 0; i < lines.length; i++) {
if (lines[i].timeMs !== null && lines[i].timeMs <= nowMs) idx = i
}
return idx
}
QbzTheme { id: theme }
// Auto-follow hook (the poll updates npElapsedSecs 1 Hz).
onActiveIndexChanged: followActive()
function followActive() {
if (root.activeIndex < 0 || linesColumn.children.length === 0) return
var row = linesColumn.children[Math.min(root.activeIndex, linesColumn.children.length - 1)]
var target = row.y + row.height / 2 - linesFlick.height / 2
target = Math.max(0, Math.min(target, linesFlick.contentHeight - linesFlick.height))
if (Math.abs(linesFlick.contentY - target) > 30) {
followAnim2.to = target
followAnim2.restart()
// 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()
}
}
@@ -79,9 +89,9 @@ Rectangle {
font.letterSpacing: 1.5
}
Text {
visible: QbzPlayer.npTitle !== ""
visible: QbzLyrics.trackTitle !== ""
width: parent.width
text: QbzPlayer.npTitle + " - " + QbzPlayer.npArtist
text: QbzLyrics.trackTitle + " - " + QbzLyrics.trackArtist
color: theme.textSecondary
font.pixelSize: 12
elide: Text.ElideRight
@@ -93,8 +103,9 @@ Rectangle {
anchors.rightMargin: theme.spacingSm
anchors.verticalCenter: parent.verticalCenter
spacing: 8
// Display settings (IconButton) — opens the flyout stub.
// Display settings — opens the controls flyout.
Rectangle {
id: dsButton
width: 32
height: 32
radius: theme.radiusSm
@@ -111,7 +122,10 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: dsFlyout.open()
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).
@@ -145,12 +159,13 @@ Rectangle {
// --- Body by status ----------------------------------------------
Item {
id: body
width: parent.width
height: parent.height - 57 - sourceLine.height
height: parent.height - 57 - sourceLine.height - noticeStrip.height
// Loading.
Column {
visible: doc.status === 1
visible: root.doc.status === 1
anchors.centerIn: parent
spacing: 10
QbzSpinner { size: 24; anchors.horizontalCenter: parent.horizontalCenter }
@@ -164,7 +179,8 @@ Rectangle {
// No lyrics (idle / not-found / ready-with-zero-lines).
Column {
visible: doc.status === 0 || doc.status === 3 || (doc.status === 2 && root.lines.length === 0)
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
@@ -197,7 +213,7 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Qt.openUrlExternally("https://lrclibup.boidu.dev/")
onClicked: QbzShell.openExternalUrl("https://lrclibup.boidu.dev/")
}
}
}
@@ -205,7 +221,7 @@ Rectangle {
// Offline with nothing cached.
Text {
visible: doc.status === 5
visible: root.doc.status === 5
anchors.centerIn: parent
width: parent.width - 32
text: QbzSession.tr("Lyrics unavailable offline", QbzSession.trRev)
@@ -217,79 +233,122 @@ Rectangle {
// Error.
Text {
visible: doc.status === 4
visible: root.doc.status === 4
anchors.centerIn: parent
width: parent.width - 32
text: doc.error || ""
color: "#e0564f"
text: root.doc.error !== undefined ? root.doc.error : ""
color: theme.danger
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
// Lyrics lines (status 2, lines > 0).
Flickable {
id: linesFlick
visible: doc.status === 2 && root.lines.length > 0
// The lines (status 2, lines > 0).
LyricsLinesView {
id: linesView
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: linesColumn.implicitHeight
boundsBehavior: Flickable.StopAtBounds
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)
}
}
Column {
id: linesColumn
width: parent.width
leftPadding: theme.spacingMd
rightPadding: theme.spacingMd
topPadding: theme.spacingMd
bottomPadding: 48
spacing: 10
Repeater {
model: root.lines
delegate: Text {
required property var modelData
required property int index
property bool isActive: index === root.activeIndex
width: linesColumn.width
text: modelData.text
// Synced: active line = accent, 15px; the rest
// secondary 13px. Unsynced: uniform secondary.
color: !root.synced ? theme.textSecondary
: isActive ? theme.accent : theme.textSecondary
font.pixelSize: root.synced && isActive ? 15 : 13
font.weight: root.synced && isActive ? theme.weightSemibold : theme.weightRegular
wrapMode: Text.WordWrap
MouseArea {
anchors.fill: parent
enabled: modelData.timeMs !== null
cursorShape: modelData.timeMs !== null ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (modelData.timeMs !== null && QbzPlayer.npDurationSecs > 0)
QbzPlayer.seek(modelData.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)
}
}
}
// Auto-follow hook (the poll updates npElapsedSecs 1 Hz).
NumberAnimation {
id: followAnim2
target: linesFlick
property: "contentY"
duration: 250
easing.type: Easing.InOutQuad
// --- 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: doc.status === 2 && root.lines.length > 0
visible: root.ready
width: parent.width
leftPadding: theme.spacingSm
rightPadding: theme.spacingSm
@@ -303,8 +362,8 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: doc.provider === "lrclib" ? "LRCLIB"
: doc.provider === "ovh" ? "lyrics.ovh" : "Qobuz"
text: root.doc.provider === "lrclib" ? "LRCLIB"
: root.doc.provider === "ovh" ? "lyrics.ovh" : "Qobuz"
color: theme.accent
font.pixelSize: 9
font.weight: theme.weightSemibold
@@ -313,26 +372,21 @@ Rectangle {
}
}
// --- Display-settings flyout (visual stub — POC-NOTE: the prefs) ----
// --- Display-settings flyout -----------------------------------------
Popup {
id: dsFlyout
x: parent.width - 260
y: 64
// 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
padding: 14
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceCard
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
}
contentItem: Text {
width: parent ? parent.width : 0
text: "QBZ Qt POC — display settings are not wired yet (Slint defaults apply)"
color: theme.textMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
height: flyoutBody.implicitHeight
padding: 0
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
background: null
contentItem: LyricsControlsFlyout {
id: flyoutBody
canCopy: root.ready
}
}
}
@@ -0,0 +1,114 @@
// Lyrics sync engine — the QML half of crates/qbz/src/lyrics_sync.rs.
//
// Slint runs this as a Rust `slint::Timer` that PUSHES active-index /
// line-progress into a global. cxx-qt has no UI-thread timer primitive and
// pumping 30 cross-thread hops a second would be strictly worse, so here the
// CADENCE lives in QML and the MATH lives in Rust: every tick pulls three
// scalar invokables off QbzLyrics (position, active index, karaoke fraction —
// the same `qbz_lyrics::sync` pure functions, over the same Rust-held parsed
// document, so native Qobuz wsync word stamps drive the fill).
//
// Cadence is ADAPTIVE, 1:1 with the Slint engine:
// - ACTIVE 33ms (~30Hz) while the gate is open (surface shown + doc synced
// + ready + effectively playing);
// - IDLE 250ms otherwise — the tick then only re-checks the gate.
// Every tick recomputes from the ABSOLUTE position, which makes seeks
// inherently safe: `fillAnimMs` is zeroed across discontinuities (line change
// or backward move) so the fill first-paints exactly at the reported
// progress, and smoothed (45ms, just above the tick) while continuous.
// Writes are equality-guarded — an unchanged tick sets nothing, so an idle
// track drives zero repaints.
//
// `kick()` runs one immediate pass that IGNORES the playing gate (doc commit
// / panel open), so the ladder lands on the correct line even while paused.
import QtQuick
import com.blitzfc.qbz
Item {
id: engine
// Host gate: the surface is on screen AND the document is synced+ready.
property bool gateOpen: false
// ---- outputs (bind these; never poll them) ------------------------
readonly property int activeIndex: engine._index
readonly property real lineProgress: engine._progress
readonly property int fillAnimMs: engine._animMs
// Slint FILL_ANIM_MS — slightly above the tick so a late tick does not
// stutter the sweep.
readonly property int smoothAnimMs: 45
property int _index: -1
property real _progress: 0.0
property int _animMs: 0
// Whether the last pass saw playback running (drives the cadence).
property bool _fast: false
visible: false
width: 0
height: 0
Timer {
id: tick
interval: engine._fast ? 33 : 250
repeat: true
running: engine.gateOpen
onTriggered: engine.pump(true)
}
// Gate opening = panel opened mid-song: land on the right line at once.
onGateOpenChanged: {
if (gateOpen)
engine.kick()
else
engine._fast = false
}
// Reset to the pre-first-line state — the host calls this on every doc
// commit so the view's "initial centering is instant" rule applies again.
function reset() {
engine._index = -1
engine._progress = 0.0
engine._animMs = 0
engine._fast = false
}
// One immediate pass that bypasses the playing gate.
function kick() {
engine.pump(false)
}
function pump(requirePlaying) {
if (!engine.gateOpen) {
engine._fast = false
return
}
var playing = QbzLyrics.playing()
if (requirePlaying && !playing) {
// Pause freezes the ladder + fill in place (Slint parity: the
// rAF gate stops, the position holds).
engine._fast = false
return
}
var pos = QbzLyrics.positionMs()
var idx = QbzLyrics.activeIndexAt(pos)
var frac = QbzLyrics.fillFractionAt(pos, idx)
var indexChanged = idx !== engine._index
var backward = frac < engine._progress - 0.0005
// Discontinuity: paint EXACTLY at the reported progress, no smoothing
// across it. Written FIRST so the row's Behavior picks up the new
// duration before the progress write starts the animation.
var anim = (indexChanged || backward) ? 0 : engine.smoothAnimMs
if (engine._animMs !== anim)
engine._animMs = anim
if (indexChanged)
engine._index = idx
if (indexChanged || Math.abs(frac - engine._progress) >= 0.001)
engine._progress = frac
engine._fast = playing
}
}
+175
View File
@@ -0,0 +1,175 @@
// Window-level history-navigation gestures — the QML port of the #653
// handler in crates/qbz-ui/ui/shell/AppShell.slint:244-321.
//
// Two input paths, ONE nav. Both end in QbzShell.navigateBack /
// navigateForward — the same calls the three sacred header buttons make —
// and both honour QbzShell.canBack / canForward, so a back gesture with no
// history does nothing, silently (no beep, no flash, no half-state).
//
// 1. Mouse side buttons (Qt.BackButton / Qt.ForwardButton). They must
// work anywhere in the window, which is why this is a window-level
// layer and not something inside HeaderBar.
// 2. Two-finger horizontal touchpad swipe.
//
// STACKING IS LOAD-BEARING — mount this as the FIRST child of the shell
// root so the whole UI paints and hit-tests on top of it (see GLUE NEEDED
// in the round report). Qt delivers a wheel event front-to-back through
// every item under the cursor until one accepts it
// (QQuickDeliveryAgentPrivate::deliverSinglePointEventUntilAccepted), so a
// scroll only reaches this bottom layer when nothing above consumed it:
//
// * a HORIZONTAL carousel Flickable/ListView CAN flick on x, so it
// accepts horizontal deltas -> the rail scrolls and no navigation
// fires. This is the whole point of the bottom placement;
// * a vertical-only page scroller cannot flick on x, and Qt's Flickable
// only accepts an axis it can actually move with a non-zero delta on
// that axis, so a pure-horizontal delta is left unaccepted and falls
// through to here. Slint reaches the same outcome by the same rule
// (Flickable::is_allowed_scroll_direction rejects when delta.y == 0
// and the viewport does not overflow horizontally).
//
// The wheel handler ALWAYS leaves the event unaccepted, on every path out,
// so no scroll routing anywhere in the shell changes because of this file.
import QtQuick
import com.blitzfc.qbz
MouseArea {
id: gestures
// Side buttons ONLY. Every other button is not in acceptedButtons, so
// Qt never even targets this layer for them and the UI above keeps its
// clicks untouched.
acceptedButtons: Qt.BackButton | Qt.ForwardButton
// No hover grab and no cursorShape assignment: this layer must stay
// invisible to hover states and to the cursor stack above it.
hoverEnabled: false
// Explicit even though it is the default — without it MouseArea drops
// phased (touchpad) wheel events straight to QQuickItem and onWheel
// never runs.
scrollGestureEnabled: true
// --- Tunables (Slint parity) -----------------------------------------
// 70 logical px accumulated in one gesture — AppShell.slint:297. High
// enough that resting fingers or small trackpad noise never trip it,
// low enough that a deliberate swipe crosses it in one motion.
readonly property real swipeThreshold: 70
// Gesture-boundary fallback for platforms that report no scroll phase
// (xcb/XI2 smooth scrolling sends Qt.NoScrollPhase) — AppShell.slint:275
// uses the same 300 ms idle timer, because Slint's PointerScrollEvent
// carries no phase at all.
readonly property int swipeIdleMs: 300
// Notched-wheel fallback: logical px per wheel notch. Slint's winit
// backend converts a line delta with `LineDelta(lx, ly) => (lx * 60.,
// ly * 60.)` (i-slint-backend-winit event_loop.rs:397), and one Qt
// notch is 120 angle units == one line, so 60 px keeps a horizontal
// tilt-wheel behaving exactly like it does in the Slint build (two
// notches to cross the 70 px threshold).
readonly property real pxPerNotch: 60
readonly property int anglePerNotch: 120
// --- Gesture accumulator ---------------------------------------------
// One navigation per gesture. Deltas arrive as wheel events; the
// gesture boundary is the scroll phase when the platform provides one,
// and otherwise the idle timeout below. A direction flip also starts a
// fresh gesture, and `swipeFired` suppresses re-triggers until the
// gesture ends.
property real swipeAccum: 0
property bool swipeFired: false
Timer {
id: swipeIdle
interval: gestures.swipeIdleMs
repeat: false
onTriggered: gestures.resetSwipe()
}
function resetSwipe() {
gestures.swipeAccum = 0
gestures.swipeFired = false
swipeIdle.stop()
}
// --- Mouse side buttons ----------------------------------------------
onPressed: function (mouse) {
if (mouse.button === Qt.BackButton) {
if (QbzShell.canBack)
QbzShell.navigateBack()
} else if (mouse.button === Qt.ForwardButton) {
if (QbzShell.canForward)
QbzShell.navigateForward()
}
}
// --- Two-finger horizontal swipe --------------------------------------
onWheel: function (wheel) {
// MouseArea sets the event ACCEPTED the moment an onWheel handler is
// connected (QQuickMouseArea::wheelEvent -> we.setAccepted(
// isWheelConnected())), so clearing it is mandatory and has to
// happen before any early return — otherwise this layer would
// swallow every scroll the views above it declined.
wheel.accepted = false
// Qt.ScrollBegin / ScrollEnd / ScrollMomentum are Qt::ScrollPhase,
// registered on the QML Qt object via Q_ENUM_NS(ScrollPhase)
// (qnamespace.h:1855 in the Qt 6.11 headers on this box).
if (wheel.phase === Qt.ScrollBegin) {
gestures.resetSwipe()
} else if (wheel.phase === Qt.ScrollEnd) {
gestures.resetSwipe()
return
} else if (wheel.phase === Qt.ScrollMomentum) {
// Kinetic tail after the fingers lift is not a new gesture, and
// it must never fire a second navigation.
return
}
// Touchpads report pixel deltas in logical px (the same unit as
// Slint's delta-x). A notched wheel reports angle deltas only.
var dx = wheel.pixelDelta.x
var dy = wheel.pixelDelta.y
if (dx === 0 && dy === 0) {
dx = wheel.angleDelta.x / gestures.anglePerNotch * gestures.pxPerNotch
dy = wheel.angleDelta.y / gestures.anglePerNotch * gestures.pxPerNotch
}
// Only clearly horizontal-dominant deltas count toward the gesture;
// vertical scroll jitter is ignored (AppShell.slint:285).
if (dx === 0 || Math.abs(dx) <= Math.abs(dy))
return
// A direction flip starts a fresh gesture.
if (gestures.swipeAccum !== 0 && (gestures.swipeAccum > 0) !== (dx > 0)) {
gestures.swipeAccum = dx
gestures.swipeFired = false
} else {
gestures.swipeAccum += dx
}
swipeIdle.restart()
if (gestures.swipeFired || Math.abs(gestures.swipeAccum) < gestures.swipeThreshold)
return
// DIRECTION (macOS/GNOME natural-scrolling convention, identical to
// AppShell.slint:298-312): fingers RIGHT = back, fingers LEFT =
// forward. With libinput natural scrolling ON (the GNOME default)
// fingers right yield a NEGATIVE horizontal delta, hence the signs.
// Qt and Slint share this sign convention — both add the delta to
// the content/viewport offset — so the mapping ports 1:1.
//
// The Slint build lets users who run without natural scrolling flip
// this with AppearanceState.invert-swipe-navigation. That pref has
// no counterpart on QbzShell yet, so this port hard-codes the
// pref's DEFAULT (false / not inverted). No settings row is drawn
// for it: a toggle with nothing behind it would be a no-op control.
if (gestures.swipeAccum < 0) {
if (QbzShell.canBack)
QbzShell.navigateBack()
} else {
if (QbzShell.canForward)
QbzShell.navigateForward()
}
gestures.swipeFired = true
gestures.swipeAccum = 0
}
}
+57 -1
View File
@@ -16,6 +16,7 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../theme"
@@ -57,7 +58,43 @@ Rectangle {
var i
for (i = 0; i < upcoming.length; i++) if (upcoming[i].artUrl) urls.push(upcoming[i].artUrl)
for (i = 0; i < historyRows.length; i++) if (historyRows[i].artUrl) urls.push(historyRows[i].artUrl)
if (urls.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
if (urls.length > 0) {
QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
root.artPulse = true
artPulseOff.restart()
}
}
// --- skeleton pulse (PER-ITEM COVERS ONLY) ---------------------------
// The queue is LOCAL state — it never "loads", so this panel has no
// list-level skeleton ON PURPOSE: there is no asynchronous fetch to
// stand in for. What IS asynchronous is each row's cover, which is why
// the placeholders here are per-item and nothing else.
// GATING RULE: freeze on NOT VISIBLE — the panel collapsed, or the
// window minimized/hidden. NEVER on lost focus (a tiling desktop keeps
// windows visible and unfocused).
property bool skelPhase: false
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
// A cover that never resolves (no art, or a failed fetch) leaves the map
// untouched, so every placeholder is bounded — see QbzSkeleton settleMs.
readonly property int artSettleMs: 2500
property bool artPulse: false
Timer {
id: artPulseOff
interval: root.artSettleMs
onTriggered: root.artPulse = false
}
Timer {
interval: 900
repeat: true
running: root.artPulse && root.visible && root.windowShowing
onTriggered: root.skelPhase = !root.skelPhase
}
function coverPending(url) {
return (url || "") !== "" && (root.coverMap[url] || "") === ""
}
component QueueTab: Text {
@@ -132,6 +169,16 @@ Rectangle {
source: root.coverMap[row.artUrl] || ""
radius: 4
}
// Per-item: this thumbnail clears when ITS cover lands, so
// History fills in progressively rather than all at once.
QbzSkeleton {
variant: "art"
anchors.fill: parent
blockRadius: 4
visible: root.coverPending(row.artUrl)
phase: root.skelPhase
settleMs: root.artSettleMs
}
}
// Title + artist.
@@ -389,6 +436,15 @@ Rectangle {
source: root.currentRow ? (root.coverMap[root.currentRow.artUrl] || "") : ""
radius: 4
}
QbzSkeleton {
variant: "art"
anchors.fill: parent
blockRadius: 4
visible: root.currentRow
? root.coverPending(root.currentRow.artUrl) : false
phase: root.skelPhase
settleMs: root.artSettleMs
}
}
Column {
width: parent.width - 34 - 28 - 2 * 9
+60 -24
View File
@@ -10,12 +10,15 @@
// (ADR-010 mount-site gating).
//
// POC-NOTE: in the Slint app these rows open dropdown flyout menus; the
// flyouts are out of scope — rows here navigate (only "home" exists, so
// only Discover is live) and carry the SidebarDirectRow active treatment
// (surface-hover bg + primary text/icon) for the current section.
// POC-NOTE: the playlist/folder tree below the nav is out of scope; the
// "PLAYLISTS" header + toolbar render for parity and the Slint empty state
// shows ("No playlists yet.").
// flyouts are out of scope — rows here navigate straight to their view and
// carry the SidebarDirectRow active treatment (surface-hover bg + primary
// text/icon) for the current section. Discover / Library / Local Library
// have views; My QBZ does NOT and is therefore rendered disabled (a row
// that clicks into nothing is a defect, not a stub).
// The playlist/folder tree below the nav IS live (sidebar_qt.rs: load,
// sort, search, expand/collapse, drag-drop target); folder CREATION,
// the Playlist Manager and the importer have no seam and are disabled in
// the "..." menu.
import QtQuick
import QtQuick.Controls
@@ -94,12 +97,16 @@ Rectangle {
property string name: ""
property string label: ""
property bool active: root.activeNav === navId
// No view behind this section in this port — render it as
// unavailable rather than as a live row that clicks into nothing.
property bool disabled: false
signal clicked()
width: parent ? parent.width : 0
height: 34
radius: 6
color: (navArea.containsMouse || active) ? theme.surfaceHover : "transparent"
opacity: disabled ? 0.45 : 1.0
color: (!disabled && (navArea.containsMouse || active)) ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
@@ -135,7 +142,8 @@ Rectangle {
MouseArea {
id: navArea
anchors.fill: parent
hoverEnabled: true
enabled: !navRow.disabled
hoverEnabled: !navRow.disabled
cursorShape: Qt.PointingHandCursor
onClicked: navRow.clicked()
}
@@ -185,12 +193,16 @@ Rectangle {
QbzShell.navigateTo("local")
}
}
// GAP: there is no MyQBZ view in this port (AppShell's loader
// maps home/library/local/localalbum/album/artist/settings/
// search/playlist — no "myqbz"). It used to take the active
// treatment on click and go nowhere; it now renders disabled.
NavRow {
navId: "myqbz"
name: "qbz-symbolic"
// Slint: MyQbzBrandingState.label, default "My QBZ".
label: QbzSession.tr("My QBZ", QbzSession.trRev)
onClicked: root.activeNav = "myqbz"
disabled: true
}
}
@@ -399,9 +411,17 @@ Rectangle {
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
// New folder / Manage / Import — visual stubs
// (POC-NOTE: folder management + importer are out
// of scope).
// New folder / Manage playlists / Import — DISABLED.
// GAP, not a half-path: this port has no folder
// creation seam (`sidebar_qt.rs` exposes only
// load/sort/search/toggle-folder — no create/delete),
// no Playlist Manager view, and no importer. They used
// to render as live-looking rows (hover highlight +
// pointing-hand cursor) whose click did nothing at
// all; a control that renders and no-ops is a defect,
// so they now read as unavailable: dimmed, no hover
// treatment, no pointer cursor, no MouseArea. Restore
// the interactive form together with the seam.
Repeater {
model: [
{ "icon": "folder-plus", "label": QbzSession.tr("New folder", QbzSession.trRev) },
@@ -413,26 +433,21 @@ Rectangle {
width: parent ? parent.width : 0
height: 30
radius: 5
color: stubArea.containsMouse ? theme.surfaceHover : "transparent"
color: "transparent"
opacity: 0.4
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 8
QbzIcon { name: modelData.icon; width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
QbzIcon { name: modelData.icon; width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter; tintName: "muted" }
Text {
height: parent.height
text: modelData.label
color: theme.textSecondary
color: theme.textMuted
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: stubArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
}
}
}
}
@@ -578,10 +593,27 @@ Rectangle {
tintName: (rowArea.containsMouse || isActive) ? "primary" : "muted"
}
}
// Name (hidden in mini).
// Name (hidden in mini). A Row is a positioner
// with no stretch, so this width must reserve
// EVERY later cell + its spacing or the excess
// is pushed past the row's right edge and the
// `clip: true` on the Flickable eats it.
// (Sidebar.slint gets this free: its
// HorizontalLayout stretches the name cell.)
// Reserve: 8 + 24 icon slot always; for a
// FOLDER also 8 + 14 chevron, plus 8 + the
// count label while it is laid out. The old
// flat -22 ignored the count, pushing the
// chevron ~15px past the clip — which is why
// the caret vanished on exactly the folders
// that HAVE children (owner report).
Text {
visible: !root.mini
width: parent.width - 24 - (isFolder ? 22 : 0)
width: Math.max(0, parent.width - 32
- (isFolder
? 22 + (countLabel.visible
? countLabel.implicitWidth + 8 : 0)
: 0))
height: parent.height
text: modelData.name
color: (rowArea.containsMouse || isActive) ? theme.textPrimary : theme.textSecondary
@@ -590,8 +622,12 @@ Rectangle {
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Folder count (hover) + chevron.
// Folder count (hover) + chevron. The count is
// hover-FADED, not hidden — opacity does not
// free layout space, so it must stay in the
// name's reserve above at all times.
Text {
id: countLabel
visible: !root.mini && isFolder && modelData.count > 0
height: parent.height
text: modelData.count