diff --git a/crates/qbz-qt/qml/rows/TrackRow.qml b/crates/qbz-qt/qml/rows/TrackRow.qml index ab59ebe0a..9ff5364e6 100644 --- a/crates/qbz-qt/qml/rows/TrackRow.qml +++ b/crates/qbz-qt/qml/rows/TrackRow.qml @@ -141,6 +141,14 @@ Rectangle { // `playlist add-tracks` as a QOBUZ catalog id — a local row dropped on a // playlist would silently add an unrelated catalog track. property bool draggable: true + /// LOCAL-mode drag arm (2026-08-30): the row IS a drag source, but its + /// `item.id` is a LOCAL library row id, so it enters the shared drag via + /// `dragStartLocal` — a typed payload the drop handlers resolve + /// source-aware, never a number that could be read as a catalog id. + /// Independent of `draggable` on purpose: `catalogRow` below keeps + /// meaning "item.id is a Qobuz catalog id". + property bool localDragSource: false + readonly property bool dragSource: root.draggable || root.localDragSource /// A live transport guard separate from catalogue availability. The full /// queue uses it when QConnect is active: incompatible rows stay visible /// and keep their non-transport menu actions, but cannot play or drag. @@ -1471,7 +1479,7 @@ Rectangle { // later, `onCanceled` fired, and the drag died at roughly the row // boundary — never reaching the sidebar. Arming at 6px and claiming // the grab there wins the race by construction, because 6 < 10. - preventStealing: (root.reorderDrag && root.draggable && !root.selectMode) + preventStealing: (root.reorderDrag && root.dragSource && !root.selectMode) || root.dragging onPressed: function (mouse) { if (mouse.button === Qt.RightButton) { @@ -1491,7 +1499,7 @@ Rectangle { // A dead row is not a drag source either: dropping it on a sidebar // playlist would add a track that cannot play, which is precisely // the state this treatment exists to stop spreading. - if (!pressed || !(pressedButtons & Qt.LeftButton) || !root.draggable + if (!pressed || !(pressedButtons & Qt.LeftButton) || !root.dragSource || root.selectMode || root.pulledDead || root.playBlocked) return const g = mapToItem(null, mouse.x, mouse.y) const dx = mouse.x - root.downPos.x @@ -1519,9 +1527,15 @@ Rectangle { if (!root.dragging && armed) { root.dragging = true root.bodyDragStarted(root.number) - QbzShell.dragStart(root.item.id, root.item.title || "", - (root.item.artist || "") + " · " + (root.item.album || ""), - g.x, g.y, root.inlineDragVisual) + if (root.localDragSource) + QbzShell.dragStartLocal(String(root.item.id), + root.item.title || "", + (root.item.artist || "") + " · " + (root.item.album || ""), + g.x, g.y, root.inlineDragVisual) + else + QbzShell.dragStart(root.item.id, root.item.title || "", + (root.item.artist || "") + " · " + (root.item.album || ""), + g.x, g.y, root.inlineDragVisual) } if (root.dragging) QbzShell.dragMove(g.x, g.y) } diff --git a/crates/qbz-qt/qml/views/local/LocalTrackRow.qml b/crates/qbz-qt/qml/views/local/LocalTrackRow.qml index cdbb71fd8..a4344566e 100644 --- a/crates/qbz-qt/qml/views/local/LocalTrackRow.qml +++ b/crates/qbz-qt/qml/views/local/LocalTrackRow.qml @@ -134,9 +134,16 @@ Item { showFavorite: false showDownload: false showMenu: false - // Local rows are NOT drag sources: item.id is a local DB row id and - // the sidebar drop handler forwards it as a Qobuz catalog id. + // Local rows drag through the LOCAL arm: item.id is a local DB row + // id, so the shared drag carries it typed (`dragStartLocal`) and the + // drop handlers resolve it source-aware — the same guardrails as the + // picker. `draggable` stays false so `catalogRow` keeps meaning + // "item.id is a Qobuz catalog id". draggable: false + // NOT for native-catalog rows: their `item.id` is the catalog seam's + // identity, not a legacy row id the drop resolver can look up — a + // ghost that lands as a silent no-op is worse than no gesture. + localDragSource: !root.nativeActions qualityStyle: "icon" // A failed physical row stays clickable so the user can retry after a // drive returns. The preflight prevents any queue/NPB mutation until diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 54563f018..6b13662de 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -2075,6 +2075,12 @@ pub(crate) fn playlist_move_row(row_id: String, delta: i32) { // DragTrack enum also carries local-library rows and Plex keys into // sidecar writes — out of scope, see module POC-NOTEs). static DRAGGED: Mutex> = Mutex::new(Vec::new()); +/// LOCAL-mode drag payload: Local Library row ids, resolved to source-aware +/// picker refs at DROP time (the rows can leave the visible cache between +/// press and release; ids survive that, refs are cheap to build once). +/// Mutually exclusive with `DRAGGED` — a drag carries one payload kind, the +/// same invariant the picker's `Payload` enum enforces. +static DRAGGED_LOCAL_ROWS: Mutex> = Mutex::new(Vec::new()); /// The claimed drop target (sidebar playlist id) — mirrored Rust-side so /// drag_end never reads the bridge off-thread. static DRAG_OVER: Mutex = Mutex::new(String::new()); @@ -2093,6 +2099,7 @@ pub(crate) fn drag_start( ) { let id = track_id.parse::().unwrap_or(0); *DRAGGED.lock().unwrap() = if id > 0 { vec![id] } else { Vec::new() }; + DRAGGED_LOCAL_ROWS.lock().unwrap().clear(); log::info!("[qbz-qt][drag] start {track_id} ({title})"); shell_bridge::ui(move |mut b| { b.as_mut().set_drag_count(if id > 0 { 1 } else { 0 }); @@ -2108,6 +2115,35 @@ pub(crate) fn drag_start( }); } +/// LOCAL-mode twin of [`drag_start`]: a Local Library / Explorer row enters +/// the shared drag carrying its ROW ID, never a number that could be read as +/// a catalog id (the picker's `Payload` invariant, kept at the drag seam). +pub(crate) fn drag_start_local( + row_id: String, + title: String, + subtitle: String, + x: f32, + y: f32, + inline_visual: bool, +) { + DRAGGED.lock().unwrap().clear(); + let ok = !row_id.is_empty(); + *DRAGGED_LOCAL_ROWS.lock().unwrap() = if ok { vec![row_id.clone()] } else { Vec::new() }; + log::info!("[qbz-qt][drag] start local row {row_id} ({title})"); + shell_bridge::ui(move |mut b| { + b.as_mut().set_drag_count(if ok { 1 } else { 0 }); + b.as_mut().set_drag_title(QString::from(title.as_str())); + b.as_mut() + .set_drag_subtitle(QString::from(subtitle.as_str())); + b.as_mut().set_drag_x(x); + b.as_mut().set_drag_y(y); + b.as_mut().set_drag_inline_visual(inline_visual); + b.as_mut().set_drag_over_playlist_id(QString::default()); + b.as_mut().set_drag_over_queue_index(-1); + b.as_mut().set_drag_active(true); + }); +} + pub(crate) fn drag_move(x: f32, y: f32) { shell_bridge::ui(move |mut b| { b.as_mut().set_drag_x(x); @@ -2140,9 +2176,70 @@ pub(crate) fn drag_end() { b.as_mut().set_drag_over_queue_index(-1); }); let tracks = std::mem::take(&mut *DRAGGED.lock().unwrap()); - if tracks.is_empty() { + let local_rows = std::mem::take(&mut *DRAGGED_LOCAL_ROWS.lock().unwrap()); + if tracks.is_empty() && local_rows.is_empty() { return; } + + // LOCAL-mode payload: Explorer / Local Library rows, resolved to + // source-aware refs at drop time and routed through the picker's own + // add matrix — the "same guardrails" contract: a library row id can + // never reach a Qobuz endpoint, a Plex/remote row rides its server key. + if !local_rows.is_empty() { + let runtime = app(); + let pid = pid.clone(); + spawn(async move { + let rows = tokio::task::spawn_blocking(move || { + local_bulk::resolve_track_rows_blocking(&local_rows) + }) + .await + .unwrap_or_default(); + if rows.is_empty() { + log::warn!("[qbz-qt][drag] local drop resolved no rows; ignored"); + return; + } + if queue_slot >= 0 { + let was_empty = runtime + .core() + .get_queue_state_full() + .await + .upcoming + .is_empty() + && runtime.core().current_track().await.is_none(); + let mut landed = false; + for (n, row) in rows.into_iter().enumerate() { + let qt = local_playback::local_queue_track(&row); + let slot = queue_slot as usize + n; + match queue_qt::insert_dragged_queue_track(&runtime, qt, slot).await { + Ok(()) => landed = true, + Err(e) => log::error!( + "[qbz-qt][drag] queue insert of local row {} at {slot} failed: {e}", + row.id + ), + } + } + if was_empty && landed { + queue_qt::set_drop_play_prompt(true); + } + return; + } + if pid.is_empty() { + return; + } + let refs: Vec = rows + .iter() + .map(local_playlist_qt::local_picker_ref_for_track) + .collect(); + playlist_picker_qt::add_dropped_payload_to_target( + &runtime, + &pid, + playlist_picker_qt::Payload::LocalRefs(refs), + ) + .await; + }); + return; + } + // The QUEUE target wins when both are claimed. They cannot both be under // the pointer geometrically, so this only decides a stale claim — and the // queue one is the more recent, since the panel overlays the sidebar. diff --git a/crates/qbz-qt/src/queue_qt.rs b/crates/qbz-qt/src/queue_qt.rs index bfffa77dc..11df787ee 100644 --- a/crates/qbz-qt/src/queue_qt.rs +++ b/crates/qbz-qt/src/queue_qt.rs @@ -1282,12 +1282,20 @@ pub async fn insert_dragged_track( // filters now (F2 + D5), so one row in can legitimately be ZERO rows out — // and a DRAG of a pulled track into the queue would have PANICKED. The seam // has already toasted the reason. - let Some(qt) = crate::playback_qt::stamped( - vec![crate::playback_qt::queue_track_for(runtime, track_id).await?], - None, - ) - .pop() else { - log::info!("[qbz-qt] queue: dragged track {track_id} was filtered out"); + let qt = crate::playback_qt::queue_track_for(runtime, track_id).await?; + insert_dragged_queue_track(runtime, qt, to_slot).await +} + +/// The same drop, from an already-built `QueueTrack` — the LOCAL-mode drag's +/// arm (Explorer / Local Library rows), and the shared tail of the Qobuz one. +pub async fn insert_dragged_queue_track( + runtime: &Arc>, + track: qbz_models::QueueTrack, + to_slot: usize, +) -> Result<(), String> { + let id = track.id; + let Some(qt) = crate::playback_qt::stamped(vec![track], None).pop() else { + log::info!("[qbz-qt] queue: dragged track {id} was filtered out"); return Ok(()); }; diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index 40e1bb3f7..27a0f6d1d 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -714,6 +714,18 @@ pub mod qbz_shell { y: f32, inline_visual: bool, ); + /// LOCAL-mode twin: a Local Library / Explorer row enters the shared + /// drag by ROW ID (resolved to source-aware refs at drop time). + #[qinvokable] + fn drag_start_local( + self: Pin<&mut QbzShell>, + row_id: QString, + title: QString, + subtitle: QString, + x: f32, + y: f32, + inline_visual: bool, + ); #[qinvokable] fn drag_move(self: Pin<&mut QbzShell>, x: f32, y: f32); /// A sidebar playlist row claims / releases the drop target. @@ -1449,6 +1461,25 @@ impl qbz_shell::QbzShell { ); } + pub fn drag_start_local( + self: Pin<&mut Self>, + row_id: QString, + title: QString, + subtitle: QString, + x: f32, + y: f32, + inline_visual: bool, + ) { + crate::drag_start_local( + row_id.to_string(), + title.to_string(), + subtitle.to_string(), + x, + y, + inline_visual, + ); + } + pub fn drag_move(self: Pin<&mut Self>, x: f32, y: f32) { crate::drag_move(x, y); }