diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 8de151af8..6b700f7e5 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -3245,6 +3245,12 @@ fn main() -> Result<(), Box> { let c = controller.clone(); qs.on_remove_upcoming(move |index| c.remove_upcoming(index.max(0) as usize)); } + { + let c = controller.clone(); + qs.on_reorder(move |from, to| { + c.reorder(from.max(0) as usize, to.max(0) as usize); + }); + } { let c = controller.clone(); qs.on_clear_queue(move || c.clear()); diff --git a/crates/qbz-slint/src/qconnect_service.rs b/crates/qbz-slint/src/qconnect_service.rs index d14ef976f..e732a9540 100644 --- a/crates/qbz-slint/src/qconnect_service.rs +++ b/crates/qbz-slint/src/qconnect_service.rs @@ -19,6 +19,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use qbz_app::shell::AppRuntime; use qconnect_app::queue_resolution::{ + find_cursor_index_by_queue_item_id, find_cursor_index_by_track_id, ordered_queue_cursors, resolve_controller_queue_item_from_snapshots, resolve_queue_item_ids_from_queue_state, QconnectRemoteSkipDirection, }; @@ -29,8 +30,9 @@ use qconnect_app::{ build_effective_renderer_snapshot, ensure_session_renderer_state, is_local_renderer_active, is_peer_renderer_active, renderer_allows_remote_volume, QConnectQueueState, QConnectRendererState, QconnectApp, QconnectAppEvent, QconnectEventSink, - QconnectFileAudioQualitySnapshot, QconnectLifecycleState, QconnectRemoteSyncState, - QconnectSessionState, QueueCommandType, RendererReport, RendererReportType, SessionLoopHost, + queue_item_snapshot_for_cursor, QconnectFileAudioQualitySnapshot, QconnectLifecycleState, + QconnectRemoteSyncState, QconnectSessionState, QueueCommandType, RendererReport, + RendererReportType, SessionLoopHost, }; use qconnect_transport_ws::{NativeWsTransport, WsTransportConfig}; use serde_json::{json, Value}; @@ -212,6 +214,120 @@ pub struct SlintQconnectService { last_pushed_queue_ids: Mutex>>, } +/// Slint-local mirror of the Tauri `QconnectVisibleQueueProjection` reduced to +/// the pieces the reorder payload needs: the current track's queue_item_id (the +/// anchor) and the ordered upcoming queue_item_ids. Built from the cloud queue + +/// renderer snapshot via the shared `qconnect-app` cursor helpers (no Tauri-local +/// code). Stores only ids so it needs no `qconnect-core::QueueItem` import. +struct VisibleUpcomingProjection { + current_track_qid: Option, + upcoming_qids: Vec, +} + +/// Rebuild the visible upcoming projection (current anchor + ordered upcoming +/// queue_item_ids) from a cloud queue + renderer snapshot, mirroring the Tauri +/// `build_visible_queue_projection` using the SHARED qconnect-app cursor helpers. +fn build_visible_upcoming_projection( + queue: &QConnectQueueState, + renderer: &QConnectRendererState, +) -> VisibleUpcomingProjection { + let cursors = ordered_queue_cursors(queue); + + let current_index = find_cursor_index_by_queue_item_id( + &cursors, + queue, + renderer.current_track.as_ref().map(|i| i.queue_item_id), + ) + .or_else(|| { + find_cursor_index_by_track_id( + &cursors, + queue, + renderer.current_track.as_ref().map(|i| i.track_id), + ) + }); + + let next_index = find_cursor_index_by_queue_item_id( + &cursors, + queue, + renderer.next_track.as_ref().map(|i| i.queue_item_id), + ) + .or_else(|| { + find_cursor_index_by_track_id( + &cursors, + queue, + renderer.next_track.as_ref().map(|i| i.track_id), + ) + }); + + // (current_track, start_index): the upcoming list starts AFTER the current + // track; if the current is unknown but the next is, infer the current from + // the cursor before next. Mirrors the Tauri projection. + let (current_track_qid, start_index) = if let Some(index) = current_index { + ( + queue_item_snapshot_for_cursor(queue, cursors[index]).map(|i| i.queue_item_id), + index.saturating_add(1), + ) + } else if let Some(index) = next_index { + let inferred = index + .checked_sub(1) + .and_then(|c| cursors.get(c).copied()) + .and_then(|cur| queue_item_snapshot_for_cursor(queue, cur)) + .map(|i| i.queue_item_id); + (inferred, index) + } else { + (None, 0) + }; + + let upcoming_qids = cursors + .into_iter() + .skip(start_index) + .filter_map(|cur| queue_item_snapshot_for_cursor(queue, cur)) + .map(|i| i.queue_item_id) + .collect(); + + VisibleUpcomingProjection { + current_track_qid, + upcoming_qids, + } +} + +/// 1:1 port of the Tauri `build_qconnect_reorder_payload`. `from_index`/ +/// `to_index` index INTO the visible upcoming list. None when out of range, +/// `Some({})` for a no-op, else the wire payload (moved id + insert_after anchor). +fn build_reorder_payload( + projection: &VisibleUpcomingProjection, + from_index: usize, + to_index: usize, +) -> Option { + let len = projection.upcoming_qids.len(); + if from_index >= len || to_index >= len { + return None; + } + if from_index == to_index { + return Some(json!({})); + } + + let mut ids = projection.upcoming_qids.clone(); + let moved = ids.remove(from_index); + let insert_position = if from_index < to_index { + to_index.saturating_sub(1) + } else { + to_index + }; + let insert_after = if insert_position == 0 { + projection.current_track_qid + } else { + ids.get(insert_position - 1).copied() + }; + + Some(json!({ + "queue_item_ids": [moved as i64], + "insert_after": insert_after.map(|v| v as i64), + "autoplay_reset": false, + "autoplay_loading": false, + })) +} + impl SlintQconnectService { pub fn new(runtime: Runtime, window: slint::Weak) -> Self { let saved_name = load_persisted_device_name(); @@ -1668,6 +1784,65 @@ impl SlintQconnectService { Ok(true) } + /// Reorder the upcoming queue through the CLOUD whenever connected (renderer + /// OR controller) — like Tauri `v2_move_queue_track` (gate = transport_connected, + /// NOT peer-active; queue order is cloud-owned). WS-AUTHORITATIVE: QBZ sends + /// only `{queue_item_ids:[moved], insert_after}`; the cloud reorders and echoes + /// a QueueUpdated that materialize applies. The local `move_track` path runs + /// ONLY when NOT connected (this returns `Ok(false)` then). + /// + /// `from_q` / `to_q` are queue-wide UPCOMING indices (0 = first upcoming). + pub async fn reorder_upcoming_if_remote( + &self, + from_q: usize, + to_q: usize, + ) -> Result { + if !self.transport_connected().await { + return Ok(false); + } + // UN-gated snapshot (Some even when QBZ itself is the active renderer), + // matching toggle_shuffle_if_remote. + let Some((renderer, queue, session)) = self.effective_active_renderer_snapshot().await? + else { + // Connected but no active renderer yet — handled no-op (do NOT fall + // through to a local reorder that would diverge from the cloud). + return Ok(true); + }; + + let projection = build_visible_upcoming_projection(&queue, &renderer); + let len = projection.upcoming_qids.len(); + if len == 0 { + return Ok(true); + } + // Clamp into the projection's [0, len) index space (the core path may pass + // to_q == len for an append-to-end slot). + let from_index = from_q.min(len - 1); + let to_index = to_q.min(len - 1); + if from_index == to_index { + return Ok(true); + } + + let Some(payload) = build_reorder_payload(&projection, from_index, to_index) else { + return Ok(true); // out of range / nothing to do — handled + }; + + self.send_command(QueueCommandType::CtrlSrvrQueueReorderTracks, payload) + .await?; + + log::info!( + "[QConnect] reorder upcoming {from_index} -> {to_index} (cloud, active={:?})", + session.active_renderer_id + ); + dev_push_event( + &self.window, + format!( + "reorder {from_index} -> {to_index} (active={:?})", + session.active_renderer_id + ), + ); + Ok(true) + } + /// Set volume on the active PEER renderer. Special case: if the renderer /// disallows remote volume, return `Ok(true)` (handled no-op) so the /// frontend does NOT fall back to local volume. Mirrors the Tauri diff --git a/crates/qbz-slint/src/queue.rs b/crates/qbz-slint/src/queue.rs index ec74985c2..6f8b98b22 100644 --- a/crates/qbz-slint/src/queue.rs +++ b/crates/qbz-slint/src/queue.rs @@ -362,6 +362,81 @@ impl QueueController { }); } + /// Reorder the upcoming list: move the page-local row `from_page` to the + /// insertion slot `to_slot` (0..=page_len). Resolves BOTH to queue-wide + /// upcoming indices (honoring the search filter), then routes: connected -> + /// the QConnect cloud (WS-authoritative, the cloud owns queue order); offline + /// -> core `move_track` (already shuffle-aware) + refresh. + pub fn reorder(&self, from_page: usize, to_slot: usize) { + let this = self.clone(); + self.handle.spawn(async move { + let Some(from_q) = this.resolve_upcoming_index(from_page).await else { + log::warn!("[qbz-slint] queue: reorder from {from_page} out of range"); + return; + }; + // `to_slot` is an insertion slot in [0, page_len]. Slot k= page_len { + match this.resolve_upcoming_index(page_len.saturating_sub(1)).await { + Some(last) => last + 1, + None => return, + } + } else { + match this.resolve_upcoming_index(to_slot).await { + Some(idx) => idx, + None => return, + } + }; + if from_q == to_q { + return; + } + + // Connected -> the cloud reorders and echoes a QueueUpdated that + // materialize applies; do NOT also reorder locally (would diverge). + if let Some(svc) = crate::qconnect_service::service() { + match svc.reorder_upcoming_if_remote(from_q, to_q).await { + Ok(true) => return, + Ok(false) => {} // not connected -> local path + Err(e) => { + log::warn!("[qbz-slint] queue: reorder handoff failed: {e}"); + return; // connected but errored -> do NOT local-reorder + } + } + } + + this.runtime.core().move_track(from_q, to_q).await; + this.refresh_async().await; + }); + } + + /// Number of upcoming rows on the current (filtered) page — used to detect + /// the "append after last" insertion slot in `reorder`. + async fn current_page_len(&self) -> usize { + let (search, page) = self + .view + .lock() + .map(|v| (v.search.clone(), v.page)) + .unwrap_or_default(); + let query = search.trim().to_lowercase(); + let state = self.runtime.core().get_queue_state_full().await; + let total = if query.is_empty() { + state.upcoming.len() + } else { + state + .upcoming + .iter() + .filter(|t| { + display_title(t).to_lowercase().contains(&query) + || t.artist.to_lowercase().contains(&query) + }) + .count() + }; + let bounds = paginate(total, page); + bounds.end - bounds.start + } + /// Resolve a page-local upcoming index to a queue-wide upcoming index. /// When a search is active the queue-wide index is found by matching /// the filtered row back against the unfiltered upcoming list. diff --git a/crates/qbz-slint/ui/shell/QueueSidebar.slint b/crates/qbz-slint/ui/shell/QueueSidebar.slint index a3c141548..e576ea825 100644 --- a/crates/qbz-slint/ui/shell/QueueSidebar.slint +++ b/crates/qbz-slint/ui/shell/QueueSidebar.slint @@ -125,9 +125,20 @@ component QueueRow inherits Rectangle { callback clicked(); callback remove(); + // Drag-reorder (UP NEXT only). `page-local-index` is the 0-based row within + // the visible page (NOT the page-global display `index`). The parent owns the + // list-level drag state; the row reports gestures in WINDOW coords. + in property page-local-index; + in property drag-source: false; // this row is the one being dragged + callback drag-begin(int /* page-local index */, length /* row window-top */); + callback drag-update(length /* pointer window-y */); + callback drag-end(); + // Compact rows with a subtle zebra so they read as distinct without the // tall, airy spacing they had before. height: 44px; + // Dim the row that is being dragged. + opacity: root.drag-source ? 0.4 : 1.0; background: ta.has-hover ? Theme.surface-hover : (Math.mod(root.index, 2) == 1 @@ -141,8 +152,46 @@ component QueueRow inherits Rectangle { // menu-button clicks — making the [...] just skip to the track. ta := TouchArea { mouse-cursor: pointer; + property down-y; + property dragging: false; clicked => { - root.clicked(); + // Suppress the release-click that ends a reorder (must not also play). + if (!ta.dragging) { + root.clicked(); + } + } + pointer-event(e) => { + if (e.kind == PointerEventKind.down) { + // absolute-position double-counts this row's own layout offset + // inside the Flickable; subtract root.{x,y} to recover the true + // window Y (scroll-safe). See reference_slint_absolute_position_flickable. + ta.down-y = root.absolute-position.y - root.y + ta.mouse-y; + ta.dragging = false; + } + if (e.kind == PointerEventKind.up && ta.dragging) { + root.drag-end(); + ta.dragging = false; + } + } + // Safety net: a Flickable/ListView can steal the grab mid-drag, so `up` + // may never arrive. `pressed` flips false on release AND grab-cancel. + changed pressed => { + if (!self.pressed && ta.dragging) { + root.drag-end(); + ta.dragging = false; + } + } + moved => { + if (ta.pressed) { + if (!ta.dragging + && Math.abs(root.absolute-position.y - root.y + ta.mouse-y - ta.down-y) > 6px) { + ta.dragging = true; + root.drag-begin(root.page-local-index, root.absolute-position.y - root.y); + } + if (ta.dragging) { + root.drag-update(root.absolute-position.y - root.y + ta.mouse-y); + } + } } } @@ -289,6 +338,36 @@ export component QueueSidebar inherits Rectangle { property queue-empty: !QueueState.has-current && QueueState.upcoming-total == 0; + // --- UP NEXT drag-reorder state (component-local; NOT the DragState global, + // which drives the track->playlist ghost drag). All indices are PAGE-LOCAL. + property reorder-active: false; + property reorder-from: -1; // page-local index of the dragged row + property reorder-over: -1; // insertion slot 0..N + // Window-Y of the upcoming list's first-row top, captured exactly at + // drag-begin (row top - row*44px); pointer-Y is converted to a slot against it. + property reorder-list-top; + // Live pointer window-Y during the drag, for positioning the floating ghost. + property reorder-pointer-y; + + // Pointer window-Y -> insertion slot [0..N]. Rows are a fixed 44px stacked + // with 0 spacing; the +0.5 row rounds the boundary to row centers (top half + // of row k -> slot k, bottom half -> slot k+1). + pure function slot-from-pointer(win-y: length) -> int { + return Math.max(0, Math.min(QueueState.upcoming-page.length, + Math.floor((win-y - root.reorder-list-top) / 44px + 0.5))); + } + function commit-reorder() { + root.reorder-active = false; + // Fire only a real move. `to == from` and `to == from+1` both drop back + // onto the same gap (no-op), matching move_track's from==to early return. + if (root.reorder-over != root.reorder-from + && root.reorder-over != root.reorder-from + 1) { + QueueState.reorder(root.reorder-from, root.reorder-over); + } + root.reorder-from = -1; + root.reorder-over = -1; + } + // The panel is conditionally mounted (`if queue-open` at the AppShell mount // site, ADR-010), so it is CREATED each time it opens — pull a fresh queue // snapshot on `init` (the queue may have changed/been restored while closed). @@ -359,6 +438,16 @@ export component QueueSidebar inherits Rectangle { // ===== Queue tab ===== if QueueState.tab == 0: Flickable { + // Drag-scroll is DISABLED on purpose: a row drag-reorder lives + // inside this Flickable, and Slint 1.16 cannot freeze the scroll + // for the drag without stranding the Flickable's internal + // `pressed_time` (it swallows the `Released` while !interactive), + // which then phantom-scrolls on the next slide. Keeping it always + // non-interactive lets the row TouchArea own the grab cleanly and + // never enters that stuck state. Wheel scroll still works (the + // Flickable only short-circuits non-wheel events); pagination + // covers long lists. + interactive: false; viewport-width: self.width; viewport-height: queue-body.preferred-height; @@ -479,21 +568,54 @@ export component QueueSidebar inherits Rectangle { font-weight: Typography.semibold; letter-spacing: 0.5px; } - // Page of upcoming rows. - VerticalLayout { - spacing: 0px; - for item[idx] in QueueState.upcoming-page: QueueRow { - item: item; - index: QueueState.page-start - 1 + idx; - show-number: true; - removable: true; - clicked => { - QueueState.play-upcoming(idx); - } - remove => { - QueueState.remove-upcoming(idx); + // Page of upcoming rows + drop-indicator overlay. Explicit + // height (rows are a fixed 44px, spacing 0) so the wrapping + // Rectangle does not collapse — a VerticalLayout does not + // auto-size its parent Rectangle. + Rectangle { + height: QueueState.upcoming-page.length * 44px; + VerticalLayout { + spacing: 0px; + for item[idx] in QueueState.upcoming-page: QueueRow { + item: item; + index: QueueState.page-start - 1 + idx; + page-local-index: idx; + show-number: true; + removable: true; + drag-source: root.reorder-active && root.reorder-from == idx; + clicked => { + QueueState.play-upcoming(idx); + } + remove => { + QueueState.remove-upcoming(idx); + } + drag-begin(local-idx, row-top) => { + root.reorder-active = true; + root.reorder-from = local-idx; + root.reorder-over = local-idx; + root.reorder-list-top = row-top - local-idx * 44px; + } + drag-update(pointer-y) => { + root.reorder-over = root.slot-from-pointer(pointer-y); + root.reorder-pointer-y = pointer-y; + } + drag-end => { + root.commit-reorder(); + } } } + // Drop indicator: a 2px accent line at the insertion + // slot, hidden for the two no-op slots (from, from+1). + if root.reorder-active + && root.reorder-over != root.reorder-from + && root.reorder-over != root.reorder-from + 1: Rectangle { + x: 0; + y: root.reorder-over * 44px - 1px; + width: parent.width; + height: 2px; + background: Theme.accent; + border-radius: 1px; + } } // Paginator — plain text controls, no pills. Only // shown when the upcoming list spans >1 page. @@ -708,5 +830,40 @@ export component QueueSidebar inherits Rectangle { } } } + + } + + // Floating drag ghost — a pill following the pointer with the dragged track's + // title, so it's clear something is being moved. A free-positioned child of + // the panel ROOT (NOT a layout child — a VerticalLayout would ignore its x/y + // and stack it), declared last so it renders above the list. Converts the live + // pointer window-Y into this panel's local space. + if root.reorder-active + && root.reorder-from >= 0 + && root.reorder-from < QueueState.upcoming-page.length: Rectangle { + x: Spacing.md; + y: root.reorder-pointer-y - root.absolute-position.y - self.height / 2; + width: parent.width - 2 * Spacing.md; + height: 34px; + background: Theme.surface-elevated; + border-radius: Radius.sm; + border-width: 1px; + border-color: Theme.accent; + opacity: 0.95; + HorizontalLayout { + padding-left: Spacing.sm; + padding-right: Spacing.sm; + VerticalLayout { + alignment: center; + horizontal-stretch: 1; + Text { + text: QueueState.upcoming-page[root.reorder-from].title; + color: Theme.text-primary; + font-size: 12px; + font-weight: Typography.medium; + overflow: elide; + } + } + } } } diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 12a3f89f5..1f45e4071 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -2071,6 +2071,11 @@ export global QueueState { callback play-history(int); // Remove the upcoming entry at `index` within the current page slice. callback remove-upcoming(int); + // Reorder the upcoming list: move the page-local row `from` to the insertion + // slot `to` (0 = before the first upcoming row, N = after the last). The Rust + // controller resolves the page-local indices to queue-wide upcoming indices + // and routes to the cloud when connected (WS-authoritative). + callback reorder(int /* from */, int /* to */); // Empty the queue. callback clear-queue(); // Toggle the favorite state of the now-playing track.