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

@@ -252,20 +252,29 @@ component PmActionButton inherits Rectangle {
// Local-availability badge (all-local green Wifi / partial amber Cloud).
component PmLocalBadge inherits Rectangle {
in property <string> status;
visible: root.status == "all_local" || root.status == "some_local";
// First-class LOCAL playlist (library.db entity, D7) — hard-drive
// marker; offline-only flavors tint accent (same badge, distinct shade).
in property <bool> is-local-pl: false;
in property <bool> offline-only: false;
visible: root.is-local-pl
|| root.status == "all_local" || root.status == "some_local";
width: 22px;
height: 22px;
border-radius: 11px;
background: #00000099;
QbzIcon {
source: root.status == "all_local"
? @image-url("../assets/icons/wifi.svg")
: @image-url("../assets/icons/cloud-download.svg");
source: root.is-local-pl
? @image-url("../assets/icons/hard-drive.svg")
: (root.status == "all_local"
? @image-url("../assets/icons/wifi.svg")
: @image-url("../assets/icons/cloud-download.svg"));
width: 13px;
height: 13px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.status == "all_local" ? #22c55e : #f59e0b;
tint: root.is-local-pl
? (root.offline-only ? Theme.accent : Theme.text-secondary)
: (root.status == "all_local" ? #22c55e : #f59e0b);
}
}
@@ -520,6 +529,8 @@ component PmGridCard inherits Rectangle {
x: parent.width - self.width - 6px;
y: 6px;
status: root.item.local-status;
is-local-pl: root.item.is-local-playlist;
offline-only: root.item.offline-only;
}
}
@@ -659,13 +670,27 @@ component PmListRow inherits Rectangle {
alignment: center;
horizontal-stretch: 1;
spacing: 2px;
// Name — single-line 14px elided (matches `.list-item .name`).
Text {
text: root.item.name;
color: Theme.text-primary;
font-size: 14px;
font-weight: Typography.medium;
overflow: elide;
// Name — single-line 14px elided (matches `.list-item .name`),
// with the LOCAL-playlist marker leading when applicable.
HorizontalLayout {
spacing: 6px;
alignment: start;
if root.item.is-local-playlist: VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/hard-drive.svg");
width: 13px;
height: 13px;
tint: root.item.offline-only ? Theme.accent : Theme.text-muted;
}
}
Text {
text: root.item.name;
color: Theme.text-primary;
font-size: 14px;
font-weight: Typography.medium;
overflow: elide;
}
}
// Meta line — 12px muted (matches `.list-item .meta`):
// "N tracks · 1h 43m (3 local)".
+109 -4
View File
@@ -6,7 +6,7 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { PlaylistState, PlaylistActions, DragState, NavState } from "../state.slint";
import { PlaylistState, PlaylistActions, DragState, NavState, OfflineState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
@@ -112,6 +112,8 @@ export component PlaylistView inherits Rectangle {
// Read-more description modal.
property <bool> show-description: false;
// "Upload to Qobuz" confirm modal (local playlists, D8).
property <bool> show-upload-confirm: false;
// Label for the active sort field.
property <string> sort-label:
@@ -364,7 +366,9 @@ export component PlaylistView inherits Rectangle {
}
}
}
VerticalLayout {
// Favorite — a Qobuz concept; hidden for LOCAL
// playlists (no server entity to favorite).
if !PlaylistState.is-local: VerticalLayout {
horizontal-stretch: 0;
alignment: center;
CircleAction {
@@ -375,8 +379,9 @@ export component PlaylistView inherits Rectangle {
}
}
}
// Make the whole playlist available offline.
VerticalLayout {
// Make the whole playlist available offline —
// resolves Qobuz membership, hidden for LOCAL ones.
if !PlaylistState.is-local: VerticalLayout {
horizontal-stretch: 0;
alignment: center;
CircleAction {
@@ -387,6 +392,21 @@ export component PlaylistView inherits Rectangle {
}
}
}
// Upload to Qobuz (D8) — LOCAL playlists that are
// NOT offline-only, while online. Asks first; the
// local entity is deleted after a full upload.
if PlaylistState.is-local && !PlaylistState.offline-only
&& !OfflineState.offline: VerticalLayout {
horizontal-stretch: 0;
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/cloud-upload.svg");
enabled: PlaylistState.tracks.length > 0;
clicked => {
root.show-upload-confirm = true;
}
}
}
// Edit (rename / delete) — only the user's own.
if PlaylistState.is-owner: VerticalLayout {
horizontal-stretch: 0;
@@ -698,6 +718,91 @@ export component PlaylistView inherits Rectangle {
}
}
// "Upload to Qobuz" confirm (D8) — destructive-ish conversion: the
// local entity is deleted once the upload completes.
if root.show-upload-confirm: Rectangle {
background: #000000bf;
TouchArea {
mouse-cursor: default;
clicked => {
root.show-upload-confirm = false;
}
}
Rectangle {
width: Math.min(root.width - 80px, 440px);
height: confirm-panel.preferred-height;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 32px;
drop-shadow-color: #00000080;
TouchArea { }
confirm-panel := VerticalLayout {
padding: 20px;
spacing: 14px;
Text {
text: @tr("Upload to Qobuz?");
color: Theme.text-primary;
font-size: Typography.heading;
font-weight: Typography.semibold;
}
Text {
text: @tr("This creates the playlist on Qobuz with its Qobuz tracks, keeps local files attached, and removes the local-only copy.");
color: Theme.text-secondary;
font-size: Typography.body;
wrap: word-wrap;
}
HorizontalLayout {
alignment: end;
spacing: 10px;
Rectangle {
width: upload-cancel-label.preferred-width + 28px;
height: 36px;
border-radius: Radius.sm;
background: upload-cancel-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
upload-cancel-label := Text {
text: @tr("Cancel");
color: Theme.text-primary;
font-size: Typography.body;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
upload-cancel-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.show-upload-confirm = false;
}
}
}
Rectangle {
width: upload-go-label.preferred-width + 36px;
height: 36px;
border-radius: Radius.sm;
background: upload-go-ta.has-hover ? #ffffffd6 : Theme.accent;
upload-go-label := Text {
text: @tr("Upload");
color: #ffffff;
font-size: Typography.body;
font-weight: Typography.semibold;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
upload-go-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.show-upload-confirm = false;
root.media-action("playlist", PlaylistState.id, "upload-to-qobuz");
}
}
}
}
}
}
}
// Read-more description modal (same treatment as Album / Artist).
if root.show-description: Rectangle {
background: #000000bf;