feat(qt): queue drag-and-drop insert at position

Dragging rows onto the queue panel now lands them AT the pointed slot
(insertion line included) instead of appending: add_track + move_track
compose the position locally (QueueManager has no insert-at-index and
the manual-block adds would corrupt manual_next_count), and the
QConnect controller path routes through CtrlSrvrQueueInsertTracks'
insert_after so the position survives to the peer. Multi-row drops keep
their own order. Dropping onto an EMPTY queue raises a one-shot
'start playing?' prompt rather than hijacking a running queue or
leaving the user one click short.

cc
This commit is contained in:
vicrodh committed 2026-08-10 17:16:46 -06:00
1 parent 7754b8bc00
commit c198afb33c
8 files changed
+490 -7

No files matched your search

+45 -6
View File
@@ -945,9 +945,28 @@ Rectangle {
// Right press opens the SAME menu as ⋯ (rowMenu), at the pointer.
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: (root.clickPlays || root.selectMode) ? Qt.PointingHandCursor : Qt.ArrowCursor
// Keep the grab on a reorderable row so the enclosing Flickable
// cannot turn a reorder drag into a scroll — see the block above.
preventStealing: root.showReorder && root.draggable && !root.selectMode
// Keep the grab so the enclosing Flickable cannot turn our drag into a
// scroll — see the block above.
//
// TWO arms, because the two gestures need opposite trades:
//
// reorder rows — held from the PRESS. A reorder drag is vertical,
// i.e. the same axis as the scroll, so there is nothing to
// discriminate on and the list gives up drag-scrolling.
// every other row — held from the moment OUR drag arms (`dragging`).
// Until then the press belongs to the Flickable, so vertical
// drag-to-scroll still works; once armed the grab is ours and the
// pointer can leave the row.
//
// That second arm is the AlbumView bug (owner, 2026-08-10: "el evento
// dragging comienza pero muere cuando sale del track row"). Off a
// reorderable list this was plain `false`: our drag armed at 6px, the
// Flickable stole the grab at `startDragDistance` (~10px) a moment
// 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.showReorder && root.draggable && !root.selectMode)
|| root.dragging
onPressed: function (mouse) {
if (mouse.button === Qt.RightButton) {
// TrackRow.slint:201 — the row menu is suppressed in
@@ -966,9 +985,29 @@ Rectangle {
if (!pressed || !(pressedButtons & Qt.LeftButton) || !root.draggable
|| root.selectMode) return
const g = mapToItem(null, mouse.x, mouse.y)
if (!root.dragging
&& (Math.abs(mouse.x - root.downPos.x) > 6
|| Math.abs(mouse.y - root.downPos.y) > 6)) {
const dx = mouse.x - root.downPos.x
const dy = mouse.y - root.downPos.y
// ARMING, and it is axis-aware off a reorderable list.
//
// A reorder drag is VERTICAL — the same axis as the scroll — so
// there is nothing to discriminate on and that arm stays "6px in
// any direction" (the list has already given up drag-scrolling
// via `preventStealing`, see below).
//
// Everywhere else the gesture is "carry this row to the sidebar
// (or the queue)", which is HORIZONTAL, while drag-to-scroll is
// vertical. So they separate cleanly, and both survive: a
// vertical press is never claimed and the Flickable scrolls it; a
// horizontal one arms here and takes the grab.
//
// The reference's own note rejects an axis constraint — but it
// rejects it FOR REORDER, where it "separates nothing". Here it
// separates everything, which is why this arm exists and that one
// does not.
const armed = root.showReorder
? (Math.abs(dx) > 6 || Math.abs(dy) > 6)
: (Math.abs(dx) > 6 && Math.abs(dx) > Math.abs(dy))
if (!root.dragging && armed) {
root.dragging = true
root.bodyDragStarted(root.number)
QbzShell.dragStart(root.item.id, root.item.title || "",
+152
View File
@@ -136,6 +136,59 @@ Rectangle {
return n
}
// --- EXTERNAL drop: a track dragged in from a list (owner ask
// 2026-08-10, "draggear hacia el queue definiendo la posición") ---------
//
// Distinct from the panel-local reorder above, and deliberately so: that
// one moves a row that is ALREADY in the queue and commits through
// `queueMoveTrack`; this one inserts a row that is not, and commits
// through the SHARED drag (`QbzShell.dragEnd` -> `insert_dragged_track`).
// They share only `slotFromPointer`, which is the part that is genuinely
// the same question.
//
// Same self-detection shape as the sidebar's playlist rows: the target
// reads the shared window-coord pointer and claims or releases itself,
// because the mouse grab belongs to the row being dragged and no hover
// event ever reaches us.
property bool queueDropHot: false
function recomputeQueueDrop() {
// Never claim while the panel's OWN reorder is running, or a row being
// reordered inside the queue would also read as an insert.
if (!QbzShell.dragActive || root.reorderActive || !root.visible) {
if (root.queueDropHot) { QbzShell.dragSetOverQueue(-1); root.queueDropHot = false }
return
}
// The hot region is the UP NEXT LIST, not the whole panel. It used to
// be `root`, which meant the header, the now-playing card and the
// footer buttons all silently accepted a drop — landing it at slot 0
// or at the end depending on which side of the rows you happened to
// release over. A drop you did not aim is worse than a drop that does
// not take.
// EMPTY QUEUE is the one case where the whole panel is the target:
// the UP NEXT section is not mounted (`upcomingTotal > 0`), so there
// are no rows to aim at and the only possible slot is 0. Without this
// arm, narrowing the region to the list would have made a drop into
// an empty queue impossible — the very case the prompt exists for.
const host = upNextSection.visible ? upNextSection : root
const tl = host.mapToItem(null, 0, 0)
const inside = QbzShell.dragX >= tl.x && QbzShell.dragX <= tl.x + host.width
&& QbzShell.dragY >= tl.y && QbzShell.dragY <= tl.y + host.height
if (!inside) {
if (root.queueDropHot) { QbzShell.dragSetOverQueue(-1); root.queueDropHot = false }
return
}
// Re-publish on every move while inside: the slot changes as the
// pointer travels, and the insertion line reads it.
QbzShell.dragSetOverQueue(root.slotFromPointer(QbzShell.dragY))
root.queueDropHot = true
}
Connections {
target: QbzShell
function onDragXChanged() { root.recomputeQueueDrop() }
function onDragYChanged() { root.recomputeQueueDrop() }
function onDragActiveChanged() { root.recomputeQueueDrop() }
}
// Commit — QueueSidebar.slint commit-reorder(). `to == from` and
// `to == from + 1` both drop back onto the SAME gap, so neither reaches
// the bridge (move_track's from == to early return would swallow them
@@ -600,6 +653,29 @@ Rectangle {
radius: 1
color: theme.accent
}
// The SAME two lines for an EXTERNAL drop (a row dragged in from a
// track list). Driven by the shared `dragOverQueueIndex` instead of
// the panel-local `reorderOver`, and with none of the no-op-slot
// suppression: every slot is a real destination for a row that is not
// in the queue yet, including the two a reorder would ignore.
Rectangle {
visible: root.queueDropHot && QbzShell.dragOverQueueIndex === qrRoot.rowIndex
y: -1
width: parent.width
height: 2
radius: 1
color: theme.accent
}
Rectangle {
visible: root.queueDropHot
&& QbzShell.dragOverQueueIndex === root.upcoming.length
&& qrRoot.rowIndex === root.upcoming.length - 1
y: parent.height - 1
width: parent.width
height: 2
radius: 1
color: theme.accent
}
// --- Track info (rows/TrackRow.qml's lazy-Loader idiom) -------------
// A full Popup tree per delegate would cost 40 of them on a full page,
@@ -812,6 +888,7 @@ Rectangle {
// UP NEXT section.
Column {
id: upNextSection
visible: (doc.upcomingTotal || 0) > 0
width: parent.width - 20
spacing: 8
@@ -1313,4 +1390,79 @@ Rectangle {
elide: Text.ElideRight
}
}
// --- Empty-queue drop prompt (owner ask 2026-08-10) -------------------
// A drop is an ADD, never a play — dropping onto a running queue must not
// hijack it. But onto an EMPTY queue that leaves the user one click short
// of what they meant, so the panel asks. A flyout, not a modal: the
// question is low-stakes and belongs to this panel, and a full-window
// scrim for "start playing?" would be heavier than the decision.
//
// Rides the panel's top edge, under the header, and takes no input away
// from the list while hidden (`visible` gates the whole item).
Rectangle {
id: dropPlayPrompt
visible: QbzQueue.dropPlayPrompt
anchors.top: parent.top
anchors.topMargin: 92
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 32
height: promptCol.implicitHeight + 24
radius: theme.radiusMd
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
z: 50
// Swallow clicks so a press on the prompt never reaches a queue row.
MouseArea { anchors.fill: parent }
Column {
id: promptCol
x: 12
y: 12
width: parent.width - 24
spacing: 10
Text {
width: parent.width
text: QbzSession.tr("Start playing now?", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
wrapMode: Text.WordWrap
}
Row {
anchors.right: parent.right
spacing: 8
IconTextButton {
label: QbzSession.tr("Not now", QbzSession.trRev)
hasIcon: false
onClicked: QbzQueue.queueAnswerDropPrompt(false)
}
// Accent-filled primary, the QbzConfirmModal shape.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: playLabel.implicitWidth + 28
height: 32
radius: theme.radiusSm
color: playArea.containsMouse ? theme.accentHover : theme.accent
Text {
id: playLabel
anchors.centerIn: parent
text: QbzSession.tr("Play now", QbzSession.trRev)
color: theme.accentGlyphColor
font.pixelSize: 13
font.weight: theme.weightSemibold
}
MouseArea {
id: playArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzQueue.queueAnswerDropPrompt(true)
}
}
}
}
}
}
+53
View File
@@ -1550,6 +1550,10 @@ static DRAGGED: Mutex<Vec<u64>> = 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<String> = Mutex::new(String::new());
/// The QUEUE drop target: the upcoming SLOT the row would land on, or -1 for
/// "not over the queue". Mirrored Rust-side for the same reason `DRAG_OVER`
/// is — `drag_end` runs off the Qt thread and must not read the bridge.
static DRAG_OVER_QUEUE: Mutex<i32> = Mutex::new(-1);
pub(crate) fn drag_start(track_id: String, title: String, subtitle: String, x: f32, y: f32) {
let id = track_id.parse::<u64>().unwrap_or(0);
@@ -1562,6 +1566,7 @@ pub(crate) fn drag_start(track_id: String, title: String, subtitle: String, x: f
b.as_mut().set_drag_x(x);
b.as_mut().set_drag_y(y);
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);
});
}
@@ -1581,16 +1586,64 @@ pub(crate) fn drag_set_over(playlist_id: String) {
});
}
pub(crate) fn drag_set_over_queue(slot: i32) {
*DRAG_OVER_QUEUE.lock().unwrap() = slot;
shell_bridge::ui(move |mut b| {
b.as_mut().set_drag_over_queue_index(slot);
});
}
pub(crate) fn drag_end() {
let pid = std::mem::take(&mut *DRAG_OVER.lock().unwrap());
let queue_slot = std::mem::replace(&mut *DRAG_OVER_QUEUE.lock().unwrap(), -1);
shell_bridge::ui(|mut b| {
b.as_mut().set_drag_active(false);
b.as_mut().set_drag_over_playlist_id(QString::default());
b.as_mut().set_drag_over_queue_index(-1);
});
let tracks = std::mem::take(&mut *DRAGGED.lock().unwrap());
if tracks.is_empty() {
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.
if queue_slot >= 0 {
let runtime = app();
let ids = tracks;
spawn(async move {
// Was the queue empty BEFORE any of this landed? Read it up front:
// after the first insert it never is, and the answer decides
// whether the panel asks about playback (owner ask 2026-08-10).
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, id) in ids.into_iter().enumerate() {
// Multi-drag lands in order: each successive row goes one slot
// further down, so a 3-row drag keeps its own ordering instead
// of arriving reversed.
let slot = queue_slot as usize + n;
match queue_qt::insert_dragged_track(&runtime, id, slot).await {
Ok(()) => landed = true,
Err(e) => {
log::error!("[qbz-qt][drag] queue insert of {id} at {slot} failed: {e}")
}
}
}
// ONE prompt for the whole drop, and only when something actually
// landed — a drop whose every row failed to resolve must not ask
// about playing nothing.
if was_empty && landed {
queue_qt::set_drop_play_prompt(true);
}
});
return;
}
let Ok(pid) = pid.parse::<u64>() else {
// Not a Qobuz playlist target (a local-library playlist row or
// nothing) — the sidecar path is out of scope (POC-NOTE).
+4 -1
View File
@@ -1306,7 +1306,10 @@ async fn catalog_queue_track(
/// point outside the Library feed — search rows, the search hero, Home's
/// Recently-Played-Tracks rail — returned Err here and rendered a control that
/// did nothing.
async fn queue_track_for(
/// `pub(crate)` since 2026-08-10: `queue_qt::insert_dragged_track` resolves a
/// dragged track id through this same path, so a drop into the queue and an
/// "Add to queue" menu item cannot disagree about what a row is.
pub(crate) async fn queue_track_for(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track_id: u64,
) -> Result<QueueTrack, String> {
+90
View File
@@ -1593,6 +1593,96 @@ impl QtQconnectService {
true
}
/// Controller DROP-AT-POSITION routing: the queue drag-and-drop insert.
///
/// Written 2026-08-10 after the owner pushed back on "the peer protocol
/// has no insert-at-index, so the position cannot be honoured". That was
/// wrong twice over. `CtrlSrvrQueueInsertTracks` carries `insert_after`,
/// which is precisely an insert-at-position — it is the same field
/// play-next and play-later already steer with, as the comment on
/// `play_later_on_peer_if_active` says out loud ("The protocol has no
/// 'later' — the position is steered via `insert_after`"). And even
/// without it the local queue has no insert-at-index either, and that was
/// solved by composing; the peer has `CtrlSrvrQueueReorderTracks` for the
/// same composition. There was never a reason to append and shrug.
///
/// `slot` is an index into the VISIBLE upcoming list — the same space
/// `reorder_upcoming_if_remote` clamps into, and the same one the panel's
/// `slotFromPointer` produces — so the anchor is simply the row BEFORE it:
/// slot 0 -> the current track (land first in upcoming)
/// slot k -> `upcoming_qids[k - 1]`
/// past the end -> the last upcoming row (append)
///
/// Same admission + echo contract as its two siblings: peer must be the
/// active renderer, the track must be Qobuz-castable, and a sent insert is
/// recorded so the manual-block tail stays honest.
pub async fn insert_at_slot_on_peer_if_active(
&self,
track_id: u64,
source: Option<&str>,
slot: usize,
) -> bool {
if !self.is_peer_renderer_active().await {
return false;
}
if !self.is_track_castable(track_id, source) {
log::info!(
"[QConnect] insert_at_slot: track {track_id} not Qobuz-castable; refusing"
);
crate::toast_qt::error(qbz_i18n::t("Track not castable to Qobuz Connect"));
dev_push_event(format!("-> insert_at_slot REFUSED (non-Qobuz track {track_id})"));
return true;
}
let insert_after = self
.effective_remote_renderer_snapshot()
.await
.ok()
.flatten()
.and_then(|(renderer, queue, _session)| {
let projection = build_visible_upcoming_projection(&queue, &renderer);
let qid = if slot == 0 {
projection.current_track_qid
} else {
// Past the end clamps to the last row, which is the
// append anchor.
let idx = (slot - 1).min(projection.upcoming_qids.len().saturating_sub(1));
projection.upcoming_qids.get(idx).copied()
};
qid.and_then(|q| i64::try_from(q).ok())
});
let mut payload = json!({
"track_ids": [track_id as i64],
"context_uuid": Uuid::new_v4().to_string(),
"autoplay_reset": false,
"autoplay_loading": false,
});
if let Some(insert_after) = insert_after {
payload["insert_after"] = json!(insert_after);
}
match self
.send_command(QueueCommandType::CtrlSrvrQueueInsertTracks, payload)
.await
{
Ok(_) => {
log::info!(
"[QConnect] insert_at_slot: inserted track {track_id} at slot {slot} (after={insert_after:?})"
);
dev_push_event(format!(
"-> insert_at_slot QueueInsertTracks {track_id} slot={slot} after={insert_after:?}"
));
self.note_controller_insert(insert_after, &[track_id]).await;
}
Err(err) => {
log::warn!("[QConnect] insert_at_slot: insert failed: {err}");
// Still handled: a peer owns playback; never fall back to local.
}
}
true
}
/// Controller play-LATER routing for a MULTI-track batch (#442). Same
/// all-or-nothing admission as `play_next_batch_on_peer_if_active`; the
/// whole block lands after the manual-block tail in NATURAL order (the
+21
View File
@@ -54,6 +54,17 @@ pub mod qbz_queue {
// string nothing else reads. Two scalar properties instead — the same
// split the reference makes with its own `SleepTimerState` global.
// `sleep_remaining` is pre-formatted in Rust (single source of truth).
// "The queue was empty and you just dropped tracks into it — start
// playing?" (owner ask 2026-08-10). A drop is an ADD, never a play:
// `insert_dragged_track` deliberately does not start playback, because
// dropping onto a queue that is already running must not hijack it.
// On an EMPTY queue that leaves the user one click short of what they
// almost certainly meant, so the panel asks instead of guessing.
//
// Set only on the empty->filled transition, and cleared by either
// answer. A property rather than a signal so a prompt that is up when
// the panel remounts is still up afterwards.
#[qproperty(bool, drop_play_prompt)]
#[qproperty(bool, sleep_active)]
#[qproperty(QString, sleep_remaining)]
@@ -72,6 +83,10 @@ pub mod qbz_queue {
/// Row actions.
#[qinvokable]
fn queue_play_upcoming(self: Pin<&mut QbzQueue>, index: i32);
/// Answer the empty-queue drop prompt: `true` starts the queue from
/// its first row, `false` just dismisses.
#[qinvokable]
fn queue_answer_drop_prompt(self: Pin<&mut QbzQueue>, play: bool);
/// Immersive coverflow / up-next rows: play by QUEUE-WIDE 0-based
/// index into the UNFILTERED upcoming list (§4.4). `queuePlayUpcoming`
/// is PAGE-LOCAL (resolved through the filtered/paginated VIEW) —
@@ -129,6 +144,7 @@ pub struct QbzQueueRust {
queue_model: QListQVariant,
queue_json: QString,
coverflow_json: QString,
drop_play_prompt: bool,
sleep_active: bool,
sleep_remaining: QString,
}
@@ -141,6 +157,7 @@ impl Default for QbzQueueRust {
// Full-shape default (trap 15): QML reads `.tracks.length` in the
// pre-publish frame.
coverflow_json: QString::from(r#"{"index":0,"tracks":[]}"#),
drop_play_prompt: false,
sleep_active: false,
sleep_remaining: QString::default(),
}
@@ -176,6 +193,10 @@ impl qbz_queue::QbzQueue {
crate::queue_set_search(query.to_string());
}
pub fn queue_answer_drop_prompt(self: Pin<&mut Self>, play: bool) {
crate::queue_qt::answer_drop_prompt(play);
}
pub fn queue_play_upcoming(self: Pin<&mut Self>, index: i32) {
crate::queue_play_upcoming(index);
}
+102
View File
@@ -689,6 +689,108 @@ pub async fn move_track(
publish(runtime).await;
}
/// Ask the panel whether to start playing after an empty-queue drop, or clear
/// the ask. Split from the insert so a MULTI-row drop raises exactly one
/// prompt, after the last row has landed.
pub fn set_drop_play_prompt(on: bool) {
crate::queue_bridge::ui(move |mut b| b.as_mut().set_drop_play_prompt(on));
}
/// The prompt's two answers. `true` starts the queue from its first upcoming
/// row — the same entry point the row's own click uses, so a dropped-then-
/// played track goes through one path, not two.
pub fn answer_drop_prompt(play: bool) {
set_drop_play_prompt(false);
if !play {
return;
}
let runtime = crate::app();
crate::spawn(async move { play_upcoming_flat(&runtime, 0).await });
}
/// Drop a dragged track into the queue AT A POSITION (owner ask 2026-08-10:
/// "poder draggear hacia el queue, definiendo la posición desde el comienzo").
///
/// `to_slot` is a slot in the VISIBLE page of upcoming — the same coordinate
/// [`move_track`] takes — so search and pagination are honoured for free.
///
/// ## Why append-then-move rather than an insert
///
/// `QueueManager` has no insert-at-index, and the three adds it does have are
/// not interchangeable here:
/// `add_track_next` / `add_track_later` both bump `manual_next_count` and
/// place the row inside the manual block. Moving it afterwards would leave
/// that counter describing a track that is no longer there.
/// `add_track` is the clean one: a plain push onto `tracks` (plus the
/// shuffle-order push), no counters touched.
/// So: `add_track` to land at the END of upcoming, then one `move_track` into
/// place. Both are existing public API — nothing is added to the shared
/// `qbz-player` crate for this.
///
/// `move_track` takes UPCOMING-relative indices in both modes (the shuffle
/// branch maps them into `shuffle_order`; the plain branch adds
/// `current_index + 1`), which is why the page mapping below can be handed to
/// it directly. Moving UP (append -> earlier slot) takes its `Up` arm, so the
/// row lands exactly at `to_slot`, i.e. before the row that was there.
pub async fn insert_dragged_track(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track_id: u64,
to_slot: usize,
) -> Result<(), String> {
let qt = crate::playback_qt::stamped(
vec![crate::playback_qt::queue_track_for(runtime, track_id).await?],
None,
)
.pop()
.expect("one track in, one track out");
// QConnect CONTROLLER mode (contract §7): the peer owns the queue, so the
// insert is routed there — AT THE DROPPED POSITION. `CtrlSrvrQueueInsertTracks`
// carries `insert_after`, the same field play-next and play-later steer
// with, so the slot survives the routing instead of collapsing to an
// append. `slot` is already in the visible-upcoming space the peer
// projection uses, so it crosses unchanged.
if let Some(svc) = crate::qconnect_qt::service() {
if svc
.insert_at_slot_on_peer_if_active(track_id, qt.source.as_deref(), to_slot)
.await
{
return Ok(());
}
}
// Resolve the drop slot BEFORE the add, while the page still describes the
// queue the user was looking at. Past the last visible row means "after
// everything on this page".
let page_rows = current_page_indices(runtime).await;
let to_upcoming = match page_rows.get(to_slot) {
Some(&idx) => idx,
None => match page_rows.last() {
Some(&last) => last + 1,
None => 0,
},
};
let added_castable = crate::playback_qt::batch_all_qconnect_castable(std::slice::from_ref(&qt));
runtime.core().add_track(qt).await;
// The appended row is the last of upcoming; re-read rather than assume,
// because `add_track` lands relative to `tracks` and the upcoming window
// depends on `current_index`.
let upcoming_len = runtime.core().get_queue_state_full().await.upcoming.len();
if upcoming_len == 0 {
return Ok(());
}
let from_upcoming = upcoming_len - 1;
if from_upcoming != to_upcoming {
runtime.core().move_track(from_upcoming, to_upcoming).await;
}
crate::playback_qt::sync_qconnect_after_add(added_castable).await;
publish(runtime).await;
Ok(())
}
/// History replay (queue.rs play_history: a fresh single-track queue).
pub async fn play_history(runtime: &Arc<AppRuntime<LoggingAdapter>>, index: usize) {
let state = runtime.core().get_queue_state_full().await;
+23
View File
@@ -262,6 +262,17 @@ pub mod qbz_shell {
#[qproperty(f32, drag_x)]
#[qproperty(f32, drag_y)]
#[qproperty(QString, drag_over_playlist_id)]
// The QUEUE drop target: the upcoming-list SLOT the dragged row would
// be inserted at, or -1 when the pointer is not over the queue. Two
// separate claims rather than one tagged target, because they are
// mutually exclusive by geometry (the panel and the sidebar cannot
// both be under the pointer) and a slot is an int, not an id — folding
// them into one string would mean parsing a discriminator on every
// pointer move.
//
// The panel reads it to draw the insertion line, so it has to be a
// property and not just Rust-side state.
#[qproperty(i32, drag_over_queue_index)]
// Floating album-art preview over the now-playing bar's small cover
// (SongCard.slint:296 + ArtPreviewOverlay.slint). Anchor is in WINDOW
// coordinates: x = the cover's horizontal centre, y = its top edge.
@@ -465,6 +476,10 @@ pub mod qbz_shell {
/// A sidebar playlist row claims / releases the drop target.
#[qinvokable]
fn drag_set_over(self: Pin<&mut QbzShell>, playlist_id: QString);
/// Claim (>= 0) or release (-1) the queue as the drop target, with the
/// upcoming SLOT the row would land on.
#[qinvokable]
fn drag_set_over_queue(self: Pin<&mut QbzShell>, slot: i32);
/// Release: add the dragged track(s) to the over-playlist target.
#[qinvokable]
fn drag_end(self: Pin<&mut QbzShell>);
@@ -558,6 +573,7 @@ pub struct QbzShellRust {
drag_x: f32,
drag_y: f32,
drag_over_playlist_id: QString,
drag_over_queue_index: i32,
art_preview_show: bool,
art_preview_x: f32,
art_preview_y: f32,
@@ -639,6 +655,9 @@ impl Default for QbzShellRust {
drag_x: 0.0,
drag_y: 0.0,
drag_over_playlist_id: QString::default(),
// -1 = the pointer is not over the queue. NOT 0, which is a valid
// slot (drop at the very top of upcoming).
drag_over_queue_index: -1,
art_preview_show: false,
art_preview_x: 0.0,
art_preview_y: 0.0,
@@ -906,6 +925,10 @@ impl qbz_shell::QbzShell {
crate::drag_set_over(playlist_id.to_string());
}
pub fn drag_set_over_queue(self: Pin<&mut Self>, slot: i32) {
crate::drag_set_over_queue(slot);
}
pub fn drag_end(self: Pin<&mut Self>) {
crate::drag_end();
}