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:
72 files changed
+2152
-1690
No files matched your search
@@ -0,0 +1,737 @@
|
||||
// Discover > Home — QML port of crates/qbz-ui/ui/discover/HomeView.slint
|
||||
// (+ Carousel / AlbumCard / SlimCarousel / SlimCard / ArtistCarousel /
|
||||
// PlaylistCarousel / HomeSkeleton / OfflinePlaceholder).
|
||||
//
|
||||
// Data: QbzHome.homeSectionsJson (one JSON document — see bridge.rs),
|
||||
// published by src/home_qt.rs; artwork file:// paths resolve through the
|
||||
// qbz-cache image cache. Section kinds: "album" | "playlist" | "slim" |
|
||||
// "artists" | "pinned" | "recentPlaceholder". Rail ORDER + VISIBILITY
|
||||
// follow the persisted Discover prefs (phase 11, discover_prefs.db).
|
||||
//
|
||||
// POC-NOTEs:
|
||||
// - The genre filter + section-configurator gear are INERT visual stubs
|
||||
// (out of scope).
|
||||
// - Editor's Picks / For You mount the same rails, ordered by each tab's
|
||||
// discover prefs (phase 13); Recommendations renders a placeholder — the
|
||||
// external reco engine (external_reco.rs) is not ported. The tab bar is
|
||||
// always fully visible (the Slint showRecommendations gate is not wired;
|
||||
// the pref is ON for this user anyway).
|
||||
// - Card clicks / hover actions (play / favorite / more / pin) and
|
||||
// "View all" / context menus are inert — album/artist pages, playback
|
||||
// and per-user stores are later phases.
|
||||
// - The offline mount mirrors AppShell's ADR-010 seam: OfflineState.offline
|
||||
// -> the OfflinePlaceholder replica (exact msgids).
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import com.blitzfc.qbz
|
||||
import "../cards"
|
||||
import "../controls"
|
||||
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 }
|
||||
|
||||
// Reparsed whenever Rust republishes the JSON documents (one per
|
||||
// Discover tab — phase 13).
|
||||
readonly property var sections: JSON.parse(QbzHome.homeSectionsJson)
|
||||
readonly property var editorSections: JSON.parse(QbzHome.editorSectionsJson)
|
||||
readonly property var forYouSections: JSON.parse(QbzHome.forYouSectionsJson)
|
||||
property string activeTab: "home"
|
||||
|
||||
// ============================ shared components =======================
|
||||
|
||||
// Circular page-control button (Carousel's NavButton).
|
||||
// Horizontal album rail (Carousel.slint): header + clipped ListView,
|
||||
// page chevrons (per-page step like the Slint paging).
|
||||
component AlbumRail: Column {
|
||||
property var sectionData: ({})
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 12
|
||||
|
||||
readonly property int perPage: Math.max(1, Math.floor((rail.width + 32) / 232))
|
||||
readonly property int step: perPage * 232
|
||||
readonly property real maxScroll: Math.max(0, rail.contentWidth - rail.width)
|
||||
|
||||
QbzSectionHeader {
|
||||
title: sectionData.title
|
||||
leftEnabled: rail.contentX > 1
|
||||
rightEnabled: rail.contentX < maxScroll - 1
|
||||
showViewAll: (sectionData.endpoint || "") !== ""
|
||||
onPageLeft: rail.contentX = Math.min(0, rail.contentX - step)
|
||||
onPageRight: rail.contentX = Math.min(maxScroll, rail.contentX + step)
|
||||
}
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 246
|
||||
ListView {
|
||||
id: rail
|
||||
anchors.fill: parent
|
||||
orientation: ListView.Horizontal
|
||||
spacing: 32
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: sectionData.items
|
||||
delegate: AlbumCard {
|
||||
albumId: modelData.id
|
||||
title: modelData.title
|
||||
artist: modelData.artist
|
||||
artistId: modelData.artistId
|
||||
genre: modelData.genre
|
||||
year: modelData.year
|
||||
qualityTier: modelData.qualityTier
|
||||
ribbon: modelData.ribbon
|
||||
ribbonKind: modelData.ribbonKind
|
||||
artSource: modelData.artPath
|
||||
isPinned: modelData.isPinned
|
||||
// POC-NOTE: Home hearts are not seeded from fav_cache
|
||||
// (store not open in the POC); toggles still hit the API.
|
||||
isFavorite: false
|
||||
}
|
||||
}
|
||||
// Cider-style edge fades (Carousel.slint): content dissolves
|
||||
// into the page background at the scrolled edges.
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: 56
|
||||
opacity: rail.contentX > 1 ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: theme.surfaceMain }
|
||||
GradientStop { position: 1.0; color: "transparent" }
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: 56
|
||||
opacity: rail.contentX < maxScroll - 1 ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: "transparent" }
|
||||
GradientStop { position: 1.0; color: theme.surfaceMain }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slim rows mount the shared qml/SlimCard.qml (SlimCard.slint).
|
||||
// Popular slim grid (SlimCarousel.slint): 4x3 pages of 12, capped 24.
|
||||
component SlimGrid: Column {
|
||||
property var sectionData: ({})
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 12
|
||||
|
||||
readonly property int perPage: 12
|
||||
readonly property int total: Math.min(sectionData.items.length, 2 * perPage)
|
||||
readonly property int pageCount: Math.max(1, Math.ceil(total / perPage))
|
||||
readonly property real maxScroll: (pageCount - 1) * grid.width
|
||||
|
||||
QbzSectionHeader {
|
||||
title: sectionData.title
|
||||
leftEnabled: grid.contentX > 1
|
||||
rightEnabled: grid.contentX < maxScroll - 1
|
||||
showViewAll: (sectionData.endpoint || "") !== ""
|
||||
onPageLeft: grid.contentX = Math.max(0, grid.contentX - grid.width)
|
||||
onPageRight: grid.contentX = Math.min(maxScroll, grid.contentX + grid.width)
|
||||
}
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 3 * 60 + 2 * 8
|
||||
clip: true
|
||||
Flickable {
|
||||
id: grid
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
contentWidth: pageCount * width
|
||||
contentHeight: height
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
readonly property real cellWidth: (width - 3 * 8) / 4
|
||||
|
||||
Repeater {
|
||||
model: sectionData.items
|
||||
delegate: SlimCard {
|
||||
readonly property int slot: index % perPage
|
||||
readonly property int pageIdx: Math.floor(index / perPage)
|
||||
visible: index < total
|
||||
width: grid.cellWidth
|
||||
height: 60
|
||||
x: pageIdx * grid.width + (slot % 4) * (grid.cellWidth + 8)
|
||||
y: Math.floor(slot / 4) * (60 + 8)
|
||||
card: modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned rail (PinnedCarousel.slint) — one 200x246 slot per item, the
|
||||
// card picked by the item's own kind: albums reuse AlbumCard, artists
|
||||
// render the ArtistGridCard circle, playlists the PlaylistCard square.
|
||||
// Fed from the shared per-user pinned_items.db (home_qt "pinned"
|
||||
// section; most-recent first).
|
||||
component PinnedRail: Column {
|
||||
property var sectionData: ({})
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 12
|
||||
|
||||
readonly property real maxScroll: Math.max(0, rail.contentWidth - rail.width)
|
||||
readonly property int perPage: Math.max(1, Math.floor((rail.width + 32) / 232))
|
||||
readonly property int step: perPage * 232
|
||||
|
||||
QbzSectionHeader {
|
||||
title: sectionData.title
|
||||
leftEnabled: rail.contentX > 1
|
||||
rightEnabled: rail.contentX < maxScroll - 1
|
||||
onPageLeft: rail.contentX = Math.max(0, rail.contentX - step)
|
||||
onPageRight: rail.contentX = Math.min(maxScroll, rail.contentX + step)
|
||||
}
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 246
|
||||
ListView {
|
||||
id: rail
|
||||
anchors.fill: parent
|
||||
orientation: ListView.Horizontal
|
||||
spacing: 32
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: sectionData.items
|
||||
delegate: Item {
|
||||
required property var modelData
|
||||
width: 200
|
||||
height: 246
|
||||
|
||||
Component {
|
||||
id: pAlbum
|
||||
AlbumCard {
|
||||
albumId: modelData.id
|
||||
title: modelData.title
|
||||
artist: modelData.artist
|
||||
artSource: modelData.artPath || ""
|
||||
isPinned: true
|
||||
isFavorite: false
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: pArtist
|
||||
ArtistCard {
|
||||
item: modelData
|
||||
artSource: modelData.artPath || ""
|
||||
isPinned: true
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: pPlaylist
|
||||
PlaylistCard {
|
||||
// Pinned model: { id, title, artist, artPath,
|
||||
// isPinned } — artist maps to the subtitle line.
|
||||
item: Object.assign({}, modelData, { subtitle: modelData.subtitle || modelData.artist || "" })
|
||||
artSource: modelData.artPath || ""
|
||||
isPinned: true
|
||||
}
|
||||
}
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
sourceComponent: modelData.itemKind === "artist" ? pArtist
|
||||
: modelData.itemKind === "playlist" ? pPlaylist : pAlbum
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Qobuz Mixes rail (QobuzMixesRow.slint) — four static 220px
|
||||
// navigation tiles (gradient art + badge + name, description below).
|
||||
// POC-NOTE: the mix DETAIL views are out of scope — tiles are inert.
|
||||
// (Slint's 135° linear gradients are approximated with corner
|
||||
// RadialGradients — QML has no angled linear gradient.)
|
||||
component MixTile: Column {
|
||||
property string badge: ""
|
||||
property string mixName: ""
|
||||
property string desc: ""
|
||||
property color c0: "#000000"
|
||||
property color c1: "#000000"
|
||||
property color c2: "#000000"
|
||||
spacing: 8
|
||||
width: 220
|
||||
|
||||
Rectangle {
|
||||
width: 220
|
||||
height: 220
|
||||
radius: 8
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: c0 }
|
||||
GradientStop { position: 0.5; color: c1 }
|
||||
GradientStop { position: 1.0; color: c2 }
|
||||
}
|
||||
// Fake the 135° sweep with a corner-centered radial overlay.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 8
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: "#00000000" }
|
||||
GradientStop { position: 1.0; color: "#33000000" }
|
||||
}
|
||||
}
|
||||
Text {
|
||||
x: 12
|
||||
y: 12
|
||||
text: badge
|
||||
color: "#ccffffff"
|
||||
font.pixelSize: 10
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: mixName
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 22
|
||||
font.weight: theme.weightBold
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// Inert (mix detail views out of scope).
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: 220
|
||||
text: desc
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
component MixesRail: Column {
|
||||
property var sectionData: ({})
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 12
|
||||
Text {
|
||||
text: sectionData.title
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
Row {
|
||||
spacing: 32
|
||||
MixTile {
|
||||
badge: "qobuz"; mixName: "DailyQ"
|
||||
desc: QbzSession.tr("Elevate your day with a customized selection of music.", QbzSession.trRev)
|
||||
c0: "#1e3a8a"; c1: "#6366f1"; c2: "#c084fc"
|
||||
}
|
||||
MixTile {
|
||||
badge: "qobuz"; mixName: "WeeklyQ"
|
||||
desc: QbzSession.tr("Take a weekly journey with a fresh mix every Friday.", QbzSession.trRev)
|
||||
c0: "#065f46"; c1: "#10b981"; c2: "#fbbf24"
|
||||
}
|
||||
MixTile {
|
||||
badge: "qbz"; mixName: "FavQ"
|
||||
desc: QbzSession.tr("A fresh shuffle from your personal library.", QbzSession.trRev)
|
||||
c0: "#7f1d1d"; c1: "#ef4444"; c2: "#fb923c"
|
||||
}
|
||||
MixTile {
|
||||
badge: "qbz"; mixName: "TopQ"
|
||||
desc: QbzSession.tr("Discover new music from your most-played playlists.", QbzSession.trRev)
|
||||
c0: "#1f2937"; c1: "#4b5563"; c2: "#fbbf24"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The section-rails renderer (one per Discover tab — the tab bodies
|
||||
// differ only in WHICH sections doc they mount).
|
||||
component SectionRails: Column {
|
||||
property var sectionsModel: []
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 40
|
||||
|
||||
Repeater {
|
||||
model: sectionsModel
|
||||
delegate: Loader {
|
||||
required property var modelData
|
||||
width: parent ? parent.width : 0
|
||||
sourceComponent: modelData.kind === "album" ? albumRailComp
|
||||
: modelData.kind === "playlist" ? playlistRailComp
|
||||
: modelData.kind === "slim" ? slimGridComp
|
||||
: modelData.kind === "artists" ? artistRailComp
|
||||
: modelData.kind === "pinned" ? pinnedRailComp
|
||||
: modelData.kind === "mixes" ? mixesRailComp
|
||||
: recentComp
|
||||
property var sectionData: modelData
|
||||
|
||||
Component {
|
||||
id: pinnedRailComp
|
||||
PinnedRail { sectionData: parent.sectionData }
|
||||
}
|
||||
Component {
|
||||
id: albumRailComp
|
||||
AlbumRail { sectionData: parent.sectionData }
|
||||
}
|
||||
Component {
|
||||
id: playlistRailComp
|
||||
Column {
|
||||
property var sectionData: parent.sectionData
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 12
|
||||
Text {
|
||||
text: sectionData.title
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
ListView {
|
||||
width: parent.width
|
||||
height: 246
|
||||
orientation: ListView.Horizontal
|
||||
spacing: 32
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: sectionData.items
|
||||
delegate: PlaylistCard {
|
||||
item: modelData
|
||||
artSource: modelData.artPath || ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: slimGridComp
|
||||
SlimGrid { sectionData: parent.sectionData }
|
||||
}
|
||||
Component {
|
||||
id: artistRailComp
|
||||
Column {
|
||||
property var sectionData: parent.sectionData
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 12
|
||||
Text {
|
||||
text: sectionData.title
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
ListView {
|
||||
width: parent.width
|
||||
height: 246
|
||||
orientation: ListView.Horizontal
|
||||
spacing: 32
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: sectionData.items
|
||||
delegate: ArtistCard {
|
||||
item: modelData
|
||||
artSource: modelData.artPath || ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: mixesRailComp
|
||||
MixesRail { sectionData: parent.sectionData }
|
||||
}
|
||||
Component {
|
||||
id: recentComp
|
||||
QbzEmptyState {
|
||||
property var sectionData: parent.sectionData
|
||||
title: sectionData.title
|
||||
body: sectionData.hint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component Shimmer: Rectangle {
|
||||
property bool phase: false
|
||||
color: theme.surfaceElevated
|
||||
radius: theme.radiusSm
|
||||
opacity: phase ? 0.85 : 0.4
|
||||
Behavior on opacity { NumberAnimation { duration: 900; easing.type: Easing.InOutQuad } }
|
||||
}
|
||||
component SkeletonRow: Column {
|
||||
id: skelRow
|
||||
property bool phase: false
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 12
|
||||
Shimmer { phase: skelRow.phase; width: 180; height: 22 }
|
||||
Row {
|
||||
spacing: 32
|
||||
Repeater {
|
||||
model: 5
|
||||
delegate: Column {
|
||||
spacing: 8
|
||||
Shimmer { phase: skelRow.phase; width: 200; height: 200 }
|
||||
Shimmer { phase: skelRow.phase; width: 140; height: 14 }
|
||||
Shimmer { phase: skelRow.phase; width: 90; height: 12 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ offline gate ============================
|
||||
// (OfflinePlaceholder.slint replica; mounted INSTEAD of the view.)
|
||||
QbzOfflinePlaceholder {
|
||||
visible: QbzSession.offline
|
||||
anchors.centerIn: parent
|
||||
// The induced-only "Open Settings" arm (Slint) is wired:
|
||||
showSettingsAction: true
|
||||
onSettingsClicked: QbzShell.navigateTo("settings")
|
||||
}
|
||||
|
||||
// ============================ the view ================================
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
visible: !QbzSession.offline
|
||||
|
||||
// --- Toolbar (fixed 56px) ---------------------------------------
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 56
|
||||
|
||||
Row {
|
||||
// Slint left-controls: x 32 + NavButtons (now a 0px
|
||||
// placeholder) + 16px spacing -> the pill starts at 48.
|
||||
x: 48
|
||||
y: 25 - height / 2
|
||||
spacing: 16
|
||||
|
||||
QbzTabBar {
|
||||
tabs: [
|
||||
{ "id": "home", "label": QbzSession.tr("Home", QbzSession.trRev) },
|
||||
{ "id": "editorPicks", "label": QbzSession.tr("Editor's Picks", QbzSession.trRev) },
|
||||
{ "id": "forYou", "label": QbzSession.tr("For You", QbzSession.trRev) },
|
||||
{ "id": "recommendations", "label": QbzSession.tr("Recommendations", QbzSession.trRev) },
|
||||
]
|
||||
activeId: root.activeTab
|
||||
// Data is per-tab JSON (no refetch on switch); scroll
|
||||
// resets to top.
|
||||
onSelected: function (id) {
|
||||
root.activeTab = id
|
||||
homeFlick.contentY = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Genre filter + configurator gear — INERT stubs (POC-NOTE).
|
||||
Row {
|
||||
x: parent.width - width - 32
|
||||
y: 25 - height / 2
|
||||
height: 32
|
||||
spacing: 6
|
||||
Rectangle {
|
||||
width: genreRow.width
|
||||
height: 32
|
||||
radius: 6
|
||||
color: genreArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
Row {
|
||||
id: genreRow
|
||||
height: parent.height
|
||||
leftPadding: 12
|
||||
rightPadding: 14
|
||||
spacing: 7
|
||||
QbzIcon {
|
||||
name: "list-filter"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "secondary"
|
||||
}
|
||||
Text {
|
||||
text: QbzSession.tr("Filter by genre", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: genreArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: 32
|
||||
height: 32
|
||||
radius: 4
|
||||
color: gearArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
QbzIcon {
|
||||
name: "home-gear"
|
||||
width: 20
|
||||
height: 20
|
||||
anchors.centerIn: parent
|
||||
tintName: gearArea.containsMouse ? "primary" : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: gearArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
|
||||
// --- Scrollable sections -----------------------------------------
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - 57
|
||||
Flickable {
|
||||
id: homeFlick
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: homeContent.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
Column {
|
||||
id: homeContent
|
||||
width: parent.width
|
||||
padding: 32
|
||||
spacing: 40
|
||||
|
||||
// ===== Home tab ==========================================
|
||||
Column {
|
||||
id: homeTab
|
||||
visible: root.activeTab === "home"
|
||||
width: parent.width - 64
|
||||
spacing: 40
|
||||
|
||||
// Loading skeleton (HomeSkeleton: two shimmer rows).
|
||||
Column {
|
||||
visible: QbzHome.homeLoading && root.sections.length === 0
|
||||
width: parent.width
|
||||
spacing: 40
|
||||
property bool phase: false
|
||||
Timer {
|
||||
interval: 900
|
||||
running: parent.visible
|
||||
repeat: true
|
||||
onTriggered: parent.phase = !parent.phase
|
||||
}
|
||||
SkeletonRow { phase: parent.phase }
|
||||
SkeletonRow { phase: parent.phase }
|
||||
}
|
||||
|
||||
// Error state with retry (the Slint Home has no error
|
||||
// arm; the box mirrors the FavoritesView Retry button).
|
||||
Rectangle {
|
||||
visible: QbzHome.homeError !== ""
|
||||
width: parent.width
|
||||
height: errorColumn.height + 28
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Column {
|
||||
id: errorColumn
|
||||
anchors.centerIn: parent
|
||||
spacing: 10
|
||||
Text {
|
||||
text: QbzHome.homeError
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
}
|
||||
Rectangle {
|
||||
width: retryText.implicitWidth + 28
|
||||
height: 32
|
||||
radius: 6
|
||||
color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Text {
|
||||
id: retryText
|
||||
anchors.centerIn: parent
|
||||
text: QbzSession.tr("Retry", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
MouseArea {
|
||||
id: retryArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzHome.reloadHome()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Section rails.
|
||||
SectionRails { sectionsModel: root.sections }
|
||||
}
|
||||
|
||||
// ===== Editor's Picks (phase 13) ========================
|
||||
Column {
|
||||
visible: root.activeTab === "editorPicks"
|
||||
width: parent.width - 64
|
||||
spacing: 40
|
||||
SectionRails { sectionsModel: root.editorSections }
|
||||
}
|
||||
|
||||
// ===== For You (phase 13) =================================
|
||||
Column {
|
||||
visible: root.activeTab === "forYou"
|
||||
width: parent.width - 64
|
||||
spacing: 40
|
||||
SectionRails { sectionsModel: root.forYouSections }
|
||||
}
|
||||
|
||||
// ===== Recommendations (POC-NOTE placeholder) =============
|
||||
// The tab follows the Slint gating (showRecommendations pref
|
||||
// — ON for this user, so the tab shows); the CONTENT is the
|
||||
// external reco engine (crates/qbz/src/external_reco.rs —
|
||||
// seeded similar albums, weeklies builders, dismissal
|
||||
// stores), which is not ported to the POC.
|
||||
Column {
|
||||
visible: root.activeTab === "recommendations"
|
||||
width: parent.width - 64
|
||||
spacing: 10
|
||||
Text {
|
||||
text: QbzSession.tr("Recommendations", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
Text {
|
||||
text: "QBZ Qt POC — the external recommendations engine is not ported (external_reco.rs)"
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Thin auto-hiding scrollbar in the right gutter (ListScrollbar).
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
target: homeFlick
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user