feat(slint): first-class local playlists with offline-only mode (D7/D8)
New local_playlists + local_playlist_tracks schema in the shared per-user library.db (ids 'local:<uuid>', sources qobuz/local/plex, position-compacted, 8 repo tests in qbz-library). PlaylistRef routing at the navigation seam makes local ids unrepresentable in Qobuz playlist calls by construction (all take u64). Local playlists appear in sidebar, manager and picker (picker hides the Qobuz set while offline); detail view reuses the existing playlist row machinery and renders offline; creation modal gains the offline-only toggle (forced on while offline - creating offline always produces a local playlist, D8) and Edit can flip it later; explicit Upload-to-Qobuz conversion for non-offline-only locals (confirm dialog, local rows attach via the existing sidecar, entity kept on failure). Never-to-Qobuz guard: queues built from an offline-only playlist carry a core-level stamp cleared by every queue replacement; the QConnect sync skips the cloud push and play-on-peer falls back to local playback while the stamp is set. Deferred (logged in the slice report): plex-row attach on upload, custom reorder for local playlists, folder/favorite/hide on local items, manager sort interleaving, offline cover thumbs for cached qobuz rows. cc
This commit is contained in:
21 files changed
+2474
-74
No files matched your search
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user