fix(slint): typed drag payload and add-to-playlist paths for local/plex rows
The drag payload was an untyped Vec<u64>, so dropping a LOCAL album row
onto a local playlist stored its library row id as a qobuz_track_id - the
D11 resolver could not resolve the fake id and hid the row (the
empty-playlist report). New DragTrack {Qobuz|LocalRow|Plex} typed per
originating view; the drop arm stores source-aware refs (qobuz_download
rows keep their real Qobuz ref, plex rows their key, local rows their
path) for both local and Qobuz playlist targets.
Add-to-playlist had collapsed to play-track in LocalAlbumView (entry
rendered, action ignored) and was an unhandled arm in the Tracks tab; both
now route to the source-aware picker, Plex rows gain the entry everywhere
(gate was excluding them), folder-detail rows route through the shared
track-action arm, and bulk arms stop skipping Plex.
D11 nuance: a local-path row missing from the library index but present on
disk renders with a filename fallback instead of hiding; only genuinely
missing files stay hidden (logged distinctly). main.rs also carries the
recovery handler hunks for the sign-in flow fix (Login screen first,
browser OAuth after, no unlocked-empty shell).
cc
This commit is contained in:
9 files changed
+514
-145
No files matched your search
@@ -1,19 +1,39 @@
|
||||
//! Track drag-and-drop state — the ids currently being dragged onto a
|
||||
//! Track drag-and-drop state — the tracks currently being dragged onto a
|
||||
//! sidebar playlist. The UI (DragState global) drives the ghost +
|
||||
//! drop highlight; the actual track ids live here (the global only
|
||||
//! drop highlight; the actual track refs live here (the global only
|
||||
//! carries a count + ghost text).
|
||||
//!
|
||||
//! The payload is SOURCE-TYPED: a row dragged from a Qobuz surface carries
|
||||
//! its catalog id, a LocalLibrary file row its `local_tracks` row id, a
|
||||
//! Plex row its rating key. The drop arm maps each variant to the right
|
||||
//! playlist ref (`qobuz_track_id` / `local_path` / `plex_key`) — storing a
|
||||
//! library row id as a Qobuz id is what made dropped local rows resolve
|
||||
//! as unavailable (hidden, D11).
|
||||
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
static DRAGGED: LazyLock<Mutex<Vec<u64>>> = LazyLock::new(|| Mutex::new(Vec::new()));
|
||||
/// One dragged track, typed by its source namespace.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DragTrack {
|
||||
/// Qobuz catalog track id (also offline-cached rows — those ARE
|
||||
/// catalog ids with a local copy).
|
||||
Qobuz(u64),
|
||||
/// LocalLibrary `local_tracks` row id (resolved source-aware at
|
||||
/// insert: offline copies -> Qobuz ref, user files -> local_path).
|
||||
LocalRow(i64),
|
||||
/// Plex track — the rating key.
|
||||
Plex(String),
|
||||
}
|
||||
|
||||
pub fn set_dragged(ids: Vec<u64>) {
|
||||
static DRAGGED: LazyLock<Mutex<Vec<DragTrack>>> = LazyLock::new(|| Mutex::new(Vec::new()));
|
||||
|
||||
pub fn set_dragged(tracks: Vec<DragTrack>) {
|
||||
if let Ok(mut d) = DRAGGED.lock() {
|
||||
*d = ids;
|
||||
*d = tracks;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dragged() -> Vec<u64> {
|
||||
pub fn dragged() -> Vec<DragTrack> {
|
||||
DRAGGED.lock().map(|d| d.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
|
||||
@@ -128,41 +128,90 @@ pub fn add_qobuz_tracks_blocking(id: &str, track_ids: &[u64]) -> usize {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Append LocalLibrary rows (by `local_tracks` row id), source-aware:
|
||||
/// Resolve a `local_tracks` row to its playlist input, 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();
|
||||
/// a local file path.
|
||||
fn local_row_input(
|
||||
db: &qbz_library::LibraryDatabase,
|
||||
rid: i64,
|
||||
) -> Result<Option<repo::LocalPlaylistTrackInput>, qbz_library::LibraryError> {
|
||||
let Some(track) = db.get_track(rid)? else {
|
||||
log::warn!("[qbz-slint] local playlist add: unknown local row {rid}");
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(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()),
|
||||
}))
|
||||
}
|
||||
|
||||
fn add_inputs_blocking(id: &str, entries: &[repo::LocalPlaylistTrackInput]) -> usize {
|
||||
if entries.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
crate::library_db::with_db(|db| {
|
||||
Ok(db.with_connection(|conn| repo::add_tracks(conn, id, &entries)))
|
||||
Ok(db.with_connection(|conn| repo::add_tracks(conn, id, entries)))
|
||||
})
|
||||
.and_then(|r| r.ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Append local-mode picker refs — `"<i64>"` LocalLibrary row ids (resolved
|
||||
/// source-aware via [`local_row_input`]) or `"plex:<rating key>"` Plex rows
|
||||
/// (synthetic Plex row ids never resolve through `get_track`, so the picker
|
||||
/// carries the key itself). Returns inserted count.
|
||||
pub fn add_local_refs_blocking(id: &str, refs: &[String]) -> usize {
|
||||
let entries: Vec<repo::LocalPlaylistTrackInput> = crate::library_db::with_db(|db| {
|
||||
let mut out = Vec::new();
|
||||
for r in refs {
|
||||
if let Some(key) = r.strip_prefix("plex:") {
|
||||
out.push(repo::LocalPlaylistTrackInput::Plex(key.to_string()));
|
||||
} else if let Ok(rid) = r.parse::<i64>() {
|
||||
if let Some(input) = local_row_input(db, rid)? {
|
||||
out.push(input);
|
||||
}
|
||||
} else {
|
||||
log::warn!("[qbz-slint] local playlist add: unrecognized ref {r}");
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
add_inputs_blocking(id, &entries)
|
||||
}
|
||||
|
||||
/// Append a drag payload (sidebar drop), mapping every variant to its own
|
||||
/// playlist ref — local file rows store `local_path`, Plex rows `plex_key`,
|
||||
/// Qobuz/offline-cached rows `qobuz_track_id`. Returns inserted count.
|
||||
pub fn add_drag_tracks_blocking(id: &str, tracks: &[crate::drag::DragTrack]) -> usize {
|
||||
let entries: Vec<repo::LocalPlaylistTrackInput> = crate::library_db::with_db(|db| {
|
||||
let mut out = Vec::new();
|
||||
for item in tracks {
|
||||
match item {
|
||||
crate::drag::DragTrack::Qobuz(tid) => {
|
||||
out.push(repo::LocalPlaylistTrackInput::Qobuz(*tid));
|
||||
}
|
||||
crate::drag::DragTrack::LocalRow(rid) => {
|
||||
if let Some(input) = local_row_input(db, *rid)? {
|
||||
out.push(input);
|
||||
}
|
||||
}
|
||||
crate::drag::DragTrack::Plex(key) => {
|
||||
out.push(repo::LocalPlaylistTrackInput::Plex(key.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
add_inputs_blocking(id, &entries)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -235,6 +284,11 @@ pub enum RowItem {
|
||||
},
|
||||
/// Local file resolved from library.db by path.
|
||||
Local(Box<qbz_library::LocalTrack>),
|
||||
/// Local file row whose metadata resolve failed but whose file EXISTS
|
||||
/// on disk — renders with a filename fallback (D11 hiding is for
|
||||
/// unavailable-offline QOBUZ rows, not for a file that is right there).
|
||||
/// Not playable until the row is back in the library index.
|
||||
LocalFile { path: String },
|
||||
/// Plex ref — no detailed resolve in v1 (rendered basic, not playable).
|
||||
Plex { key: String },
|
||||
}
|
||||
@@ -264,6 +318,7 @@ fn total_duration_label(rows: &[LoadedRow]) -> String {
|
||||
RowItem::Qobuz(t) => t.duration as u64,
|
||||
RowItem::Cached { duration_secs, .. } => *duration_secs,
|
||||
RowItem::Local(t) => t.duration_secs,
|
||||
RowItem::LocalFile { .. } => 0,
|
||||
RowItem::Plex { .. } => 0,
|
||||
})
|
||||
.sum();
|
||||
@@ -278,8 +333,10 @@ fn total_duration_label(rows: &[LoadedRow]) -> String {
|
||||
/// 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`.
|
||||
/// Plex rows stay basic. Unresolvable QOBUZ rows are filtered out (D11);
|
||||
/// a LOCAL row that misses the index still renders (filename fallback)
|
||||
/// while its file exists, and hides (logged distinctly) only when the
|
||||
/// file itself is gone.
|
||||
pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistData> {
|
||||
let id = playlist_id.to_string();
|
||||
let (header, tracks) = tokio::task::spawn_blocking({
|
||||
@@ -343,13 +400,18 @@ pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistD
|
||||
}
|
||||
}
|
||||
|
||||
// Local rows: resolve library rows by file path (blocking).
|
||||
// Local rows: resolve library rows by file path (blocking). Paths the
|
||||
// index doesn't know are stat'ed on the same worker — an existing file
|
||||
// renders as a filename-fallback row instead of hiding (D11 nuance).
|
||||
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()
|
||||
let (locals, on_disk): (
|
||||
HashMap<String, qbz_library::LocalTrack>,
|
||||
std::collections::HashSet<String>,
|
||||
) = if local_paths.is_empty() {
|
||||
Default::default()
|
||||
} else {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::library_db::with_db(|db| {
|
||||
let resolved = 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)? {
|
||||
@@ -358,7 +420,14 @@ pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistD
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
let on_disk: std::collections::HashSet<String> = local_paths
|
||||
.iter()
|
||||
.filter(|p| !resolved.contains_key(*p))
|
||||
.filter(|p| std::path::Path::new(p.as_str()).exists())
|
||||
.cloned()
|
||||
.collect();
|
||||
(resolved, on_disk)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
@@ -366,6 +435,7 @@ pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistD
|
||||
|
||||
let mut rows: Vec<LoadedRow> = Vec::new();
|
||||
let mut hidden = 0usize;
|
||||
let mut missing_files = 0usize;
|
||||
for t in tracks {
|
||||
let item = match t.source {
|
||||
repo::LocalPlaylistTrackSource::Qobuz => {
|
||||
@@ -384,11 +454,25 @@ pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistD
|
||||
}
|
||||
}
|
||||
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()))
|
||||
match t.local_path.as_ref() {
|
||||
Some(p) => {
|
||||
if let Some(track) = locals.get(p) {
|
||||
RowItem::Local(Box::new(track.clone()))
|
||||
} else if on_disk.contains(p) {
|
||||
// Index miss but the file exists — render it
|
||||
// (filename fallback) instead of hiding.
|
||||
RowItem::LocalFile { path: p.clone() }
|
||||
} else {
|
||||
// The file itself is gone — hide, but say so.
|
||||
missing_files += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
hidden += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
repo::LocalPlaylistTrackSource::Plex => RowItem::Plex {
|
||||
key: t.plex_key.clone().unwrap_or_default(),
|
||||
@@ -402,6 +486,11 @@ pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistD
|
||||
if hidden > 0 {
|
||||
log::info!("[qbz-slint] local playlist {id}: {hidden} row(s) unavailable, hidden (D11)");
|
||||
}
|
||||
if missing_files > 0 {
|
||||
log::info!(
|
||||
"[qbz-slint] local playlist {id}: {missing_files} local file row(s) missing on disk, hidden (D11.local)"
|
||||
);
|
||||
}
|
||||
|
||||
Some(LocalPlaylistData {
|
||||
id: header.id,
|
||||
@@ -471,6 +560,9 @@ fn row_queue_track(item: &RowItem) -> Option<QueueTrack> {
|
||||
source_item_id_hint: None,
|
||||
}),
|
||||
RowItem::Local(track) => Some(crate::playback::local_queue_track(track)),
|
||||
// Filename-fallback rows have no library row to resolve playback
|
||||
// through — render-only until the file is re-indexed.
|
||||
RowItem::LocalFile { .. } => None,
|
||||
RowItem::Plex { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -555,6 +647,34 @@ fn row_item(item: &RowItem, queue: Option<&QueueTrack>) -> TrackItem {
|
||||
unlocking: false,
|
||||
}
|
||||
}
|
||||
RowItem::LocalFile { path } => {
|
||||
let name = std::path::Path::new(path)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(path.as_str());
|
||||
TrackItem {
|
||||
id: format!("file:{path}").into(),
|
||||
number: "".into(),
|
||||
title: name.into(),
|
||||
artist: "".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: "local".into(),
|
||||
unlocking: false,
|
||||
}
|
||||
}
|
||||
RowItem::Plex { key } => TrackItem {
|
||||
id: format!("plex:{key}").into(),
|
||||
number: "".into(),
|
||||
|
||||
+301
-89
@@ -1554,29 +1554,110 @@ fn navigate_playlist(
|
||||
});
|
||||
}
|
||||
|
||||
/// Resolve the track ids for a drag started on `track_id`. If the
|
||||
/// current view has a multi-selection that includes the dragged row
|
||||
/// (and is >1), the whole selection is dragged; otherwise just the
|
||||
/// row. Mirrors Tauri's group-drag rule.
|
||||
fn gather_drag_ids(w: &AppWindow, track_id: &str) -> Vec<u64> {
|
||||
/// Type a LocalLibrary row for the drag payload: Plex rows carry their
|
||||
/// rating key (their row id is synthetic — never resolvable in
|
||||
/// `local_tracks`), everything else its real library row id.
|
||||
fn local_drag_track(track: &qbz_library::LocalTrack) -> drag::DragTrack {
|
||||
if track.source.as_deref() == Some("plex") {
|
||||
drag::DragTrack::Plex(track.file_path.clone())
|
||||
} else {
|
||||
drag::DragTrack::LocalRow(track.id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a playlist-picker local-mode ref for a LocalLibrary row: Plex rows
|
||||
/// carry their rating key ("plex:<key>" — their synthetic row id never
|
||||
/// resolves through `get_track`), everything else its library row id.
|
||||
fn local_picker_ref(track: &qbz_library::LocalTrack) -> String {
|
||||
if track.source.as_deref() == Some("plex") {
|
||||
format!("plex:{}", track.file_path)
|
||||
} else {
|
||||
track.id.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Type a model row (Playlist / Artist surfaces) for the drag payload.
|
||||
/// The LOCAL playlist detail mixes namespaces: "plex:<key>" Plex rows,
|
||||
/// library row ids on `source == "local"` rows, Qobuz catalog ids on
|
||||
/// everything else (incl. offline-cached rows). Render-only rows
|
||||
/// ("file:<path>" fallbacks) type to None and drop out of the drag.
|
||||
fn row_drag_track(row: &TrackItem) -> Option<drag::DragTrack> {
|
||||
let id = row.id.to_string();
|
||||
if let Some(key) = id.strip_prefix("plex:") {
|
||||
return Some(drag::DragTrack::Plex(key.to_string()));
|
||||
}
|
||||
if row.source.as_str() == "local" {
|
||||
return id.parse::<i64>().ok().map(drag::DragTrack::LocalRow);
|
||||
}
|
||||
id.parse::<u64>().ok().map(drag::DragTrack::Qobuz)
|
||||
}
|
||||
|
||||
/// Resolve the SOURCE-TYPED track refs for a drag started on `track_id`
|
||||
/// — the id namespace depends on the view the drag started in (Qobuz
|
||||
/// surfaces carry catalog ids; LocalLibrary surfaces carry library row
|
||||
/// ids, Plex rows rating keys). If the current view has a multi-selection
|
||||
/// that includes the dragged row (and is >1), the whole selection is
|
||||
/// dragged; otherwise just the row. Mirrors Tauri's group-drag rule.
|
||||
fn gather_drag_tracks(w: &AppWindow, track_id: &str) -> Vec<drag::DragTrack> {
|
||||
use slint::Model;
|
||||
let view = w.global::<NavState>().get_view();
|
||||
let model = match view {
|
||||
ContentView::Playlist => Some(w.global::<PlaylistState>().get_tracks()),
|
||||
ContentView::Artist => Some(w.global::<ArtistState>().get_top_tracks()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(model) = model {
|
||||
let selected: Vec<u64> = (0..model.row_count())
|
||||
.filter_map(|i| model.row_data(i))
|
||||
.filter(|t| t.selected)
|
||||
.filter_map(|t| t.id.parse::<u64>().ok())
|
||||
.collect();
|
||||
if selected.len() > 1 && selected.iter().any(|&id| id.to_string() == track_id) {
|
||||
return selected;
|
||||
match view {
|
||||
ContentView::LocalAlbum => {
|
||||
// Single-row surface; resolve through the open album's version
|
||||
// cache (the only place a Plex row's rating key lives).
|
||||
local_library::current_album_version_tracks(w)
|
||||
.iter()
|
||||
.find(|t| t.id.to_string() == track_id)
|
||||
.map(|t| vec![local_drag_track(t)])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
ContentView::LocalLibrary => {
|
||||
// Tracks tab (group-drag over the multi-selection first).
|
||||
let selected = local_library::selected_local_tracks(w);
|
||||
if selected.len() > 1 && selected.iter().any(|t| t.id.to_string() == track_id) {
|
||||
return selected.iter().map(local_drag_track).collect();
|
||||
}
|
||||
if let Some(track) = local_library::local_track_by_id(track_id) {
|
||||
return vec![local_drag_track(&track)];
|
||||
}
|
||||
// Folder-detail rows aren't in the Tracks cache but are real
|
||||
// library rows — type by row id (resolved at insert).
|
||||
track_id
|
||||
.parse::<i64>()
|
||||
.map(|id| vec![drag::DragTrack::LocalRow(id)])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
ContentView::Playlist | ContentView::Artist => {
|
||||
let model = match view {
|
||||
ContentView::Playlist => w.global::<PlaylistState>().get_tracks(),
|
||||
_ => w.global::<ArtistState>().get_top_tracks(),
|
||||
};
|
||||
let rows: Vec<TrackItem> = (0..model.row_count())
|
||||
.filter_map(|i| model.row_data(i))
|
||||
.collect();
|
||||
let selected: Vec<drag::DragTrack> = rows
|
||||
.iter()
|
||||
.filter(|t| t.selected)
|
||||
.filter_map(row_drag_track)
|
||||
.collect();
|
||||
if selected.len() > 1 && rows.iter().any(|t| t.selected && t.id == track_id) {
|
||||
return selected;
|
||||
}
|
||||
if let Some(row) = rows.iter().find(|t| t.id == track_id) {
|
||||
return row_drag_track(row).map(|d| vec![d]).unwrap_or_default();
|
||||
}
|
||||
track_id
|
||||
.parse::<u64>()
|
||||
.map(|id| vec![drag::DragTrack::Qobuz(id)])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
// Every other surface (album / search / favorites / mix / …) is
|
||||
// Qobuz-backed: rows carry catalog ids.
|
||||
_ => track_id
|
||||
.parse::<u64>()
|
||||
.map(|id| vec![drag::DragTrack::Qobuz(id)])
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
track_id.parse::<u64>().map(|id| vec![id]).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Load (or reload) the sidebar playlists list off-thread.
|
||||
@@ -3868,14 +3949,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let image_cache = image_cache.clone();
|
||||
let settings_ctx = settings_ctx.clone();
|
||||
handle.spawn(async move {
|
||||
// No pre-lift needed: the auth endpoints are EXEMPT from the
|
||||
// offline gate (qbz-qobuz client), so the token login and the
|
||||
// OAuth exchange pass the closed gate. Net effect: the
|
||||
// offline_session flag ends up false only on SUCCESS paths —
|
||||
// restore_saved_session clears it on success only, and the
|
||||
// browser fallback's upfront clear is restored on failure
|
||||
// below — so a failed attempt leaves the live offline session
|
||||
// (and the recovery banner) intact.
|
||||
// No pre-lift anywhere: the auth endpoints are EXEMPT from
|
||||
// the offline gate (qbz-qobuz client), so the token login and
|
||||
// the OAuth exchange pass the closed gate — and
|
||||
// login_via_system_browser no longer clears offline_session
|
||||
// up front either. The flag ends up false only on SUCCESS
|
||||
// paths (restore_saved_session / login_via_system_browser
|
||||
// clear it after the login completes), so the shell never
|
||||
// sits unlocked-and-empty while an attempt is pending, and a
|
||||
// failed attempt leaves the live offline session intact.
|
||||
match auth::restore_saved_session(&runtime).await {
|
||||
Ok(Some(session)) => {
|
||||
log::info!(
|
||||
@@ -3887,10 +3969,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(None) => {
|
||||
// No saved token, or the token was explicitly
|
||||
// rejected (and cleared). The user asked to sign in —
|
||||
// fall back to the full system-browser OAuth.
|
||||
// fall back to the full system-browser OAuth. Show
|
||||
// the LOGIN screen FIRST: its UX narrates the
|
||||
// browser flow (the user shouldn't have to notice
|
||||
// the opened browser on their own), and it replaces
|
||||
// the offline shell instead of leaving it on screen
|
||||
// while the attempt runs.
|
||||
log::warn!(
|
||||
"[qbz-slint] recovery login: saved session unusable — falling back to browser OAuth"
|
||||
);
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
w.set_screen(AppScreen::Login);
|
||||
});
|
||||
match auth::login_via_system_browser(&runtime).await {
|
||||
Ok(session) => {
|
||||
log::info!(
|
||||
@@ -3902,13 +3992,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] recovery browser sign-in failed: {e}");
|
||||
// login_via_system_browser ends the offline
|
||||
// session upfront (login-screen semantics);
|
||||
// here the user is still inside the
|
||||
// unauthenticated offline shell — restore the
|
||||
// session flag so the gate closes again and
|
||||
// the banner returns.
|
||||
offline_mode::engine().set_offline_session(true);
|
||||
// The offline session was never lifted, so
|
||||
// there is nothing to restore. Stay on the
|
||||
// Login screen: the error box explains the
|
||||
// failure, and the "Start offline" link
|
||||
// (has-previous-session) leads back into
|
||||
// the offline shell.
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
toast::error(&w, format!("Sign-in failed: {e}"));
|
||||
w.global::<OfflineState>().set_login_error(e.into());
|
||||
@@ -3917,9 +4006,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Network-class failure: the offline_session flag was
|
||||
// never lifted (no pre-lift), so the offline shell
|
||||
// state is already intact — just surface the error.
|
||||
// Init-class failure (gated/unreachable cold bundle
|
||||
// fetch): any transient flag lift was already undone
|
||||
// inside auth, so the offline shell state is intact —
|
||||
// just surface the error.
|
||||
log::error!("[qbz-slint] recovery login failed: {e}");
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
toast::error(&w, format!("Sign-in failed: {e}"));
|
||||
@@ -7301,13 +7391,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
window.global::<LocalAlbumActions>().on_add_to_playlist(move || {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
let tracks = local_library::current_album_version_tracks(&w);
|
||||
let ids: Vec<String> = tracks
|
||||
.iter()
|
||||
.filter(|t| t.source.as_deref() != Some("plex"))
|
||||
.map(|t| t.id.to_string())
|
||||
.collect();
|
||||
if !ids.is_empty() {
|
||||
playlist_picker::open_multi(&w, &ids, true);
|
||||
let refs: Vec<String> = tracks.iter().map(local_picker_ref).collect();
|
||||
if !refs.is_empty() {
|
||||
playlist_picker::open_multi(&w, &refs, true);
|
||||
let runtime = runtime.clone();
|
||||
let weak2 = weak.clone();
|
||||
handle.spawn(async move {
|
||||
@@ -7320,6 +7406,50 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
// Per-row context-menu actions on the local album detail (play-next /
|
||||
// queue / add-to-playlist) — resolved against the open version's
|
||||
// track cache; "play" stays on LocalAlbumActions.play-track.
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window
|
||||
.global::<LocalAlbumActions>()
|
||||
.on_track_menu_action(move |id, action| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let tracks = local_library::current_album_version_tracks(&w);
|
||||
let Some(row) = tracks.iter().find(|t| t.id.to_string() == id.as_str())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
match action.as_str() {
|
||||
"play-next" | "queue" => {
|
||||
playback::enqueue_local_tracks(
|
||||
runtime.clone(),
|
||||
handle.clone(),
|
||||
vec![row.clone()],
|
||||
action.as_str() == "play-next",
|
||||
);
|
||||
}
|
||||
"add-to-playlist" => {
|
||||
playlist_picker::open_multi(&w, &[local_picker_ref(row)], true);
|
||||
let runtime = runtime.clone();
|
||||
let weak2 = weak.clone();
|
||||
handle.spawn(async move {
|
||||
let pls = playlist_picker::load(&runtime).await;
|
||||
let _ = weak2.upgrade_in_event_loop(move |w| {
|
||||
playlist_picker::apply(&w, pls);
|
||||
});
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
log::debug!(
|
||||
"[qbz-slint] unhandled local album track action: {id} {action}"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
// Add the whole local/Plex album to a Mixtape/Collection. Builds the
|
||||
// `album` payload (source "local", no artwork_url — 1:1 PSD) from the
|
||||
@@ -7558,16 +7688,59 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
"play-next" | "queue" => {
|
||||
// Resolve the row from the loaded cache (no DB) and enqueue.
|
||||
// Resolve the row from the loaded cache (no DB) and
|
||||
// enqueue; folder-detail rows aren't in the Tracks
|
||||
// cache, so fall back to a DB resolve off-thread.
|
||||
let play_next = action.as_str() == "play-next";
|
||||
if let Some(row) = local_library::local_track_by_id(id.as_str()) {
|
||||
playback::enqueue_local_tracks(
|
||||
runtime.clone(),
|
||||
handle.clone(),
|
||||
vec![row],
|
||||
action.as_str() == "play-next",
|
||||
play_next,
|
||||
);
|
||||
} else if let Ok(rid) = id.parse::<i64>() {
|
||||
let runtime = runtime.clone();
|
||||
let handle2 = handle.clone();
|
||||
handle.spawn(async move {
|
||||
let row = tokio::task::spawn_blocking(move || {
|
||||
crate::library_db::with_db(|db| db.get_track(rid))
|
||||
.flatten()
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
if let Some(row) = row {
|
||||
playback::enqueue_local_tracks(
|
||||
runtime,
|
||||
handle2,
|
||||
vec![row],
|
||||
play_next,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
"add-to-playlist" => {
|
||||
// Per-row picker (Tracks tab + folder-detail rows).
|
||||
// Plex rows ride as "plex:<key>"; plain row ids are
|
||||
// resolved source-aware at insert, so a folder row
|
||||
// missing from the Tracks cache still works.
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let track_ref = match local_library::local_track_by_id(id.as_str()) {
|
||||
Some(row) => local_picker_ref(&row),
|
||||
None => id.to_string(),
|
||||
};
|
||||
playlist_picker::open_multi(&w, &[track_ref], true);
|
||||
let runtime = runtime.clone();
|
||||
let weak2 = weak.clone();
|
||||
handle.spawn(async move {
|
||||
let playlists = playlist_picker::load(&runtime).await;
|
||||
let _ = weak2.upgrade_in_event_loop(move |w| {
|
||||
playlist_picker::apply(&w, playlists);
|
||||
});
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
log::debug!("[qbz-slint] unhandled local track action: {id} {action}");
|
||||
}
|
||||
@@ -7621,18 +7794,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
local_library::clear_tracks_selection(&w);
|
||||
}
|
||||
"add-to-playlist" => {
|
||||
// Plex rows can't be added (no local add path); skip them,
|
||||
// deciding on the underlying LocalTrack.source.
|
||||
// Source-aware refs: Plex rows ride as "plex:<key>",
|
||||
// the rest as library row ids (resolved at insert).
|
||||
let rows = local_library::selected_local_tracks(&w);
|
||||
let ids: Vec<String> = rows
|
||||
.iter()
|
||||
.filter(|t| t.source.as_deref() != Some("plex"))
|
||||
.map(|t| t.id.to_string())
|
||||
.collect();
|
||||
let skipped = rows.len() - ids.len();
|
||||
if skipped > 0 {
|
||||
log::info!("[qbz-slint] add-to-playlist skipped {skipped} Plex track(s)");
|
||||
}
|
||||
let ids: Vec<String> = rows.iter().map(local_picker_ref).collect();
|
||||
if !ids.is_empty() {
|
||||
playlist_picker::open_multi(&w, &ids, true);
|
||||
let runtime = runtime.clone();
|
||||
@@ -7735,12 +7900,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
local_library::tree_clear_selection(&w);
|
||||
}
|
||||
"add-to-playlist" => {
|
||||
// Source-aware refs (Plex rows as "plex:<key>").
|
||||
let rows = local_library::tree_selected_snapshot();
|
||||
let ids: Vec<String> = rows
|
||||
.iter()
|
||||
.filter(|t| t.source.as_deref() != Some("plex"))
|
||||
.map(|t| t.id.to_string())
|
||||
.collect();
|
||||
let ids: Vec<String> = rows.iter().map(local_picker_ref).collect();
|
||||
if !ids.is_empty() {
|
||||
playlist_picker::open_multi(&w, &ids, true);
|
||||
let runtime = runtime.clone();
|
||||
@@ -8154,18 +8316,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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())
|
||||
// Local-mode refs — LocalLibrary row ids ("<i64>",
|
||||
// source-aware mapping: local path / offline-copy
|
||||
// Qobuz id) or Plex rows ("plex:<rating key>").
|
||||
let refs: Vec<String> = (0..ids_model.row_count())
|
||||
.filter_map(|i| ids_model.row_data(i))
|
||||
.filter_map(|s| s.parse::<i64>().ok())
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
if ids_i64.is_empty() {
|
||||
if refs.is_empty() {
|
||||
return;
|
||||
}
|
||||
handle.spawn(async move {
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
local_playlist::add_local_rows_blocking(&target, &ids_i64)
|
||||
local_playlist::add_local_refs_blocking(&target, &refs)
|
||||
})
|
||||
.await;
|
||||
});
|
||||
@@ -8197,19 +8360,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
};
|
||||
|
||||
if is_local {
|
||||
// Local row ids are i64 -> add_local_track_to_playlist.
|
||||
let ids_i64: Vec<i64> = (0..ids_model.row_count())
|
||||
// Local-mode refs onto a QOBUZ playlist: row ids attach
|
||||
// via the local sidecar, "plex:<key>" refs via the Plex
|
||||
// sidecar (same tables the offline detail renders).
|
||||
let refs: Vec<String> = (0..ids_model.row_count())
|
||||
.filter_map(|i| ids_model.row_data(i))
|
||||
.filter_map(|s| s.parse::<i64>().ok())
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
if ids_i64.is_empty() {
|
||||
if refs.is_empty() {
|
||||
return;
|
||||
}
|
||||
handle.spawn(async move {
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
crate::library_db::with_db(|db| {
|
||||
for (pos, lid) in ids_i64.iter().enumerate() {
|
||||
db.add_local_track_to_playlist(pid, *lid, pos as i32)?;
|
||||
for (pos, r) in refs.iter().enumerate() {
|
||||
if let Some(key) = r.strip_prefix("plex:") {
|
||||
db.add_plex_track_to_playlist(pid, key, pos as i32)?;
|
||||
} else if let Ok(lid) = r.parse::<i64>() {
|
||||
db.add_local_track_to_playlist(pid, lid, pos as i32)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -8258,10 +8427,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
move |track_id, title, subtitle, gx, gy| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
log::info!("[qbz-slint][drag] start gx={gx} gy={gy} (cursor should be here)");
|
||||
let ids = gather_drag_ids(&w, track_id.as_str());
|
||||
drag::set_dragged(ids.clone());
|
||||
let tracks = gather_drag_tracks(&w, track_id.as_str());
|
||||
let count = tracks.len();
|
||||
drag::set_dragged(tracks);
|
||||
let ds = w.global::<DragState>();
|
||||
ds.set_count(ids.len() as i32);
|
||||
ds.set_count(count as i32);
|
||||
ds.set_ghost_title(title);
|
||||
ds.set_ghost_subtitle(subtitle);
|
||||
ds.set_pointer_x(gx);
|
||||
@@ -8291,17 +8461,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let pid = ds.get_over_playlist_id().to_string();
|
||||
ds.set_active(false);
|
||||
ds.set_over_playlist_id("".into());
|
||||
let ids = drag::dragged();
|
||||
let tracks = drag::dragged();
|
||||
drag::clear();
|
||||
if ids.is_empty() {
|
||||
if tracks.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Drop onto a LOCAL playlist row — write the repo (D7 routing;
|
||||
// dragged ids are Qobuz track ids).
|
||||
// Drop onto a LOCAL playlist row — write the repo source-aware
|
||||
// (D7 routing): local file rows store local_path, Plex rows
|
||||
// plex_key, Qobuz/offline-cached rows qobuz_track_id.
|
||||
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)
|
||||
local_playlist::add_drag_tracks_blocking(&pid, &tracks)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
@@ -8310,14 +8481,55 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
return;
|
||||
}
|
||||
if let Ok(pid) = pid.parse::<u64>() {
|
||||
// Qobuz playlist target: catalog ids become real membership;
|
||||
// local rows / Plex rows attach via the mixed-playlist
|
||||
// sidecars (the same tables the picker's local mode writes).
|
||||
let mut qobuz: Vec<u64> = Vec::new();
|
||||
let mut local_rows: Vec<i64> = Vec::new();
|
||||
let mut plex: Vec<String> = Vec::new();
|
||||
for item in tracks {
|
||||
match item {
|
||||
drag::DragTrack::Qobuz(id) => qobuz.push(id),
|
||||
drag::DragTrack::LocalRow(id) => local_rows.push(id),
|
||||
drag::DragTrack::Plex(key) => plex.push(key),
|
||||
}
|
||||
}
|
||||
let runtime = runtime.clone();
|
||||
handle.spawn(async move {
|
||||
match runtime.core().add_tracks_to_playlist(pid, &ids).await {
|
||||
Ok(()) => log::info!(
|
||||
"[qbz-slint] dropped {} track(s) onto playlist {pid}",
|
||||
ids.len()
|
||||
),
|
||||
Err(e) => log::error!("[qbz-slint] drop add to playlist failed: {e}"),
|
||||
let mut added = 0usize;
|
||||
if !qobuz.is_empty() {
|
||||
match runtime.core().add_tracks_to_playlist(pid, &qobuz).await {
|
||||
Ok(()) => added += qobuz.len(),
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] drop add to playlist failed: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !local_rows.is_empty() || !plex.is_empty() {
|
||||
let n = tokio::task::spawn_blocking(move || {
|
||||
crate::library_db::with_db(|db| {
|
||||
for (pos, rid) in local_rows.iter().enumerate() {
|
||||
db.add_local_track_to_playlist(pid, *rid, pos as i32)?;
|
||||
}
|
||||
for (pos, key) in plex.iter().enumerate() {
|
||||
db.add_plex_track_to_playlist(
|
||||
pid,
|
||||
key,
|
||||
(local_rows.len() + pos) as i32,
|
||||
)?;
|
||||
}
|
||||
Ok(local_rows.len() + plex.len())
|
||||
})
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
added += n;
|
||||
}
|
||||
if added > 0 {
|
||||
log::info!(
|
||||
"[qbz-slint] dropped {added} track(s) onto playlist {pid}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,9 +28,11 @@ pub fn open(window: &AppWindow, track_id: &str) {
|
||||
state.set_open(true);
|
||||
}
|
||||
|
||||
/// Open the picker for a batch of track ids (bulk add). `local` routes the ids
|
||||
/// as LocalLibrary row ids (i64) to `add_local_track_to_playlist` instead of
|
||||
/// the Qobuz endpoint. UI thread.
|
||||
/// Open the picker for a batch of track refs (bulk add). `local` marks the
|
||||
/// refs as LocalLibrary local-mode refs — `"<i64>"` library row ids (resolved
|
||||
/// source-aware at insert: local path / offline-copy Qobuz id) or
|
||||
/// `"plex:<rating key>"` Plex rows — routed to the library.db add paths
|
||||
/// instead of the Qobuz endpoint. UI thread.
|
||||
pub fn open_multi(window: &AppWindow, ids: &[String], local: bool) {
|
||||
let state = window.global::<PlaylistPickerState>();
|
||||
state.set_track_id("".into());
|
||||
|
||||
@@ -483,10 +483,16 @@ export component LocalAlbumView inherits Rectangle {
|
||||
// Show the per-row source glyph (local / qobuz-offline / Plex).
|
||||
show-source: true;
|
||||
media-action(kind, id, action) => {
|
||||
// Local-only: ignore album/artist link clicks; any other
|
||||
// row action plays the track.
|
||||
// Local-only: ignore album/artist link clicks. "play"
|
||||
// (row body / play cell) plays; the context-menu
|
||||
// actions (play-next / queue / add-to-playlist) route
|
||||
// to their own handler instead of collapsing to play.
|
||||
if (kind != "album" && kind != "artist") {
|
||||
LocalAlbumActions.play-track(id);
|
||||
if (action == "play") {
|
||||
LocalAlbumActions.play-track(id);
|
||||
} else {
|
||||
LocalAlbumActions.track-menu-action(id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2071,8 +2071,14 @@ export component LocalLibraryView inherits Rectangle {
|
||||
LocalLibraryActions.open-album(id);
|
||||
} else if (kind == "artist" && action == "open") {
|
||||
LocalLibraryActions.open-artist(id);
|
||||
} else {
|
||||
} else if (action == "play") {
|
||||
LocalLibraryActions.folders-play-track(id);
|
||||
} else {
|
||||
// Context-menu actions (play-next /
|
||||
// queue / add-to-playlist) — the rows
|
||||
// are real library rows, so the shared
|
||||
// track-action arm resolves them.
|
||||
LocalLibraryActions.track-action(id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ export component TrackContextMenu inherits PopupWindow {
|
||||
// source). `qobuz-actions` = the Qobuz-backed block (favorite, radio,
|
||||
// mixtape, share, make-offline, go-to album/artist, track info) — on for
|
||||
// Qobuz + offline, off for local / Plex / ephemeral. `playlist-action` =
|
||||
// Add to playlist — on for Qobuz + offline + local (local playlists), off
|
||||
// for Plex + ephemeral. The transient actions (play / next / queue) always
|
||||
// show.
|
||||
// Add to playlist — on for Qobuz + offline + local + Plex (local playlists
|
||||
// store each source's own ref), off for ephemeral. The transient actions
|
||||
// (play / next / queue) always show.
|
||||
in property <bool> qobuz-actions: true;
|
||||
in property <bool> playlist-action: true;
|
||||
callback media-action(string, string, string);
|
||||
|
||||
@@ -550,13 +550,13 @@ export component TrackRow inherits Rectangle {
|
||||
track-id: root.track.id;
|
||||
// Per-source menu logic: Qobuz-backed actions (favorite, radio, share,
|
||||
// mixtape, offline, go-to, track-info) only for Qobuz/offline rows;
|
||||
// Add-to-playlist for Qobuz/offline/local (local playlists), not Plex/
|
||||
// ephemeral; transient play/queue always.
|
||||
// Add-to-playlist for Qobuz/offline/local/Plex (local playlists store
|
||||
// each source's own ref), not ephemeral (no persistent row to
|
||||
// reference); transient play/queue always.
|
||||
qobuz-actions: root.track.source != "local"
|
||||
&& root.track.source != "plex"
|
||||
&& root.track.source != "ephemeral";
|
||||
playlist-action: root.track.source != "plex"
|
||||
&& root.track.source != "ephemeral";
|
||||
playlist-action: root.track.source != "ephemeral";
|
||||
media-action(k, i, a) => {
|
||||
root.media-action(k, i, a);
|
||||
}
|
||||
|
||||
@@ -699,6 +699,9 @@ export global LocalAlbumActions {
|
||||
callback add-to-playlist();
|
||||
callback add-to-mixtape();
|
||||
callback play-track(string /* track id */);
|
||||
// Per-row context-menu actions beyond "play" (play-next / queue /
|
||||
// add-to-playlist) — resolved against the open version's track cache.
|
||||
callback track-menu-action(string /* track id */, string /* action */);
|
||||
callback select-version(int);
|
||||
// Client-side track filter (mirrors AlbumActions.search), applied over the
|
||||
// current version's tracks.
|
||||
|
||||
Reference in new issue
Block a user