feat(qt): immersive split view, lyrics, track info, suggestions, queue tabs (B4)

km
This commit is contained in:
vicrodh committed 2026-08-03 08:07:24 -06:00
1 parent 46c1c42408
commit 0b6e8ed7c9
12 files changed
+2809 -115

No files matched your search

+8 -2
View File
@@ -51,7 +51,7 @@ fn main() {
// (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain
// modules and must NOT be listed — only files that declare a
// #[cxx_qt::bridge] mod belong in this array.
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs"],
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs"],
qml_files: &[
"qml/LoginScreen.qml",
"qml/Main.qml",
@@ -143,7 +143,9 @@ fn main() {
// views/playlistmanager/. B2 shipped the root overlay + the
// header band; B3 adds the atmosphere underlay, the five
// FOCUS panels and the song card / track meta / equalizer;
// B4-B5 add the remaining 8 files here.
// B4 adds the SPLIT panels (lyrics split mount lives in
// ImmersiveView) + the two remaining FOCUS panels; B5 adds
// the player bar + the search cortinilla.
"qml/immersive/AlbumReactivePanel.qml",
"qml/immersive/CoverflowPanel.qml",
"qml/immersive/EqualizerBars.qml",
@@ -152,8 +154,12 @@ fn main() {
"qml/immersive/ImmersiveSongCard.qml",
"qml/immersive/ImmersiveTrackMeta.qml",
"qml/immersive/ImmersiveView.qml",
"qml/immersive/LyricsFocusPanel.qml",
"qml/immersive/QueueTabsPanel.qml",
"qml/immersive/SpectrumPanel.qml",
"qml/immersive/StaticPanel.qml",
"qml/immersive/SuggestionsPanel.qml",
"qml/immersive/TrackInfoPanel.qml",
"qml/immersive/WaveBedPanel.qml",
"qml/theme/QbzIcon.qml",
"qml/theme/QbzScrollBar.qml",
+5
View File
@@ -123,6 +123,11 @@ ApplicationWindow {
// atmosphere_url to this singleton on every track change — a missing
// boot line would drop those publishes silently (see above).
QbzImmersive.boot()
// Immersive Suggestions (the same contract, block B4 §4.5) — booted
// WITH its bridge: every publish the suggestions loader makes rides
// this hop, so a missing line is the forever-"{}" silent no-op the
// comment below warns about (trap 2).
QbzSuggestions.boot()
QbzLocal.boot()
QbzLibrary.boot()
QbzAlbum.boot()
@@ -6,9 +6,9 @@
// LEFT — the view-menu trigger (44x36 glass) + a 256px QbzContextMenu
// popup with the FOCUS and SPLIT groups in SLINT ORDER. Every row
// fires QbzImmersive.setView(...); the data-panel rows also fire
// their §5.5 entry load (Queue / Track Info in B2 — Suggestions is
// B4 with its singleton, Lyrics needs nothing: Qt fetches lyrics
// automatically per track, §5.5).
// their §5.5 entry load (Queue / Track Info / Suggestions —
// Lyrics needs nothing: Qt fetches lyrics automatically per
// track, §5.5).
// CENTER — the QbzLineEdit-based search field, absolutely centered,
// min(band.width, 420) wide. The cortinilla itself ships in B5;
// the Rust search invokables are the inert B1 surface until then
@@ -76,11 +76,11 @@ Item {
// One row model entry: vm 0 = FOCUS (mode m), vm 1 = SPLIT (panel sp).
// `entry` names the §5.5 entry load fired alongside setView:
// "queue" -> QbzQueue.queuePanelOpened()
// "trackinfo" -> QbzAlbum.openTrackInfo(QbzPlayer.npTrackId)
// "" -> none (Lyrics rows: Qt fetches lyrics automatically
// per track, §5.5; Suggestions: setView ONLY in B2 —
// B4 wires QbzSuggestions.load with its singleton).
// "queue" -> QbzQueue.queuePanelOpened()
// "trackinfo" -> QbzAlbum.openTrackInfo(QbzPlayer.npTrackId)
// "suggestions" -> QbzSuggestions.load(QbzPlayer.npTrackId) (B4)
// "" -> none (Lyrics rows: Qt fetches lyrics
// automatically per track, §5.5).
function fireRow(row) {
QbzImmersive.setView(
row.vm === 0 ? 0 : 1,
@@ -90,6 +90,8 @@ Item {
QbzQueue.queuePanelOpened()
else if (row.entry === "trackinfo")
QbzAlbum.openTrackInfo(QbzPlayer.npTrackId)
else if (row.entry === "suggestions")
QbzSuggestions.load(QbzPlayer.npTrackId)
viewMenu.close()
}
@@ -192,7 +194,7 @@ Item {
"label": QbzSession.tr("Lyrics", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 1, "entry": "trackinfo",
"label": QbzSession.tr("Track Info", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 2, "entry": "",
{ "vm": 1, "m": -1, "sp": 2, "entry": "suggestions",
"label": QbzSession.tr("Suggestions", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 3, "entry": "queue",
"label": QbzSession.tr("Queue", QbzSession.trRev) },
+260 -104
View File
@@ -3,14 +3,16 @@
// AppShell AFTER QbzToast and BEFORE ArtPreviewOverlay/QbzTooltip (§5.1
// declaration-order z convention).
//
// BLOCK B2+B3 SCOPE: the overlay shell (root + click-blocker + chrome +
// BLOCK B2+B3+B4 SCOPE: the overlay shell (root + click-blocker + chrome +
// auto-hide + exit paths 2-4 + the fullscreen guard + seek arrows) PLUS the
// B3 panels: the ImmersiveAtmosphere underlay (layer 2), the five FOCUS
// panels (Album Reactive / Static / Coverflow / Spectrum / Wave Bed) and the
// layer-4 ImmersiveSongCard. Lyrics (mode 4) and Queue (mode 5) stay STUBS
// until B4; the SPLIT stubs stay until B4 (ImmersiveTrackMeta is already
// wired under splitArtStub, §6.7); B5 adds the player bar + search
// cortinilla (layer 7 is therefore absent, per contract §12 B2).
// B3 panels (the ImmersiveAtmosphere underlay, the five FOCUS panels Album
// Reactive / Static / Coverflow / Spectrum / Wave Bed, the layer-4
// ImmersiveSongCard) PLUS the B4 panels: FOCUS Lyrics (mode 4,
// LyricsFocusPanel) and Queue (mode 5, QueueTabsPanel), and the full §5.6
// SPLIT layout (50/50: artwork + ImmersiveTrackMeta LEFT, the Lyrics /
// TrackInfo / Suggestions / Queue panels RIGHT) with the §5.5 entry loads.
// B5 adds the player bar + search cortinilla (layer 7 is therefore absent,
// per contract §12 B2).
//
// Layer order bottom->top (§5.1):
// 1. root color #0a0a0b, clip (:1199-1200)
@@ -20,14 +22,17 @@
// clicks pass through to the desktop header search field, the dock's
// spectrum band and the viz eye toggle; port of the Slint root
// TouchArea :1283-1286). Stacked BELOW panels + chrome.
// 3. FOCUS panels (viewMode==0 && mode==N) / SPLIT stubs (viewMode==1)
// 3. FOCUS panels (viewMode==0 && mode==N) / the SPLIT layout (viewMode==1)
// 4. ImmersiveSongCard (viewMode==0 && mode==6 && npHasTrack — trap 19;
// does NOT fade with the chrome, :1602-1605)
// 6. ImmersiveHeader band (layer 5 — the PlayerBar — is B5)
import QtQuick
import QtQuick.Effects
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../shell"
import "../theme"
Item {
@@ -176,13 +181,10 @@ Item {
}
}
// --- Layer 3: the FOCUS panels (B3) + the remaining stubs (B4) ---------
// --- Layer 3: the FOCUS panels (B3 + B4) --------------------------------
// FOCUS: one panel per mode, gated viewMode==0 && mode==N, full-viewport
// — the B2 stubs' 24/64/132 insets are replaced by each panel's own
// Slint-internal reserves (pad-top 52/70, the 132px player clearance;
// §6.1/§6.2). Lyrics (mode 4) and Queue (mode 5) stay stub Rectangles
// until B4; the Queue stub keeps its §5.5 entry load
// (QbzQueue.queuePanelOpened) so the B2 header-menu wiring still works.
// — each panel carries its own Slint-internal reserves (pad-top 52/70,
// the 132px player clearance; §6.1/§6.2/§6.6).
AlbumReactivePanel {
anchors.fill: parent
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 0
@@ -203,100 +205,244 @@ Item {
anchors.fill: parent
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 6
}
Repeater {
model: [
{ "m": 4, "label": QbzSession.tr("Lyrics", QbzSession.trRev) },
{ "m": 5, "label": QbzSession.tr("Queue", QbzSession.trRev) },
]
delegate: Rectangle {
required property var modelData
anchors.fill: parent
anchors.leftMargin: 24
anchors.rightMargin: 24
anchors.topMargin: 64
anchors.bottomMargin: 132
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === modelData.m
radius: 12
color: "#0dffffff"
border.width: 1
border.color: "#2effffff"
Text {
anchors.centerIn: parent
text: modelData.label
color: "#80ffffff"
font.pixelSize: 15
}
onVisibleChanged: if (visible && modelData.m === 5)
QbzQueue.queuePanelOpened()
// FOCUS Lyrics (mode 4, B4 §6.6): the giant-line variant. It shares the
// immersive LyricsSyncEngine with the split lyrics mount (trap 22 — the
// gate covers BOTH lyric surfaces and nothing else). No entry load: Qt
// fetches lyrics automatically per track (§5.5).
LyricsFocusPanel {
anchors.fill: parent
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 4
lines: root.immLyricsLines
synced: root.immLyricsSynced
status: root.immLyricsStatus
sync: immLyricsEngine
}
// FOCUS Queue (mode 5, B4 §6.5): the same QueueTabsPanel as the split
// placement, full-screen (centered: false -> min(vw-96, 720) column).
QueueTabsPanel {
anchors.fill: parent
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 5
centered: false
}
// --- The shared immersive lyrics engine + document (§6.6, trap 22) ------
// ONE LyricsSyncEngine for BOTH immersive lyric surfaces (FOCUS mode 4
// AND SPLIT panel 0); the gate is the §6.6 formula verbatim
// (lyrics_sync.rs:214-217 parity). The QbzLyrics doc fields are parsed
// once here and handed to both mounts.
readonly property var immLyricsDoc: {
try {
return JSON.parse(QbzLyrics.docJson)
} catch (e) {
return ({})
}
}
readonly property var immLyricsLines: root.immLyricsDoc.lines !== undefined
&& root.immLyricsDoc.lines !== null ? root.immLyricsDoc.lines : []
readonly property bool immLyricsSynced: root.immLyricsDoc.synced === true
readonly property int immLyricsStatus: root.immLyricsDoc.status !== undefined
? root.immLyricsDoc.status : 0
LyricsSyncEngine {
id: immLyricsEngine
gateOpen: QbzImmersive.open
&& ((QbzImmersive.viewMode === 0 && QbzImmersive.mode === 4)
|| (QbzImmersive.viewMode === 1 && QbzImmersive.splitPanel === 0))
}
// Every doc commit resets the ladder and re-lands on the right line (the
// LyricsPanel.qml:60-66 idiom — the Slint kick() on commit/panel open).
Connections {
target: QbzLyrics
function onDocJsonChanged() {
immLyricsEngine.reset()
immLyricsEngine.kick()
}
}
// SPLIT (viewMode==1): left artwork placeholder + right plate with one
// stub per splitPanel. The real 50/50 layout lands in B4 (§5.6/D1).
// --- Layer 3 (viewMode==1): the SPLIT layout (§5.6/D1) -------------------
// HorizontalLayout padding-top 56 bottom 132 sides 56 spacing 48
// (:1423-1427); LEFT column EXPLICIT width (root.width - 160)/2 — the
// shipped Slint is a true 50/50 (:1435,:1516; the stale "55/45"/"11:9"
// comments at :1406-1410,:1503-1510 are wrong, D1). Artwork + the B3
// ImmersiveTrackMeta LEFT; the active data panel RIGHT, on a pad-20
// clipped plate with NO glass backing (owner removed it,
// :1484-1487,:1517-1518).
Item {
id: splitRoot
anchors.fill: parent
anchors.leftMargin: 24
anchors.rightMargin: 24
anchors.topMargin: 64
anchors.bottomMargin: 132
visible: QbzImmersive.viewMode === 1
Rectangle {
id: splitArtStub
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: Math.max(240, Math.min(parent.width / 2 - 24, parent.height - 200))
height: width
radius: 12
color: "#0dffffff"
border.width: 1
border.color: "#2effffff"
}
// B3 seam (§6.7): the real 50/50 SPLIT left column lands in B4
// (§5.6/D1); ImmersiveTrackMeta is already wired under the artwork
// placeholder so the SPLIT gate renders something real.
ImmersiveTrackMeta {
anchors.top: splitArtStub.bottom
anchors.topMargin: 20
anchors.left: splitArtStub.left
width: splitArtStub.width
}
Repeater {
model: [
{ "sp": 0, "label": QbzSession.tr("Lyrics", QbzSession.trRev) },
{ "sp": 1, "label": QbzSession.tr("Track Info", QbzSession.trRev) },
{ "sp": 2, "label": QbzSession.tr("Suggestions", QbzSession.trRev) },
{ "sp": 3, "label": QbzSession.tr("Queue", QbzSession.trRev) },
]
delegate: Rectangle {
required property var modelData
anchors.left: splitArtStub.right
anchors.leftMargin: 48
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
visible: QbzImmersive.viewMode === 1
&& QbzImmersive.splitPanel === modelData.sp
radius: 12
color: "#0dffffff"
border.width: 1
border.color: "#2effffff"
Text {
anchors.centerIn: parent
text: modelData.label
color: "#80ffffff"
font.pixelSize: 15
// col-h: the column height below the header/player clearance
// (:1415). colW: the explicit half (:1435).
readonly property real colW: (root.width - 160) / 2
readonly property real colH: root.height - 56 - 132
Row {
anchors.fill: parent
anchors.topMargin: 56
anchors.bottomMargin: 132
anchors.leftMargin: 56
anchors.rightMargin: 56
spacing: 48
// LEFT (50%): artwork + the shared metadata block, centered in
// its half (:1433-1501), spacing 28.
Item {
id: leftCol
width: splitRoot.colW
height: parent.height
// Native source resolution (physical px) of the active
// cover, so the art is never UPSCALED beyond its own size
// (:1449-1452) — the AlbumReactivePanel.qml artProbe idiom
// (a hidden probe Image on the same file:// cache path; the
// shared image cache makes the second decode free).
Image {
id: splitArtProbe
visible: false
source: QbzPlayer.npArtworkPath
}
onVisibleChanged: {
if (!visible)
return
if (modelData.sp === 3)
QbzQueue.queuePanelOpened()
else if (modelData.sp === 1)
QbzAlbum.openTrackInfo(QbzPlayer.npTrackId)
// sp==2 Suggestions: entry load lands in B4 with the
// QbzSuggestions singleton (contract §12 cross-block rule:
// no QML may reference QbzSuggestions before B4).
readonly property real srcNative: splitArtProbe.status === Image.Ready
? splitArtProbe.implicitWidth : 0
// max(240, min(colW, colH-200, native)) (:1453-1455).
readonly property real artSize: Math.max(240,
Math.min(splitRoot.colW, splitRoot.colH - 200, leftCol.srcNative))
Column {
anchors.centerIn: parent
width: parent.width
spacing: 28
// Artwork square, radius 12, shadow 40/#00000099/12
// (:1458-1476).
Item {
width: parent.width
height: leftCol.artSize
// Static drop shadow, offset-y 12 — the
// CoverflowPanel MultiEffect idiom (blur 40 of
// blurMax 64); no shaders -> a plain offset rect.
Rectangle {
x: (parent.width - leftCol.artSize) / 2
y: 12
width: leftCol.artSize
height: leftCol.artSize
radius: 12
color: "#99000000"
layer.enabled: !root._noShaders
layer.effect: MultiEffect {
blurEnabled: true
blurMax: 64
blur: 0.625 // 40/64
}
}
// Neutral placeholder while the cover is unresolved.
Rectangle {
visible: QbzPlayer.npArtworkPath === ""
x: (parent.width - leftCol.artSize) / 2
width: leftCol.artSize
height: leftCol.artSize
radius: 12
color: "#14ffffff"
}
RoundedImage {
x: (parent.width - leftCol.artSize) / 2
width: leftCol.artSize
height: leftCol.artSize
visible: QbzPlayer.npArtworkPath !== ""
radius: 12
source: QbzPlayer.npArtworkPath
}
}
// The shared metadata block (:1484-1500). The Slint
// meta-plate is padding-only (the glass fill was removed
// by the owner) — 22px side gutters so the elided rows
// have a real width to truncate against.
ImmersiveTrackMeta {
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 44
equalizerTint: "#7c3aed"
}
}
}
// RIGHT (50%): the active split panel on a clipped plate, NO
// glass backing (:1511-1596). The panel's inner box is inset by
// the 20px pad on all sides (:1522-1527).
Item {
width: splitRoot.colW
height: parent.height
clip: true
Item {
anchors.fill: parent
anchors.margins: 20
clip: true
// splitPanel 0: the shared lyrics lines view with the
// §6.6 immersive knobs EXACT (:1528-1549). The sizes are
// computed from the WINDOW width, not the column
// (:1533-1537). Slint's show-attribution: false has NO
// Qt twin (LyricsLinesView.qml has no such property and
// no attribution row — D13, nothing to set).
LyricsLinesView {
anchors.fill: parent
visible: QbzImmersive.splitPanel === 0
lines: root.immLyricsLines
synced: root.immLyricsSynced
showTranslation: QbzLyrics.showTranslation
sync: immLyricsEngine
sidePadding: 8
lineGap: Math.round(Math.max(16,
Math.min(root.width * 0.014, 26)))
sizeInactive: Math.round(Math.max(24,
Math.min(root.width * 0.018, 34)))
sizeActive: Math.round(Math.max(28,
Math.min(root.width * 0.022, 42)))
dimmingMode: QbzLyrics.dimmingMode
autoFollow: true
uppercase: QbzLyrics.uppercase
fontIndex: QbzLyrics.fontIndex
// Solid white active line in immersive
// (data-panels.md §2, :1548).
activeColor: "#ffffff"
onSeekRequested: function (timeMs) {
if (QbzPlayer.npDurationSecs > 0)
QbzPlayer.seek(timeMs / 1000 / QbzPlayer.npDurationSecs)
}
}
// splitPanel 1: the inline Track Info credits panel
// (§6.3). Entry load at mount + reload on track change
// below (:1557-1560, §5.5).
TrackInfoPanel {
anchors.fill: parent
visible: QbzImmersive.splitPanel === 1
onVisibleChanged: if (visible)
QbzAlbum.openTrackInfo(QbzPlayer.npTrackId)
}
// splitPanel 2: the live Suggestions panel (§6.4). Entry
// load at mount + reload on track change below
// (:1574-1577, §5.5).
SuggestionsPanel {
anchors.fill: parent
visible: QbzImmersive.splitPanel === 2
onVisibleChanged: if (visible)
QbzSuggestions.load(QbzPlayer.npTrackId)
}
// splitPanel 3: the Queue + History panel, centered in
// the right half (§6.5; same component as the focus
// mount, centered: true fills the slot). The panel
// self-fires queuePanelOpened() when it becomes visible
// (:253-255).
QueueTabsPanel {
anchors.fill: parent
visible: QbzImmersive.splitPanel === 3
centered: true
}
}
}
}
@@ -315,17 +461,27 @@ Item {
&& QbzPlayer.npHasTrack
}
// §5.5: TrackInfo RELOADS on track change while its panel is mounted
// (:1557-1560). The Suggestions twin of this reload is B4.
// §5.5: TrackInfo + Suggestions RELOAD on track change while their panel
// is mounted (:1557-1560, :1574-1577 — the Slint `changed live-track-id`
// watchers; the dedup guard lives in the Rust loaders).
Connections {
target: QbzPlayer
function onNpTrackIdChanged() {
if (root.visible && QbzImmersive.viewMode === 1
&& QbzImmersive.splitPanel === 1)
if (!root.visible || QbzImmersive.viewMode !== 1)
return
if (QbzImmersive.splitPanel === 1)
QbzAlbum.openTrackInfo(QbzPlayer.npTrackId)
else if (QbzImmersive.splitPanel === 2)
QbzSuggestions.load(QbzPlayer.npTrackId)
}
}
// No-shader renderer detection, verbatim from SpectrumBand.qml (the
// SPLIT artwork drop shadow is a MultiEffect blur; no shaders -> plain
// offset rect).
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
// --- Exit paths 3 + seek arrows (§5.4, §7) -------------------------------
// Escape on the root: dismisses the search cortinilla FIRST when open
// (§3.4), exits immersive otherwise. The search field's own Escape
@@ -0,0 +1,145 @@
// 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
}
}
}
@@ -0,0 +1,686 @@
// QueueTabsPanel — the immersive Queue/History panel (§6.5 of the
// 2026-08-02 immersive-port contract), port of
// crates/qbz-ui/ui/immersive/ImmersiveQueuePanel.slint:253-543. ONE component
// used in BOTH placements: the FOCUS full-screen mount (mode == 5,
// `centered: false`) and the SPLIT right slot (split-panel == 3,
// `centered: true`) — mirroring Tauri's single QueuePanel.svelte.
//
// Two sub-tabs ("Queue" / "History"). The QUEUE tab shows a highlighted NOW
// PLAYING row plus a scrolling UP NEXT list sliced off the FLAT coverflow
// document; the HISTORY tab shows the most-recent-first history list. The
// playback logic is NOT duplicated — rows only fire QbzQueue invokables:
// up-next row at flat index f -> queuePlayUpcomingFlat(f - index - 1) (the
// QUEUE-WIDE flat invokable, §4.4/trap 23 — NEVER the page-local
// queuePlayUpcoming); history row i -> queuePlayHistory(i) (already
// queue-wide, most-recent-first).
//
// BADGES (verified formulas, §6.5): Queue = max(0, coverflow.tracks.length -
// coverflow.index - 1) — from the FLAT coverflow model, NOT the paged
// queueJson (:267-268); History = queueJson.history.length (:304).
//
// Covers: both documents carry artUrl only; resolution rides the shared
// artwork pipeline (QbzShell.sidebarArtworkWindow dispatch +
// QbzLibrary.libraryArtworkReady -> coverMap), the CoverflowPanel.qml:81-104
// idiom. The Slint `upcoming-count` art-decode window widening is
// Slint-Rust-internal — nothing to port (D9).
//
// The panel re-pulls the queue snapshot when it becomes visible
// (QueueState.panel-opened, :253-255) — with Qt's eager instantiation +
// visible-gating the visible false->true edge IS the conditional mount.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
// true in the split (centered) placement, false in focus full-screen
// (:244).
property bool centered: true
// --- Data -------------------------------------------------------------
// Guarded try/catch (trap 15); the bridges seed full-shape defaults so
// the fallbacks are paranoia, not the steady state.
readonly property var queueDoc: {
try {
var d = JSON.parse(QbzQueue.queueJson)
if (d)
return d
} catch (e) {
}
return ({})
}
readonly property var cfDoc: {
try {
var d = JSON.parse(QbzQueue.coverflowJson)
if (d && d.tracks)
return d
} catch (e) {
}
return { "index": 0, "tracks": [] }
}
// Doc FIELD reads go through plain functions, NEVER chained readonly
// vars: `readonly property var x: queueDoc.field` stays UNDEFINED in the
// app context (measured 2026-08-03, offscreen probe) — the binding is
// never installed. The TrackInfoModal.qml parseDoc() idiom re-reads the
// doc per call and the binding dependency is captured through the call.
function historyRows() {
return root.queueDoc.history || []
}
function hasCurrentTrack() {
return root.queueDoc.hasCurrent === true
}
function currentTrack() {
return hasCurrentTrack() ? root.queueDoc.current : null
}
// The upcoming entries are the slice of the coverflow tracks AFTER the
// now-playing index; their COUNT drives the tab badge + the section
// header. Floored at 0 (:266-268).
readonly property int upcomingCount: Math.max(0,
root.cfDoc.tracks.length - root.cfDoc.index - 1)
// The active sub-tab. Slint stores this in the shared QueueState.tab;
// panel-local state is the same observable behavior within the panel
// (the desktop QueuePanel's tab state is likewise its own).
property int tab: 0
// Becoming visible = the Slint conditional mount -> re-pull the queue
// (:253-255).
onVisibleChanged: if (visible) QbzQueue.queuePanelOpened()
// --- Cover resolution (shared artwork pipeline) ------------------------
property var coverMap: ({})
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
var m = root.coverMap
m[key] = path
root.coverMap = Object.assign({}, m)
}
}
Component.onCompleted: root.dispatchCovers()
onCfDocChanged: root.dispatchCovers()
onQueueDocChanged: root.dispatchCovers()
function dispatchCovers() {
var urls = []
var tracks = root.cfDoc.tracks || []
for (var i = 0; i < tracks.length; i++)
if (tracks[i].artUrl)
urls.push(tracks[i].artUrl)
var hist = root.historyRows()
for (var j = 0; j < hist.length; j++)
if (hist[j].artUrl)
urls.push(hist[j].artUrl)
var cur = root.currentTrack()
if (cur && cur.artUrl)
urls.push(cur.artUrl)
if (urls.length > 0)
QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
}
function coverFor(row) {
return (row && row.artUrl) ? (root.coverMap[row.artUrl] || "") : ""
}
// Content width: focus full-screen caps to a comfortable reading column
// (min(vw-96, 720)); split already lives in the right half and fills it
// (:260-262).
readonly property real contentWidth: root.centered
? root.width : Math.min(root.width - 96, 720)
// --- Inline components -------------------------------------------------
// Explicit "E" badge — the immersive (white-on-glass) variant (:28-44).
component ImmExplicitBadge: Rectangle {
width: 16
height: 16
radius: 3
color: "#29ffffff"
Text {
anchors.centerIn: parent
text: "E"
color: "#d9ffffff"
font.pixelSize: 9
font.weight: Font.DemiBold // 600
}
}
// One Queue / History sub-tab — plain TEXT, NO pill background (ADR-008);
// the active tab is marked by a 2px underline (:51-98).
component SubTab: Rectangle {
id: subTab
property string label: ""
property int count: 0
property bool active: false
signal clicked()
width: tabRow.implicitWidth
height: 34
color: "transparent"
Row {
id: tabRow
height: parent.height
spacing: 5
Text {
text: subTab.label
color: subTab.active ? "#ffffff"
: (tabArea.containsMouse ? "#e6ffffff" : "#99ffffff")
font.pixelSize: 18
font.weight: subTab.active ? Font.Bold : Font.Medium // 700 / 500
Behavior on color { ColorAnimation { duration: 150 } }
}
Text {
visible: subTab.count > 0
text: "(" + subTab.count + ")"
color: subTab.active ? "#b3ffffff" : "#66ffffff"
font.pixelSize: 15
font.weight: Font.Medium
Behavior on color { ColorAnimation { duration: 150 } }
}
}
// Active underline — 2px flush to the bottom (:84-92).
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 2
radius: 1
color: subTab.active ? "#ffffff"
: (tabArea.containsMouse ? "#33ffffff" : "transparent")
Behavior on color { ColorAnimation { duration: 150 } }
}
MouseArea {
id: tabArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: subTab.clicked()
}
}
// Section header — small, uppercase, tracked-out (:102-107). The msgids
// are already caps.
component SectionLabel: Text {
color: "#b3ffffff"
font.pixelSize: 13
font.weight: Font.Bold // 700
font.letterSpacing: 1.2
}
// One UP NEXT / History row (:113-238): 56px, 40x40 cover, title/artist,
// duration, hover-revealed circular play button. Fires play() on click
// OR the play button — the mount maps it to the right QbzQueue call.
component ImmQueueRow: Rectangle {
id: immRow
property var item: null
property bool current: false // NOW PLAYING highlight
property string artSource: ""
signal play()
width: parent ? parent.width : 0
height: 56
radius: 10
// `.track-item.current`: brighter fill + 1px hairline so it reads as
// the anchor over ANY album atmosphere; plain rows lift on hover
// (:122-127).
color: current ? "#24ffffff"
: (rowArea.containsMouse ? "#1affffff" : "transparent")
border.width: current ? 1 : 0
border.color: "#3dffffff"
Behavior on color { ColorAnimation { duration: 150 } }
// The row TouchArea is declared FIRST (Slint :130-133) so the
// content — the hover-revealed play button's own MouseArea — stacks
// ABOVE it; declared last it would swallow the button's clicks.
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: immRow.play()
}
Row {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 8
spacing: 12
// 40x40 cover with a 1px inset hairline (:142-159).
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 40
height: 40
radius: 5
color: "#14ffffff"
border.width: 1
border.color: "#1fffffff"
clip: true
Image {
anchors.fill: parent
source: immRow.artSource
visible: source !== "" && status === Image.Ready
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
// Title + artist (:161-190).
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 40 - 12 - 36 - 12
spacing: 3
Row {
spacing: 6
// The Row's width must be EXPLICIT (the Column's): the
// title's elide width binds against it, so an implicit
// (content-derived) Row width loops the binding and the
// title collapses to 0px — measured over RFB 2026-08-03
// (rows rendered artist-only).
width: parent.width
Text {
text: item ? (item.title || "") : ""
color: "#ffffff"
font.pixelSize: 17
font.weight: current ? Font.DemiBold : Font.Medium // 600 / 500
elide: Text.ElideRight
width: Math.min(implicitWidth,
parent.width - (expBadge.visible ? 22 : 0))
}
ImmExplicitBadge {
id: expBadge
visible: item && item.explicit === true
anchors.verticalCenter: parent.verticalCenter
}
}
Text {
text: item ? (item.artist || "") : ""
color: "#b3ffffff"
font.pixelSize: 15
elide: Text.ElideRight
width: parent.width
}
}
// Duration + hover-revealed play affordance share one trailing
// slot (:192-236).
Item {
anchors.verticalCenter: parent.verticalCenter
width: 36
height: 30
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: item ? (item.duration || "") : ""
color: "#99ffffff"
font.pixelSize: 14
font.weight: Font.Medium
opacity: (!current && rowArea.containsMouse) ? 0.0 : 1.0
Behavior on opacity { NumberAnimation { duration: 150 } }
}
// 30px circular play button; hidden for the current row
// (:213-235).
Rectangle {
visible: !current
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 30
height: 30
radius: 15
color: playArea.containsMouse ? "#59ffffff" : "#33ffffff"
opacity: rowArea.containsMouse || playArea.containsMouse ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
Behavior on color { ColorAnimation { duration: 120 } }
QbzIcon {
name: "play-fill"
width: 13
height: 13
anchors.centerIn: parent
anchors.horizontalCenterOffset: 1
tintName: "white"
}
MouseArea {
id: playArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: immRow.play()
}
}
}
}
}
// --- Centered content column (:271-279) ---------------------------------
// padding-top 56 (clear the top chrome) / padding-bottom 132 (clear the
// floating player) — applied in BOTH placements, as Slint; the 8px side
// padding is focus-only (:277-278).
Item {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.topMargin: 56
anchors.bottomMargin: 132
width: root.contentWidth
Column {
anchors.fill: parent
anchors.leftMargin: root.centered ? 0 : 8
anchors.rightMargin: root.centered ? 0 : 8
spacing: 16
// --- Sub-tab header (:287-354) ----------------------------------
// Tabs LEFT, Clear flush at the TRAILING edge; a fixed 34px row
// height puts the labels and the button on one baseline.
Item {
width: parent.width
height: 34
Row {
anchors.left: parent.left
height: parent.height
spacing: 18
SubTab {
label: QbzSession.tr("Queue", QbzSession.trRev)
count: root.upcomingCount
active: root.tab === 0
onClicked: root.tab = 0
}
SubTab {
label: QbzSession.tr("History", QbzSession.trRev)
count: root.historyRows().length
active: root.tab === 1
onClicked: root.tab = 1
}
}
// Clear-queue button — Queue tab only, when there is
// something upcoming (:316-353). NO pill — a soft bordered
// rectangle (ADR-008).
Rectangle {
visible: root.tab === 0 && root.upcomingCount > 0
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: clearRow.implicitWidth + 24
height: 32
radius: 8
color: clearArea.containsMouse ? "#29ffffff" : "#14ffffff"
border.width: 1
border.color: clearArea.containsMouse ? "#40ffffff" : "#26ffffff"
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
Row {
id: clearRow
anchors.centerIn: parent
spacing: 7
QbzIcon {
name: "trash-2"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "white"
opacity: clearArea.containsMouse ? 1.0 : 0.8
}
Text {
text: QbzSession.tr("Clear", QbzSession.trRev)
color: clearArea.containsMouse ? "#ffffff" : "#ccffffff"
font.pixelSize: 15
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: clearArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzQueue.queueClear()
}
}
}
// Header underline (:356-359).
Rectangle {
width: parent.width
height: 1
color: "#21ffffff"
}
// --- Body (:361-552) --------------------------------------------
Item {
width: parent.width
height: parent.height - 34 - 1 - 32
// ===== QUEUE tab =====
Item {
anchors.fill: parent
visible: root.tab === 0
// NOW PLAYING + UP NEXT, OR the empty state (:368).
Column {
anchors.fill: parent
visible: root.hasCurrentTrack() || root.upcomingCount > 0
spacing: 16
// NOW PLAYING section (:372-381).
Column {
id: nowCol
visible: root.hasCurrentTrack()
width: parent.width
spacing: 10
SectionLabel {
text: QbzSession.tr("NOW PLAYING", QbzSession.trRev)
}
ImmQueueRow {
item: root.currentTrack()
current: true
// The unified now-playing cover is sharper;
// fall back to the row's own art.
artSource: QbzPlayer.npArtworkPath !== ""
? QbzPlayer.npArtworkPath
: root.coverFor(root.currentTrack())
}
}
// UP NEXT section (:383-455). Fills the body left
// under the NOW PLAYING section.
Column {
visible: root.upcomingCount > 0
width: parent.width
height: parent.height
- (nowCol.visible ? nowCol.implicitHeight + 16 : 0)
spacing: 10
Row {
id: upHeader
spacing: 8
SectionLabel {
text: QbzSession.tr("UP NEXT", QbzSession.trRev)
}
SectionLabel {
text: "· " + root.upcomingCount
color: "#80ffffff"
font.weight: Font.Medium
font.letterSpacing: 0.4
}
}
Item {
width: parent.width
height: parent.height - upHeader.implicitHeight - 10
Flickable {
id: upFlick
anchors.fill: parent
// Trailing gutter so rows never tuck
// under the overlaid scrollbar (:413).
contentWidth: width - 16
contentHeight: upList.implicitHeight
boundsBehavior: Flickable.StopAtBounds
clip: true
Column {
id: upList
width: upFlick.contentWidth
// spacing 0 — the inter-row gap is
// baked into each VISIBLE wrapper's
// height (60 = 56 row + 4 gap), with
// the row anchored to the bottom.
// History entries (idx <= index)
// collapse to 0px so a long history
// adds NO phantom top space
// (:414-421).
spacing: 0
Repeater {
model: root.cfDoc.tracks
delegate: Item {
required property int index
required property var modelData
width: upList.width
height: index > root.cfDoc.index ? 60 : 0
visible: index > root.cfDoc.index
ImmQueueRow {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
item: parent.modelData
artSource: root.coverFor(parent.modelData)
// Flat index f ->
// queuePlayUpcomingFlat(
// f - index - 1)
// (:429-441, trap 23).
onPlay: QbzQueue.queuePlayUpcomingFlat(
parent.index - root.cfDoc.index - 1)
}
}
}
}
}
QbzScrollBar {
target: upFlick
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
}
// Empty state — nothing playing and nothing upcoming
// (:459-476).
Column {
visible: !root.hasCurrentTrack() && root.upcomingCount === 0
anchors.centerIn: parent
width: parent.width
spacing: 8
Text {
width: parent.width
text: QbzSession.tr("Your queue is empty", QbzSession.trRev)
color: "#ccffffff"
font.pixelSize: 19
font.weight: Font.DemiBold // 600
horizontalAlignment: Text.AlignHCenter
}
Text {
width: parent.width
text: QbzSession.tr("Play an album or track to get started", QbzSession.trRev)
color: "#80ffffff"
font.pixelSize: 15
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
}
// ===== HISTORY tab =====
Item {
anchors.fill: parent
visible: root.tab === 1
Column {
anchors.fill: parent
visible: root.historyRows().length > 0
spacing: 10
// Section header — parity with QueueSidebar
// "RECENTLY PLAYED" (:485-499).
Row {
id: histHeader
spacing: 8
SectionLabel {
text: QbzSession.tr("RECENTLY PLAYED", QbzSession.trRev)
}
SectionLabel {
text: "· " + root.historyRows().length
color: "#80ffffff"
font.weight: Font.Medium
font.letterSpacing: 0.4
}
}
Item {
width: parent.width
height: parent.height - histHeader.implicitHeight - 10
Flickable {
id: histFlick
anchors.fill: parent
contentWidth: width - 16
contentHeight: histList.implicitHeight
boundsBehavior: Flickable.StopAtBounds
clip: true
Column {
id: histList
width: histFlick.contentWidth
spacing: 4
// history is already most-recent-first,
// and queuePlayHistory(i) indexes it
// directly — no coverflow math here
// (:512-518).
Repeater {
model: root.historyRows()
delegate: ImmQueueRow {
required property int index
required property var modelData
item: modelData
artSource: root.coverFor(modelData)
onPlay: QbzQueue.queuePlayHistory(index)
}
}
}
}
QbzScrollBar {
target: histFlick
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
Column {
visible: root.historyRows().length === 0
anchors.centerIn: parent
width: parent.width
spacing: 8
Text {
width: parent.width
text: QbzSession.tr("Nothing played yet", QbzSession.trRev)
color: "#ccffffff"
font.pixelSize: 19
font.weight: Font.DemiBold
horizontalAlignment: Text.AlignHCenter
}
Text {
width: parent.width
text: QbzSession.tr("Tracks you play will appear here", QbzSession.trRev)
color: "#80ffffff"
font.pixelSize: 15
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
}
}
}
}
}
@@ -0,0 +1,596 @@
// SuggestionsPanel — the SPLIT-right Suggestions panel (split-panel == 2;
// §6.4 of the 2026-08-02 immersive-port contract), port of
// crates/qbz-ui/ui/immersive/ImmersiveSuggestionsPanel.slint:35-477.
//
// A cards row (curated artist-playlist BOOK-collage cards + the seed
// Song-Radio DIAMOND-collage card, each with play/queue/play-next hover
// overlays + a corner badge; the radio card shows a spinner while building)
// and below, a "Recommended tracks" list (play on click). Split-only.
//
// Data: QbzSuggestions.suggestionsJson (§4.5 shape — {loading, error,
// cards, tracks}; status derivations live HERE: loading / error / empty).
// Covers are REMOTE URLs in the doc (the §4.4 coverflowJson precedent):
// resolution rides the shared artwork pipeline
// (QbzShell.sidebarArtworkWindow + QbzLibrary.libraryArtworkReady ->
// coverMap), the CoverflowPanel.qml:81-104 idiom. The Slint artwork-job
// half + the cover0..3 decoded-image slots have no Qt twin.
//
// COLLAGES ARE HAND-ROLLED, cards/PlaylistCollage.qml is NOT reused: that
// component implements the discover MOSAIC (grid cell splits 0.62/0.38,
// 0.34/0.66 with a 2px seam), a different layout FUNCTION from the
// suggestions BOOK collage (three overlapping, vertically-centered tiles at
// 0.62/0.62/0.66 of the well, :116-134) and the radio DIAMOND (four
// overlapping tiles, :143-168) — reuse would fork its layout, which its own
// header forbids.
//
// Slint RRGGBBAA -> Qt #AARRGGBB throughout (e.g. #000000a6 -> #a6000000).
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
// --- Data (§4.5 doc; guarded parse, trap 15) ----------------------------
readonly property var doc: {
try {
var d = JSON.parse(QbzSuggestions.suggestionsJson)
if (d && d.cards)
return d
} catch (e) {
}
return { "loading": false, "error": false, "cards": [], "tracks": [] }
}
// Doc FIELD reads go through plain functions, NEVER chained readonly
// vars (`readonly property var x: doc.field` stays UNDEFINED in the app
// context, measured 2026-08-03) — the TrackInfoModal.qml parseDoc()
// idiom.
function cardsList() {
return root.doc.cards || []
}
function tracksList() {
return root.doc.tracks || []
}
// The four states (:394-441). Slint's empty branch reads artist-id == ""
// — the §4.5 doc keeps that field Rust-side, so the empty state is the
// derived !loading && !error && nothing-loaded (the same observable
// state machine: empty only when a load produced nothing or no load ran).
readonly property bool stateEmpty: !doc.loading && !doc.error
&& root.cardsList().length === 0 && root.tracksList().length === 0
readonly property bool stateLoading: doc.loading
&& root.cardsList().length === 0 && root.tracksList().length === 0
readonly property bool stateError: doc.error
readonly property bool stateLoaded: !doc.error
&& (root.cardsList().length > 0 || root.tracksList().length > 0)
// --- Cover resolution (shared artwork pipeline) ------------------------
property var coverMap: ({})
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
var m = root.coverMap
m[key] = path
root.coverMap = Object.assign({}, m)
}
}
Component.onCompleted: root.dispatchCovers()
onDocChanged: root.dispatchCovers()
function dispatchCovers() {
var urls = []
var cards = root.cardsList()
for (var i = 0; i < cards.length; i++) {
var cu = cards[i].coverUrls || []
for (var j = 0; j < cu.length; j++)
if (cu[j])
urls.push(cu[j])
}
var tracks = root.tracksList()
for (var k = 0; k < tracks.length; k++)
if (tracks[k].artUrl)
urls.push(tracks[k].artUrl)
if (urls.length > 0)
QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
}
function coverFor(url) {
return url ? (root.coverMap[url] || "") : ""
}
// --- Inline components -------------------------------------------------
// Circular overlay action button (:29-61): `primary` = 44px accent disc,
// else a 34px translucent disc with a hairline rim.
component OverlayButton: Rectangle {
property string icon: ""
property bool primary: false
property int iconSize: 16
signal clicked()
width: primary ? 44 : 34
height: primary ? 44 : 34
radius: width / 2
color: primary
? (btnArea.containsMouse ? theme.accentHover : theme.accent)
: (btnArea.containsMouse ? "#33ffffff" : "#1fffffff")
border.width: primary ? 0 : 1
border.color: "#4dffffff"
Behavior on color { ColorAnimation { duration: 120 } }
QbzTheme { id: theme }
QbzIcon {
name: parent.icon
width: parent.iconSize
height: parent.iconSize
anchors.centerIn: parent
tintName: "white"
}
MouseArea {
id: btnArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.clicked()
}
}
// One collage cover tile (:64-76): absolutely placed, radius 4, crop-fit.
// (The Slint tile carries drop-shadow 8/#00000066; per-tile MultiEffect
// shadows inside a scroll list are not worth the FBOs — dropped, the
// TrackInfoModal shadow-drop precedent.)
component CollageTile: Rectangle {
property string artSource: ""
visible: artSource !== ""
radius: 4
clip: true
color: "#40000000"
Image {
anchors.fill: parent
source: parent.artSource
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
// One suggestion card (:81-289): 168x224, collage well 144px on top,
// title/subtitle below; hover overlay with the actions.
component SuggestionCardView: Rectangle {
id: sugCard
property var card: null
readonly property bool isRadio: card && card.kind === "radio"
// Collage slot URLs, guarded (the doc array can be short).
function coverUrl(i) {
var cu = card ? (card.coverUrls || []) : []
return i < cu.length ? cu[i] : ""
}
width: 168
height: 224
radius: 12
color: cardArea.containsMouse ? "#2effffff" : "#1fffffff"
border.width: 1
border.color: cardArea.containsMouse ? "#40ffffff" : "#26ffffff"
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
MouseArea {
id: cardArea
anchors.fill: parent
hoverEnabled: true
}
Column {
anchors.fill: parent
anchors.margins: 12
spacing: 10
// ---- Collage / image well (:104-272) -------------------------
Rectangle {
id: well
width: parent.width
height: 144
radius: 8
color: "#40000000"
clip: true
// Book collage (playlist): 3 covers, left/center/right —
// declared in the Slint paint order (cover0, cover2, then
// cover1 ON TOP, :114-134).
Item {
visible: !isRadio
anchors.fill: parent
CollageTile {
artSource: root.coverFor(sugCard.coverUrl(0))
width: parent.width * 0.62
height: parent.height * 0.82
x: parent.width * 0.06
y: (parent.height - height) / 2
}
CollageTile {
artSource: root.coverFor(sugCard.coverUrl(2))
width: parent.width * 0.62
height: parent.height * 0.82
x: parent.width * 0.32
y: (parent.height - height) / 2
}
CollageTile {
artSource: root.coverFor(sugCard.coverUrl(1))
width: parent.width * 0.66
height: parent.height * 0.88
x: (parent.width - width) / 2
y: (parent.height - height) / 2
}
}
// Diamond collage (radio): up to 4 covers (:138-169).
Item {
visible: isRadio
anchors.fill: parent
CollageTile {
artSource: root.coverFor(sugCard.coverUrl(1))
width: parent.width * 0.62
height: parent.height * 0.62
x: parent.width * 0.04
y: (parent.height - height) / 2
}
CollageTile {
artSource: root.coverFor(sugCard.coverUrl(2))
width: parent.width * 0.62
height: parent.height * 0.62
x: parent.width * 0.34
y: (parent.height - height) / 2
}
CollageTile {
artSource: root.coverFor(sugCard.coverUrl(0))
width: parent.width * 0.64
height: parent.height * 0.64
x: (parent.width - width) / 2
y: parent.height * 0.06
}
CollageTile {
artSource: root.coverFor(sugCard.coverUrl(3))
width: parent.width * 0.5
height: parent.height * 0.5
x: (parent.width - width) / 2
y: parent.height * 0.42
}
}
// Corner badge (:174-197): 30px dark disc; the full-colour
// Qobuz logo (playlist cards — UNTINTED, the SourceIcon.qml
// load-bearing rule) or a white radio glyph ("qbz").
Rectangle {
x: parent.width - width - 6
y: 6
width: 30
height: 30
radius: 15
color: "#a6000000"
Image {
visible: card && card.badge === "qobuz"
width: 20
height: 20
anchors.centerIn: parent
source: "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/brand/qobuz-logo-filled.svg"
fillMode: Image.PreserveAspectFit
sourceSize.width: 20
sourceSize.height: 20
}
QbzIcon {
visible: card && card.badge !== "qobuz"
name: "radio"
width: 18
height: 18
anchors.centerIn: parent
tintName: "white"
}
}
// Radio building spinner — dim veil + spinner (:201-211).
// QbzSpinner is accent-tinted; the Slint spinner is
// #ffffffcc, so this one is hand-rolled (a white
// loader-circle on the same 360deg/1000ms rotation).
Rectangle {
visible: isRadio && card && card.loading === true
anchors.fill: parent
color: "#99000000"
QbzIcon {
name: "loader-circle"
width: 28
height: 28
anchors.centerIn: parent
tintName: "white"
opacity: 0.8
RotationAnimation on rotation {
running: parent.visible
from: 0
to: 360
duration: 1000
loops: Animation.Infinite
}
}
}
// Hover overlay with actions (:214-271). The hover sensor is
// a HoverHandler on the WELL, NOT a MouseArea inside the
// overlay: a MouseArea under the buttons loses containsMouse
// the moment the cursor crosses onto a button, so the
// overlay faded out under the cursor's target (measured over
// RFB 2026-08-03). A HoverHandler does not compete with the
// buttons' MouseAreas, so the veil stays up across the whole
// well — the observable Slint behavior (its ov-ta covers the
// full well, :220). Hovering the title/subtitle shows the
// card fill but NOT the overlay — only the well drives it.
HoverHandler {
id: wellHover
}
Rectangle {
anchors.fill: parent
color: "#99000000"
opacity: wellHover.hovered ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
Row {
visible: !isRadio
anchors.centerIn: parent
spacing: 10
OverlayButton {
icon: "list-end"
onClicked: QbzSuggestions.queuePlaylist(card.playlistId)
}
OverlayButton {
primary: true
icon: "play-fill"
iconSize: 18
onClicked: QbzSuggestions.playPlaylist(card.playlistId)
}
OverlayButton {
icon: "list-end"
onClicked: QbzSuggestions.playNextPlaylist(card.playlistId)
}
}
Row {
visible: isRadio
anchors.centerIn: parent
OverlayButton {
primary: true
icon: "radio"
iconSize: 18
// Gated !loading (:261) — a second tap while the
// session builds is inert.
onClicked: {
if (!card.loading)
QbzSuggestions.startRadio(
card.seedTrackId,
card.seedTrackName,
card.seedArtistId)
}
}
}
}
}
// ---- Title + subtitle (:275-287) ------------------------------
Text {
width: parent.width
text: card ? (card.title || "") : ""
color: "#ffffff"
font.pixelSize: 15
font.weight: Font.DemiBold // 600
elide: Text.ElideRight
}
Text {
width: parent.width
text: card ? (card.subtitle || "") : ""
color: "#b3ffffff"
font.pixelSize: 13
elide: Text.ElideRight
}
}
}
// One recommended-track row (:292-375): 40px cover + title/artist +
// duration, play on click.
component RecTrackRow: Rectangle {
id: recRow
property var track: null
property string artSource: ""
width: parent ? parent.width : 0
height: 56
radius: 8
color: rowArea.containsMouse ? "#21ffffff" : "transparent"
Behavior on color { ColorAnimation { duration: 150 } }
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 10
// Artwork + hover play overlay (:307-337).
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 40
height: 40
radius: 4
clip: true
color: "#14ffffff"
Image {
anchors.fill: parent
source: recRow.artSource
visible: source !== "" && status === Image.Ready
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
Rectangle {
anchors.fill: parent
color: "#80000000"
opacity: rowArea.containsMouse ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
QbzIcon {
name: "play-fill"
width: 14
height: 14
anchors.centerIn: parent
tintName: "white"
}
}
}
// Title + artist (:340-357).
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 40 - 10 - durText.implicitWidth - 10
spacing: 2
Text {
width: parent.width
text: track ? (track.title || "") : ""
color: "#ffffff"
font.pixelSize: 15
font.weight: Font.Medium // 500
elide: Text.ElideRight
}
Text {
width: parent.width
text: track ? (track.artist || "") : ""
color: "#b3ffffff"
font.pixelSize: 13
elide: Text.ElideRight
}
}
// Duration (:359-368).
Text {
id: durText
anchors.verticalCenter: parent.verticalCenter
text: track ? (track.duration || "") : ""
color: "#99ffffff"
font.pixelSize: 13
font.weight: Font.Medium
}
}
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzSuggestions.playTrack(track.id)
}
}
// --- Centered, height-capped content (:386-392) -------------------------
// height min(vh, clamp(340, vh*0.62, 760)) — vh is the plate's height.
Item {
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: Math.min(root.height, Math.max(340, Math.min(root.height * 0.62, 760)))
// ---- Empty (no track selected, :395-413) -------------------------
Column {
visible: root.stateEmpty
anchors.centerIn: parent
spacing: 12
QbzIcon {
name: "radio"
width: 32
height: 32
anchors.horizontalCenter: parent.horizontalCenter
tintName: "white"
opacity: 0.7 // #ffffffb3
}
Text {
text: QbzSession.tr("No track selected", QbzSession.trRev)
color: "#b3ffffff"
font.pixelSize: 18
}
}
// ---- Loading (no content yet, :416-425) ---------------------------
Text {
visible: root.stateLoading
anchors.centerIn: parent
text: QbzSession.tr("Loading suggestions...", QbzSession.trRev)
color: "#ccffffff"
font.pixelSize: 18
}
// ---- Error (:428-437) ---------------------------------------------
Text {
visible: root.stateError
anchors.centerIn: parent
text: QbzSession.tr("Failed to load suggestions", QbzSession.trRev)
color: "#e6ffffff"
font.pixelSize: 18
font.weight: Font.DemiBold // 600
}
// ---- Loaded (:440-489) ---------------------------------------------
Item {
visible: root.stateLoaded
anchors.fill: parent
clip: true
Flickable {
id: flick
anchors.fill: parent
contentWidth: width - 12 // padding-right 12 (:450)
contentHeight: body.implicitHeight
boundsBehavior: Flickable.StopAtBounds
clip: true
Column {
id: body
width: flick.contentWidth
spacing: 24
// Cards row (:454-461).
Row {
visible: root.cardsList().length > 0
spacing: 12
Repeater {
model: root.cardsList()
delegate: SuggestionCardView {
required property var modelData
card: modelData
}
}
}
// Recommended tracks (:464-477).
Text {
visible: root.tracksList().length > 0
text: QbzSession.tr("Recommended tracks", QbzSession.trRev)
color: "#e6ffffff"
font.pixelSize: 14
font.weight: Font.DemiBold // 600
font.letterSpacing: 0.4
}
Column {
visible: root.tracksList().length > 0
width: parent.width
spacing: 4
Repeater {
model: root.tracksList()
delegate: RecTrackRow {
required property var modelData
track: modelData
artSource: root.coverFor(modelData.artUrl)
}
}
}
}
}
QbzScrollBar {
target: flick
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
}
@@ -0,0 +1,139 @@
// TrackInfoPanel — the SPLIT-right Track Info panel (split-panel == 1; §6.3
// of the 2026-08-02 immersive-port contract), the Qt answer to
// crates/qbz-ui/ui/immersive/ImmersiveTrackInfoPanel.slint.
//
// NOT a re-render: the panel mounts the SHARED qml/shell/TrackInfoBody.qml
// through a SHIM host (the QtObject below) that exposes the exact host API
// TrackInfoBody reads (doc/loading/errorText/credits/creditsLeft/
// creditsRight + close() + openArtist + openLabel + openMusician —
// TrackInfoBody.qml:27,:31,:47,:260,:283,:295,:159,:133,:252,:288,:301).
// The shim parses QbzAlbum.trackInfoJson EXACTLY like
// TrackInfoModal.qml:71-90 (same guarded parse, same even/odd credit
// split — info_modals.rs step_by(2)).
//
// DIVERGENCES (contract §6.3/D10):
// * The body's 32x32 close X is a visual element Slint's immersive panel
// doesn't have — close() here is a PANEL-SWITCH to Lyrics
// (setView(1, mode, 0)), not an overlay exit.
// * openLabel / openMusician are INERT in the desktop modal (its TODO
// comments) but LIVE here per the contract: openLabel ->
// QbzHome.openLabel, openMusician -> QbzArtist.resolveMusician,
// openArtist -> QbzArtist.openArtist (the existing artist nav,
// TrackInfoModal.qml:109-114).
// * PARITY: immersive STAYS OPEN on navigation — the shell navigates
// UNDER the overlay (Slint main.rs:15549-15586 closes only
// TrackInfoState, never ImmersiveState.open).
//
// CAVEAT (trap 18): trackInfoJson is ONE global doc — the desktop
// TrackInfoModal and this panel share it; never open both.
//
// Content height: min(vh, clamp(340, vh*0.62, 760)), vertically centered
// (:84-87 — Tauri .centered-layout height clamp(340px, 62vh, 760px)).
import QtQuick
import com.blitzfc.qbz
import "../shell"
import "../theme"
Item {
id: root
// --- The shim host (TrackInfoBody's `host` contract) -------------------
// One QtObject: the parsed document fields + the four actions the body
// calls as `host.close()` / `host.openArtist(id)` / `host.openLabel(id)`
// / `host.openMusician(name, role)`.
QtObject {
id: host
// Verbatim TrackInfoModal.qml:71-90.
readonly property var doc: parseDoc()
function parseDoc() {
try {
return JSON.parse(QbzAlbum.trackInfoJson || "{}")
} catch (e) {
return ({})
}
}
readonly property bool loading: {
try {
return QbzAlbum.trackInfoLoading === true
} catch (e) {
return false
}
}
readonly property string errorText: doc.error || ""
readonly property var credits: doc.credits || []
// Even index -> left column, odd -> right (info_modals.rs step_by(2)).
readonly property var creditsLeft: credits.filter(function (c, i) { return i % 2 === 0 })
readonly property var creditsRight: credits.filter(function (c, i) { return i % 2 === 1 })
// close() — the body's X is a panel-switch to Lyrics (D10).
function close() {
QbzImmersive.setView(1, QbzImmersive.mode, 0)
}
// Navigation — immersive STAYS OPEN (§6.3 parity); the shell
// navigates UNDER the overlay.
function openArtist(artistId) {
if (!artistId || artistId === "")
return
QbzArtist.openArtist(artistId)
}
function openLabel(labelId) {
if (!labelId || labelId === "")
return
QbzHome.openLabel(labelId)
}
function openMusician(name, roleRaw) {
if (!name || name === "")
return
QbzArtist.resolveMusician(name, roleRaw || "")
}
}
// --- Centered, height-capped content (:81-87) --------------------------
Item {
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: Math.min(root.height, Math.max(340, Math.min(root.height * 0.62, 760)))
// ---- Empty (no track / not yet loaded) -------------------------
// Slint :121-129: !loading && error == "" && title == "".
Text {
visible: !host.loading && host.errorText === ""
&& ((host.doc.title || "") === "")
anchors.centerIn: parent
text: QbzSession.tr("No track selected", QbzSession.trRev)
color: "#b3ffffff"
font.pixelSize: 18
}
// ---- Loading / error / loaded — the shared body ---------------
Flickable {
id: flick
anchors.fill: parent
visible: host.loading || host.errorText !== ""
|| ((host.doc.title || "") !== "")
contentWidth: width
contentHeight: body.implicitHeight
boundsBehavior: Flickable.StopAtBounds
clip: true
TrackInfoBody {
id: body
host: host
width: flick.width
}
}
// The Slint mounts a 14px ListScrollbar inset 4px from the right
// (:271-278 via the modal's idiom).
QbzScrollBar {
target: flick
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
+7
View File
@@ -30,6 +30,9 @@ mod viz_bridge;
// QbzImmersive singleton (§3) — open funnel + view persistence + the §3.4
// search surface. The QML overlay lands in B2.
mod immersive_bridge;
// Immersive Suggestions (the same contract, block B4, §4.5): the
// QbzSuggestions singleton — the one genuinely absent domain.
mod suggestions_bridge;
mod local_bridge;
mod library_bridge;
mod album_bridge;
@@ -171,6 +174,10 @@ mod playlist_edit_qt;
mod playlist_qt;
mod queue_qt;
mod search_qt;
// Immersive Suggestions controller (2026-08-02 immersive-port contract §4.5,
// block B4): the loader + action arms behind QbzSuggestions. Plain module —
// no #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files.
mod suggestions_qt;
mod settings_qt;
mod sidebar_qt;
mod sleep_timer_qt;
+8
View File
@@ -234,6 +234,14 @@ pub fn repeat_mode() -> i32 {
with_model(|m| m.repeat_mode).0
}
/// (artist_id, title) of the current track — the read the immersive
/// Suggestions loader makes off the Slint `NowPlayingState`
/// (`main.rs:16699-16702`: the panel only has the track id, the seed artist
/// and name come from here). Empty strings when idle.
pub(crate) fn seed_meta() -> (String, String) {
with_model(|m| (m.artist_id.clone(), m.title.clone())).0
}
pub fn set_playing(playing: bool) {
mutate(|m| m.playing = playing);
}
+173
View File
@@ -0,0 +1,173 @@
//! QbzSuggestions — immersive Suggestions domain bridge (viz_bridge.rs
//! exemplar: OnceLock<CxxQtThread>, `ui()` no-op pre-boot, `boot()`
//! invokable, `impl Default` for construction-seeded values).
//!
//! Port of the Slint `SuggestionsState` + `SuggestionsActions` globals per
//! the immersive-port contract
//! (`qbz-nix-docs/qt-frontend/2026-08-02-immersive-port/00-CONTRACT.md` §4.5,
//! block B4). ONE JSON document with EXACTLY the Slint field set
//! (`state.slint:872-908`, translated to the §4.5 JSON shape):
//! `{loading, error, cards:[{kind,title,subtitle,coverUrls,playlistId,
//! seedTrackId,seedTrackName,seedArtistId,badge,loading}], tracks:[{id,
//! title,artist,artistId,duration,artUrl,explicit}]}` — full-shape default,
//! NEVER "{}" (trap 15). The Slint `error: string` ("" | "error") becomes a
//! bool per the contract; the Slint top-level artist-id/seed-track-id dedup
//! fields stay Rust-side (the panel derives loading/error/empty from this
//! shape alone, §4.5).
//!
//! Covers ride the shared artwork pipeline like `coverflowJson` (§4.4): the
//! document carries remote URLs, QML resolves them through
//! `QbzShell.sidebarArtworkWindow` + `QbzLibrary.libraryArtworkReady` — Qt
//! has no per-slot decoded-image slots (Slint's cover0..cover3).
use std::pin::Pin;
use std::sync::OnceLock;
use cxx_qt::CxxQtThread;
use cxx_qt::Threading as _;
use cxx_qt_lib::QString;
/// Full-shape default (trap 15): QML reads `.cards.length` in the
/// pre-publish frame.
const SUGGESTIONS_EMPTY: &str = r#"{"loading":false,"error":false,"cards":[],"tracks":[]}"#;
#[cxx_qt::bridge]
pub mod qbz_suggestions {
extern "C++" {
include!("cxx-qt-lib/qstring.h");
type QString = cxx_qt_lib::QString;
}
#[auto_cxx_name]
extern "RustQt" {
#[qobject]
#[qml_element]
#[qml_singleton]
// The §4.5 document (see the file header). Written ONLY by
// suggestions_qt through `ui()`.
#[qproperty(QString, suggestions_json)]
type QbzSuggestions = super::QbzSuggestionsRust;
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
/// domain singleton; only QbzSession.boot also fires crate::on_boot).
#[qinvokable]
fn boot(self: Pin<&mut QbzSuggestions>);
/// Entry + now-playing-change refresh (state.slint:919-923). The
/// Rust side reads the artist-id + title off the now-playing model
/// (the Qt NowPlayingState, main.rs:16699-16702 parity). A "" id
/// resets to the empty (no-track) state.
#[qinvokable]
fn load(self: Pin<&mut QbzSuggestions>, track_id: QString);
/// Play / queue / play-next a curated artist playlist by id
/// (state.slint:912-914).
#[qinvokable]
fn play_playlist(self: Pin<&mut QbzSuggestions>, playlist_id: QString);
#[qinvokable]
fn queue_playlist(self: Pin<&mut QbzSuggestions>, playlist_id: QString);
#[qinvokable]
fn play_next_playlist(self: Pin<&mut QbzSuggestions>, playlist_id: QString);
/// Start the seed-track Song Radio: (seed-track-id, seed-track-name,
/// seed-artist-id) — state.slint:916.
#[qinvokable]
fn start_radio(
self: Pin<&mut QbzSuggestions>,
seed_track_id: QString,
seed_track_name: QString,
seed_artist_id: QString,
);
/// Play a single recommended track by id (state.slint:918).
#[qinvokable]
fn play_track(self: Pin<&mut QbzSuggestions>, track_id: QString);
}
impl cxx_qt::Threading for QbzSuggestions {}
}
use qbz_suggestions::QbzSuggestions;
/// Rust side of the suggestions bridge (plain storage, phase-1 pattern).
pub struct QbzSuggestionsRust {
suggestions_json: QString,
}
impl Default for QbzSuggestionsRust {
fn default() -> Self {
Self {
suggestions_json: QString::from(SUGGESTIONS_EMPTY),
}
}
}
// ---------------------------------------------------------------------------
// The UI hop (viz_bridge.rs pattern)
// ---------------------------------------------------------------------------
static QT_THREAD: OnceLock<CxxQtThread<QbzSuggestions>> = OnceLock::new();
/// Queue a suggestions-bridge mutation onto the Qt event loop (no-op before
/// boot registers the thread).
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzSuggestions>) + Send + 'static) {
if let Some(thread) = QT_THREAD.get() {
let _ = thread.queue(f);
}
}
impl qbz_suggestions::QbzSuggestions {
pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() {
log::warn!("[qbz-qt] suggestions Qt thread already registered");
}
}
pub fn load(self: Pin<&mut Self>, track_id: QString) {
crate::suggestions_qt::load(track_id.to_string());
}
pub fn play_playlist(self: Pin<&mut Self>, playlist_id: QString) {
crate::suggestions_qt::play_playlist(playlist_id.to_string());
}
pub fn queue_playlist(self: Pin<&mut Self>, playlist_id: QString) {
crate::suggestions_qt::queue_playlist(playlist_id.to_string());
}
pub fn play_next_playlist(self: Pin<&mut Self>, playlist_id: QString) {
crate::suggestions_qt::play_next_playlist(playlist_id.to_string());
}
pub fn start_radio(
self: Pin<&mut Self>,
seed_track_id: QString,
seed_track_name: QString,
seed_artist_id: QString,
) {
crate::suggestions_qt::start_radio(
seed_track_id.to_string(),
seed_track_name.to_string(),
seed_artist_id.to_string(),
);
}
pub fn play_track(self: Pin<&mut Self>, track_id: QString) {
crate::suggestions_qt::play_track(track_id.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_doc_is_full_shape() {
// trap 15: the construction-seeded default parses and carries all
// four top-level fields — QML derives loading/error/empty from it
// in the pre-publish frame.
let doc: serde_json::Value = serde_json::from_str(SUGGESTIONS_EMPTY).unwrap();
assert_eq!(doc["loading"], false);
assert_eq!(doc["error"], false);
assert!(doc["cards"].is_array());
assert!(doc["tracks"].is_array());
}
}
+771
View File
@@ -0,0 +1,771 @@
//! Immersive Suggestions panel controller (split-only, split-panel == 2) —
//! the Qt port of `crates/qbz/src/suggestions.rs` + the `SuggestionsActions`
//! handlers (`main.rs:16684-16845`), per the immersive-port contract
//! (`qbz-nix-docs/qt-frontend/2026-08-02-immersive-port/00-CONTRACT.md` §4.5,
//! block B4).
//!
//! The loader ports `suggestions::load_suggestions` 1:1 — SAME client calls
//! (`get_artist_detail` with the playlists/tracks_appears_on extras, the
//! `get_artist_tracks` sparse fallback, `get_playlist` for the book-collage
//! covers), same constants (REC 10 / SPARSE 5 / FALLBACK 30 / 2 playlist
//! cards / 3 book covers / 4 radio covers), same deterministic splitmix64
//! shuffle seeded `(artist_id << 1) ^ (track_id + 1)`. Two data products:
//!
//! * RECOMMENDED TRACKS — `artist.tracks_appears_on`, sparse-merged with
//! artist popular tracks, deduped by lowercase title, the current track
//! filtered out, shuffled, take 10.
//! * CARDS — the first 2 curated `artist.playlists` (book collage) + ONE
//! seed "Song Radio" card (diamond collage) trailing them (Tauri order).
//!
//! The document holds REMOTE cover URLs only (the §4.4 coverflowJson
//! precedent): QML resolves them through the shared artwork pipeline
//! (`QbzShell.sidebarArtworkWindow` + `QbzLibrary.libraryArtworkReady`), so
//! the Slint artwork-job half (`suggestions_artwork_jobs` + the cover0..3
//! decoded-image slots) has no Qt twin.
//!
//! The Slint top-level `artist-id` / `seed-track-id` state fields stay
//! RUST-SIDE here: they exist for the load dedup guard
//! (`main.rs:16703-16712`), which is exactly what LOADED_IDS carries. The
//! published document is the §4.5 shape only — the panel derives
//! loading / error / empty from `{loading, error, cards, tracks}` alone.
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use cxx_qt_lib::QString;
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use serde::Serialize;
/// Recommended-track target count (Tauri `slice(0, 10)`).
const REC_LIMIT: usize = 10;
/// Sparse threshold below which the artist-tracks fallback runs (Tauri `< 5`).
const SPARSE_THRESHOLD: usize = 5;
/// Artist-tracks fallback page size (Tauri `limit: 30`).
const FALLBACK_LIMIT: u32 = 30;
/// Max curated playlist cards (Tauri `slice(0, 2)`).
const MAX_PLAYLIST_CARDS: usize = 2;
/// Book-collage cover count per playlist card (Tauri 3).
const BOOK_COVERS: usize = 3;
/// Diamond-collage cover count for the radio card (Tauri max 4).
const RADIO_COVERS: usize = 4;
// ---------------------------------------------------------------------------
// The §4.5 document (EXACT Slint field set, translated)
// ---------------------------------------------------------------------------
/// One suggestion card (`state.slint:872-894 SuggestionCard`). `coverUrls`
/// carries the (up to 4) remote collage URLs — Slint's `cover-urls` input
/// array; the decoded cover0..3 slots are the Slint artwork pipeline's
/// business, not the document's.
#[derive(Clone, Default, Serialize)]
pub struct CardDoc {
/// "playlist" | "radio".
pub kind: String,
pub title: String,
pub subtitle: String,
#[serde(rename = "coverUrls")]
pub cover_urls: Vec<String>,
/// Playlist-card target id ("" for the radio card).
#[serde(rename = "playlistId")]
pub playlist_id: String,
/// Radio-card seed (empty for playlist cards).
#[serde(rename = "seedTrackId")]
pub seed_track_id: String,
#[serde(rename = "seedTrackName")]
pub seed_track_name: String,
#[serde(rename = "seedArtistId")]
pub seed_artist_id: String,
/// "qobuz" (playlist) | "qbz" (radio) — drives the corner badge glyph.
pub badge: String,
/// Radio card: true while the Song Radio session is building (spinner).
pub loading: bool,
}
/// One recommended track row (the §4.5 translation of the Slint TrackItem
/// fields the panel reads: id/title/artist/duration/artwork, plus artistId
/// and explicit per the contract shape).
#[derive(Clone, Default, Serialize)]
pub struct TrackDoc {
pub id: String,
pub title: String,
pub artist: String,
#[serde(rename = "artistId")]
pub artist_id: String,
/// Pre-formatted "m:ss" (Slint `mmss(track.duration)`).
pub duration: String,
#[serde(rename = "artUrl")]
pub art_url: String,
pub explicit: bool,
}
/// The §4.5 top-level shape. `error` is a BOOL (the contract's translation
/// of Slint's `error: "" | "error"` string); `loading` mirrors
/// `SuggestionsState.loading`.
#[derive(Clone, Default, Serialize)]
pub struct SuggestionsDoc {
pub loading: bool,
pub error: bool,
pub cards: Vec<CardDoc>,
pub tracks: Vec<TrackDoc>,
}
/// The live document + the dedup identity. One Mutex for both so the guard
/// and the doc can never disagree.
#[derive(Default)]
struct State {
doc: SuggestionsDoc,
/// (artist-id, seed-track-id) of the last ACCEPTED load — the dedup
/// guard of `main.rs:16703-16712`. Empty pair = the no-track state.
loaded: (String, String),
}
static STATE: Mutex<Option<State>> = Mutex::new(None);
fn with_state<T>(f: impl FnOnce(&mut State) -> T) -> T {
let mut guard = STATE.lock().unwrap();
f(guard.get_or_insert_with(State::default))
}
/// Serialize + hop the current document onto the bridge (Qt thread).
fn publish(doc: &SuggestionsDoc) {
let json = serde_json::to_string(doc).unwrap_or_else(|_| {
r#"{"loading":false,"error":false,"cards":[],"tracks":[]}"#.to_string()
});
crate::suggestions_bridge::ui(move |mut b| {
b.as_mut().set_suggestions_json(QString::from(json.as_str()));
});
}
/// Mutate the stored document and republish.
fn mutate(f: impl FnOnce(&mut SuggestionsDoc)) {
let doc = with_state(|s| {
f(&mut s.doc);
s.doc.clone()
});
publish(&doc);
}
// ---------------------------------------------------------------------------
// The loader (suggestions.rs:119-263, 1:1)
// ---------------------------------------------------------------------------
/// Deterministic splitmix64 step (qbz-radio's RNG family) — verbatim from
/// `suggestions.rs:80-86`; the shuffle MUST stay reproducible per
/// (artist, track) or the panel reshuffles on every reload.
fn splitmix64(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E3779B97F4A7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
z ^ (z >> 31)
}
/// Fisher-Yates shuffle seeded off the (artist, track) ids.
fn shuffle_tracks(tracks: &mut [qbz_models::Track], seed: u64) {
let mut state = seed ^ 0xD1B54A32D192ED03;
for i in (1..tracks.len()).rev() {
let j = (splitmix64(&mut state) % (i as u64 + 1)) as usize;
tracks.swap(i, j);
}
}
/// Best collage cover URL for a track's album (large → best variant).
fn track_album_cover(track: &qbz_models::Track) -> Option<String> {
track
.album
.as_ref()
.and_then(|a| a.image.best().cloned())
.filter(|s| !s.is_empty())
}
/// Album id of a track (for distinct-cover dedupe in the book collage).
fn track_album_id(track: &qbz_models::Track) -> Option<String> {
track
.album
.as_ref()
.map(|a| a.id.clone())
.filter(|s| !s.is_empty())
}
/// "m:ss" (Slint `mmss`, playlist.rs:630).
fn mmss(secs: u32) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
/// `Track` -> the §4.5 track row (the fields of `playlist::to_item` the
/// panel reads, plus `artistId` + `explicit` per the contract shape).
fn to_track_doc(track: &qbz_models::Track) -> TrackDoc {
TrackDoc {
id: track.id.to_string(),
title: track.title.clone(),
artist: track
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default(),
artist_id: track
.performer
.as_ref()
.map(|p| p.id.to_string())
.unwrap_or_default(),
duration: mmss(track.duration),
art_url: track_album_cover(track).unwrap_or_default(),
explicit: track.parental_warning,
}
}
/// A resolved playlist card (book collage of up to 3 distinct album covers).
struct PlaylistCard {
id: String,
name: String,
track_count: u32,
cover_urls: Vec<String>,
}
/// Build a `CardDoc` for a playlist (book collage) — `playlist_to_card`,
/// `suggestions.rs:266-294`.
fn playlist_to_card(card: &PlaylistCard) -> CardDoc {
CardDoc {
kind: "playlist".to_string(),
title: card.name.clone(),
subtitle: qbz_i18n::tf(
"{} track",
"{} tracks",
card.track_count as i64,
&[&card.track_count.to_string()],
),
cover_urls: card.cover_urls.clone(),
playlist_id: card.id.clone(),
seed_track_id: String::new(),
seed_track_name: String::new(),
seed_artist_id: String::new(),
badge: "qobuz".to_string(),
loading: false,
}
}
/// Build the seed "Song Radio" card (diamond collage) — `radio_card`,
/// `suggestions.rs:297-319`. The seed triple comes from the payload.
fn radio_card(
seed_track_id: &str,
seed_track_name: &str,
seed_artist_id: &str,
radio_cover_urls: &[String],
) -> CardDoc {
CardDoc {
kind: "radio".to_string(),
title: qbz_i18n::t("Song Radio"),
subtitle: seed_track_name.to_string(),
cover_urls: radio_cover_urls.to_vec(),
playlist_id: String::new(),
seed_track_id: seed_track_id.to_string(),
seed_track_name: seed_track_name.to_string(),
seed_artist_id: seed_artist_id.to_string(),
badge: "qbz".to_string(),
loading: false,
}
}
/// `load_suggestions` (`suggestions.rs:119-263`): build the (cards, tracks)
/// pair for `artist_id` + `current_track_id`. On the top-level
/// artist-detail failure returns `Err` (drives the panel's error branch);
/// individual playlist-cover fetch failures are tolerated.
async fn load_suggestions(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
artist_id: u64,
current_track_id: u64,
seed_track_name: &str,
) -> Result<SuggestionsDoc, ()> {
let artist = match runtime.core().get_artist_detail(artist_id, None, None).await {
Ok(a) => a,
Err(e) => {
log::error!("[qbz-qt] suggestions get_artist_detail({artist_id}) failed: {e}");
return Err(());
}
};
// ---- Recommended tracks --------------------------------------------
// Base = tracks_appears_on (current track filtered, deduped by title).
let mut rec: Vec<qbz_models::Track> = Vec::new();
let mut seen_titles: HashSet<String> = HashSet::new();
if let Some(container) = artist.tracks_appears_on.as_ref() {
for track in &container.items {
if track.id == current_track_id {
continue;
}
let key = track.title.to_lowercase().trim().to_string();
if key.is_empty() || !seen_titles.insert(key) {
continue;
}
rec.push(track.clone());
}
}
// Sparse fallback: merge artist popular tracks (dedupe by title + id).
if rec.len() < SPARSE_THRESHOLD {
match runtime
.core()
.get_artist_tracks(artist_id, FALLBACK_LIMIT, 0)
.await
{
Ok(container) => {
let existing_ids: HashSet<u64> = rec.iter().map(|t| t.id).collect();
for track in container.items {
if track.id == current_track_id || existing_ids.contains(&track.id) {
continue;
}
let key = track.title.to_lowercase().trim().to_string();
if key.is_empty() || !seen_titles.insert(key) {
continue;
}
rec.push(track);
}
}
Err(e) => log::warn!("[qbz-qt] suggestions artist-tracks fallback failed: {e}"),
}
}
// Shuffle (deterministic per artist+track), take 10.
let seed = (artist_id << 1) ^ current_track_id.wrapping_add(1);
shuffle_tracks(&mut rec, seed);
rec.truncate(REC_LIMIT);
// Radio diamond collage: up to 4 distinct rec-track album covers.
let mut radio_cover_urls: Vec<String> = Vec::new();
for track in &rec {
if let Some(url) = track_album_cover(track) {
if !radio_cover_urls.contains(&url) {
radio_cover_urls.push(url);
if radio_cover_urls.len() >= RADIO_COVERS {
break;
}
}
}
}
// ---- Curated playlist cards (first 2) ------------------------------
let mut playlist_cards: Vec<PlaylistCard> = Vec::new();
if let Some(playlists) = artist.playlists.as_ref() {
for playlist in playlists.iter().take(MAX_PLAYLIST_CARDS) {
// Fetch the full playlist to harvest up to 3 distinct album covers.
let mut cover_urls: Vec<String> = Vec::new();
match runtime.core().get_playlist(playlist.id).await {
Ok(full) => {
if let Some(container) = full.tracks.as_ref() {
let mut seen_albums: HashSet<String> = HashSet::new();
for track in &container.items {
let (Some(url), Some(album_id)) =
(track_album_cover(track), track_album_id(track))
else {
continue;
};
if seen_albums.insert(album_id) {
cover_urls.push(url);
if cover_urls.len() >= BOOK_COVERS {
break;
}
}
}
}
}
Err(e) => {
log::warn!("[qbz-qt] suggestions get_playlist({}) failed: {e}", playlist.id);
}
}
// Fallback to the playlist's own images when no track covers found.
if cover_urls.is_empty() {
if let Some(images) = playlist.images.as_ref() {
if let Some(img) = images.iter().find(|s| !s.is_empty()) {
cover_urls.push(img.clone());
}
}
}
playlist_cards.push(PlaylistCard {
id: playlist.id.to_string(),
name: playlist.name.clone(),
track_count: playlist.tracks_count,
cover_urls,
});
}
}
// `apply_suggestions` (suggestions.rs:323-343): playlist cards first,
// the radio card always trailing (Tauri order).
let mut cards: Vec<CardDoc> = playlist_cards.iter().map(playlist_to_card).collect();
cards.push(radio_card(
&current_track_id.to_string(),
seed_track_name,
&artist_id.to_string(),
&radio_cover_urls,
));
let tracks = rec.iter().map(to_track_doc).collect();
Ok(SuggestionsDoc {
loading: false,
error: false,
cards,
tracks,
})
}
// ---------------------------------------------------------------------------
// The invokable arms (main.rs:16684-16845)
// ---------------------------------------------------------------------------
/// `SuggestionsActions.load(track-id)` — entry + now-playing-change refresh
/// (`main.rs:16695-16722` → `navigate_suggestions` :3650-3679). Reads the
/// artist-id + title off the now-playing model (the Qt NowPlayingState).
/// An unparseable track id resets to the empty state; the dedup guard skips
/// a reload when the panel already shows this (artist, track).
pub(crate) fn load(track_id: String) {
let (artist_id, track_name) = crate::now_playing::seed_meta();
let (Ok(aid), Ok(tid)) = (
artist_id.parse::<u64>(),
track_id.parse::<u64>(),
) else {
// No track / no artist -> reset to the empty state and stop
// (`navigate_suggestions`'s empty-payload arm, :3662-3667).
with_state(|s| {
s.loaded = (String::new(), String::new());
});
mutate(|doc| {
doc.loading = false;
doc.error = false;
doc.cards.clear();
doc.tracks.clear();
});
return;
};
// Dedup (main.rs:16706-16712): the changed-watcher can refire on
// unrelated now-playing churn.
let dup = with_state(|s| s.loaded.0 == artist_id && s.loaded.1 == track_id);
if dup {
return;
}
with_state(|s| s.loaded = (artist_id.clone(), track_id.clone()));
// `reset_suggestions` (:372-378): clear both lists, error off, loading on.
mutate(|doc| {
doc.cards.clear();
doc.tracks.clear();
doc.error = false;
doc.loading = true;
});
let runtime = crate::app();
crate::spawn(async move {
match load_suggestions(&runtime, aid, tid, &track_name).await {
Ok(doc) => publish(&doc),
Err(()) => {
// Top-level failure -> the error branch (`apply_suggestions`
// with `error: true`, :341).
mutate(|doc| {
doc.loading = false;
doc.error = true;
doc.cards.clear();
doc.tracks.clear();
});
}
}
});
}
/// `set_radio_loading` (`suggestions.rs:348-369`): flip the radio card's
/// `loading` flag (the building spinner). The radio card is found by
/// `kind == "radio"`, falling back to the LAST card (Tauri order:
/// playlists then radio).
fn set_radio_loading(loading: bool) {
mutate(|doc| {
if doc.cards.is_empty() {
return;
}
let idx = doc
.cards
.iter()
.position(|c| c.kind == "radio")
.unwrap_or(doc.cards.len() - 1);
doc.cards[idx].loading = loading;
});
}
/// `start-radio` (main.rs:16789-16818): build the Song Radio off the seed
/// track via core, then start it (set-queue + play) through the shared
/// track-list play seam. The radio-card spinner flips on optimistically and
/// clears on completion (success OR failure, :16805).
pub(crate) fn start_radio(seed_track_id: String, seed_track_name: String, seed_artist_id: String) {
let (Ok(tid), Ok(aid)) = (
seed_track_id.parse::<u64>(),
seed_artist_id.parse::<u64>(),
) else {
return;
};
set_radio_loading(true);
let runtime = crate::app();
crate::spawn(async move {
let result = runtime
.core()
.create_smart_track_radio(tid, aid, seed_track_name)
.await;
set_radio_loading(false);
match result {
Ok(tracks) if !tracks.is_empty() => {
// The shared play_tracks seam: flat queue from the top, no
// shuffle — `foryou_qt::play_flat`'s shape (:1144-1157).
let queue: Vec<qbz_models::QueueTrack> =
tracks.iter().map(crate::foryou_qt::to_queue_track).collect();
if let Err(e) =
crate::playback_qt::play_track_list(&runtime, queue, 0, false).await
{
log::warn!("[qbz-qt] song radio play failed: {e}");
}
}
Ok(_) => log::warn!("[qbz-qt] song radio returned no tracks"),
Err(e) => log::error!("[qbz-qt] song radio failed: {e}"),
}
});
}
/// `play-track` (main.rs:16827-16844): play a single recommended track NOW.
/// Qt twin: the same `play_single_track` the track-row click uses
/// (`crate::play_track`, main.rs:1195-1202).
pub(crate) fn play_track(track_id: String) {
let Ok(tid) = track_id.parse::<u64>() else {
return;
};
let runtime = crate::app();
crate::spawn(async move {
if let Err(e) = crate::playback_qt::play_single_track(&runtime, tid).await {
log::error!("[qbz-qt] suggestions play-track {tid} failed: {e}");
}
});
}
/// `play-playlist` (main.rs:16732-16738): fetch + play the whole playlist
/// from the top — the card-level `playlist_qt::play_playlist_by_id` seam.
pub(crate) fn play_playlist(playlist_id: String) {
let Ok(id) = playlist_id.parse::<u64>() else {
return;
};
let runtime = crate::app();
crate::spawn(async move {
if let Err(e) = crate::playlist_qt::play_playlist_by_id(&runtime, id).await {
log::error!("[qbz-qt] suggestions play-playlist {id} failed: {e}");
}
});
}
/// `queue-playlist` (main.rs:16746-16758): append the whole playlist.
pub(crate) fn queue_playlist(playlist_id: String) {
let Ok(id) = playlist_id.parse::<u64>() else {
return;
};
let runtime = crate::app();
crate::spawn(async move {
if let Err(e) =
crate::playlist_qt::enqueue_playlist_by_id(&runtime, id, "queue").await
{
log::error!("[qbz-qt] suggestions queue-playlist {id} failed: {e}");
}
});
}
/// `play-next-playlist` (main.rs:16766-16778): the whole playlist after the
/// current track.
pub(crate) fn play_next_playlist(playlist_id: String) {
let Ok(id) = playlist_id.parse::<u64>() else {
return;
};
let runtime = crate::app();
crate::spawn(async move {
if let Err(e) =
crate::playlist_qt::enqueue_playlist_by_id(&runtime, id, "next").await
{
log::error!("[qbz-qt] suggestions play-next-playlist {id} failed: {e}");
}
});
}
#[cfg(test)]
mod tests {
use super::*;
/// The §4.5 doc shape, field-for-field: the four top-level keys and the
/// EXACT card/track field sets (incl. badge + the seed triple) — a
/// rename or a dropped key turns a QML read into `undefined` silently,
/// so the shape is asserted here against the serialized JSON.
#[test]
fn doc_shape_is_the_contract_field_set() {
let doc = SuggestionsDoc {
loading: true,
error: false,
cards: vec![
// Playlist card: badge "qobuz", EMPTY seed triple.
CardDoc {
kind: "playlist".to_string(),
title: "Deep Cuts".to_string(),
subtitle: "42 tracks".to_string(),
cover_urls: vec![
"https://a/1.jpg".to_string(),
"https://a/2.jpg".to_string(),
"https://a/3.jpg".to_string(),
],
playlist_id: "123".to_string(),
badge: "qobuz".to_string(),
..CardDoc::default()
},
// Radio card: badge "qbz", FULL seed triple.
CardDoc {
kind: "radio".to_string(),
title: "Song Radio".to_string(),
subtitle: "Seed Song".to_string(),
cover_urls: vec!["https://b/1.jpg".to_string()],
playlist_id: String::new(),
seed_track_id: "777".to_string(),
seed_track_name: "Seed Song".to_string(),
seed_artist_id: "888".to_string(),
badge: "qbz".to_string(),
loading: true,
},
],
tracks: vec![TrackDoc {
id: "42".to_string(),
title: "Song".to_string(),
artist: "Artist".to_string(),
artist_id: "7".to_string(),
duration: "3:45".to_string(),
art_url: "https://c/1.jpg".to_string(),
explicit: true,
}],
};
let v: serde_json::Value = serde_json::to_value(&doc).unwrap();
// Top level: EXACTLY {loading, error, cards, tracks}.
let top: HashSet<&str> = v.as_object().unwrap().keys().map(String::as_str).collect();
assert_eq!(top, HashSet::from(["loading", "error", "cards", "tracks"]));
assert_eq!(v["loading"], true);
assert_eq!(v["error"], false);
// Card: EXACTLY the Slint SuggestionCard field set (§4.5).
let card_keys: HashSet<&str> = v["cards"][0]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(
card_keys,
HashSet::from([
"kind",
"title",
"subtitle",
"coverUrls",
"playlistId",
"seedTrackId",
"seedTrackName",
"seedArtistId",
"badge",
"loading",
])
);
// The badge + seed triple survive the round-trip.
assert_eq!(v["cards"][0]["badge"], "qobuz");
assert_eq!(v["cards"][0]["playlistId"], "123");
assert_eq!(v["cards"][1]["badge"], "qbz");
assert_eq!(v["cards"][1]["seedTrackId"], "777");
assert_eq!(v["cards"][1]["seedTrackName"], "Seed Song");
assert_eq!(v["cards"][1]["seedArtistId"], "888");
assert_eq!(v["cards"][1]["loading"], true);
assert_eq!(v["cards"][0]["coverUrls"].as_array().unwrap().len(), 3);
// Track: EXACTLY the §4.5 track field set.
let track_keys: HashSet<&str> = v["tracks"][0]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(
track_keys,
HashSet::from([
"id",
"title",
"artist",
"artistId",
"duration",
"artUrl",
"explicit",
])
);
assert_eq!(v["tracks"][0]["duration"], "3:45");
assert_eq!(v["tracks"][0]["explicit"], true);
}
#[test]
fn default_doc_is_full_shape_and_not_loading() {
// The pre-publish frame: QML must see the empty state, never "{}".
let v: serde_json::Value = serde_json::to_value(SuggestionsDoc::default()).unwrap();
assert_eq!(v["loading"], false);
assert_eq!(v["error"], false);
assert!(v["cards"].as_array().unwrap().is_empty());
assert!(v["tracks"].as_array().unwrap().is_empty());
}
#[test]
fn mmss_formats_like_the_slint_mmss() {
assert_eq!(mmss(0), "0:00");
assert_eq!(mmss(65), "1:05");
assert_eq!(mmss(600), "10:00");
}
#[test]
fn shuffle_is_deterministic_per_seed() {
// The splitmix64 shuffle must be reproducible: same input + seed ->
// same permutation (the Slint loader relies on it so a reload does
// not reshuffle the visible list).
fn shuffled(seed: u64) -> Vec<u64> {
let mut tracks: Vec<qbz_models::Track> = (1..=20)
.map(|id| qbz_models::Track {
id,
..qbz_models::Track::default()
})
.collect();
shuffle_tracks(&mut tracks, seed);
tracks.iter().map(|t| t.id).collect()
}
assert_eq!(shuffled(42), shuffled(42));
assert_ne!(shuffled(42), shuffled(43));
// A permutation, not a loss.
let mut sorted = shuffled(42);
sorted.sort_unstable();
assert_eq!(sorted, (1..=20).collect::<Vec<u64>>());
}
#[test]
fn radio_card_carries_the_seed_triple_and_qbz_badge() {
// `radio_card()` sources the seed triple from the payload
// (`suggestions.rs:297-319`) — the startRadio hover action reads
// these back verbatim.
let card = radio_card("11", "Seed", "22", &["https://x/1.jpg".to_string()]);
assert_eq!(card.kind, "radio");
assert_eq!(card.badge, "qbz");
assert_eq!(card.seed_track_id, "11");
assert_eq!(card.seed_track_name, "Seed");
assert_eq!(card.seed_artist_id, "22");
assert_eq!(card.playlist_id, "");
assert!(!card.loading);
}
#[test]
fn playlist_card_has_qobuz_badge_and_empty_seed() {
let card = playlist_to_card(&PlaylistCard {
id: "5".to_string(),
name: "P".to_string(),
track_count: 3,
cover_urls: vec![],
});
assert_eq!(card.kind, "playlist");
assert_eq!(card.badge, "qobuz");
assert_eq!(card.playlist_id, "5");
assert_eq!(card.seed_track_id, "");
assert_eq!(card.seed_artist_id, "");
}
}