Library and Home showed grey squares that filled in unevenly, which reads as broken rather than as loading. One shared QbzSkeleton — a card shape and a row shape — now stands in while data is on its way, and each placeholder clears as its own item arrives rather than all at once, so loading reads as progressive. The animation is the part that could have undone the point of this track, so it is capped: a bounded number of placeholders breathe, and the timer stops when the view is not visible or the window is minimized. Not on focus loss — a tiling desktop keeps windows visible and unfocused, which is the rule already established for the ambient field. cc
817 lines
33 KiB
QML
817 lines
33 KiB
QML
// 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 QtQuick.Window
|
|
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"
|
|
|
|
// --- skeleton pulse ---------------------------------------------------
|
|
// ONE 900ms Timer drives EVERY placeholder in this view (QbzSkeleton's
|
|
// preferred drive mode). GATING RULE: freeze on NOT VISIBLE — the view
|
|
// hidden, or the window minimized/hidden. NEVER on lost focus (a tiling
|
|
// desktop keeps windows visible and unfocused).
|
|
property bool skelPhase: false
|
|
readonly property bool windowShowing: root.Window.window
|
|
? (root.Window.window.visibility !== Window.Minimized
|
|
&& root.Window.window.visibility !== Window.Hidden)
|
|
: true
|
|
// Runs only while something can actually be shimmering: the first fetch
|
|
// (section skeletons) or a card still waiting for its cover.
|
|
readonly property bool skelNeeded: QbzHome.homeLoading || root.artPending
|
|
// Cheap "some card in the mounted rails has artUrl but no artPath yet"
|
|
// probe — recomputed only when a sections document is republished.
|
|
readonly property bool artPending: root.anyArtPending(
|
|
root.activeTab === "editorPicks" ? root.editorSections
|
|
: root.activeTab === "forYou" ? root.forYouSections : root.sections)
|
|
function anyArtPending(model) {
|
|
for (var s = 0; s < model.length; s++) {
|
|
var items = model[s].items || []
|
|
for (var i = 0; i < items.length; i++) {
|
|
if ((items[i].artUrl || "") !== "" && (items[i].artPath || "") === "")
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
Timer {
|
|
interval: 900
|
|
repeat: true
|
|
running: root.visible && root.windowShowing && root.skelNeeded
|
|
onTriggered: root.skelPhase = !root.skelPhase
|
|
}
|
|
|
|
// ============================ 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: Item {
|
|
required property var modelData
|
|
required property int index
|
|
width: 200
|
|
height: 246
|
|
|
|
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
|
|
}
|
|
// Per-item artwork placeholder: the grey tile shimmers
|
|
// until THIS card's cover lands, then disappears on its
|
|
// own — the rail fills in progressively instead of all
|
|
// at once. A bare Rectangle, so it does not eat the
|
|
// card's hover/click areas underneath.
|
|
QbzSkeleton {
|
|
variant: "art"
|
|
width: 200
|
|
height: 200
|
|
visible: (modelData.artUrl || "") !== ""
|
|
&& (modelData.artPath || "") === ""
|
|
phase: root.skelPhase
|
|
cellIndex: index
|
|
}
|
|
}
|
|
}
|
|
// 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
|
|
required property int index
|
|
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
|
|
}
|
|
// Square-art slots only — a pinned ARTIST keeps the
|
|
// designed round gradient+glyph placeholder ArtistCard
|
|
// already draws (Slint's ArtistGridCard), which reads as
|
|
// a portrait, not as a missing tile.
|
|
QbzSkeleton {
|
|
variant: "art"
|
|
width: 200
|
|
height: 200
|
|
visible: modelData.itemKind !== "artist"
|
|
&& (modelData.artUrl || "") !== ""
|
|
&& (modelData.artPath || "") === ""
|
|
phase: root.skelPhase
|
|
cellIndex: index
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// HomeSkeleton.slint's SkeletonRow, 1:1 on the shared QbzSkeleton
|
|
// control: a 180x22 title bar over five 200px card placeholders,
|
|
// spacing 32/12/8. `phase` comes from the ONE Timer on the view root.
|
|
component SkeletonRow: Column {
|
|
id: skelRow
|
|
property bool phase: false
|
|
width: parent ? parent.width : 0
|
|
spacing: 12
|
|
QbzSkeleton { variant: "block"; width: 180; height: 22; phase: skelRow.phase }
|
|
Row {
|
|
spacing: 32
|
|
Repeater {
|
|
model: 5
|
|
delegate: QbzSkeleton {
|
|
required property int index
|
|
variant: "card"
|
|
width: 200
|
|
phase: skelRow.phase
|
|
cellIndex: index
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Two SkeletonRows = the whole HomeSkeleton component. Mounted by each
|
|
// Discover tab while its sections document is still empty.
|
|
component TabSkeleton: Column {
|
|
id: tabSkel
|
|
property bool phase: false
|
|
width: parent ? parent.width : 0
|
|
spacing: 40
|
|
SkeletonRow { phase: tabSkel.phase }
|
|
SkeletonRow { phase: tabSkel.phase }
|
|
}
|
|
|
|
// ============================ 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). The
|
|
// pulse comes from the view-root Timer, which is itself
|
|
// gated on visibility + window state.
|
|
TabSkeleton {
|
|
visible: QbzHome.homeLoading && root.sections.length === 0
|
|
phase: root.skelPhase
|
|
}
|
|
|
|
// 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
|
|
TabSkeleton {
|
|
visible: QbzHome.homeLoading && root.editorSections.length === 0
|
|
phase: root.skelPhase
|
|
}
|
|
SectionRails { sectionsModel: root.editorSections }
|
|
}
|
|
|
|
// ===== For You (phase 13) =================================
|
|
Column {
|
|
visible: root.activeTab === "forYou"
|
|
width: parent.width - 64
|
|
spacing: 40
|
|
TabSkeleton {
|
|
visible: QbzHome.homeLoading && root.forYouSections.length === 0
|
|
phase: root.skelPhase
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|