feat(slint): playlist snapshot (names+membership) and offline favorites rail

Qobuz playlist snapshot, captured opportunistically with zero new API
traffic: every online sidebar/manager list load upserts id+name+owner+
track_count for the user's playlists; opening a playlist detail online
full-replaces its membership track ids (refusing playlists never listed,
so viewed public playlists don't land). Offline consumers: synthesized
sidebar/manager entries resolve the persisted name (killing the cold-start
'Playlist (N local)' fallback when a snapshot exists), and the mixed-
playlist offline detail now renders the snapshot tracks that are playable
(snapshot intersect cached intersect grace) from the offline-cache index -
snapshot block first in snapshot order, sidecar local/plex block after.
D11 availability widens: a playlist with zero sidecar rows but cached
snapshot tracks now shows offline. 4 repo tests.

Offline favorites rail: the Favorites placeholder gains an 'Available
offline (N)' block - disk-cached favorite ids intersected with the
offline-cache READY index and LocalLibrary qobuz_download copies, compact
rows with the offline cover chain, click plays the rail as a queue through
the cache tier. Shared OfflinePlaceholder gains an @children slot; only
the Favorites mount injects the rail.

Single commit: the two features interleave in main.rs/state wiring and
ship together to keep every commit compiling.

cc
This commit is contained in:
vicrodh committed 2026-06-10 02:27:54 -06:00
1 parent 23129cb98e
commit 81587f4d17
17 files changed
+1154 -95

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
import { Toast } from "primitives/Toast.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, OfflineState, OfflineModeActions } from "state.slint";
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
@@ -0,0 +1,49 @@
// B9 — "playable favorites" rail, shown INSIDE the offline placeholder on
// the Favorites view only (the AppShell mounts it as the placeholder's
// children — ADR-010: both `if`s live at the AppShell mount site). Lists
// the favorite tracks that are still playable offline (offline-cache READY
// copies + library qobuz_download rows, intersected with the disk-seeded
// favorite set on the Rust side) as compact SlimCard rows; clicking a row
// plays the whole rail from there through the offline-capable queue path.
//
// Collapses to nothing while the list is empty, so the placeholder then
// reads exactly like the generic one.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { OfflineFavoritesState, OfflineFavoritesActions } from "../state.slint";
import { SlimCard } from "../discover/SlimCard.slint";
import { ListView } from "std-widgets.slint";
export component FavoritesOfflineRail inherits VerticalLayout {
init => {
OfflineFavoritesActions.load();
}
property <int> count: OfflineFavoritesState.tracks.length;
if root.count > 0: Rectangle { height: 28px; }
if root.count > 0: HorizontalLayout {
alignment: center;
VerticalLayout {
spacing: 8px;
Text {
text: @tr("Available offline") + " (\{root.count})";
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.semibold;
}
ListView {
width: 480px;
// Up to five 60px rows visible; longer lists scroll inside.
height: Math.min(root.count, 5) * 60px;
for item in OfflineFavoritesState.tracks: SlimCard {
item: item;
clicked(id) => {
OfflineFavoritesActions.play(id);
}
}
}
}
}
}
@@ -62,5 +62,10 @@ export component OfflinePlaceholder inherits Rectangle {
}
}
}
// Optional extra content under the placeholder copy. Empty on the
// generic mounts; the Favorites mount injects the B9 "playable
// favorites" rail here (see AppShell + FavoritesOfflineRail).
@children
}
}
+8 -2
View File
@@ -44,6 +44,7 @@ import { LabelReleasesView } from "../label/LabelReleasesView.slint";
import { LabelPageView } from "../label/LabelPageView.slint";
import { ArtistsByLocationView } from "../location/ArtistsByLocationView.slint";
import { FavoritesView } from "../favorites/FavoritesView.slint";
import { FavoritesOfflineRail } from "../favorites/FavoritesOfflineRail.slint";
import { MixView } from "../mix/MixView.slint";
import { PlaylistView } from "../playlist/PlaylistView.slint";
import { PlaylistManagerView } from "../playlist/PlaylistManagerView.slint";
@@ -183,8 +184,13 @@ export component AppShell inherits Rectangle {
// Offline: every Qobuz-only view below mounts only while
// online; the shared placeholder takes the pane otherwise
// (ADR-010 — the `if` lives here at the mount site).
if root.qobuz-view-blocked: OfflinePlaceholder { }
// (ADR-010 — the `if` lives here at the mount site). The
// Favorites mount alone injects the B9 "playable favorites"
// rail into the placeholder's children slot.
if root.qobuz-view-blocked && NavState.view != ContentView.favorites: OfflinePlaceholder { }
if root.qobuz-view-blocked && NavState.view == ContentView.favorites: OfflinePlaceholder {
FavoritesOfflineRail { }
}
if NavState.view == ContentView.home && !OfflineState.offline: HomeView {
album-clicked(id) => {
+20
View File
@@ -3192,3 +3192,23 @@ export global OfflineModeActions {
// persistence + #279 snapshot path as the Settings toggle.
callback set-offline(bool);
}
// B9 — offline Favorites "playable favorites" rail. While OFFLINE the
// Favorites view is replaced by the OfflinePlaceholder; this rail (mounted
// as the placeholder's children on the Favorites mount only) lists the
// favorite tracks that are still playable — offline-cache READY copies and
// library qobuz_download rows — so the view isn't a dead end.
export global OfflineFavoritesState {
// The playable favorite tracks, display order = play order (the Rust
// side keeps a matching queue; a row click plays the rail from there).
in property <[SlimItem]> tracks: [];
}
export global OfflineFavoritesActions {
// (Re)build the rail from the local stores — fired by the rail's init
// on every mount of the Favorites offline placeholder.
callback load();
// Play the rail starting at the clicked track id (offline-capable
// queue path; the cached copies play through the offline tier).
callback play(string);
}