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

+27
View File
@@ -153,6 +153,13 @@ pub struct QbzCore<A: FrontendAdapter> {
musicbrainz_cache: Arc<std::sync::Mutex<Option<MusicBrainzCache>>>,
/// Whether the core is initialized
initialized: Arc<RwLock<bool>>,
/// D8 guard: true when the current queue was built from an OFFLINE-ONLY
/// local playlist — such a queue must never be pushed to the Qobuz
/// Connect cloud. Cleared by every queue REPLACEMENT (`set_queue` /
/// `set_queue_with_order` / `clear_queue`); append-style ops preserve it.
/// Set explicitly by the frontend's local-playlist play path right after
/// its `set_queue`.
queue_offline_only: Arc<std::sync::atomic::AtomicBool>,
}
impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
@@ -169,9 +176,24 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
musicbrainz: Arc::new(MusicBrainzClient::new()),
musicbrainz_cache: Arc::new(std::sync::Mutex::new(None)),
initialized: Arc::new(RwLock::new(false)),
queue_offline_only: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}
}
/// Mark (or unmark) the current queue as built from an OFFLINE-ONLY local
/// playlist (D8). Call right after the `set_queue` that loaded it.
pub fn set_queue_offline_only(&self, on: bool) {
self.queue_offline_only
.store(on, std::sync::atomic::Ordering::Relaxed);
}
/// True when the current queue originates from an offline-only local
/// playlist — QConnect must skip its cloud queue push.
pub fn queue_is_offline_only(&self) -> bool {
self.queue_offline_only
.load(std::sync::atomic::Ordering::Relaxed)
}
/// Install a MusicBrainz cache. The frontend owns the data path
/// and opens the cache; QbzCore just stores the handle and uses
/// it transparently in `musicbrainz_get_artist_metadata` and
@@ -399,6 +421,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
/// slot — use when nothing is actively playing and the user wants a full
/// reset.
pub async fn clear_queue(&self, keep_current: bool) {
self.set_queue_offline_only(false);
let queue = self.queue.write().await;
queue.clear(keep_current);
self.emit(CoreEvent::QueueUpdated {
@@ -439,6 +462,9 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
/// Set the entire queue (replaces existing)
pub async fn set_queue(&self, tracks: Vec<QueueTrack>, start_index: Option<usize>) {
// Any queue replacement drops the offline-only-playlist stamp; the
// local-playlist play path re-sets it right after when it applies.
self.set_queue_offline_only(false);
let queue = self.queue.write().await;
queue.set_queue(tracks, start_index);
self.emit(CoreEvent::QueueUpdated {
@@ -455,6 +481,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
shuffle_enabled: bool,
shuffle_order: Option<Vec<usize>>,
) {
self.set_queue_offline_only(false);
let queue = self.queue.write().await;
queue.set_queue_with_order(tracks, start_index, shuffle_enabled, shuffle_order);
self.emit(CoreEvent::QueueUpdated {
+4
View File
@@ -48,6 +48,10 @@ impl LibraryDatabase {
let db = Self { conn };
db.init_schema()?;
db.run_migrations()?;
// First-class LOCAL playlists (offline-mode D7) — separate module,
// same database file. Idempotent CREATE IF NOT EXISTS.
crate::local_playlists::init_schema(&db.conn)
.map_err(|e| LibraryError::Database(format!("local_playlists schema: {}", e)))?;
Ok(db)
}
+1
View File
@@ -32,6 +32,7 @@ pub mod album_grouping;
mod cue_parser;
mod database;
pub mod ephemeral;
pub mod local_playlists;
mod errors;
mod metadata;
mod models;
+523
View File
@@ -0,0 +1,523 @@
//! First-class LOCAL playlists (offline-mode port, decisions D7/D8).
//!
//! Unlike the `playlist_*` sidecar tables (which enhance a *Qobuz* playlist
//! keyed by its server id), these are standalone entities living entirely in
//! the per-user `library.db`. Ids are TEXT `local:<uuid>` (Mixtape precedent)
//! so they are unrepresentable in any Qobuz-bound call that takes a `u64`
//! playlist id — the type guard demanded by D7.
//!
//! All functions take `&Connection` (the qbz-mixtape repo idiom): no Tauri
//! state, no async runtime — testable with in-memory SQLite. The Slint
//! command layer reaches them through `LibraryDatabase::with_connection`.
use rusqlite::{params, Connection, OptionalExtension, Result};
use uuid::Uuid;
/// Id prefix that marks a local playlist. A `local:<uuid>` id can never
/// parse as the `u64` every Qobuz playlist endpoint takes.
pub const LOCAL_PLAYLIST_PREFIX: &str = "local:";
/// True when `id` names a local playlist (`local:<uuid>` namespace).
pub fn is_local_playlist_id(id: &str) -> bool {
id.starts_with(LOCAL_PLAYLIST_PREFIX)
}
/// Track source inside a local playlist.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalPlaylistTrackSource {
Qobuz,
Local,
Plex,
}
impl LocalPlaylistTrackSource {
pub fn as_str(&self) -> &'static str {
match self {
Self::Qobuz => "qobuz",
Self::Local => "local",
Self::Plex => "plex",
}
}
fn parse(s: &str) -> Self {
match s {
"local" => Self::Local,
"plex" => Self::Plex,
_ => Self::Qobuz,
}
}
}
/// One playlist row (header metadata + per-source counts).
#[derive(Debug, Clone)]
pub struct LocalPlaylist {
pub id: String,
pub name: String,
pub description: Option<String>,
/// D8: never offered for upload, never reaches any Qobuz call or
/// QConnect queue push.
pub offline_only: bool,
pub custom_artwork_path: Option<String>,
pub created_at: i64,
pub updated_at: i64,
pub track_count: u32,
pub qobuz_count: u32,
pub local_count: u32,
pub plex_count: u32,
}
/// One membership row, ordered by `position`.
#[derive(Debug, Clone)]
pub struct LocalPlaylistTrack {
pub playlist_id: String,
pub position: i32,
pub source: LocalPlaylistTrackSource,
pub qobuz_track_id: Option<u64>,
pub local_path: Option<String>,
pub plex_key: Option<String>,
pub added_at: i64,
}
/// Input for `add_tracks` — exactly one of the three refs per source.
#[derive(Debug, Clone)]
pub enum LocalPlaylistTrackInput {
Qobuz(u64),
Local(String),
Plex(String),
}
fn now_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
/// Create the local-playlist tables. Idempotent (`IF NOT EXISTS`), run by
/// `LibraryDatabase::open` next to the rest of the schema.
pub fn init_schema(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS local_playlists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
offline_only INTEGER NOT NULL DEFAULT 0,
custom_artwork_path TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS local_playlist_tracks (
playlist_id TEXT NOT NULL REFERENCES local_playlists(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
source TEXT NOT NULL, -- 'qobuz' | 'local' | 'plex'
qobuz_track_id INTEGER,
local_path TEXT,
plex_key TEXT,
added_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_local_playlist_tracks_playlist
ON local_playlist_tracks(playlist_id, position);
"#,
)
}
// ──────────────────────────── Playlist CRUD ────────────────────────────
/// Create a playlist; returns its `local:<uuid>` id.
pub fn create(
conn: &Connection,
name: &str,
description: Option<&str>,
offline_only: bool,
) -> Result<String> {
let id = format!("{LOCAL_PLAYLIST_PREFIX}{}", Uuid::new_v4());
let ts = now_ms();
conn.execute(
"INSERT INTO local_playlists (id, name, description, offline_only, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?5)",
params![id, name, description, offline_only as i64, ts],
)?;
Ok(id)
}
pub fn rename(conn: &Connection, id: &str, new_name: &str) -> Result<()> {
conn.execute(
"UPDATE local_playlists SET name = ?1, updated_at = ?2 WHERE id = ?3",
params![new_name, now_ms(), id],
)?;
Ok(())
}
pub fn set_description(conn: &Connection, id: &str, description: Option<&str>) -> Result<()> {
conn.execute(
"UPDATE local_playlists SET description = ?1, updated_at = ?2 WHERE id = ?3",
params![description, now_ms(), id],
)?;
Ok(())
}
pub fn set_offline_only(conn: &Connection, id: &str, offline_only: bool) -> Result<()> {
conn.execute(
"UPDATE local_playlists SET offline_only = ?1, updated_at = ?2 WHERE id = ?3",
params![offline_only as i64, now_ms(), id],
)?;
Ok(())
}
pub fn set_custom_artwork(conn: &Connection, id: &str, path: Option<&str>) -> Result<()> {
conn.execute(
"UPDATE local_playlists SET custom_artwork_path = ?1, updated_at = ?2 WHERE id = ?3",
params![path, now_ms(), id],
)?;
Ok(())
}
/// Delete the playlist. Membership rows are removed explicitly as well as
/// by the FK cascade — `PRAGMA foreign_keys` is connection-scoped and the
/// app's connections don't enable it, so don't rely on the cascade alone.
pub fn delete(conn: &Connection, id: &str) -> Result<()> {
conn.execute(
"DELETE FROM local_playlist_tracks WHERE playlist_id = ?1",
params![id],
)?;
conn.execute("DELETE FROM local_playlists WHERE id = ?1", params![id])?;
Ok(())
}
fn row_to_playlist(r: &rusqlite::Row) -> Result<LocalPlaylist> {
Ok(LocalPlaylist {
id: r.get("id")?,
name: r.get("name")?,
description: r.get("description")?,
offline_only: r.get::<_, i64>("offline_only")? != 0,
custom_artwork_path: r.get("custom_artwork_path")?,
created_at: r.get("created_at")?,
updated_at: r.get("updated_at")?,
track_count: 0,
qobuz_count: 0,
local_count: 0,
plex_count: 0,
})
}
/// Fill the per-source counts on a loaded playlist header.
fn hydrate_counts(conn: &Connection, p: &mut LocalPlaylist) -> Result<()> {
let mut stmt = conn.prepare(
"SELECT source, COUNT(*) FROM local_playlist_tracks
WHERE playlist_id = ?1 GROUP BY source",
)?;
let rows = stmt.query_map(params![p.id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, u32>(1)?))
})?;
for row in rows {
let (source, count) = row?;
match LocalPlaylistTrackSource::parse(&source) {
LocalPlaylistTrackSource::Qobuz => p.qobuz_count = count,
LocalPlaylistTrackSource::Local => p.local_count = count,
LocalPlaylistTrackSource::Plex => p.plex_count = count,
}
p.track_count += count;
}
Ok(())
}
/// All local playlists (counts hydrated), newest first.
pub fn list(conn: &Connection) -> Result<Vec<LocalPlaylist>> {
let mut stmt = conn.prepare(
"SELECT id, name, description, offline_only, custom_artwork_path,
created_at, updated_at
FROM local_playlists
ORDER BY created_at DESC",
)?;
let mut out: Vec<LocalPlaylist> = Vec::new();
for r in stmt.query_map([], row_to_playlist)? {
out.push(r?);
}
for p in out.iter_mut() {
hydrate_counts(conn, p)?;
}
Ok(out)
}
/// One playlist header (counts hydrated), or None.
pub fn get(conn: &Connection, id: &str) -> Result<Option<LocalPlaylist>> {
let maybe = conn
.query_row(
"SELECT id, name, description, offline_only, custom_artwork_path,
created_at, updated_at
FROM local_playlists WHERE id = ?1",
params![id],
row_to_playlist,
)
.optional()?;
match maybe {
Some(mut p) => {
hydrate_counts(conn, &mut p)?;
Ok(Some(p))
}
None => Ok(None),
}
}
// ──────────────────────────── Track CRUD ────────────────────────────
/// Membership rows in position order.
pub fn get_tracks(conn: &Connection, playlist_id: &str) -> Result<Vec<LocalPlaylistTrack>> {
let mut stmt = conn.prepare(
"SELECT playlist_id, position, source, qobuz_track_id, local_path, plex_key, added_at
FROM local_playlist_tracks
WHERE playlist_id = ?1
ORDER BY position ASC",
)?;
let mut out = Vec::new();
for r in stmt.query_map(params![playlist_id], |r| {
Ok(LocalPlaylistTrack {
playlist_id: r.get("playlist_id")?,
position: r.get("position")?,
source: LocalPlaylistTrackSource::parse(&r.get::<_, String>("source")?),
qobuz_track_id: r.get::<_, Option<i64>>("qobuz_track_id")?.map(|v| v as u64),
local_path: r.get("local_path")?,
plex_key: r.get("plex_key")?,
added_at: r.get("added_at")?,
})
})? {
out.push(r?);
}
Ok(out)
}
/// Append tracks at the end (positions continue after the current max).
/// Exact duplicates (same source + same ref already in the playlist) are
/// skipped silently (the sidecar tables' UNIQUE idempotence, kept here by
/// an explicit existence check since position is part of every row).
/// Returns the number of rows actually inserted.
pub fn add_tracks(
conn: &Connection,
playlist_id: &str,
entries: &[LocalPlaylistTrackInput],
) -> Result<usize> {
let mut next_pos: i32 = conn.query_row(
"SELECT COALESCE(MAX(position), -1) + 1
FROM local_playlist_tracks WHERE playlist_id = ?1",
params![playlist_id],
|r| r.get(0),
)?;
let ts = now_ms();
let mut inserted = 0usize;
for entry in entries {
let (source, qobuz_id, local_path, plex_key): (
&str,
Option<i64>,
Option<&str>,
Option<&str>,
) = match entry {
LocalPlaylistTrackInput::Qobuz(id) => ("qobuz", Some(*id as i64), None, None),
LocalPlaylistTrackInput::Local(path) => ("local", None, Some(path.as_str()), None),
LocalPlaylistTrackInput::Plex(key) => ("plex", None, None, Some(key.as_str())),
};
let exists: bool = conn
.prepare(
"SELECT 1 FROM local_playlist_tracks
WHERE playlist_id = ?1 AND source = ?2
AND COALESCE(qobuz_track_id, -1) = COALESCE(?3, -1)
AND COALESCE(local_path, '') = COALESCE(?4, '')
AND COALESCE(plex_key, '') = COALESCE(?5, '')
LIMIT 1",
)?
.exists(params![playlist_id, source, qobuz_id, local_path, plex_key])?;
if exists {
continue;
}
conn.execute(
"INSERT INTO local_playlist_tracks
(playlist_id, position, source, qobuz_track_id, local_path, plex_key, added_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![playlist_id, next_pos, source, qobuz_id, local_path, plex_key, ts],
)?;
next_pos += 1;
inserted += 1;
}
if inserted > 0 {
conn.execute(
"UPDATE local_playlists SET updated_at = ?1 WHERE id = ?2",
params![ts, playlist_id],
)?;
}
Ok(inserted)
}
/// Remove the row at `position` and compact the positions above it.
pub fn remove_track(conn: &Connection, playlist_id: &str, position: i32) -> Result<()> {
conn.execute(
"DELETE FROM local_playlist_tracks WHERE playlist_id = ?1 AND position = ?2",
params![playlist_id, position],
)?;
conn.execute(
"UPDATE local_playlist_tracks SET position = position - 1
WHERE playlist_id = ?1 AND position > ?2",
params![playlist_id, position],
)?;
conn.execute(
"UPDATE local_playlists SET updated_at = ?1 WHERE id = ?2",
params![now_ms(), playlist_id],
)?;
Ok(())
}
// ──────────────────────────── Tests ────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn fresh_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
init_schema(&conn).unwrap();
conn
}
#[test]
fn create_assigns_namespaced_id_and_roundtrips() {
let conn = fresh_db();
let id = create(&conn, "Road Trip", Some("desc"), false).unwrap();
assert!(is_local_playlist_id(&id), "id must carry the local: prefix");
let p = get(&conn, &id).unwrap().unwrap();
assert_eq!(p.name, "Road Trip");
assert_eq!(p.description.as_deref(), Some("desc"));
assert!(!p.offline_only);
assert_eq!(p.track_count, 0);
}
#[test]
fn offline_only_flag_persists_and_flips() {
let conn = fresh_db();
let id = create(&conn, "Vault", None, true).unwrap();
assert!(get(&conn, &id).unwrap().unwrap().offline_only);
set_offline_only(&conn, &id, false).unwrap();
assert!(!get(&conn, &id).unwrap().unwrap().offline_only);
}
#[test]
fn rename_and_description_update() {
let conn = fresh_db();
let id = create(&conn, "Old", None, false).unwrap();
rename(&conn, &id, "New").unwrap();
set_description(&conn, &id, Some("hello")).unwrap();
let p = get(&conn, &id).unwrap().unwrap();
assert_eq!(p.name, "New");
assert_eq!(p.description.as_deref(), Some("hello"));
}
#[test]
fn add_tracks_appends_positions_across_sources() {
let conn = fresh_db();
let id = create(&conn, "Mixed", None, false).unwrap();
let n = add_tracks(
&conn,
&id,
&[
LocalPlaylistTrackInput::Qobuz(111),
LocalPlaylistTrackInput::Local("/music/a.flac".into()),
LocalPlaylistTrackInput::Plex("plex-key-9".into()),
],
)
.unwrap();
assert_eq!(n, 3);
// Second batch continues the position sequence.
let n2 = add_tracks(&conn, &id, &[LocalPlaylistTrackInput::Qobuz(222)]).unwrap();
assert_eq!(n2, 1);
let rows = get_tracks(&conn, &id).unwrap();
assert_eq!(rows.len(), 4);
assert_eq!(
rows.iter().map(|r| r.position).collect::<Vec<_>>(),
vec![0, 1, 2, 3]
);
assert_eq!(rows[0].qobuz_track_id, Some(111));
assert_eq!(rows[1].local_path.as_deref(), Some("/music/a.flac"));
assert_eq!(rows[2].plex_key.as_deref(), Some("plex-key-9"));
assert_eq!(rows[3].qobuz_track_id, Some(222));
let p = get(&conn, &id).unwrap().unwrap();
assert_eq!(p.track_count, 4);
assert_eq!(p.qobuz_count, 2);
assert_eq!(p.local_count, 1);
assert_eq!(p.plex_count, 1);
}
#[test]
fn add_tracks_skips_exact_duplicates() {
let conn = fresh_db();
let id = create(&conn, "Dedupe", None, false).unwrap();
add_tracks(&conn, &id, &[LocalPlaylistTrackInput::Qobuz(7)]).unwrap();
let n = add_tracks(
&conn,
&id,
&[
LocalPlaylistTrackInput::Qobuz(7),
LocalPlaylistTrackInput::Local("/x.flac".into()),
],
)
.unwrap();
assert_eq!(n, 1, "duplicate qobuz id skipped, new local row inserted");
assert_eq!(get_tracks(&conn, &id).unwrap().len(), 2);
}
#[test]
fn remove_track_compacts_positions() {
let conn = fresh_db();
let id = create(&conn, "Compact", None, false).unwrap();
add_tracks(
&conn,
&id,
&[
LocalPlaylistTrackInput::Qobuz(1),
LocalPlaylistTrackInput::Qobuz(2),
LocalPlaylistTrackInput::Qobuz(3),
],
)
.unwrap();
remove_track(&conn, &id, 1).unwrap();
let rows = get_tracks(&conn, &id).unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].qobuz_track_id, Some(1));
assert_eq!(rows[0].position, 0);
assert_eq!(rows[1].qobuz_track_id, Some(3));
assert_eq!(rows[1].position, 1);
}
#[test]
fn delete_cascades_membership_rows() {
let conn = fresh_db();
let id = create(&conn, "Doomed", None, true).unwrap();
add_tracks(&conn, &id, &[LocalPlaylistTrackInput::Qobuz(42)]).unwrap();
delete(&conn, &id).unwrap();
assert!(get(&conn, &id).unwrap().is_none());
assert!(get_tracks(&conn, &id).unwrap().is_empty());
// The membership table holds no orphans.
let orphans: i64 = conn
.query_row("SELECT COUNT(*) FROM local_playlist_tracks", [], |r| r.get(0))
.unwrap();
assert_eq!(orphans, 0);
}
#[test]
fn list_returns_all_with_counts() {
let conn = fresh_db();
let a = create(&conn, "A", None, false).unwrap();
let b = create(&conn, "B", None, true).unwrap();
add_tracks(&conn, &a, &[LocalPlaylistTrackInput::Qobuz(5)]).unwrap();
let all = list(&conn).unwrap();
assert_eq!(all.len(), 2);
let pa = all.iter().find(|p| p.id == a).unwrap();
let pb = all.iter().find(|p| p.id == b).unwrap();
assert_eq!(pa.track_count, 1);
assert!(!pa.offline_only);
assert_eq!(pb.track_count, 0);
assert!(pb.offline_only);
}
}
+987
View File
@@ -0,0 +1,987 @@
//! First-class LOCAL playlists — Slint glue (offline-mode port, D7/D8).
//!
//! Storage lives in the shared per-user `library.db`
//! (`qbz_library::local_playlists`, ids `local:<uuid>`). This module routes
//! everything id-prefixed `local:` away from the Qobuz-bound playlist paths:
//! the detail view renders from the local repo through the SAME
//! `PlaylistState` + `playlist.rs` row machinery (search / sort /
//! multi-select / artwork reuse), playback builds `QueueTrack`s from the
//! resolvable rows, and an offline-only playlist stamps the queue
//! (`QbzCore::set_queue_offline_only`) so the QConnect push site skips the
//! cloud (D8: NOTHING from an offline-only playlist ever reaches Qobuz).
use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Mutex};
use qbz_app::shell::AppRuntime;
use qbz_library::local_playlists as repo;
use qbz_models::QueueTrack;
use slint::{ComponentHandle, Model};
use crate::adapter::SlintAdapter;
use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache};
use crate::playback::{after_track_change, refresh_sidebar};
use crate::{AppWindow, ContentView, NavState, PlaylistState, TrackItem};
type Runtime = Arc<AppRuntime<SlintAdapter>>;
/// Type guard (D7): a playlist reference is EITHER a Qobuz `u64` id or a
/// `local:<uuid>` string — Qobuz-bound calls take `u64` only, so a Local ref
/// is unrepresentable there by construction.
#[derive(Debug, Clone)]
pub enum PlaylistRef {
Qobuz(u64),
Local(String),
}
impl PlaylistRef {
pub fn parse(id: &str) -> Option<Self> {
if repo::is_local_playlist_id(id) {
Some(Self::Local(id.to_string()))
} else {
id.parse::<u64>().ok().map(Self::Qobuz)
}
}
}
/// True when `id` names a local playlist.
pub fn is_local_id(id: &str) -> bool {
repo::is_local_playlist_id(id)
}
// ──────────────────────── blocking repo wrappers ────────────────────────
// All open the per-user library.db fresh on the calling (blocking) thread
// via `library_db::with_db` — never call on the UI/event-loop thread.
pub fn list_blocking() -> Vec<repo::LocalPlaylist> {
crate::library_db::with_db(|db| Ok(db.with_connection(repo::list)))
.and_then(|r| r.ok())
.unwrap_or_default()
}
pub fn get_blocking(id: &str) -> Option<repo::LocalPlaylist> {
crate::library_db::with_db(|db| Ok(db.with_connection(|conn| repo::get(conn, id))))
.and_then(|r| r.ok())
.flatten()
}
pub fn get_tracks_blocking(id: &str) -> Vec<repo::LocalPlaylistTrack> {
crate::library_db::with_db(|db| Ok(db.with_connection(|conn| repo::get_tracks(conn, id))))
.and_then(|r| r.ok())
.unwrap_or_default()
}
pub fn create_blocking(name: &str, description: Option<&str>, offline_only: bool) -> Option<String> {
crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| repo::create(conn, name, description, offline_only)))
})
.and_then(|r| r.ok())
}
pub fn update_blocking(id: &str, name: &str, description: Option<&str>, offline_only: bool) -> bool {
crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| {
repo::rename(conn, id, name)?;
repo::set_description(conn, id, description)?;
repo::set_offline_only(conn, id, offline_only)
}))
})
.map(|r| r.is_ok())
.unwrap_or(false)
}
pub fn delete_blocking(id: &str) -> bool {
crate::library_db::with_db(|db| Ok(db.with_connection(|conn| repo::delete(conn, id))))
.map(|r| r.is_ok())
.unwrap_or(false)
}
/// Append Qobuz track ids. Returns inserted count.
pub fn add_qobuz_tracks_blocking(id: &str, track_ids: &[u64]) -> usize {
let entries: Vec<repo::LocalPlaylistTrackInput> = track_ids
.iter()
.map(|&tid| repo::LocalPlaylistTrackInput::Qobuz(tid))
.collect();
crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| repo::add_tracks(conn, id, &entries)))
})
.and_then(|r| r.ok())
.unwrap_or(0)
}
/// Append LocalLibrary rows (by `local_tracks` row id), source-aware:
/// offline copies (`qobuz_download`) become Qobuz refs (real catalog id),
/// Plex rows become Plex refs (rating key in `file_path`), everything else
/// a local file path. Returns inserted count.
pub fn add_local_rows_blocking(id: &str, row_ids: &[i64]) -> usize {
let entries: Vec<repo::LocalPlaylistTrackInput> = crate::library_db::with_db(|db| {
let mut out = Vec::new();
for &rid in row_ids {
let Some(track) = db.get_track(rid)? else {
log::warn!("[qbz-slint] local playlist add: unknown local row {rid}");
continue;
};
let input = match track.source.as_deref() {
Some("qobuz_download") => match track.qobuz_track_id {
Some(qid) => repo::LocalPlaylistTrackInput::Qobuz(qid as u64),
None => repo::LocalPlaylistTrackInput::Local(track.file_path.clone()),
},
Some("plex") => repo::LocalPlaylistTrackInput::Plex(track.file_path.clone()),
_ => repo::LocalPlaylistTrackInput::Local(track.file_path.clone()),
};
out.push(input);
}
Ok(out)
})
.unwrap_or_default();
if entries.is_empty() {
return 0;
}
crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| repo::add_tracks(conn, id, &entries)))
})
.and_then(|r| r.ok())
.unwrap_or(0)
}
/// Copy `src` into the artwork cache and store it as this local playlist's
/// custom artwork (mirrors `playlist::set_custom_artwork` for Qobuz ones).
/// Returns the stored path. Blocking.
pub fn set_custom_artwork_blocking(id: &str, src: &str) -> Option<String> {
let cache = crate::library_db::artwork_cache_dir()?;
std::fs::create_dir_all(&cache).ok()?;
let ext = std::path::Path::new(src)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("jpg");
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let suffix = id.trim_start_matches(repo::LOCAL_PLAYLIST_PREFIX);
let dest = cache.join(format!("local_playlist_{suffix}_{ts}.{ext}"));
if let Err(e) = std::fs::copy(src, &dest) {
log::error!("[qbz-slint] copy local playlist artwork failed: {e}");
return None;
}
let dest_str = dest.to_string_lossy().to_string();
match crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| repo::set_custom_artwork(conn, id, Some(&dest_str))))
}) {
Some(Ok(())) => Some(dest_str),
Some(Err(e)) => {
log::error!("[qbz-slint] store local playlist artwork failed: {e}");
None
}
None => None,
}
}
/// Clear this local playlist's custom artwork. Blocking.
pub fn clear_custom_artwork_blocking(id: &str) {
crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| repo::set_custom_artwork(conn, id, None)))
});
}
// ──────────────────────── detail view (load/apply) ────────────────────────
/// The open local playlist's playable queue snapshot, aligned with the row
/// `TrackItem.id`s (`QueueTrack.id.to_string()`), plus per-item repo
/// positions for removal. Mirrors `playlist.rs::CURRENT` for Qobuz lists.
static CURRENT_QUEUE: LazyLock<Mutex<Vec<QueueTrack>>> = LazyLock::new(|| Mutex::new(Vec::new()));
/// (playlist id, offline_only) of the open local playlist detail.
static CURRENT_META: LazyLock<Mutex<Option<(String, bool)>>> = LazyLock::new(|| Mutex::new(None));
/// Row TrackItem id -> repo `position` (for remove-selected).
static ROW_POSITIONS: LazyLock<Mutex<HashMap<String, i32>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// One resolved, renderable row (Send — built on the worker).
pub enum RowItem {
/// Full catalog track (online fetch).
Qobuz(Box<qbz_models::Track>),
/// Offline-cache metadata (D11: a Qobuz row renders offline ONLY when
/// this metadata source exists; un-cached rows are filtered out).
Cached {
track_id: u64,
title: String,
artist: String,
album: String,
duration_secs: u64,
bit_depth: Option<u32>,
sample_rate: Option<f64>,
},
/// Local file resolved from library.db by path.
Local(Box<qbz_library::LocalTrack>),
/// Plex ref — no detailed resolve in v1 (rendered basic, not playable).
Plex { key: String },
}
pub struct LoadedRow {
pub position: i32,
pub item: RowItem,
}
pub struct LocalPlaylistData {
pub id: String,
pub name: String,
pub description: String,
pub offline_only: bool,
pub custom_artwork_path: Option<String>,
pub rows: Vec<LoadedRow>,
}
fn mmss(secs: u64) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
fn total_duration_label(rows: &[LoadedRow]) -> String {
let secs: u64 = rows
.iter()
.map(|r| match &r.item {
RowItem::Qobuz(t) => t.duration as u64,
RowItem::Cached { duration_secs, .. } => *duration_secs,
RowItem::Local(t) => t.duration_secs,
RowItem::Plex { .. } => 0,
})
.sum();
let mins = secs / 60;
if mins >= 60 {
format!("{} h {} min", mins / 60, mins % 60)
} else {
format!("{} min", mins)
}
}
/// Load + resolve a local playlist off the UI thread. Qobuz rows resolve
/// via `get_tracks_batch` when online, via the offline-cache index when
/// offline (or when the batch fails); local rows via library.db by path;
/// Plex rows stay basic. Unresolvable rows are filtered out (D11) and
/// counted in `hidden_unavailable`.
pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistData> {
let id = playlist_id.to_string();
let (header, tracks) = tokio::task::spawn_blocking({
let id = id.clone();
move || (get_blocking(&id), get_tracks_blocking(&id))
})
.await
.ok()?;
let header = header?;
let offline = crate::offline_mode::engine().is_offline();
// Qobuz rows: one batch fetch when online; cached-metadata fallback for
// everything the batch did not return (and for the whole set offline).
let qobuz_ids: Vec<u64> = tracks.iter().filter_map(|t| t.qobuz_track_id).collect();
let mut fetched: HashMap<u64, qbz_models::Track> = HashMap::new();
if !offline && !qobuz_ids.is_empty() {
match runtime.core().get_tracks_batch(&qobuz_ids).await {
Ok(list) => {
for t in list {
fetched.insert(t.id, t);
}
}
Err(e) => {
log::warn!("[qbz-slint] local playlist {id}: qobuz batch failed: {e}");
}
}
}
let missing: Vec<u64> = qobuz_ids
.iter()
.copied()
.filter(|tid| !fetched.contains_key(tid))
.collect();
let mut cached: HashMap<u64, RowItem> = HashMap::new();
if !missing.is_empty() {
if let Some(off) = crate::offline::get().await {
let guard = off.db.lock().await;
if let Some(db) = guard.as_ref() {
for tid in &missing {
if let Ok(Some(info)) = db.get_track(*tid) {
if matches!(info.status, qbz_offline_cache::OfflineCacheStatus::Ready) {
cached.insert(
*tid,
RowItem::Cached {
track_id: info.track_id,
title: info.title,
artist: info.artist,
album: info.album.unwrap_or_default(),
duration_secs: info.duration_secs,
bit_depth: info.bit_depth,
sample_rate: info.sample_rate,
},
);
}
}
}
}
}
}
// Local rows: resolve library rows by file path (blocking).
let local_paths: Vec<String> = tracks.iter().filter_map(|t| t.local_path.clone()).collect();
let locals: HashMap<String, qbz_library::LocalTrack> = if local_paths.is_empty() {
HashMap::new()
} else {
tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| {
let mut out = HashMap::new();
for path in &local_paths {
if let Some(track) = db.get_track_by_path(path)? {
out.insert(path.clone(), track);
}
}
Ok(out)
})
.unwrap_or_default()
})
.await
.unwrap_or_default()
};
let mut rows: Vec<LoadedRow> = Vec::new();
let mut hidden = 0usize;
for t in tracks {
let item = match t.source {
repo::LocalPlaylistTrackSource::Qobuz => {
let Some(tid) = t.qobuz_track_id else {
hidden += 1;
continue;
};
if let Some(track) = fetched.remove(&tid) {
RowItem::Qobuz(Box::new(track))
} else if let Some(item) = cached.remove(&tid) {
item
} else {
// D11: no metadata source for this Qobuz row right now.
hidden += 1;
continue;
}
}
repo::LocalPlaylistTrackSource::Local => {
let Some(track) = t.local_path.as_ref().and_then(|p| locals.get(p)) else {
hidden += 1;
continue;
};
RowItem::Local(Box::new(track.clone()))
}
repo::LocalPlaylistTrackSource::Plex => RowItem::Plex {
key: t.plex_key.clone().unwrap_or_default(),
},
};
rows.push(LoadedRow {
position: t.position,
item,
});
}
if hidden > 0 {
log::info!("[qbz-slint] local playlist {id}: {hidden} row(s) unavailable, hidden (D11)");
}
Some(LocalPlaylistData {
id: header.id,
name: header.name,
description: header.description.unwrap_or_default(),
offline_only: header.offline_only,
custom_artwork_path: header.custom_artwork_path.filter(|p| !p.is_empty()),
rows,
})
}
/// Build the queue track for a resolved row, if it is playable.
fn row_queue_track(item: &RowItem) -> Option<QueueTrack> {
match item {
RowItem::Qobuz(track) => {
let (album_id, album_title, album_artwork) = track
.album
.as_ref()
.map(|a| {
(
a.id.clone(),
a.title.clone(),
a.image.best().cloned().unwrap_or_default(),
)
})
.unwrap_or_default();
let album_artist = track
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default();
Some(crate::playback::make_queue_track(
track,
&album_id,
&album_title,
&album_artist,
&album_artwork,
))
}
RowItem::Cached {
track_id,
title,
artist,
album,
duration_secs,
bit_depth,
sample_rate,
} => Some(QueueTrack {
id: *track_id,
title: title.clone(),
version: None,
artist: artist.clone(),
album: album.clone(),
duration_secs: *duration_secs,
artwork_url: None,
hires: bit_depth.map(|d| d >= 24).unwrap_or(false),
bit_depth: *bit_depth,
sample_rate: *sample_rate,
is_local: false,
album_id: None,
artist_id: None,
streamable: true,
// Plain "qobuz": the play tier-walk serves the offline-cache hit.
source: Some("qobuz".to_string()),
parental_warning: false,
source_item_id_hint: None,
}),
RowItem::Local(track) => Some(crate::playback::local_queue_track(track)),
RowItem::Plex { .. } => None,
}
}
/// Build the display row for a resolved item. `queue` (when playable)
/// dictates the row id so visible-order playback maps 1:1.
fn row_item(item: &RowItem, queue: Option<&QueueTrack>) -> TrackItem {
match item {
RowItem::Qobuz(track) => crate::playlist::to_item(track),
RowItem::Cached {
track_id,
title,
artist,
album,
duration_secs,
bit_depth,
sample_rate,
} => TrackItem {
id: track_id.to_string().into(),
number: "".into(),
title: title.clone().into(),
artist: artist.clone().into(),
album: album.clone().into(),
duration: mmss(*duration_secs).into(),
quality_tier: match bit_depth {
Some(d) if *d >= 24 => "hires",
Some(_) => "cd",
None => "",
}
.into(),
quality_detail: crate::quality::detail(*bit_depth, *sample_rate).into(),
explicit: false,
selected: false,
artwork_url: "".into(),
artwork: slint::Image::default(),
is_favorite: crate::fav_cache::is_favorite(&track_id.to_string()),
artist_id: "".into(),
album_id: "".into(),
removing: false,
cache_status: 3,
cache_progress: 0.0,
source: "qobuz".into(),
unlocking: false,
},
RowItem::Local(track) => {
let (tier, quality_detail, _) = crate::quality::badge(
&track.format.to_string(),
track.bit_depth,
Some(track.sample_rate),
);
TrackItem {
// The queue id (library row id; the Qobuz id for offline
// copies) so visible-order playback resolves this row.
id: queue
.map(|q| q.id.to_string())
.unwrap_or_else(|| track.id.to_string())
.into(),
number: "".into(),
title: track.title.clone().into(),
artist: track.artist.clone().into(),
album: track.album.clone().into(),
duration: mmss(track.duration_secs).into(),
quality_tier: tier.into(),
quality_detail: quality_detail.into(),
explicit: false,
selected: false,
artwork_url: track.artwork_path.clone().unwrap_or_default().into(),
artwork: slint::Image::default(),
is_favorite: false,
artist_id: "".into(),
album_id: "".into(),
removing: false,
cache_status: 0,
cache_progress: 0.0,
source: match track.source.as_deref() {
Some("qobuz_download") => "qobuz",
Some("plex") => "plex",
_ => "local",
}
.into(),
unlocking: false,
}
}
RowItem::Plex { key } => TrackItem {
id: format!("plex:{key}").into(),
number: "".into(),
title: "Plex track".into(),
artist: "Plex".into(),
album: "".into(),
duration: "".into(),
quality_tier: "".into(),
quality_detail: "".into(),
explicit: false,
selected: false,
artwork_url: "".into(),
artwork: slint::Image::default(),
is_favorite: false,
artist_id: "".into(),
album_id: "".into(),
removing: false,
cache_status: 0,
cache_progress: 0.0,
source: "plex".into(),
unlocking: false,
},
}
}
/// Apply loaded data into `PlaylistState` (header + rows through the shared
/// `playlist.rs` row machinery) and snapshot the playable queue. UI thread.
pub fn apply(window: &AppWindow, data: LocalPlaylistData) {
let mut queue: Vec<QueueTrack> = Vec::new();
let mut items: Vec<TrackItem> = Vec::with_capacity(data.rows.len());
let mut positions: HashMap<String, i32> = HashMap::new();
for row in &data.rows {
let qt = row_queue_track(&row.item);
let item = row_item(&row.item, qt.as_ref());
positions.insert(item.id.to_string(), row.position);
if let Some(qt) = qt {
queue.push(qt);
}
items.push(item);
}
if let Ok(mut cur) = CURRENT_QUEUE.lock() {
*cur = queue;
}
if let Ok(mut meta) = CURRENT_META.lock() {
*meta = Some((data.id.clone(), data.offline_only));
}
if let Ok(mut pos) = ROW_POSITIONS.lock() {
*pos = positions;
}
let duration = total_duration_label(&data.rows);
let state = window.global::<PlaylistState>();
state.set_id(data.id.into());
state.set_name(data.name.into());
state.set_owner(if data.offline_only {
"Offline-only playlist"
} else {
"Local playlist"
}
.into());
let description = crate::strip_html::strip_html(&data.description);
state.set_description(description.clone().into());
state.set_description_short(description.into());
state.set_is_local(true);
state.set_offline_only(data.offline_only);
state.set_is_owner(true);
// Custom artwork (local file) or the row-collage fallback.
let custom = data
.custom_artwork_path
.as_ref()
.filter(|p| std::path::Path::new(p).exists())
.and_then(|p| slint::Image::load_from_path(std::path::Path::new(p)).ok());
if let Some(img) = custom {
state.set_cover(img);
state.set_cover_url(data.custom_artwork_path.clone().unwrap_or_default().into());
state.set_has_custom(true);
} else {
state.set_cover_url("".into());
state.set_has_custom(false);
}
state.set_total_duration(duration.into());
crate::playlist::apply_local_items(window, items);
}
/// Row artwork jobs — Qobuz rows have http URLs, local rows file paths.
/// Returns (http jobs, local-file jobs) targeting `PlaylistTrack{index}`
/// (the same target the Qobuz detail uses; indexes are FULL_ITEMS order).
pub fn artwork_jobs(data: &LocalPlaylistData) -> (Vec<ArtworkJob>, Vec<ArtworkJob>) {
let mut http = Vec::new();
let mut local = Vec::new();
for (index, row) in data.rows.iter().enumerate() {
match &row.item {
RowItem::Qobuz(track) => {
if let Some(url) = track.album.as_ref().and_then(|a| a.image.smallest().cloned()) {
http.push(ArtworkJob {
url,
target: ArtworkTarget::PlaylistTrack { index },
});
}
}
RowItem::Local(track) => {
if let Some(path) = track.artwork_path.clone().filter(|p| !p.is_empty()) {
local.push(ArtworkJob {
url: path,
target: ArtworkTarget::PlaylistTrack { index },
});
}
}
_ => {}
}
}
(http, local)
}
/// Open a local playlist detail (the `local:` branch of
/// `navigate_playlist`). Loads + resolves off-thread, then renders through
/// the shared playlist view.
pub fn navigate(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: ImageCache,
playlist_id: String,
) {
handle.spawn(async move {
let active = playlist_id.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
crate::playlist::reset(&w);
let state = w.global::<PlaylistState>();
state.set_is_local(true);
state.set_offline_only(false);
crate::sidebar::set_active(&w, &active);
w.global::<NavState>().set_view(ContentView::Playlist);
});
let Some(data) = load(&runtime, &playlist_id).await else {
log::warn!("[qbz-slint] local playlist {playlist_id} not found");
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<PlaylistState>().set_loading(false);
});
return;
};
let (http_jobs, local_jobs) = artwork_jobs(&data);
let _ = weak.upgrade_in_event_loop(move |w| {
apply(&w, data);
});
if !http_jobs.is_empty() {
artwork::spawn_loads(http_jobs, weak.clone(), image_cache.clone());
}
if !local_jobs.is_empty() {
artwork::spawn_local_loads(local_jobs, weak.clone(), image_cache.clone());
}
});
}
// ──────────────────────── playback ────────────────────────
/// Replace the queue with `tracks`, stamp the offline-only flag (D8 guard:
/// the QConnect push site reads it and skips the cloud), start at `start`.
async fn play_stamped(runtime: &Runtime, weak: &slint::Weak<AppWindow>, tracks: Vec<QueueTrack>, start: usize) {
if tracks.is_empty() {
crate::toast::error_weak(weak, "Nothing playable in this playlist right now");
return;
}
let offline_only = CURRENT_META
.lock()
.ok()
.and_then(|m| m.as_ref().map(|(_, o)| *o))
.unwrap_or(false);
let start = start.min(tracks.len() - 1);
let first_id = tracks[start].id;
runtime.core().set_queue(tracks, Some(start)).await;
// AFTER set_queue (which clears the stamp on every replacement).
runtime.core().set_queue_offline_only(offline_only);
after_track_change(runtime, weak, first_id).await;
refresh_sidebar(true);
}
/// Order the queue snapshot by the VISIBLE row order (sort/search applied),
/// mirroring `playback`'s visible-order rule for the Qobuz detail.
fn visible_ordered_queue(window: &AppWindow) -> Vec<QueueTrack> {
let snapshot = CURRENT_QUEUE.lock().map(|q| q.clone()).unwrap_or_default();
let by_id: HashMap<String, &QueueTrack> =
snapshot.iter().map(|q| (q.id.to_string(), q)).collect();
let model = window.global::<PlaylistState>().get_tracks();
let mut out: Vec<QueueTrack> = Vec::new();
for i in 0..model.row_count() {
if let Some(it) = model.row_data(i) {
if let Some(q) = by_id.get(it.id.as_str()) {
out.push((*q).clone());
}
}
}
if out.is_empty() {
snapshot
} else {
out
}
}
/// Hero Play (visible order) / Shuffle for the open local playlist.
pub fn play_all(
window: &AppWindow,
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
shuffle: bool,
) {
let mut tracks = visible_ordered_queue(window);
if shuffle {
let mut seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(1)
| 1;
for i in (1..tracks.len()).rev() {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let j = (seed % (i as u64 + 1)) as usize;
tracks.swap(i, j);
}
}
let ctx_id = window.global::<PlaylistState>().get_id().to_string();
crate::playback::set_now_playing_context(&weak, "playlist", &ctx_id);
handle.spawn(async move {
play_stamped(&runtime, &weak, tracks, 0).await;
});
}
/// Per-row "play from here" — queue the visible order starting at the
/// clicked row (the local branch of `play_track_in_context`'s Playlist arm).
/// Returns false when the clicked row is not in the playable snapshot.
pub fn play_from_visible(
window: &AppWindow,
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
clicked_id: &str,
) -> bool {
let tracks = visible_ordered_queue(window);
let Some(idx) = tracks.iter().position(|q| q.id.to_string() == clicked_id) else {
return false;
};
let ctx_id = window.global::<PlaylistState>().get_id().to_string();
crate::playback::set_now_playing_context(&weak, "playlist", &ctx_id);
handle.spawn(async move {
play_stamped(&runtime, &weak, tracks, idx).await;
});
true
}
/// Queue / play-next a local playlist by id (sidebar + now-playing context
/// actions). When the playlist is OFFLINE-ONLY the queue gets stamped even
/// on append (D8 strict reading: not even its numeric track ids may reach
/// the QConnect cloud push) — the stamp clears on the next replacement.
pub fn enqueue_by_id(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
playlist_id: String,
play_next: bool,
) {
handle.spawn(async move {
let Some(data) = load(&runtime, &playlist_id).await else {
crate::toast::error_weak(&weak, "Couldn't load this playlist");
return;
};
let tracks: Vec<QueueTrack> = data
.rows
.iter()
.filter_map(|r| row_queue_track(&r.item))
.collect();
if tracks.is_empty() {
crate::toast::error_weak(&weak, "Nothing playable in this playlist right now");
return;
}
if play_next {
for track in tracks.into_iter().rev() {
runtime.core().add_track_next(track).await;
}
} else {
runtime.core().add_tracks(tracks).await;
}
if data.offline_only {
runtime.core().set_queue_offline_only(true);
}
refresh_sidebar(false);
crate::toast::success_weak(&weak, if play_next { "Playing next" } else { "Added to queue" });
});
}
// ──────────────────────── removal (multi-select) ────────────────────────
/// Remove the selected rows from the open local playlist (repo positions,
/// highest first so each removal's compaction never shifts a pending one),
/// then reload the detail. UI thread entry; DB work off-thread.
pub fn remove_selected(
window: &AppWindow,
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: ImageCache,
) {
let playlist_id = window.global::<PlaylistState>().get_id().to_string();
if !is_local_id(&playlist_id) {
return;
}
let model = window.global::<PlaylistState>().get_tracks();
let selected: Vec<String> = (0..model.row_count())
.filter_map(|i| model.row_data(i))
.filter(|t| t.selected)
.map(|t| t.id.to_string())
.collect();
if selected.is_empty() {
return;
}
let mut positions: Vec<i32> = {
let map = ROW_POSITIONS.lock().map(|m| m.clone()).unwrap_or_default();
selected.iter().filter_map(|id| map.get(id).copied()).collect()
};
positions.sort_unstable_by(|a, b| b.cmp(a));
if positions.is_empty() {
return;
}
crate::playlist::set_multi_select(window, false);
let handle2 = handle.clone();
handle.spawn(async move {
let pid = playlist_id.clone();
tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| {
for pos in positions {
if let Err(e) = repo::remove_track(conn, &pid, pos) {
log::error!("[qbz-slint] local playlist remove pos {pos}: {e}");
}
}
}))
})
})
.await
.ok();
navigate(runtime, weak, &handle2, image_cache, playlist_id);
});
}
// ──────────────────────── Upload to Qobuz (D8) ────────────────────────
/// Convert a non-offline-only local playlist into a real Qobuz playlist:
/// create it, add the Qobuz-source rows, attach local rows as the existing
/// mixed-playlist sidecar (`playlist_local_tracks`), then delete the local
/// entity. Plex rows have no sidecar write path here yet — they are dropped
/// with a log (deferred). Never reached for offline-only playlists (the UI
/// hides the action and this guards again).
pub fn upload_to_qobuz(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: ImageCache,
playlist_id: String,
) {
handle.clone().spawn(async move {
let id = playlist_id.clone();
let (header, rows) = match tokio::task::spawn_blocking({
let id = id.clone();
move || (get_blocking(&id), get_tracks_blocking(&id))
})
.await
{
Ok(pair) => pair,
Err(_) => return,
};
let Some(header) = header else {
crate::toast::error_weak(&weak, "Couldn't load this playlist");
return;
};
if header.offline_only {
log::warn!("[qbz-slint] upload_to_qobuz refused: {id} is offline-only (D8)");
return;
}
if crate::offline_mode::engine().is_offline() {
crate::toast::error_weak(&weak, "You're offline — try again when connected");
return;
}
let desc = header.description.as_deref().filter(|d| !d.trim().is_empty());
let created = match runtime.core().create_playlist(&header.name, desc, false).await {
Ok(p) => p,
Err(e) => {
log::error!("[qbz-slint] upload to Qobuz: create failed: {e}");
crate::toast::error_weak(&weak, "Couldn't create the Qobuz playlist");
return;
}
};
let new_id = created.id;
// Qobuz rows -> real membership.
let qobuz_ids: Vec<u64> = rows.iter().filter_map(|r| r.qobuz_track_id).collect();
if !qobuz_ids.is_empty() {
if let Err(e) = runtime.core().add_tracks_to_playlist(new_id, &qobuz_ids).await {
// Leave BOTH entities in place — the user can retry; deleting
// the local copy after a partial upload would lose data.
log::error!("[qbz-slint] upload to Qobuz: add tracks failed: {e}");
crate::toast::error_weak(&weak, "Upload incomplete — local playlist kept");
return;
}
}
// Local rows -> the existing mixed-playlist sidecar, positioned
// after the Qobuz block (Tauri's append convention). Plex rows are
// deferred (no Slint sidecar write path yet) — logged + dropped.
let local_paths: Vec<String> = rows.iter().filter_map(|r| r.local_path.clone()).collect();
let plex_count = rows.iter().filter(|r| r.plex_key.is_some()).count();
if plex_count > 0 {
log::warn!(
"[qbz-slint] upload to Qobuz: {plex_count} Plex row(s) dropped (sidecar attach deferred)"
);
}
let qobuz_count = qobuz_ids.len();
let id_for_delete = id.clone();
tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| {
for (i, path) in local_paths.iter().enumerate() {
match db.get_track_by_path(path)? {
Some(track) => {
db.add_local_track_to_playlist(
new_id,
track.id,
(qobuz_count + i) as i32,
)?;
}
None => {
log::warn!(
"[qbz-slint] upload to Qobuz: local row missing from library: {path}"
);
}
}
}
Ok(())
});
delete_blocking(&id_for_delete);
})
.await
.ok();
crate::toast::success_weak(&weak, "Playlist uploaded to Qobuz");
let weak2 = weak.clone();
let r2 = runtime.clone();
let h2 = handle.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
crate::load_sidebar_playlists(r2.clone(), weak2.clone(), &h2);
crate::nav::record(crate::nav::NavEntry::Playlist(new_id.to_string()));
crate::navigate_playlist(r2, weak2.clone(), &h2, image_cache, new_id.to_string());
crate::update_nav_flags(&w);
});
});
}
+359 -19
View File
@@ -60,6 +60,7 @@ mod ephemeral;
mod folders;
mod library_db;
mod local_library;
mod local_playlist;
mod local_library_settings;
mod media_controls;
mod locallibrary_prefs;
@@ -1450,9 +1451,18 @@ fn navigate_playlist(
image_cache: artwork::ImageCache,
playlist_id: String,
) {
let Ok(id) = playlist_id.parse::<u64>() else {
log::warn!("[qbz-slint] navigate_playlist: bad id {playlist_id}");
return;
// Route by id namespace (D7 type guard): `local:<uuid>` ids open the
// LOCAL detail path and can never reach the Qobuz fetch below.
let id = match local_playlist::PlaylistRef::parse(&playlist_id) {
Some(local_playlist::PlaylistRef::Local(id)) => {
local_playlist::navigate(runtime, weak, handle, image_cache, id);
return;
}
Some(local_playlist::PlaylistRef::Qobuz(id)) => id,
None => {
log::warn!("[qbz-slint] navigate_playlist: bad id {playlist_id}");
return;
}
};
handle.spawn(async move {
let active = playlist_id.clone();
@@ -4757,6 +4767,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
("playlist", "play-all") => {
// LOCAL playlist detail — its own queue snapshot +
// offline-only stamp (D8); Qobuz path unchanged below.
if let Some(w) = weak.upgrade() {
if w.global::<PlaylistState>().get_is_local() {
local_playlist::play_all(
&w,
runtime.clone(),
weak.clone(),
handle.clone(),
false,
);
return;
}
}
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
@@ -4766,6 +4790,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
("playlist", "shuffle") => {
if let Some(w) = weak.upgrade() {
if w.global::<PlaylistState>().get_is_local() {
local_playlist::play_all(
&w,
runtime.clone(),
weak.clone(),
handle.clone(),
true,
);
return;
}
}
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
@@ -4774,24 +4810,69 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
playback::play_tracks(runtime, weak, handle, tracks, 0);
});
}
("playlist", "queue") => playback::enqueue_playlist(
runtime.clone(),
weak.clone(),
handle.clone(),
id,
false,
),
("playlist", "play-next") => playback::enqueue_playlist(
runtime.clone(),
weak.clone(),
handle.clone(),
id,
true,
),
("playlist", "queue") => {
if local_playlist::is_local_id(&id) {
local_playlist::enqueue_by_id(
runtime.clone(),
weak.clone(),
handle.clone(),
id,
false,
);
return;
}
playback::enqueue_playlist(
runtime.clone(),
weak.clone(),
handle.clone(),
id,
false,
)
}
("playlist", "play-next") => {
if local_playlist::is_local_id(&id) {
local_playlist::enqueue_by_id(
runtime.clone(),
weak.clone(),
handle.clone(),
id,
true,
);
return;
}
playback::enqueue_playlist(
runtime.clone(),
weak.clone(),
handle.clone(),
id,
true,
)
}
("playlist", "upload-to-qobuz") => {
// D8: convert a non-offline-only LOCAL playlist into a
// real Qobuz playlist (explicit user action, confirmed
// in the detail view — nothing ever auto-syncs).
if local_playlist::is_local_id(&id) {
local_playlist::upload_to_qobuz(
runtime.clone(),
weak.clone(),
handle.clone(),
image_cache.clone(),
id,
);
}
}
("playlist", "favorite") => {
// Favorite/unfavorite the open playlist.
if let Some(w) = weak.upgrade() {
let pid = w.global::<PlaylistState>().get_id().to_string();
// LOCAL playlists have no Qobuz favorite — the UI
// hides the button; guard the call path too (the
// favorite endpoint takes the id as a string, so a
// "local:" id COULD otherwise leak through).
if local_playlist::is_local_id(&pid) {
return;
}
let was_fav = w.global::<PlaylistState>().get_is_favorite();
w.global::<PlaylistState>().set_is_favorite(!was_fav);
let runtime = runtime.clone();
@@ -4820,6 +4901,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
("playlist", "remove-selected") => {
if let Some(w) = weak.upgrade() {
// LOCAL playlist — remove the selected rows from the
// library.db repo by stored position.
if w.global::<PlaylistState>().get_is_local() {
local_playlist::remove_selected(
&w,
runtime.clone(),
weak.clone(),
handle.clone(),
image_cache.clone(),
);
return;
}
let pid = w.global::<PlaylistState>().get_id().to_string();
let ids = playlist::selected_ids(&w);
if let (Ok(pid), false) = (pid.parse::<u64>(), ids.is_empty()) {
@@ -4859,6 +4952,36 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// it as the playlist's custom cover, then reload.
if let Some(w) = weak.upgrade() {
let pid = w.global::<PlaylistState>().get_id().to_string();
// LOCAL playlist — same flow, repo-backed.
if local_playlist::is_local_id(&pid) {
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
let image_cache = image_cache.clone();
handle.clone().spawn(async move {
let Some(file) = rfd::AsyncFileDialog::new()
.add_filter("Images", &["png", "jpg", "jpeg", "webp"])
.pick_file()
.await
else {
return;
};
let src = file.path().to_string_lossy().into_owned();
let lid = pid.clone();
let ok = tokio::task::spawn_blocking(move || {
local_playlist::set_custom_artwork_blocking(&lid, &src)
.is_some()
})
.await
.unwrap_or(false);
if ok {
local_playlist::navigate(
runtime, weak, &handle, image_cache, pid,
);
}
});
return;
}
if let Ok(pid) = pid.parse::<u64>() {
let runtime = runtime.clone();
let weak = weak.clone();
@@ -4890,6 +5013,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
("playlist", "clear-artwork") => {
if let Some(w) = weak.upgrade() {
let pid = w.global::<PlaylistState>().get_id().to_string();
// LOCAL playlist — clear the repo column + reload.
if local_playlist::is_local_id(&pid) {
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
let image_cache = image_cache.clone();
handle.clone().spawn(async move {
let lid = pid.clone();
tokio::task::spawn_blocking(move || {
local_playlist::clear_custom_artwork_blocking(&lid);
})
.await
.ok();
local_playlist::navigate(
runtime, weak, &handle, image_cache, pid,
);
});
return;
}
if let Ok(pid) = pid.parse::<u64>() {
let runtime = runtime.clone();
let weak = weak.clone();
@@ -4915,10 +5057,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let pid = ps.get_id();
let name = ps.get_name();
let desc = ps.get_description();
let is_local = ps.get_is_local();
let offline_only = ps.get_offline_only();
let es = w.global::<EditPlaylistState>();
es.set_id(pid);
es.set_name(name);
es.set_description(desc);
es.set_is_local(is_local);
es.set_offline_only(offline_only);
es.set_open(true);
}
}
@@ -7563,6 +7709,50 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let ids_model = picker.get_track_ids();
let track_id_single = picker.get_track_id().to_string();
picker.set_open(false);
// LOCAL playlist target (id "local:<uuid>") — writes go to
// the library.db repo (works offline; D7 routing).
if local_playlist::is_local_id(playlist_id.as_str()) {
let target = playlist_id.to_string();
if is_local {
// LocalLibrary row ids (i64) — source-aware mapping
// (local path / offline-copy Qobuz id / Plex key).
let ids_i64: Vec<i64> = (0..ids_model.row_count())
.filter_map(|i| ids_model.row_data(i))
.filter_map(|s| s.parse::<i64>().ok())
.collect();
if ids_i64.is_empty() {
return;
}
handle.spawn(async move {
let _ = tokio::task::spawn_blocking(move || {
local_playlist::add_local_rows_blocking(&target, &ids_i64)
})
.await;
});
return;
}
let mut ids: Vec<u64> = (0..ids_model.row_count())
.filter_map(|i| ids_model.row_data(i))
.filter_map(|s| s.parse::<u64>().ok())
.collect();
if ids.is_empty() {
if let Ok(tid) = track_id_single.parse::<u64>() {
ids.push(tid);
}
}
if ids.is_empty() {
return;
}
handle.spawn(async move {
let _ = tokio::task::spawn_blocking(move || {
local_playlist::add_qobuz_tracks_blocking(&target, &ids)
})
.await;
});
return;
}
let Ok(pid) = playlist_id.parse::<u64>() else {
return;
};
@@ -7664,7 +7854,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ds.set_over_playlist_id("".into());
let ids = drag::dragged();
drag::clear();
if let (Ok(pid), false) = (pid.parse::<u64>(), ids.is_empty()) {
if ids.is_empty() {
return;
}
// Drop onto a LOCAL playlist row — write the repo (D7 routing;
// dragged ids are Qobuz track ids).
if local_playlist::is_local_id(&pid) {
handle.spawn(async move {
let n = tokio::task::spawn_blocking(move || {
local_playlist::add_qobuz_tracks_blocking(&pid, &ids)
})
.await
.unwrap_or(0);
log::info!("[qbz-slint] dropped {n} track(s) onto local playlist");
});
return;
}
if let Ok(pid) = pid.parse::<u64>() {
let runtime = runtime.clone();
handle.spawn(async move {
match runtime.core().add_tracks_to_playlist(pid, &ids).await {
@@ -7747,6 +7953,46 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if name.trim().is_empty() || es.get_busy() {
return;
}
// LOCAL playlist (id "local:<uuid>") — rename/description/
// offline-only write the library.db repo; no Qobuz call.
if local_playlist::is_local_id(&id) {
let offline_only = es.get_offline_only();
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
let nm = name.trim().to_string();
let ds = description.trim().to_string();
let lid = id.clone();
let (nm2, ds2) = (nm.clone(), ds.clone());
let ok = tokio::task::spawn_blocking(move || {
let desc = if ds2.is_empty() { None } else { Some(ds2.as_str()) };
local_playlist::update_blocking(&lid, &nm2, desc, offline_only)
})
.await
.unwrap_or(false);
if !ok {
log::error!("[qbz-slint] update local playlist failed");
return;
}
let r2 = runtime.clone();
let w2 = weak.clone();
let h2 = handle.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
let ps = w.global::<PlaylistState>();
// Only refresh the open detail header if this IS
// the open playlist.
if ps.get_id().as_str() == id {
ps.set_name(nm.into());
ps.set_description(ds.into());
ps.set_offline_only(offline_only);
}
w.global::<EditPlaylistState>().set_open(false);
load_sidebar_playlists(r2, w2, &h2);
});
});
return;
}
let (Ok(pid),) = (id.parse::<u64>(),) else { return; };
let runtime = runtime.clone();
let weak = weak.clone();
@@ -7786,6 +8032,34 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.on_delete(move || {
let Some(w) = weak.upgrade() else { return; };
let id = w.global::<EditPlaylistState>().get_id().to_string();
// LOCAL playlist — delete the library.db entity (cascades
// its membership rows), then back + sidebar reload.
if local_playlist::is_local_id(&id) {
w.global::<EditPlaylistState>().set_busy(true);
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
let lid = id.clone();
let ok = tokio::task::spawn_blocking(move || {
local_playlist::delete_blocking(&lid)
})
.await
.unwrap_or(false);
let r2 = runtime.clone();
let w2 = weak.clone();
let h2 = handle.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<EditPlaylistState>().set_busy(false);
if ok {
w.global::<EditPlaylistState>().set_open(false);
load_sidebar_playlists(r2, w2, &h2);
w.global::<NavState>().invoke_request_back();
}
});
});
return;
}
let Ok(pid) = id.parse::<u64>() else { return; };
w.global::<EditPlaylistState>().set_busy(true);
let runtime = runtime.clone();
@@ -7861,6 +8135,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
cps.set_is_public(false);
cps.set_creating(false);
cps.set_folder_index(0);
// D8: while offline, creation always produces a LOCAL
// playlist — the toggle shows ON and locked with a hint.
let offline = offline_mode::engine().is_offline();
cps.set_offline_only(offline);
cps.set_offline_locked(offline);
// Build the folder dropdown from the sidebar's folder
// list: index 0 = "No folder" (id ""), then each folder.
let folders = w.global::<SidebarState>().get_folders();
@@ -8057,15 +8336,32 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.global::<SidebarActions>()
.on_edit_playlist(move |id| {
let Some(w) = weak.upgrade() else { return };
let es = w.global::<EditPlaylistState>();
// LOCAL playlist row — prefill from the sidebar's local
// cache (name/description/offline-only).
if local_playlist::is_local_id(id.as_str()) {
let (name, description, offline_only) =
sidebar::local_playlist_meta(id.as_str())
.unwrap_or_else(|| (id.to_string(), String::new(), false));
es.set_id(id);
es.set_name(name.into());
es.set_description(description.into());
es.set_is_local(true);
es.set_offline_only(offline_only);
es.set_busy(false);
es.set_open(true);
return;
}
let (name, description) = id
.parse::<u64>()
.ok()
.and_then(sidebar::playlist_name_desc)
.unwrap_or_else(|| (id.to_string(), String::new()));
let es = w.global::<EditPlaylistState>();
es.set_id(id);
es.set_name(name.into());
es.set_description(description.into());
es.set_is_local(false);
es.set_offline_only(false);
es.set_busy(false);
es.set_open(true);
});
@@ -8215,6 +8511,50 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if name.trim().is_empty() || state.get_creating() {
return;
}
// D8: offline-only toggle ON — or the app is offline (always
// local then) — creates a LOCAL playlist in library.db. The
// online + toggle OFF path below stays byte-unchanged.
let offline_now = offline_mode::engine().is_offline();
if state.get_offline_only() || offline_now {
// Offline-only when the user opted in; a playlist forced
// local by being offline keeps the flag too (it can be
// unmarked later in Edit to enable "Upload to Qobuz").
state.set_creating(true);
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
let image_cache = image_cache.clone();
handle.clone().spawn(async move {
let nm = name.trim().to_string();
let ds = description.trim().to_string();
let created = tokio::task::spawn_blocking(move || {
let desc = if ds.is_empty() { None } else { Some(ds.as_str()) };
local_playlist::create_blocking(&nm, desc, true)
})
.await
.ok()
.flatten();
let weak2 = weak.clone();
let r2 = runtime.clone();
let h2 = handle.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<CreatePlaylistState>().set_creating(false);
match created {
Some(new_id) => {
w.global::<CreatePlaylistState>().set_open(false);
load_sidebar_playlists(r2.clone(), weak2.clone(), &h2);
nav::record(nav::NavEntry::Playlist(new_id.clone()));
navigate_playlist(r2, weak2.clone(), &h2, image_cache, new_id);
update_nav_flags(&w);
}
None => {
log::error!("[qbz-slint] create local playlist failed");
}
}
});
});
return;
}
state.set_creating(true);
let runtime = runtime.clone();
let weak = weak.clone();
+15 -3
View File
@@ -956,7 +956,7 @@ pub fn play_local_tracks_from(
/// source. Offline copies carry the Qobuz id (so the shared resolver finds
/// them) + `source = "qobuz_download"`; user files carry the library row id +
/// `source = "local"`.
fn local_queue_track(track: &qbz_library::LocalTrack) -> QueueTrack {
pub(crate) fn local_queue_track(track: &qbz_library::LocalTrack) -> QueueTrack {
// Source-aware: offline copies read as Qobuz downloads (carry the Qobuz id
// so the shared resolver finds them); ephemeral tracks keep their synthetic
// high id + an "ephemeral" tag so playback routes to the in-memory store;
@@ -1283,7 +1283,7 @@ pub fn set_now_playing_context(weak: &slint::Weak<AppWindow>, kind: &str, id: &s
/// Build a `QueueTrack` for the queue from the catalog `Track`, filling
/// the album metadata from `album_meta` (the track's own album summary is
/// often partial in album responses).
fn make_queue_track(
pub(crate) fn make_queue_track(
track: &qbz_models::Track,
album_id: &str,
album_title: &str,
@@ -1890,7 +1890,19 @@ pub fn play_track_in_context(
// Views with an authoritative Vec<Track> cache: order it by the
// visible model so sort/filter are respected.
ContentView::Playlist => {
if let Some((tracks, idx)) = order_by_visible(
// LOCAL playlist detail (id "local:<uuid>") — queue from its
// own resolved snapshot + the D8 offline-only stamp.
if window.global::<PlaylistState>().get_is_local() {
if crate::local_playlist::play_from_visible(
window,
runtime.clone(),
weak.clone(),
handle.clone(),
clicked_id,
) {
return;
}
} else if let Some((tracks, idx)) = order_by_visible(
&window.global::<PlaylistState>().get_tracks(),
crate::playlist::current_tracks(),
clicked_id,
+22 -1
View File
@@ -329,7 +329,7 @@ fn total_duration(tracks: &[Track]) -> String {
}
}
fn to_item(track: &Track) -> TrackItem {
pub(crate) fn to_item(track: &Track) -> TrackItem {
let mut title = track.title.clone();
if let Some(v) = track.version.as_ref().filter(|v| !v.is_empty()) {
title = format!("{title} ({v})");
@@ -406,6 +406,10 @@ pub fn reset(window: &AppWindow) {
state.set_cover(slint::Image::default());
state.set_sort_field("default".into());
state.set_sort_asc(true);
// Local-playlist flags reset on every navigation; the local detail
// path re-sets them after this shared reset.
state.set_is_local(false);
state.set_offline_only(false);
state.set_loading(true);
}
@@ -444,6 +448,23 @@ pub fn apply(window: &AppWindow, data: PlaylistData) {
state.set_loading(false);
}
/// Apply a prebuilt row list (the LOCAL-playlist detail path, which
/// resolves its rows from the local repo instead of a Qobuz fetch) into the
/// SAME per-view statics this module owns, so in-page search / sort /
/// multi-select / the artwork pipeline all work unchanged. Clears the Qobuz
/// `CURRENT` track cache — local playlists drive playback from
/// `crate::local_playlist`'s own queue snapshot. UI thread.
pub fn apply_local_items(window: &AppWindow, items: Vec<TrackItem>) {
FULL_ITEMS.with(|cell| *cell.borrow_mut() = items.clone());
if let Ok(mut cur) = CURRENT.lock() {
cur.clear();
}
let state = window.global::<PlaylistState>();
state.set_track_count(items.len() as i32);
state.set_tracks(ModelRc::new(VecModel::from(items)));
state.set_loading(false);
}
/// Artwork jobs for the loaded playlist — one per row plus the header
/// cover (resolved into PlaylistState.cover).
pub fn artwork_jobs(data: &PlaylistData) -> Vec<ArtworkJob> {
+103 -12
View File
@@ -55,10 +55,22 @@ impl PmPlaylist {
}
}
/// Send view of one LOCAL playlist (library.db entity, id `local:<uuid>`).
/// Listed alongside the Qobuz set with a hard-drive marker; folder
/// membership / favorite / hidden are Qobuz-side concepts and don't apply.
#[derive(Clone)]
struct PmLocalPlaylist {
id: String,
name: String,
offline_only: bool,
track_count: u32,
}
#[derive(Clone, Default)]
pub struct PmData {
playlists: Vec<PmPlaylist>,
folders: Vec<FolderFull>,
locals: Vec<PmLocalPlaylist>,
}
/// Last-loaded data (so toolbar changes rebuild from cache, no refetch).
@@ -101,16 +113,29 @@ where
Vec::new()
});
let (folders, settings, play_counts, local_counts) = tokio::task::spawn_blocking(|| {
(
crate::folders::load_folders_full(),
crate::folders::playlist_settings_map(),
crate::folders::playlist_play_counts(),
crate::folders::playlist_local_counts(),
)
})
.await
.unwrap_or_else(|_| (Vec::new(), HashMap::new(), HashMap::new(), HashMap::new()));
let (folders, settings, play_counts, local_counts, locals) =
tokio::task::spawn_blocking(|| {
let locals: Vec<PmLocalPlaylist> = crate::local_playlist::list_blocking()
.into_iter()
.map(|p| PmLocalPlaylist {
id: p.id,
name: p.name,
offline_only: p.offline_only,
track_count: p.track_count,
})
.collect();
(
crate::folders::load_folders_full(),
crate::folders::playlist_settings_map(),
crate::folders::playlist_play_counts(),
crate::folders::playlist_local_counts(),
locals,
)
})
.await
.unwrap_or_else(|_| {
(Vec::new(), HashMap::new(), HashMap::new(), HashMap::new(), Vec::new())
});
let folder_ids: std::collections::HashSet<&String> = folders.iter().map(|f| &f.id).collect();
@@ -139,7 +164,11 @@ where
})
.collect();
PmData { playlists, folders }
PmData {
playlists,
folders,
locals,
}
}
pub fn set_loading(window: &AppWindow, loading: bool) {
@@ -282,6 +311,8 @@ fn playlist_item(p: &PmPlaylist) -> PmPlaylistItem {
local_status: local_status.into(),
is_favorite: p.is_favorite,
is_hidden: p.is_hidden,
is_local_playlist: false,
offline_only: false,
folder_id: p.folder_id.clone().unwrap_or_default().into(),
cover_count: p.cover_urls.len().min(4) as i32,
url1: url(0),
@@ -295,6 +326,34 @@ fn playlist_item(p: &PmPlaylist) -> PmPlaylistItem {
}
}
fn local_playlist_item(p: &PmLocalPlaylist) -> PmPlaylistItem {
PmPlaylistItem {
id: p.id.clone().into(),
name: p.name.clone().into(),
tracks_line: format!("{} tracks", p.track_count).into(),
duration_line: "".into(),
local_line: "".into(),
local_count: 0,
total_count: p.track_count as i32,
play_count: 0,
local_status: "".into(),
is_favorite: false,
is_hidden: false,
is_local_playlist: true,
offline_only: p.offline_only,
folder_id: "".into(),
cover_count: 0,
url1: Default::default(),
url2: Default::default(),
url3: Default::default(),
url4: Default::default(),
cover1: slint::Image::default(),
cover2: slint::Image::default(),
cover3: slint::Image::default(),
cover4: slint::Image::default(),
}
}
/// Rebuild the visible grid/list model + the folder model + the tree
/// from the cache, honoring the active toolbar state. UI thread only.
pub fn rebuild(window: &AppWindow) {
@@ -328,7 +387,19 @@ pub fn rebuild(window: &AppWindow) {
.cloned()
.collect();
sort_playlists(&mut filtered, &sort);
let playlist_items: Vec<PmPlaylistItem> = filtered.iter().map(playlist_item).collect();
let mut playlist_items: Vec<PmPlaylistItem> = filtered.iter().map(playlist_item).collect();
// LOCAL playlists (library.db, D7) — appended after the Qobuz set,
// name-sorted, honoring the search query. They have no hidden flag, so
// the "hidden" filter excludes them; folder filtering N/A (root-only).
if filter != "hidden" {
let mut locals: Vec<&PmLocalPlaylist> = data
.locals
.iter()
.filter(|p| query.is_empty() || p.name.to_lowercase().contains(&query))
.collect();
locals.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
playlist_items.extend(locals.iter().map(|p| local_playlist_item(p)));
}
let visible_count = playlist_items.len();
// Tree rows (folder headers + nested + root playlists). Built only
@@ -464,6 +535,26 @@ fn build_tree(data: &PmData, query: &str, filter: &str, sort: &str) -> Vec<PmTre
indent: false,
});
}
// LOCAL playlists as root tree rows (never in folders), name-sorted,
// honoring the search query (no hidden flag — the hidden filter hides
// them like the grid does).
if filter != "hidden" {
let mut locals: Vec<&PmLocalPlaylist> = data
.locals
.iter()
.filter(|p| !searching || p.name.to_lowercase().contains(query))
.collect();
locals.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
for p in locals {
rows.push(PmTreeRow {
kind: "playlist".into(),
expanded: false,
folder: PmFolderItem::default(),
playlist: local_playlist_item(p),
indent: false,
});
}
}
rows
}
+30 -6
View File
@@ -12,6 +12,9 @@ pub struct PickPlaylist {
pub id: String,
pub name: String,
pub tracks: u32,
/// LOCAL playlist (library.db, id `local:<uuid>`) — adds write the
/// local repo (works offline) instead of the Qobuz endpoint.
pub is_local: bool,
}
/// Open the picker for `track_id` and mark it loading. UI thread.
@@ -39,25 +42,45 @@ pub fn open_multi(window: &AppWindow, ids: &[String], local: bool) {
state.set_open(true);
}
/// Fetch the user's playlists (worker thread).
/// Fetch the user's playlists (worker thread): the LOCAL playlists
/// (library.db — always available) followed by the Qobuz set. While
/// OFFLINE the Qobuz fetch is skipped entirely (D3/D11: Qobuz playlists
/// can't be written to offline, so they are hidden from the picker).
pub async fn load<A>(runtime: &AppRuntime<A>) -> Vec<PickPlaylist>
where
A: FrontendAdapter + Send + Sync + 'static,
{
match runtime.core().get_user_playlists().await {
Ok(playlists) => playlists
let mut out: Vec<PickPlaylist> = tokio::task::spawn_blocking(|| {
crate::local_playlist::list_blocking()
.into_iter()
.map(|p| PickPlaylist {
id: p.id,
name: p.name,
tracks: p.track_count,
is_local: true,
})
.collect::<Vec<_>>()
})
.await
.unwrap_or_default();
if crate::offline_mode::engine().is_offline() {
return out;
}
match runtime.core().get_user_playlists().await {
Ok(playlists) => {
out.extend(playlists.into_iter().map(|p| PickPlaylist {
id: p.id.to_string(),
name: p.name,
tracks: p.tracks_count,
})
.collect(),
is_local: false,
}));
}
Err(e) => {
log::warn!("[qbz-slint] playlist picker load failed: {e}");
Vec::new()
}
}
out
}
pub fn apply(window: &AppWindow, playlists: Vec<PickPlaylist>) {
@@ -71,6 +94,7 @@ pub fn apply(window: &AppWindow, playlists: Vec<PickPlaylist>) {
} else {
"".into()
},
is_local: p.is_local,
})
.collect();
let state = window.global::<PlaylistPickerState>();
+19
View File
@@ -696,6 +696,14 @@ impl SlintQconnectService {
}
}
// D8 guard: a queue built from an OFFLINE-ONLY local playlist never
// reaches the Connect cloud. Debug level — this runs on every track
// tick and must not spam the log (and never toasts).
if self.runtime.core().queue_is_offline_only() {
log::debug!("[QConnect] queue is from an offline-only playlist; skipping cloud push");
return;
}
let (tracks, current_index) = self.runtime.core().get_all_queue_tracks().await;
if tracks.is_empty() {
return;
@@ -802,6 +810,17 @@ impl SlintQconnectService {
return false;
}
// D8 guard: an offline-only-playlist queue is never pushed to the
// cloud. Returning false lets the play proceed LOCALLY (unlike the
// mixed-queue refusal below, local playback of these tracks is fine —
// the prohibition is only on the cloud push).
if self.runtime.core().queue_is_offline_only() {
log::info!(
"[QConnect] queue is from an offline-only playlist; not routing to peer renderer"
);
return false;
}
// (a) Push the CURRENT core queue to the peer (unconditional).
let (tracks, current_index) = self.runtime.core().get_all_queue_tracks().await;
if tracks.is_empty() {
+77 -3
View File
@@ -54,6 +54,16 @@ fn playlist_cover_urls(p: &qbz_models::Playlist) -> Vec<String> {
out
}
/// One LOCAL playlist (library.db entity, id `local:<uuid>`) listed
/// alongside the Qobuz playlists. Always available — including offline.
#[derive(Clone)]
pub struct LocalSidebarPlaylist {
pub id: String,
pub name: String,
pub description: String,
pub offline_only: bool,
}
#[derive(Clone, Default)]
pub struct SidebarData {
pub playlists: Vec<SidebarPlaylist>,
@@ -61,6 +71,8 @@ pub struct SidebarData {
pub folder_map: HashMap<u64, String>,
/// Playlist ids the user has hidden from the sidebar (local settings).
pub hidden_playlists: HashSet<u64>,
/// First-class local playlists (offline-mode D7), appended as root rows.
pub local_playlists: Vec<LocalSidebarPlaylist>,
}
/// Session-only folder expand state (matches Tauri — not persisted).
@@ -148,9 +160,9 @@ where
}
};
// Folders (hidden folders excluded) + folder membership +
// per-playlist custom-sort positions + hidden-playlist set (all
// local, library.db).
let (folders, folder_map, positions, hidden_playlists) =
// per-playlist custom-sort positions + hidden-playlist set + the
// first-class LOCAL playlists (all local, library.db).
let (folders, folder_map, positions, hidden_playlists, local_playlists) =
tokio::task::spawn_blocking(|| {
let folders: Vec<FolderInfo> = crate::folders::load_folders_full()
.into_iter()
@@ -165,11 +177,22 @@ where
.filter(|(_, s)| s.hidden)
.map(|(id, _)| id)
.collect();
let local_playlists: Vec<LocalSidebarPlaylist> =
crate::local_playlist::list_blocking()
.into_iter()
.map(|p| LocalSidebarPlaylist {
id: p.id,
name: p.name,
description: p.description.unwrap_or_default(),
offline_only: p.offline_only,
})
.collect();
(
folders,
crate::folders::playlist_folder_map(),
crate::folders::playlist_positions(),
hidden_playlists,
local_playlists,
)
})
.await
@@ -193,9 +216,21 @@ where
folders,
folder_map,
hidden_playlists,
local_playlists,
}
}
/// Name + description + offline_only for a LOCAL playlist id, from the
/// last loaded sidebar cache (the local twin of `playlist_name_desc`).
pub fn local_playlist_meta(id: &str) -> Option<(String, String, bool)> {
CACHE.lock().ok().and_then(|c| {
c.local_playlists
.iter()
.find(|p| p.id == id)
.map(|p| (p.name.clone(), p.description.clone(), p.offline_only))
})
}
/// Name + description for `id`, from the last loaded playlist payload.
/// Used by the sidebar context menu to prefill the edit-playlist modal
/// without a refetch. Returns None when the playlist is unknown.
@@ -237,6 +272,7 @@ fn playlist_entry(p: &SidebarPlaylist, indent: bool, folder_id: &str) -> Sidebar
count: 0,
indent,
folder_id: folder_id.into(),
local_kind: "".into(),
cover_count: p.cover_urls.len().min(4) as i32,
url1: url(0),
url2: url(1),
@@ -249,6 +285,29 @@ fn playlist_entry(p: &SidebarPlaylist, indent: bool, folder_id: &str) -> Sidebar
}
}
/// Build a LOCAL playlist root row (hard-drive glyph; no cover collage).
fn local_playlist_entry(p: &LocalSidebarPlaylist) -> SidebarEntry {
SidebarEntry {
kind: "playlist".into(),
id: p.id.clone().into(),
name: p.name.clone().into(),
expanded: false,
count: 0,
indent: false,
folder_id: "".into(),
local_kind: if p.offline_only { "offline" } else { "local" }.into(),
cover_count: 0,
url1: Default::default(),
url2: Default::default(),
url3: Default::default(),
url4: Default::default(),
cover1: slint::Image::default(),
cover2: slint::Image::default(),
cover3: slint::Image::default(),
cover4: slint::Image::default(),
}
}
/// Populate the collapsed-sidebar folder flyout with `folder_id`'s playlists,
/// built from the cache so it works even for collapsed folders (whose
/// children are absent from the flattened `entries`).
@@ -309,6 +368,7 @@ pub fn rebuild(window: &AppWindow) {
count: members.len() as i32,
indent: false,
folder_id: "".into(),
local_kind: "".into(),
cover_count: 0,
url1: Default::default(),
url2: Default::default(),
@@ -336,6 +396,20 @@ pub fn rebuild(window: &AppWindow) {
entries.push(playlist_entry(p, false, ""));
}
}
// LOCAL playlists (library.db, D7) — root rows after the Qobuz set,
// name-sorted, honoring the same search filter. Always present, online
// or offline (they never depend on a Qobuz fetch).
{
let mut locals: Vec<&LocalSidebarPlaylist> = data
.local_playlists
.iter()
.filter(|p| !searching || p.name.to_lowercase().contains(&query))
.collect();
locals.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
for p in locals {
entries.push(local_playlist_entry(p));
}
}
let folders: Vec<SidebarPlaylistItem> = data
.folders
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 13v8" />
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
<path d="m8 17 4-4 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 309 B

@@ -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;
@@ -123,13 +123,16 @@ export component CreatePlaylistModal inherits Rectangle {
}
}
// Public checkbox row.
// Public checkbox row — meaningless for a local playlist,
// so it dims + locks while offline-only is ON.
HorizontalLayout {
spacing: 10px;
opacity: CreatePlaylistState.offline-only ? 0.4 : 1.0;
toggle := TouchArea {
width: 20px;
height: 20px;
mouse-cursor: pointer;
mouse-cursor: CreatePlaylistState.offline-only ? default : pointer;
enabled: !CreatePlaylistState.offline-only;
clicked => {
CreatePlaylistState.is-public = !CreatePlaylistState.is-public;
}
@@ -161,6 +164,56 @@ export component CreatePlaylistModal inherits Rectangle {
}
}
// Offline-only checkbox row (D8) — creates a LOCAL playlist
// (library.db) that never reaches Qobuz. While the app is
// offline the toggle is forced ON and locked, with a hint.
VerticalLayout {
spacing: 6px;
HorizontalLayout {
spacing: 10px;
opacity: CreatePlaylistState.offline-locked ? 0.7 : 1.0;
offline-toggle := TouchArea {
width: 20px;
height: 20px;
mouse-cursor: CreatePlaylistState.offline-locked ? default : pointer;
enabled: !CreatePlaylistState.offline-locked;
clicked => {
CreatePlaylistState.offline-only = !CreatePlaylistState.offline-only;
}
Rectangle {
width: 18px;
height: 18px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: 4px;
background: CreatePlaylistState.offline-only ? Theme.accent : transparent;
border-width: CreatePlaylistState.offline-only ? 0px : 1.5px;
border-color: Theme.text-muted;
QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 12px;
height: 12px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
visible: CreatePlaylistState.offline-only;
}
}
}
Text {
text: @tr("Offline-only playlist");
color: Theme.text-secondary;
font-size: Typography.body;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
if CreatePlaylistState.offline-locked: Text {
text: @tr("Created locally — you're offline");
color: Theme.text-muted;
font-size: Typography.legal;
}
}
// Cancel + Create.
HorizontalLayout {
alignment: end;
@@ -82,6 +82,45 @@ export component EditPlaylistModal inherits Rectangle {
text <=> EditPlaylistState.description;
}
// Offline-only toggle (D8) — LOCAL playlists only. Unmark it
// to make the "Upload to Qobuz" action available.
if EditPlaylistState.is-local: HorizontalLayout {
spacing: 10px;
offline-toggle := TouchArea {
width: 20px;
height: 20px;
mouse-cursor: pointer;
clicked => {
EditPlaylistState.offline-only = !EditPlaylistState.offline-only;
}
Rectangle {
width: 18px;
height: 18px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: 4px;
background: EditPlaylistState.offline-only ? Theme.accent : transparent;
border-width: EditPlaylistState.offline-only ? 0px : 1.5px;
border-color: Theme.text-muted;
QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 12px;
height: 12px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
visible: EditPlaylistState.offline-only;
}
}
}
Text {
text: @tr("Offline-only playlist");
color: Theme.text-secondary;
font-size: Typography.body;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
HorizontalLayout {
spacing: 10px;
// Delete (destructive, left).
@@ -30,7 +30,11 @@ component PickRow inherits Rectangle {
border-radius: 6px;
background: Theme.surface-elevated;
QbzIcon {
source: @image-url("../assets/icons/list-music.svg");
// LOCAL playlists (library.db) get the hard-drive glyph
// — adds write the local repo and work offline.
source: root.item.is-local
? @image-url("../assets/icons/hard-drive.svg")
: @image-url("../assets/icons/list-music.svg");
width: 18px;
height: 18px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
+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;
+28
View File
@@ -964,6 +964,9 @@ export struct SidebarEntry {
count: int, // folder playlist count
indent: bool, // playlist nested in a folder
folder-id: string, // playlist's current folder id ("" = root; folders "")
// "" (Qobuz) | "local" | "offline" — local playlists (library.db,
// ids "local:<uuid>") show a hard-drive glyph instead of list-music.
local-kind: string,
cover-count: int, // number of cover images, 0-4 (playlists)
url1: string,
url2: string,
@@ -1094,6 +1097,12 @@ export global CreatePlaylistState {
in property <[string]> folder-ids: [""];
// Selected index into `folder-options` (0 = No folder).
in-out property <int> folder-index: 0;
// D8 "Offline-only playlist" opt-in: ON -> created as a LOCAL entity
// (library.db, id "local:<uuid>") that never reaches Qobuz.
in-out property <bool> offline-only: false;
// True while the app is offline: creation always produces a local
// playlist, so the toggle shows ON and locked with a hint.
in property <bool> offline-locked: false;
}
export global CreatePlaylistActions {
@@ -1240,6 +1249,12 @@ export global PlaylistState {
in property <bool> is-favorite: false;
// True when the playlist is one of the user's own (editable).
in property <bool> is-owner: false;
// LOCAL playlist (id "local:<uuid>", library.db entity — never on
// Qobuz). Qobuz-bound header actions (favorite / cache) hide; the
// "Upload to Qobuz" action appears when not offline-only.
in property <bool> is-local: false;
// D8: never offered for upload, never reaches Qobuz/QConnect.
in property <bool> offline-only: false;
// Track edit mode — rows show a selection checkbox and a bulk bar
// appears (remove selected). Only available to the owner.
in-out property <bool> multi-select-mode: false;
@@ -1266,6 +1281,12 @@ export global EditPlaylistState {
in-out property <string> name;
in-out property <string> description;
in property <bool> busy: false;
// LOCAL playlist (id "local:<uuid>") — edits route to library.db and
// the offline-only toggle below shows.
in property <bool> is-local: false;
// D8 flag, editable so an offline-created playlist can be unmarked
// later and gain the "Upload to Qobuz" action.
in-out property <bool> offline-only: false;
}
export global EditPlaylistActions {
@@ -1321,6 +1342,10 @@ export struct PmPlaylistItem {
local-status: string,
is-favorite: bool,
is-hidden: bool,
// LOCAL playlist entity (id "local:<uuid>", library.db) — the card /
// list-row shows a hard-drive marker; offline-only flavors label it.
is-local-playlist: bool,
offline-only: bool,
folder-id: string, // "" = root
// Micro-collage covers (same 0-4 scheme as the sidebar).
cover-count: int,
@@ -2895,6 +2920,9 @@ export struct PlaylistPickItem {
id: string,
name: string,
tracks-line: string, // "N tracks"
// LOCAL playlist (library.db entity, id "local:<uuid>") — the row
// shows a hard-drive glyph; adds write the local repo (works offline).
is-local: bool,
}
// "Add to playlist" picker — a global overlay opened from the track