feat(qbz-slint): queue drag-and-drop reorder (WS-authoritative when connected)

Add drag-and-drop reordering to the Queue sidebar: a per-row drag handle, a
floating drag ghost, and a drop indicator. When a QConnect transport is
connected the reorder is routed to the cloud (CtrlSrvrQueueReorderTracks) so
order stays WS-authoritative; otherwise it moves the local queue track via
core().move_track. The Flickable is non-interactive so a stale pressed_time
cannot strand the gesture and leave phantom scroll after release.

cc
This commit is contained in:
vicrodh committed 2026-06-05 21:09:33 -06:00
1 parent 02fdd94c46
commit 5bc6552e98
5 files changed
+434 -16

No files matched your search

+6
View File
@@ -3245,6 +3245,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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());
+177 -2
View File
@@ -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<Option<Vec<u64>>>,
}
/// 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<u64>,
upcoming_qids: Vec<u64>,
}
/// 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<Value> {
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<AppWindow>) -> 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<bool, String> {
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
+75
View File
@@ -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 sits
// before page-local row k; slot==page_len appends after the last
// visible row (one past it in queue-wide upcoming space).
let page_len = this.current_page_len().await;
let to_q = if to_slot >= 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.
+171 -14
View File
@@ -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 <int> page-local-index;
in property <bool> 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 <length> down-y;
property <bool> 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 <bool> 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 <bool> reorder-active: false;
property <int> reorder-from: -1; // page-local index of the dragged row
property <int> 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 <length> reorder-list-top;
// Live pointer window-Y during the drag, for positioning the floating ghost.
property <length> 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;
}
}
}
}
}
+5
View File
@@ -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.