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:
21 files changed
+2474
-74
No files matched your search
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user