feat(qbz-qt): phase 11 — final polish vs owner's Slint reference shots
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
This commit is contained in:
6 files changed
+329
-28
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<HomeCard>,
|
||||
}
|
||||
|
||||
@@ -83,6 +93,7 @@ fn push_albums(
|
||||
out: &mut Vec<HomeSection>,
|
||||
id: &str,
|
||||
title: String,
|
||||
endpoint: &str,
|
||||
container: Option<DiscoverContainer<DiscoverAlbum>>,
|
||||
) {
|
||||
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<HomeSection> {
|
||||
let mut out: Vec<HomeSection> = 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<HomeCard> = 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<HomeCard> = 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<HomeSection> = 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
|
||||
|
||||
@@ -281,7 +281,8 @@ fn enumerate_devices(backend: AudioBackendType) -> (Vec<DeviceOption>, Vec<Strin
|
||||
|
||||
if backend == AudioBackendType::Alsa {
|
||||
// Stable sort by section; the section header lands on each section's
|
||||
// first row (settings.rs `group_alsa_devices`).
|
||||
// first row (settings.rs `group_alsa_devices`). rows[i] aligns with
|
||||
// ids[i] (both lead with the synthetic "System default"/"" entry).
|
||||
let section_labels = [
|
||||
qbz_i18n::t("Defaults"),
|
||||
qbz_i18n::t("Bit-perfect (Hardware / Digital)"),
|
||||
@@ -290,17 +291,9 @@ fn enumerate_devices(backend: AudioBackendType) -> (Vec<DeviceOption>, Vec<Strin
|
||||
];
|
||||
let mut indexed: Vec<(usize, DeviceOption, String)> = 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).
|
||||
|
||||
@@ -30,10 +30,14 @@ use serde::Serialize;
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static PINNED: OnceLock<Mutex<PinnedItemsService>> = 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<std::path::PathBuf> = OnceLock::new();
|
||||
|
||||
/// Bind `<dir>/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<PinnedItem> {
|
||||
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<std::path::PathBuf> {
|
||||
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<bool> {
|
||||
|
||||
Reference in new issue
Block a user