From da36d86ccc930a2053e9559aca7ea5cd1109895e Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:01:02 -0600 Subject: [PATCH] =?UTF-8?q?feat(qbz-qt):=20phase=2011=20=E2=80=94=20final?= =?UTF-8?q?=20polish=20vs=20owner's=20Slint=20reference=20shots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed an ALSA enumeration zip bug that silently dropped the last device (the stored DAC now shows with its BP badge). Home rails now follow the persisted DiscoverPrefsStore (order + disabled sections) like the Slint shell; Pinned rail built from pinned_items.db (albums/artists/playlists, ArtistGridCard circle); View-all header links on endpoint rails; pin badge hover-gated like the Slint overlay buttons. Restore path now binds the pinned + local-favorites stores (was login-only). Settings panels verified row-for-row against the 3440px references. km --- crates/qbz-qt/qml/AlbumCard.qml | 10 +- crates/qbz-qt/qml/HomeView.qml | 210 ++++++++++++++++++++++++++++++- crates/qbz-qt/src/auth_qt.rs | 9 +- crates/qbz-qt/src/home_qt.rs | 95 ++++++++++++-- crates/qbz-qt/src/settings_qt.rs | 15 +-- crates/qbz-qt/src/sidebar_qt.rs | 18 +++ 6 files changed, 329 insertions(+), 28 deletions(-) diff --git a/crates/qbz-qt/qml/AlbumCard.qml b/crates/qbz-qt/qml/AlbumCard.qml index 19934cbcc..6b0881761 100644 --- a/crates/qbz-qt/qml/AlbumCard.qml +++ b/crates/qbz-qt/qml/AlbumCard.qml @@ -118,9 +118,11 @@ Rectangle { onClicked: QbzBridge.openAlbum(root.albumId) } - // Pin badge — top-right. Filled accent pin always visible when - // pinned; outline pin hover-revealed otherwise. Always-mounted - // (opacity) so its hover joins overlayOn. + // Pin badge — top-right. Hover-revealed like the overlay + // buttons (AlbumCard.slint: opacity follows overlay-on even + // when pinned — the pinned state reads in the icon swap only: + // filled accent pin vs outline). Always-mounted (opacity) so + // its hover joins overlayOn. Rectangle { x: parent.width - width - 8 y: 8 @@ -128,7 +130,7 @@ Rectangle { height: 26 radius: 13 color: pinArea.containsMouse ? "#cc000000" : "#99000000" - opacity: root.overlayOn ? 1.0 : (root.isPinned ? 1.0 : 0.0) + opacity: root.overlayOn ? 1.0 : 0.0 Behavior on opacity { NumberAnimation { duration: 150 } } QbzIcon { name: root.isPinned ? "pin-filled" : "pin" diff --git a/crates/qbz-qt/qml/HomeView.qml b/crates/qbz-qt/qml/HomeView.qml index fed086c19..1c496a3c4 100644 --- a/crates/qbz-qt/qml/HomeView.qml +++ b/crates/qbz-qt/qml/HomeView.qml @@ -5,7 +5,8 @@ // Data: QbzBridge.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" | "recentPlaceholder". +// "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 @@ -62,11 +63,37 @@ Rectangle { } } - // Section header: title + page chevrons (Carousel header metrics). + // "View all →" header link (Carousel.slint ViewAllLink) — shown when + // the section carries a discover endpoint. POC-NOTE: the click is + // INERT (the DiscoverBrowse full-list page is out of scope). + component ViewAllLink: Rectangle { + width: linkText.implicitWidth + 16 + height: 26 + radius: 4 + color: vaArea.containsMouse ? theme.surfaceHover : "transparent" + Text { + id: linkText + anchors.centerIn: parent + text: QbzBridge.tr("View all →") + color: vaArea.containsMouse ? theme.textPrimary : theme.textSecondary + font.pixelSize: 14 + font.weight: theme.weightMedium + } + MouseArea { + id: vaArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + } + } + + // Section header: title + [View all] + page chevrons (Carousel header + // metrics — the link sits LEFT of the chevrons, Tauri parity). component RailHeader: Item { property string title: "" property bool leftEnabled: false property bool rightEnabled: false + property bool showViewAll: false signal pageLeft() signal pageRight() @@ -84,6 +111,10 @@ Rectangle { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 4 + ViewAllLink { + visible: parent.parent.showViewAll + anchors.verticalCenter: parent.verticalCenter + } NavBtn { name: "chevron-left" btnEnabled: parent.parent.leftEnabled @@ -112,6 +143,7 @@ Rectangle { 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) } @@ -256,6 +288,7 @@ Rectangle { 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) } @@ -289,6 +322,173 @@ Rectangle { } } + // 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 PinnedArtistCard: Item { + property var card: ({}) + width: 200 + height: 246 + + Column { + spacing: 0 + // Art zone: 200x200 rounded-square surface (AlbumCard parity) + // framing a 190px round portrait (ArtistGridCard). + Rectangle { + width: 200 + height: 200 + radius: theme.radiusSm + color: theme.surfaceElevated + Rectangle { + width: 190 + height: 190 + radius: 95 + anchors.centerIn: parent + color: theme.surfaceMain + clip: true + Image { + anchors.fill: parent + source: card.artPath || "" + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.openArtist(card.id) + } + } + } + Item { width: 1; height: 6 } + Text { + width: 200 + text: card.title + color: paNameArea.containsMouse ? theme.accent : theme.textPrimary + font.pixelSize: theme.fontBody - 2 + font.weight: theme.weightMedium + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + MouseArea { + id: paNameArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.openArtist(card.id) + } + } + } + } + + component PinnedPlaylistCard: Item { + property var card: ({}) + width: 200 + height: 246 + + Column { + spacing: 0 + Rectangle { + width: 200 + height: 200 + radius: theme.radiusSm + color: theme.surfaceElevated + clip: true + Image { + anchors.fill: parent + source: card.artPath || "" + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + // POC-NOTE: playlist page out of scope — inert. + } + } + Item { width: 1; height: 6 } + Text { + width: 200 + text: card.title + color: theme.textPrimary + font.pixelSize: theme.fontBody - 2 + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Text { + width: 200 + visible: card.artist !== "" + text: card.artist + color: theme.textMuted + font.pixelSize: 12 + elide: Text.ElideRight + } + } + } + + 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 + + RailHeader { + 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 + PinnedArtistCard { card: modelData } + } + Component { + id: pPlaylist + PinnedPlaylistCard { card: modelData } + } + Loader { + anchors.fill: parent + sourceComponent: modelData.itemKind === "artist" ? pArtist + : modelData.itemKind === "playlist" ? pPlaylist : pAlbum + } + } + } + } + } + // Playlist card (PlaylistCard.slint): 200x246 — 200px cover + category // subtag + title. component PlaylistCard: Rectangle { @@ -692,9 +892,15 @@ Rectangle { : modelData.kind === "playlist" ? playlistRailComp : modelData.kind === "slim" ? slimGridComp : modelData.kind === "artists" ? artistRailComp + : modelData.kind === "pinned" ? pinnedRailComp : recentComp property var sectionData: modelData + Component { + id: pinnedRailComp + PinnedRail { sectionData: parent.sectionData } + } + Component { id: albumRailComp AlbumRail { sectionData: parent.sectionData } diff --git a/crates/qbz-qt/src/auth_qt.rs b/crates/qbz-qt/src/auth_qt.rs index e6d372161..f87d18328 100644 --- a/crates/qbz-qt/src/auth_qt.rs +++ b/crates/qbz-qt/src/auth_qt.rs @@ -240,10 +240,15 @@ where let subscription = session.subscription_label.clone(); core.set_session(session).await.map_err(|e| e.to_string())?; runtime.activate(user_id).await?; - // POC-NOTE: per-user store fan-out skipped here (see module - // docs) — only the offline-mode binding + D4 verdict stay. + // Per-user stores the shell depends on (pinned items for the + // Home rail + pin badges, local favorites for hearts) — the + // same binding the fresh-login path does (auth_qt login). + // POC-NOTE: the rest of the Slint per-user store fan-out stays + // skipped (see module docs). if let Some(dir) = crate::offline_fwd::user_data_dir(user_id) { crate::offline_fwd::init_for_user(&dir); + crate::library_qt::init_local_favorites(&dir); + crate::sidebar_qt::init_pinned(&dir); } crate::offline_fwd::subscription_mark_valid(); crate::offline_fwd::engine().set_offline_session(false); diff --git a/crates/qbz-qt/src/home_qt.rs b/crates/qbz-qt/src/home_qt.rs index b42b86c0b..a77d06e82 100644 --- a/crates/qbz-qt/src/home_qt.rs +++ b/crates/qbz-qt/src/home_qt.rs @@ -15,10 +15,11 @@ //! view renders the Slint empty-data placeholders instead. //! - Reco-scored taste ordering of favorite albums (reco store skipped): //! favorites render in plain favorite order. -//! - Pinned / Most Played Albums / Qobuz Mixes rows: stores/static tiles -//! out of scope. -//! - Discover prefs (per-tab section visibility/reorder): OUT OF SCOPE — -//! a fixed superset order renders (see `SECTION_ORDER`). +//! - Most Played Albums / Qobuz Mixes rows: stores/static tiles out of +//! scope (prefs entries for them are skipped). +//! - The "View all" full-list pages: the rails show the link when the +//! section carries an endpoint (1:1 header) but the click is INERT — the +//! DiscoverBrowse page is out of scope. //! - Editor's Picks / For You / Recommendations tabs: placeholder pages //! (the editor section set is not built). @@ -57,6 +58,10 @@ pub struct HomeCard { pub is_pinned: bool, /// Slim rows ("Popular albums"): the 1-based rank ("" = none). pub rank: String, + /// Pinned rail only: the card's own kind ("album" | "artist" | + /// "playlist") — the mixed PinnedCarousel slot dispatch. + #[serde(rename = "itemKind", default)] + pub item_kind: String, /// Playlist rows: the UPPERCASE first-tag category subtag. pub category: String, #[serde(rename = "artUrl")] @@ -70,10 +75,15 @@ pub struct HomeCard { pub struct HomeSection { pub id: String, pub title: String, - /// "album" | "slim" | "playlist" | "artists" | "recentPlaceholder". + /// "album" | "slim" | "playlist" | "artists" | "pinned" | + /// "recentPlaceholder". pub kind: String, /// Placeholder hint for recentPlaceholder sections. pub hint: String, + /// Discover endpoint path for the "View all" header link ("" = no + /// full-list page — home.rs SectionData.endpoint). + #[serde(default)] + pub endpoint: String, pub items: Vec, } @@ -83,6 +93,7 @@ fn push_albums( out: &mut Vec, id: &str, title: String, + endpoint: &str, container: Option>, ) { let Some(container) = container else { @@ -96,6 +107,7 @@ fn push_albums( title, kind: "album".to_string(), hint: String::new(), + endpoint: endpoint.to_string(), items: container.data.items.into_iter().map(map_album).collect(), }); } @@ -111,8 +123,34 @@ fn build_sections( ) -> Vec { let mut out: Vec = Vec::new(); - push_albums(&mut out, "newReleases", qbz_i18n::t("New Releases"), containers.new_releases); - push_albums(&mut out, "pressAwards", qbz_i18n::t("Press Accolades"), containers.press_awards); + push_albums(&mut out, "newReleases", qbz_i18n::t("New Releases"), "/discover/newReleases", containers.new_releases); + push_albums(&mut out, "pressAwards", qbz_i18n::t("Press Accolades"), "/discover/pressAward", containers.press_awards); + + // Pinned rail (phase 11) — the user's pinned albums/artists/playlists + // from the shared per-user store (PinnedCarousel.slint: mixed 200x246 + // slots, most-recent first). Self-hides while empty. + let pinned: Vec = crate::sidebar_qt::list_pinned() + .into_iter() + .map(|p| HomeCard { + id: p.id, + title: p.title, + artist: p.subtitle, + art_url: p.artwork_url, + item_kind: p.kind, + is_pinned: true, + ..Default::default() + }) + .collect(); + if !pinned.is_empty() { + out.push(HomeSection { + id: "pinned".to_string(), + title: qbz_i18n::t("Pinned"), + kind: "pinned".to_string(), + hint: String::new(), + endpoint: String::new(), + items: pinned, + }); + } // Qobuz Playlists row (single-cover cards, first-tag category subtag). let playlists: Vec = containers @@ -129,6 +167,9 @@ fn build_sections( title: qbz_i18n::t("Qobuz Playlists"), kind: "playlist".to_string(), hint: String::new(), + // The Slint playlist "View all" opens the local browse page (an + // action, not a discover endpoint) — no header link here. + endpoint: String::new(), items: playlists, }); } @@ -140,6 +181,7 @@ fn build_sections( title: qbz_i18n::t("Recently Played Albums"), kind: "recentPlaceholder".to_string(), hint: qbz_i18n::t("Albums you play will appear here."), + endpoint: String::new(), items: Vec::new(), }); out.push(HomeSection { @@ -147,6 +189,7 @@ fn build_sections( title: qbz_i18n::t("Recently Played Tracks"), kind: "recentPlaceholder".to_string(), hint: qbz_i18n::t("Tracks you play will appear here."), + endpoint: String::new(), items: Vec::new(), }); @@ -154,6 +197,7 @@ fn build_sections( &mut out, "idealDiscography", qbz_i18n::t("Ideal Discography"), + "/discover/idealDiscography", containers.ideal_discography, ); @@ -174,6 +218,7 @@ fn build_sections( title: qbz_i18n::t("Popular albums"), kind: "slim".to_string(), hint: String::new(), + endpoint: "/discover/mostStreamed".to_string(), items: popular, }); } @@ -182,9 +227,10 @@ fn build_sections( &mut out, "editorPicks", qbz_i18n::t("Albums of the Week"), + "/discover/albumOfTheWeek", containers.album_of_the_week, ); - push_albums(&mut out, "qobuzissimes", qbz_i18n::t("Qobuzissimes"), containers.qobuzissims); + push_albums(&mut out, "qobuzissimes", qbz_i18n::t("Qobuzissimes"), "/discover/qobuzissims", containers.qobuzissims); // Personalized rails (self-hide while empty, 1:1 Slint). if !favorite_albums.is_empty() { @@ -193,6 +239,7 @@ fn build_sections( title: qbz_i18n::t("Library Albums"), kind: "album".to_string(), hint: String::new(), + endpoint: String::new(), items: favorite_albums, }); } @@ -202,6 +249,7 @@ fn build_sections( title: qbz_i18n::t("Release Watch"), kind: "album".to_string(), hint: String::new(), + endpoint: String::new(), items: release_watch, }); } @@ -211,11 +259,40 @@ fn build_sections( title: qbz_i18n::t("Your Top Artists"), kind: "artists".to_string(), hint: String::new(), + endpoint: String::new(), items: top_artists, }); } - out + // Phase 11: order + gate by the persisted Discover prefs — the Slint + // renders per `DiscoverState.home-sections`, which the prefs drive + // (discover_prefs.rs). A section whose prefs entry is DISABLED does not + // render at all; ids the POC does not implement (qobuzMixes, + // mostPlayedAlbums, ...) are skipped. + let prefs = crate::sidebar_qt::user_dir() + .and_then(|dir| { + qbz_app::settings::discover_prefs::DiscoverPrefsStore::new_at(&dir).ok() + }) + .map(|store| store.load()) + .unwrap_or_else(qbz_app::settings::discover_prefs::default_prefs); + let home_prefs = prefs.tab(qbz_app::settings::discover_prefs::DiscoveryTab::Home); + let mut gated: Vec = Vec::with_capacity(out.len()); + for pref in home_prefs { + if !pref.enabled { + continue; + } + if let Some(pos) = out.iter().position(|s| s.id == pref.id.as_str()) { + gated.push(out.remove(pos)); + } + } + // Defensive: built sections with NO prefs entry at all keep their + // relative order at the end (every built id has one today). Sections + // that HAVE an entry but are disabled stay hidden (1:1 Slint). + let known: std::collections::HashSet<&str> = + home_prefs.iter().map(|p| p.id.as_str()).collect(); + out.retain(|s| !known.contains(s.id.as_str())); + gated.append(&mut out); + gated } /// Fetch the discover index (UNFILTERED — genre filter out of scope) and diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 211d25229..1a4f040fd 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -281,7 +281,8 @@ fn enumerate_devices(backend: AudioBackendType) -> (Vec, Vec (Vec, Vec = rows .into_iter() - .zip(ids.iter().skip(1).map(|id| { - // ids[i] aligns with rows[i-1] after the synthetic lead; - // rebuilt below — simpler to recompute section per row id. - id.clone() - })) - .map(|(row, _)| row) + .zip(ids.iter().cloned()) .enumerate() - .map(|(i, row)| { - let id = if i == 0 { String::new() } else { ids[i].clone() }; - (alsa_section(&id, i == 0, &row.label), row, id) - }) + .map(|(i, (row, id))| (alsa_section(&id, i == 0, &row.label), row, id)) .collect(); indexed.sort_by_key(|(section, _, _)| *section); // Rebuild ids in the SAME order (they're the index map). diff --git a/crates/qbz-qt/src/sidebar_qt.rs b/crates/qbz-qt/src/sidebar_qt.rs index ac29ab70d..79ef12203 100644 --- a/crates/qbz-qt/src/sidebar_qt.rs +++ b/crates/qbz-qt/src/sidebar_qt.rs @@ -30,10 +30,14 @@ use serde::Serialize; // --------------------------------------------------------------------------- static PINNED: OnceLock> = OnceLock::new(); +/// The per-user base dir the pinned store was bound to (also where the +/// discover-prefs DB lives) — stashed so Home can read section prefs. +static USER_DIR: OnceLock = OnceLock::new(); /// Bind `/pinned_items.db` on every session activation (mirrors /// `pinned::init_for_user`; fail-open). pub fn init_pinned(base_dir: &Path) { + let _ = USER_DIR.set(base_dir.to_path_buf()); let path = base_dir.join(DB_FILE_NAME); match PinnedItemsService::new(&path) { Ok(service) => { @@ -51,6 +55,20 @@ pub fn is_pinned(kind: &str, id: &str) -> bool { .unwrap_or(false) } +/// All pinned items, most-recent first (PinnedItemsService::list) — feeds +/// the Home "Pinned" rail. +pub fn list_pinned() -> Vec { + PINNED + .get() + .and_then(|s| s.lock().unwrap().list().ok()) + .unwrap_or_default() +} + +/// The per-user base dir (None before any session activation). +pub fn user_dir() -> Option { + USER_DIR.get().cloned() +} + /// Toggle the pin state of an album/artist/playlist. Returns the new /// state, or None with no store bound / on error. pub fn toggle_pin(kind: &str, id: &str, title: &str, subtitle: &str, artwork_url: &str) -> Option {