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:
8 files changed
+490
-7
No files matched your search
@@ -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 || "",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user