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:
5 files changed
+434
-16
No files matched your search
@@ -3245,6 +3245,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let c = controller.clone();
|
let c = controller.clone();
|
||||||
qs.on_remove_upcoming(move |index| c.remove_upcoming(index.max(0) as usize));
|
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();
|
let c = controller.clone();
|
||||||
qs.on_clear_queue(move || c.clear());
|
qs.on_clear_queue(move || c.clear());
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
use qbz_app::shell::AppRuntime;
|
use qbz_app::shell::AppRuntime;
|
||||||
use qconnect_app::queue_resolution::{
|
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,
|
resolve_controller_queue_item_from_snapshots, resolve_queue_item_ids_from_queue_state,
|
||||||
QconnectRemoteSkipDirection,
|
QconnectRemoteSkipDirection,
|
||||||
};
|
};
|
||||||
@@ -29,8 +30,9 @@ use qconnect_app::{
|
|||||||
build_effective_renderer_snapshot, ensure_session_renderer_state, is_local_renderer_active,
|
build_effective_renderer_snapshot, ensure_session_renderer_state, is_local_renderer_active,
|
||||||
is_peer_renderer_active, renderer_allows_remote_volume, QConnectQueueState,
|
is_peer_renderer_active, renderer_allows_remote_volume, QConnectQueueState,
|
||||||
QConnectRendererState, QconnectApp, QconnectAppEvent, QconnectEventSink,
|
QConnectRendererState, QconnectApp, QconnectAppEvent, QconnectEventSink,
|
||||||
QconnectFileAudioQualitySnapshot, QconnectLifecycleState, QconnectRemoteSyncState,
|
queue_item_snapshot_for_cursor, QconnectFileAudioQualitySnapshot, QconnectLifecycleState,
|
||||||
QconnectSessionState, QueueCommandType, RendererReport, RendererReportType, SessionLoopHost,
|
QconnectRemoteSyncState, QconnectSessionState, QueueCommandType, RendererReport,
|
||||||
|
RendererReportType, SessionLoopHost,
|
||||||
};
|
};
|
||||||
use qconnect_transport_ws::{NativeWsTransport, WsTransportConfig};
|
use qconnect_transport_ws::{NativeWsTransport, WsTransportConfig};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -212,6 +214,120 @@ pub struct SlintQconnectService {
|
|||||||
last_pushed_queue_ids: Mutex<Option<Vec<u64>>>,
|
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 {
|
impl SlintQconnectService {
|
||||||
pub fn new(runtime: Runtime, window: slint::Weak<AppWindow>) -> Self {
|
pub fn new(runtime: Runtime, window: slint::Weak<AppWindow>) -> Self {
|
||||||
let saved_name = load_persisted_device_name();
|
let saved_name = load_persisted_device_name();
|
||||||
@@ -1668,6 +1784,65 @@ impl SlintQconnectService {
|
|||||||
Ok(true)
|
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
|
/// Set volume on the active PEER renderer. Special case: if the renderer
|
||||||
/// disallows remote volume, return `Ok(true)` (handled no-op) so the
|
/// disallows remote volume, return `Ok(true)` (handled no-op) so the
|
||||||
/// frontend does NOT fall back to local volume. Mirrors the Tauri
|
/// frontend does NOT fall back to local volume. Mirrors the Tauri
|
||||||
|
|||||||
@@ -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.
|
/// 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
|
/// When a search is active the queue-wide index is found by matching
|
||||||
/// the filtered row back against the unfiltered upcoming list.
|
/// the filtered row back against the unfiltered upcoming list.
|
||||||
|
|||||||
@@ -125,9 +125,20 @@ component QueueRow inherits Rectangle {
|
|||||||
callback clicked();
|
callback clicked();
|
||||||
callback remove();
|
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
|
// Compact rows with a subtle zebra so they read as distinct without the
|
||||||
// tall, airy spacing they had before.
|
// tall, airy spacing they had before.
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
// Dim the row that is being dragged.
|
||||||
|
opacity: root.drag-source ? 0.4 : 1.0;
|
||||||
background: ta.has-hover
|
background: ta.has-hover
|
||||||
? Theme.surface-hover
|
? Theme.surface-hover
|
||||||
: (Math.mod(root.index, 2) == 1
|
: (Math.mod(root.index, 2) == 1
|
||||||
@@ -141,8 +152,46 @@ component QueueRow inherits Rectangle {
|
|||||||
// menu-button clicks — making the [...] just skip to the track.
|
// menu-button clicks — making the [...] just skip to the track.
|
||||||
ta := TouchArea {
|
ta := TouchArea {
|
||||||
mouse-cursor: pointer;
|
mouse-cursor: pointer;
|
||||||
|
property <length> down-y;
|
||||||
|
property <bool> dragging: false;
|
||||||
clicked => {
|
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:
|
property <bool> queue-empty:
|
||||||
!QueueState.has-current && QueueState.upcoming-total == 0;
|
!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
|
// 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
|
// 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).
|
// snapshot on `init` (the queue may have changed/been restored while closed).
|
||||||
@@ -359,6 +438,16 @@ export component QueueSidebar inherits Rectangle {
|
|||||||
|
|
||||||
// ===== Queue tab =====
|
// ===== Queue tab =====
|
||||||
if QueueState.tab == 0: Flickable {
|
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-width: self.width;
|
||||||
viewport-height: queue-body.preferred-height;
|
viewport-height: queue-body.preferred-height;
|
||||||
|
|
||||||
@@ -479,21 +568,54 @@ export component QueueSidebar inherits Rectangle {
|
|||||||
font-weight: Typography.semibold;
|
font-weight: Typography.semibold;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
// Page of upcoming rows.
|
// Page of upcoming rows + drop-indicator overlay. Explicit
|
||||||
VerticalLayout {
|
// height (rows are a fixed 44px, spacing 0) so the wrapping
|
||||||
spacing: 0px;
|
// Rectangle does not collapse — a VerticalLayout does not
|
||||||
for item[idx] in QueueState.upcoming-page: QueueRow {
|
// auto-size its parent Rectangle.
|
||||||
item: item;
|
Rectangle {
|
||||||
index: QueueState.page-start - 1 + idx;
|
height: QueueState.upcoming-page.length * 44px;
|
||||||
show-number: true;
|
VerticalLayout {
|
||||||
removable: true;
|
spacing: 0px;
|
||||||
clicked => {
|
for item[idx] in QueueState.upcoming-page: QueueRow {
|
||||||
QueueState.play-upcoming(idx);
|
item: item;
|
||||||
}
|
index: QueueState.page-start - 1 + idx;
|
||||||
remove => {
|
page-local-index: idx;
|
||||||
QueueState.remove-upcoming(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
|
// Paginator — plain text controls, no pills. Only
|
||||||
// shown when the upcoming list spans >1 page.
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2071,6 +2071,11 @@ export global QueueState {
|
|||||||
callback play-history(int);
|
callback play-history(int);
|
||||||
// Remove the upcoming entry at `index` within the current page slice.
|
// Remove the upcoming entry at `index` within the current page slice.
|
||||||
callback remove-upcoming(int);
|
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.
|
// Empty the queue.
|
||||||
callback clear-queue();
|
callback clear-queue();
|
||||||
// Toggle the favorite state of the now-playing track.
|
// Toggle the favorite state of the now-playing track.
|
||||||
|
|||||||
Reference in new issue
Block a user