feat(slint): first-class local playlists with offline-only mode (D7/D8)

New local_playlists + local_playlist_tracks schema in the shared per-user
library.db (ids 'local:<uuid>', sources qobuz/local/plex, position-compacted,
8 repo tests in qbz-library). PlaylistRef routing at the navigation seam
makes local ids unrepresentable in Qobuz playlist calls by construction
(all take u64). Local playlists appear in sidebar, manager and picker
(picker hides the Qobuz set while offline); detail view reuses the existing
playlist row machinery and renders offline; creation modal gains the
offline-only toggle (forced on while offline - creating offline always
produces a local playlist, D8) and Edit can flip it later; explicit
Upload-to-Qobuz conversion for non-offline-only locals (confirm dialog,
local rows attach via the existing sidecar, entity kept on failure).

Never-to-Qobuz guard: queues built from an offline-only playlist carry a
core-level stamp cleared by every queue replacement; the QConnect sync
skips the cloud push and play-on-peer falls back to local playback while
the stamp is set.

Deferred (logged in the slice report): plex-row attach on upload, custom
reorder for local playlists, folder/favorite/hide on local items, manager
sort interleaving, offline cover thumbs for cached qobuz rows.

cc
This commit is contained in:
vicrodh committed 2026-06-09 22:17:13 -06:00
1 parent 74142e9a44
commit da6e9599fb
21 files changed
+2474 -74

No files matched your search

+5 -2
View File
@@ -9,7 +9,7 @@ import { Theme } from "../foundation/semantic-colors.slint";
import { Layout } from "../foundation/layout.slint";
import { Radius } from "../foundation/radius.slint";
import { Typography } from "../foundation/typography.slint";
import { NavState, ContentView, ShellState, OfflineState } from "../state.slint";
import { NavState, ContentView, ShellState, OfflineState, PlaylistState } from "../state.slint";
import { SecondaryButton } from "../primitives/SecondaryButton.slint";
import { Sidebar } from "Sidebar.slint";
import { HeaderBar } from "HeaderBar.slint";
@@ -320,7 +320,10 @@ export component AppShell inherits Rectangle {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.playlist && !OfflineState.offline: PlaylistView {
// LOCAL playlists (PlaylistState.is-local) render OFFLINE
// too — they live in library.db, not on Qobuz (D7/D11).
if NavState.view == ContentView.playlist
&& (!OfflineState.offline || PlaylistState.is-local): PlaylistView {
play-track(id) => {
root.media-action("track", id, "play");
}
+24 -9
View File
@@ -125,6 +125,9 @@ component SidebarRow inherits Rectangle {
callback hover-changed(bool);
property <bool> is-folder: root.entry.kind == "folder";
// LOCAL playlist row (library.db entity) — the Qobuz-bound context
// actions (hide / mixtape / folders) don't apply and are hidden.
property <bool> is-local-pl: root.entry.local-kind != "";
// Live filter for the move-to-folder list (per-row search input).
property <string> folder-query;
@@ -238,7 +241,7 @@ component SidebarRow inherits Rectangle {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
if !root.is-folder && !root.use-collage: QbzIcon {
if !root.is-folder && !root.use-collage && root.entry.local-kind == "": QbzIcon {
source: @image-url("../assets/icons/list-music.svg");
width: 15px;
height: 15px;
@@ -246,6 +249,18 @@ component SidebarRow inherits Rectangle {
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active ? Theme.text-primary : Theme.text-muted;
}
// LOCAL playlist marker (library.db entity) — hard-drive glyph;
// offline-only flavors tint accent (same badge, distinct shade).
if !root.is-folder && !root.use-collage && root.entry.local-kind != "": QbzIcon {
source: @image-url("../assets/icons/hard-drive.svg");
width: 15px;
height: 15px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.entry.local-kind == "offline"
? Theme.accent
: (root.active ? Theme.text-primary : Theme.text-muted);
}
}
if !ShellState.sidebar-mini: Text {
text: root.entry.name;
@@ -397,7 +412,7 @@ component SidebarRow inherits Rectangle {
menu.close();
}
}
if !root.is-folder: ContextMenuItem {
if !root.is-folder && !root.is-local-pl: ContextMenuItem {
icon: @image-url("../assets/icons/eye-off.svg");
label: @tr("Hide from sidebar");
clicked => {
@@ -405,7 +420,7 @@ component SidebarRow inherits Rectangle {
menu.close();
}
}
if !root.is-folder: ContextMenuItem {
if !root.is-folder && !root.is-local-pl: ContextMenuItem {
icon: @image-url("../assets/icons/cassette-tape.svg");
label: @tr("Add to Mixtape/Collection");
clicked => {
@@ -414,7 +429,7 @@ component SidebarRow inherits Rectangle {
}
}
// Divider before the move-to-folder section.
if !root.is-folder: Rectangle {
if !root.is-folder && !root.is-local-pl: Rectangle {
height: 7px;
Rectangle {
height: 1px;
@@ -424,7 +439,7 @@ component SidebarRow inherits Rectangle {
}
// MOVE TO FOLDER section (wrapped so the `for` is not a direct
// child of an `if`, which Slint disallows).
if !root.is-folder: VerticalLayout {
if !root.is-folder && !root.is-local-pl: VerticalLayout {
padding-left: 11px;
padding-right: 16px;
padding-bottom: 3px;
@@ -442,7 +457,7 @@ component SidebarRow inherits Rectangle {
// with "Recursion detected"). The substring filter runs in
// Rust (Slint strings have no `contains`); it fills
// `menu-folders`, which the list below iterates.
if !root.is-folder && SidebarState.folders.length >= 8: HorizontalLayout {
if !root.is-folder && !root.is-local-pl && SidebarState.folders.length >= 8: HorizontalLayout {
padding-left: 11px;
padding-right: 16px;
padding-bottom: 4px;
@@ -458,7 +473,7 @@ component SidebarRow inherits Rectangle {
// The folder list — each folder except the playlist's current
// one. The query filter is already applied to `menu-folders`
// by Rust; the current-folder exclusion stays in markup.
if !root.is-folder: ScrollView {
if !root.is-folder && !root.is-local-pl: ScrollView {
// Cap the list at ~5 rows (33px each); the rest scroll.
height: min(SidebarState.menu-folders.length * 33px, 165px);
vertical-scrollbar-policy: ScrollBarPolicy.as-needed;
@@ -476,7 +491,7 @@ component SidebarRow inherits Rectangle {
}
}
// Move to root — only when the playlist is currently in a folder.
if !root.is-folder && root.entry.folder-id != "": ContextMenuItem {
if !root.is-folder && !root.is-local-pl && root.entry.folder-id != "": ContextMenuItem {
icon: @image-url("../assets/icons/chevron-left.svg");
label: @tr("Move to root");
clicked => {
@@ -485,7 +500,7 @@ component SidebarRow inherits Rectangle {
}
}
// No folders at all.
if !root.is-folder && SidebarState.folders.length == 0: HorizontalLayout {
if !root.is-folder && !root.is-local-pl && SidebarState.folders.length == 0: HorizontalLayout {
padding-left: 11px;
padding-right: 16px;
padding-bottom: 4px;