feat(qt): immersive split view, lyrics, track info, suggestions, queue tabs (B4)
km
This commit is contained in:
12 files changed
+2809
-115
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
¤t_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, "");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user