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();
|
||||
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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user