refactor(qbz-qt): phase 23 part 1 — QML module layout + first five domain bridges

Two fronts of the modularization, both gated green before this commit.

QML leaves the flat qml/ directory for seven modules: cards, controls,
rows, settings, shell, theme, views. Only Main.qml and LoginScreen.qml
stay at the root. build.rs matches the tree exactly (55 files), relative
imports and asset paths were re-anchored for the new depth.

The QbzBridge God-object starts breaking up: QbzSession, QbzShell,
QbzPlayer, QbzQueue and QbzHome are now their own #[cxx_qt::bridge]
singletons, each with its own OnceLock<CxxQtThread> and ui() hop, booted
in order from Main.qml. Bridge files stay directly under src/ because
cxx-qt-build only accepts rust_files from one directory (QTBUG-93443).

bridge.rs keeps 5.5 domains (library, album/artist, settings,
integrations, search, playlist) and dies once they are split out.

cc
This commit is contained in:
vicrodh committed 2026-07-28 11:08:20 -06:00
1 parent 580b72084f
commit 9a4fec6b70
72 files changed
+2152 -1690

No files matched your search

+723
View File
@@ -0,0 +1,723 @@
// Album detail page — QML port of album/AlbumPageView.slint.
//
// Header (224px cover, title, credited-artist line, meta with label link,
// description + Read more, CircleAction row), divider, toolbar (quality
// badge + track search), column header, track list (Disc/work headers,
// TrackRow replica with the playing-row pill + number↔play cell + live
// heart), label/awards sidebar, and the two bottom carousels ("From the
// same artist", "Listening suggestions").
//
// POC-NOTEs: multi-select + bulk bar, offline download column, booklet,
// external links, custom-cover menu, album-info modal, the header
// atmosphere (album-header-gradient pref) — out of scope (visible stubs
// are inert). Text keeps the theme colors (header-light = false here).
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../rows"
import "../theme"
Rectangle {
id: root
// Transparent while the ambient background is active (phase 14 —
// HomeView.slint:163: the frosted content panel shows through).
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
// Round to the AppShell content-frame bezel (Radius.md): QML clips
// are rectangular, so the frame's own rounding never reaches the
// view — the view's own fill must round instead.
radius: 12
QbzTheme { id: theme }
// The view's album + url-keyed cover map (artwork pipeline).
readonly property var album: JSON.parse(QbzBridge.albumJson)
readonly property var header: album.header || ({})
readonly property var tracks: album.tracks || []
readonly property var awards: album.awards || []
property var coverMap: ({})
// Client-side track search (AlbumActions.search equivalent).
property string trackQuery: ""
readonly property var visibleTracks: {
if (trackQuery === "") return tracks
var q = trackQuery.toLowerCase()
return tracks.filter(function (t) {
return t.title.toLowerCase().indexOf(q) >= 0
})
}
// Disc headers + work headers precede their first row (computed once
// per track list change, mirroring AlbumState's disc-header-number /
// work-header model fields).
function headerFor(i) {
var t = visibleTracks[i]
if (!t) return null
if (i === 0) {
return { disc: t.disc > 1 || (visibleTracks.length > 0 && visibleTracks[visibleTracks.length - 1].disc > 1) ? t.disc : 0,
work: t.workHeader }
}
var prev = visibleTracks[i - 1]
var multi = visibleTracks[visibleTracks.length - 1].disc > 1
return { disc: (multi && t.disc !== prev.disc) ? t.disc : 0,
work: t.workHeader !== prev.workHeader ? t.workHeader : "" }
}
Connections {
target: QbzBridge
function onLibraryArtworkReady(key, path) {
var m = root.coverMap
m[key] = path
root.coverMap = Object.assign({}, m)
}
}
Component.onCompleted: dispatchCovers()
onAlbumChanged: dispatchCovers()
// The derived binding settles AFTER onAlbumChanged fires (stale race) —
// redispatch when the header itself updates.
onHeaderChanged: dispatchCovers()
function dispatchCovers() {
var urls = []
if (header && header.artUrl) urls.push(header.artUrl)
var more = album.moreFromArtist || []
for (var i = 0; i < more.length; i++) if (more[i].artUrl) urls.push(more[i].artUrl)
var sug = album.suggestions || []
for (i = 0; i < sug.length; i++) if (sug[i].artUrl) urls.push(sug[i].artUrl)
if (urls.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
}
// Ghost CircleAction (secondary, on-surface variant): elevated disc,
// strong ring, text-primary icon (accent when active).
// Track list row (TrackRow.slint replica: number cell, no artwork,
// Sidebar label/award card (SidebarCard).
component SidebarCard: Rectangle {
property string name: ""
property color gradA: "#6366f1"
property color gradB: "#8b5cf6"
property string iconName: "disc"
signal clicked()
width: parent ? parent.width : 0
height: 48
radius: theme.radiusSm
color: scArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.margins: 6
spacing: 10
Rectangle {
width: 28
height: 28
radius: 14
anchors.verticalCenter: parent.verticalCenter
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: gradA }
GradientStop { position: 1.0; color: gradB }
}
QbzIcon {
name: iconName
width: 13
height: 13
anchors.centerIn: parent
tintName: "primary"
}
}
Text {
width: parent.width - 38
anchors.verticalCenter: parent.verticalCenter
text: name
color: theme.textSecondary
font.pixelSize: 12
font.weight: theme.weightMedium
wrapMode: Text.WordWrap
}
}
MouseArea {
id: scArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.clicked()
}
}
component SidebarHeading: Text {
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1
}
// ============================ the page ================================
Flickable {
id: pageFlick
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: page.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: page
width: parent.width
leftPadding: 32
rightPadding: 32
topPadding: 11
bottomPadding: 100
spacing: 0
// NavButtons is a 0px placeholder in the Slint source.
Item { width: 1; height: 22 }
// --- Album header -------------------------------------------
Row {
width: parent.width - 64
spacing: 32
Rectangle {
width: 224
height: 224
radius: 12
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: root.coverMap[header.artUrl] || ""
radius: 12
}
}
Column {
width: parent.width - 224 - 32
anchors.top: parent.top
anchors.topMargin: 4
spacing: 0
Text {
width: parent.width
text: header.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
Item { width: 1; height: 4 }
// Credited-artist line (links + role suffixes).
Flow {
width: parent.width
spacing: 0
Repeater {
model: header.credits || []
delegate: Row {
required property var modelData
required property int index
spacing: 0
Text {
visible: index > 0
text: " • "
color: theme.textSecondary
font.pixelSize: theme.fontHeading
font.weight: theme.weightBold
}
Text {
text: modelData[0]
color: creditArea.containsMouse && modelData[1] !== "" ? theme.textPrimary : theme.textSecondary
font.pixelSize: theme.fontHeading
font.weight: theme.weightBold
MouseArea {
id: creditArea
anchors.fill: parent
enabled: modelData[1] !== ""
hoverEnabled: true
cursorShape: modelData[1] !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: QbzBridge.openArtist(modelData[1])
}
}
Text {
visible: modelData[2] !== ""
text: " (" + modelData[2] + ")"
color: theme.textSecondary
font.pixelSize: theme.fontHeading
}
}
}
}
Item { width: 1; height: 10 }
// Meta line (label as a clickable link when navigable).
Row {
spacing: 0
visible: (header.labelId || "") !== "" && (header.label || "") !== ""
Text {
visible: (header.metaPre || "") !== ""
text: (header.metaPre || "") + " • "
color: theme.textSecondary
font.pixelSize: theme.fontBody
}
Text {
text: header.label || ""
color: labelArea.containsMouse ? theme.accent : theme.textSecondary
font.pixelSize: theme.fontBody
MouseArea {
id: labelArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// POC-NOTE: no label view yet.
}
}
Text {
visible: (header.metaPost || "") !== ""
text: " • " + (header.metaPost || "")
color: theme.textSecondary
font.pixelSize: theme.fontBody
}
}
Text {
visible: (header.labelId || "") === "" || (header.label || "") === ""
width: parent.width
text: header.infoLine || ""
color: theme.textSecondary
font.pixelSize: theme.fontBody
elide: Text.ElideRight
}
// Editorial description + Read more.
Item { visible: (header.description || "") !== ""; width: 1; height: 12 }
Text {
visible: (header.description || "") !== ""
width: parent.width
text: header.descriptionShort || ""
color: theme.textSecondary
font.pixelSize: theme.fontLegal
wrapMode: Text.WordWrap
}
Item { visible: (header.description || "") !== (header.descriptionShort || ""); width: 1; height: 4 }
Text {
visible: (header.description || "") !== (header.descriptionShort || "")
text: QbzSession.tr("Read more", QbzSession.trRev)
color: readMoreArea.containsMouse ? theme.accentHover : theme.accent
font.pixelSize: theme.fontLegal
MouseArea {
id: readMoreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
var shell = root.parent
while (shell && shell.openTextModal === undefined) shell = shell.parent
if (shell) shell.openTextModal(QbzSession.tr("About this album", QbzSession.trRev), header.description || "")
}
}
}
Item { width: 1; height: 20 }
// Action row (on-surface CircleActions).
Row {
spacing: 12
// Play — accent disc, white glyph (on-surface primary).
Rectangle {
width: 44
height: 44
radius: 22
color: playHdrArea.containsMouse ? theme.accentHover : theme.accent
QbzIcon {
anchors.centerIn: parent
name: "play-fill"
width: 19
height: 19
tintName: "primary"
}
MouseArea {
id: playHdrArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzPlayer.playAlbum(header.id)
}
}
QbzCircleAction {
name: "shuffle"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.playAlbumShuffled(header.id)
}
QbzCircleAction {
name: header.isFavorite ? "heart-filled" : "heart"
active: header.isFavorite === true
anchors.verticalCenter: parent.verticalCenter
onClicked: {
header.isFavorite = !header.isFavorite
QbzBridge.libraryToggleFavorite("album", header.id)
}
}
// Radio / Mixtape / Info — INERT stubs (POC-NOTE:
// radio engines, mixtape store, album-info modal).
QbzCircleAction { name: "radio"; anchors.verticalCenter: parent.verticalCenter }
QbzCircleAction { name: "cassette-tape"; anchors.verticalCenter: parent.verticalCenter }
QbzCircleAction { name: "info"; anchors.verticalCenter: parent.verticalCenter }
QbzCircleAction {
id: albumMenuBtn
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: function (mouse) { albumMenu.openAtCursor(albumMenuBtn, mouse.x, mouse.y) }
}
}
}
}
Item { width: 1; height: 20 }
// Header divider (the atmosphere ends here in Slint).
Rectangle { width: parent.width - 64; height: 1; color: theme.borderSubtle }
Item { width: 1; height: 8 }
// --- Track list + label/awards sidebar ----------------------
Row {
width: parent.width - 64
spacing: 32
Column {
width: parent.width - ((header.label || "") !== "" || awards.length > 0 ? 232 : 0)
spacing: 0
// Loading.
Item {
visible: QbzBridge.albumLoading && tracks.length === 0
width: parent.width
height: 280
Column {
anchors.centerIn: parent
spacing: 18
QbzSpinner { size: 36; anchors.horizontalCenter: parent.horizontalCenter }
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Loading album…", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
}
}
}
// Toolbar — quality badge + track search (+ inert select).
Row {
visible: !QbzBridge.albumLoading
width: parent.width
height: 52
spacing: 16
Row {
id: qualityRow
anchors.verticalCenter: parent.verticalCenter
spacing: 7
Image {
visible: header.qualityTier === "hires"
source: "../assets/hi-res.svg"
width: 42
height: 28
anchors.verticalCenter: parent.verticalCenter
sourceSize: Qt.size(84, 56)
fillMode: Image.PreserveAspectFit
}
Text {
visible: (header.qualityDetail || "") !== ""
text: header.qualityDetail || ""
color: theme.textMuted
font.pixelSize: 12
anchors.verticalCenter: parent.verticalCenter
}
}
Item { width: parent.width - qualityRow.width - 168 - 30 - 3 * 16; height: 1 }
Rectangle {
width: 168
height: 34
radius: 6
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Row {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 7
QbzIcon {
name: "search"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
TextInput {
width: parent.width - 21
height: parent.height
color: theme.textPrimary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
clip: true
onTextEdited: root.trackQuery = text
Text {
visible: parent.text === ""
anchors.fill: parent
text: QbzSession.tr("Search tracks...", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
}
}
Rectangle {
width: 30
height: 30
radius: 6
anchors.verticalCenter: parent.verticalCenter
color: selectArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
QbzIcon {
name: "square-check-big"
width: 15
height: 15
anchors.centerIn: parent
tintName: selectArea.containsMouse ? "primary" : "secondary"
}
MouseArea {
id: selectArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// POC-NOTE: multi-select out of scope.
}
}
}
// Column header.
Row {
visible: !QbzBridge.albumLoading
width: parent.width
height: 40
leftPadding: 12
rightPadding: 12
spacing: 16
Text { text: "#"; width: 32; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; anchors.verticalCenter: parent.verticalCenter }
Text { text: QbzSession.tr("Title", QbzSession.trRev); width: parent.width - 32 - 80 - 80 - 28 - 28 - 32 - 5 * 16 - 24; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; anchors.verticalCenter: parent.verticalCenter; elide: Text.ElideRight }
Text { text: QbzSession.tr("Duration", QbzSession.trRev); width: 80; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; horizontalAlignment: Text.AlignHCenter; anchors.verticalCenter: parent.verticalCenter }
Text { text: QbzSession.tr("Quality", QbzSession.trRev); width: 80; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; horizontalAlignment: Text.AlignHCenter; anchors.verticalCenter: parent.verticalCenter }
Item { width: 28; height: 1; QbzIcon { name: "heart"; width: 14; height: 14; anchors.centerIn: parent; tintName: "muted" } }
Item { width: 28; height: 1; QbzIcon { name: "cloud-download"; width: 14; height: 14; anchors.centerIn: parent; tintName: "muted" } }
Item { width: 32; height: 1 }
}
// Rows (with Disc / work headers).
Repeater {
model: root.visibleTracks
delegate: Column {
required property var modelData
required property int index
property var hdr: root.headerFor(index)
width: parent ? parent.width : 0
Rectangle {
visible: hdr && hdr.disc > 0
width: parent.width
height: 40
color: "transparent"
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Disc", QbzSession.trRev) + " " + hdr.disc
color: theme.textMuted
font.pixelSize: 13
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
}
Row {
visible: hdr && hdr.work !== ""
width: parent.width
leftPadding: 12
rightPadding: 12
topPadding: 14
bottomPadding: 4
spacing: 0
Text {
text: hdr.work
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightBold
}
Text {
visible: modelData.workComposerName !== ""
text: " ("
color: theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: theme.weightBold
}
Text {
visible: modelData.workComposerName !== ""
text: modelData.workComposerName
color: composerArea.containsMouse && modelData.workComposerId !== "" ? theme.textPrimary : theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: theme.weightBold
MouseArea {
id: composerArea
anchors.fill: parent
enabled: modelData.workComposerId !== ""
hoverEnabled: true
cursorShape: modelData.workComposerId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: QbzBridge.openArtist(modelData.workComposerId)
}
}
Text {
visible: modelData.workComposerName !== ""
text: ")"
color: theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: theme.weightBold
}
}
TrackRow {
item: modelData
number: index + 1
zebra: true
clickPlays: false
artistLink: true
qualityStyle: "text"
showDownload: true
downloadGlyph: true
menuShowLater: false
menuShowGoTo: false
onPlayRequested: QbzPlayer.playAlbumFrom(header.id, item.id)
onEnqueueRequested: function (m) {
QbzPlayer.enqueueAlbumTrack(header.id, item.id, m === "next" ? "next" : "later")
}
}
}
}
}
// Label / awards sidebar (200px).
Column {
visible: (header.label || "") !== "" || awards.length > 0
width: 200
spacing: 24
Column {
visible: (header.label || "") !== ""
width: parent.width
spacing: 8
SidebarHeading { text: QbzSession.tr("LABEL", QbzSession.trRev) }
SidebarCard {
name: header.label || ""
iconName: "disc"
gradA: "#6366f1"
gradB: "#8b5cf6"
// POC-NOTE: no label view yet.
}
}
Column {
visible: awards.length > 0
width: parent.width
spacing: 8
SidebarHeading { text: QbzSession.tr("AWARDS", QbzSession.trRev) }
Repeater {
model: awards
delegate: SidebarCard {
required property var modelData
name: modelData[1]
iconName: "award"
gradA: "#b45309"
gradB: "#eab308"
// POC-NOTE: no award view yet.
}
}
}
}
}
// --- Bottom carousels ---------------------------------------
Item { visible: (album.moreFromArtist || []).length > 0; width: 1; height: 40 }
SectionRail {
visible: (album.moreFromArtist || []).length > 0
title: QbzSession.tr("From the same artist", QbzSession.trRev)
items: album.moreFromArtist || []
coverMap: root.coverMap
}
Item { visible: (album.suggestions || []).length > 0; width: 1; height: 40 }
SectionRail {
visible: (album.suggestions || []).length > 0
title: QbzSession.tr("Listening suggestions", QbzSession.trRev)
items: album.suggestions || []
coverMap: root.coverMap
}
}
}
// Thin auto-hiding scrollbar (ListScrollbar).
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: pageFlick
}
// Album ⋯ menu (AlbumContextMenu subset — card menu + pin).
QbzContextMenu {
id: albumMenu
menuWidth: 196
Repeater {
model: [
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-plus", "action": "next" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
{ "label": header.isFavorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": header.isFavorite ? "heart-filled" : "heart", "action": "favorite" },
{ "label": header.isPinned ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": header.isPinned ? "pin-filled" : "pin", "action": "pin" },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: amiArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 8
QbzIcon { name: modelData.icon; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
Text {
height: parent.height
width: parent.width - 23
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: amiArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
albumMenu.close()
var a = modelData.action
if (a === "play") QbzPlayer.playAlbum(header.id)
else if (a === "next") QbzPlayer.enqueueAlbum(header.id, "next")
else if (a === "queue") QbzPlayer.enqueueAlbum(header.id, "later")
else if (a === "favorite") {
header.isFavorite = !header.isFavorite
QbzBridge.libraryToggleFavorite("album", header.id)
} else if (a === "pin") {
header.isPinned = !header.isPinned
QbzBridge.togglePin("album", header.id, header.title, header.artist, header.artUrl)
}
}
}
}
}
}
}