diff --git a/ANDROID.md b/ANDROID.md index 9a3dd21..ef29dd5 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -441,12 +441,54 @@ describe / pin / duplicate / delete, `playlist_add_items`, a file (the desktop's repair panel as a sheet: the library searched by the row's name, a pick relinks the row through `playlist_relink_item`; an unknown placeholder adopts the track's identity), Go to artist, - Remove from playlist; Move up, Move down and Move to the top reorder - without a drag (`playlist_set_order`, the whole order in one call, as the - desktop's drop does). + Remove from playlist; Move up, Move down and Move to the top reorder from + the sheet (`playlist_set_order`, the whole order in one call, as the + desktop's drop does) — the accessible path, and the one that stays honest + while the filter is on. A removal is undoable for seven seconds from the neutral toast (`notice.svelte.js` — the desktop's undo pill); the stash pins the playlist it came from. +- **Drag to reorder** (`src/mobile/reorder.js` — one Svelte action, on the + container, for the playlist page's rows and the player sheet's queue + alike). Press and hold a row for 320ms and it lifts (with a short tap in + the phone's motor), the rows below make room, and letting go lands it. + Release without having moved and the row's sheet opens exactly as a long + press always opened it — which matters most on the queue, where a row has + no ⋯ and `oncontextmenu` is the only door to Play now, Move to next and + Remove from queue. A press that moves more than 8px before the hold lands + is a scroll, and the gesture lets go of it. + Nothing carries `touch-action: none`: a flick that starts on a row still + scrolls the list. `touch-action` is latched at touchstart and cannot be + flipped mid-gesture, so the page's own scroll is refused instead by a + non-passive `touchmove` listener added at the lift — which works because + the finger has been still for 320ms and the browser has not started a + scroll yet. A click and a contextmenu guard sit on the container in the + CAPTURE phase while a lift is live (a released drag would otherwise fire + the row's own click and play the track, and Android's own 500ms long + press would open the sheet in the middle of the drag); both are off when + no lift is live, so a tap and a long press behave as they always have. + Every row is measured once at the lift, in the scroller's CONTENT space, + so the edge pull — within 64px of either edge, ramped, up to 14px a + frame — leaves the snapshot true and the row keeps up with the finger. + Each displaced row moves by the exact difference between its own top and + its neighbour's rather than by one assumed row height (the desktop's + `rowH`), because the queue face puts an album separator between rows. + The drop reuses the mutations the Move rows already use: `moveItem` and + `moveInQueue` each take a numeric target now as well as `"up"` / + `"down"` / `"top"`, so there is still exactly one call to + `playlist_set_order` with the whole id order. It is optimistic — the rows + go where you put them at once, and come back with the error toast if the + backend says no. Two refusals guard the middle of a gesture: the playlist + drops nothing if a refetch has replaced its rows (`pls.dragging` also + defers the page's own re-read while a drag is live, the phone's version + of the desktop's `skippedWhileDrag`), and the queue drops nothing if + `player.qi` has moved, because a track boundary shifts the whole rendered + window and the absolute indices would then address the wrong rows. + Dragging is off while the playlist's filter has anything in it — a + filtered list's visible order is not the stored order — and the note + under the field says so; the sheet's Move rows address rows by id and + stay correct under a filter, which is why they are the way to reorder + without clearing it. - **Add to library / Download all tracks** on the page's sheet — the desktop's bulk actions (`runBulk` in `playlists.svelte.js`, the rules verbatim): the first files every already-streamed track whose cache is @@ -522,7 +564,7 @@ describe / pin / duplicate / delete, `playlist_add_items`, itself (`adb shell am start -a android.intent.action.SEND …`). Not on the phone yet: the listener door (it needs -yt-dlp, a desktop tool), drag reorder, and Share on SoulStream. +yt-dlp, a desktop tool), and Share on SoulStream. ## The first run @@ -1146,14 +1188,18 @@ a catalog record, its Save and the Transfers burst (and the shade's notice: posted with a count while rows are in flight, gone when they are not) → Gaps → the playlist shelf and a page with every row kind (a local row, the stream row through -the resolver, the filter, a removal and its undo, unpin, rename, a new -playlist, Add to playlist from a record's sheet, delete) → the import doors +the resolver, the filter, a removal and its undo, a row dragged into place +and dragged back, a drag refused while the filter is on, a press released +without moving still opening the row's sheet, a drop the backend rejects +snapping back, unpin, rename, a new playlist, Add to playlist from a +record's sheet, delete) → the import doors (a pasted tracklist, a link with its catalog verification, pasted track links read a page each, a folder of audio files off the phone, a playlist file through the phone's own document picker — with that picker cancelled and a file it can hand no path for refused — and a share from another app) → the playlist repair (an unknown row relinked from the -library) → the artwork viewer → a queue row's sheet → a catalog song's, +library) → the artwork viewer → a queue row's sheet and a queue row +dragged into place → a catalog song's, a catalog card's and the record page's sheets → the now-playing Save → a peer's sheet, a raw file's sheet, a user's shares browsed and a folder of theirs streamed → a buddy's sheet → You (every switch and chip saved @@ -1190,7 +1236,7 @@ list, a null page), `offline(on)` takes the client down — everything the backend funnels through `with_client()` answers "not connected to Soulseek", the two connects answer the server string, and `soulseek:state` goes out — and `clear()` puts all of it back. They reject with a plain string because that is -what Tauri rejects with. Twelve mock steps drive them, and the phone walk carries +what Tauri rejects with. Fourteen mock steps drive them, and the phone walk carries one safe step of its own (`trouble-words`: eleven real backend strings through `__ss.say` on the device, every output asserted free of Rust). A mock step can also set the @@ -1259,10 +1305,10 @@ symptom. - **Off by design — external runtimes.** yt-dlp and deno, the vocal separator, the lyric alignment models, and the listener door that needs them. -- **Still desktop-only, smaller.** Drag reorder (Move up / down / to the top - stand in), catalog track previews, the full discography page, and the - fused-artist portrait verdict — that last one needs the identity merge the - phone does not run, so porting it means porting the merge first. +- **Still desktop-only, smaller.** Catalog track previews, the full + discography page, and the fused-artist portrait verdict — that last one + needs the identity merge the phone does not run, so porting it means + porting the merge first. - **Per library folder.** Follows and the play history live in the library folder, so the phone's own folder starts empty of both; they arrive with a shared library. diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte index e2da71f..6611b33 100644 --- a/src/mobile/HalfSheet.svelte +++ b/src/mobile/HalfSheet.svelte @@ -380,7 +380,7 @@ {@render row("play", "Play now", "", act(() => playQueueAt(trackArg.queueIndex)))} {#if trackArg.queueIndex > player.qi + 1} {@render row("skip-forward", "Move to next", "right after this one", act(() => moveToNext(trackArg.queueIndex)))} - {@render row("chevron-up", "Move up", "", act(() => moveInQueue(trackArg.queueIndex, "up")))} + {@render row("chevron-up", "Move up", "or press and hold a row to drag it", act(() => moveInQueue(trackArg.queueIndex, "up")))} {/if} {#if trackArg.queueIndex < player.queue.length - 1} {@render row("chevron-down", "Move down", "", act(() => moveInQueue(trackArg.queueIndex, "down")))} @@ -521,7 +521,7 @@ {#if rows.length > 1} {@const at = rows.findIndex((r) => r.id === item.id)}
- {#if at > 0}{@render row("chevron-up", "Move up", "", act(() => moveItem(p, rows, item, "up").catch(oops)))}{/if} + {#if at > 0}{@render row("chevron-up", "Move up", "or press and hold a row to drag it", act(() => moveItem(p, rows, item, "up").catch(oops)))}{/if} {#if at < rows.length - 1}{@render row("chevron-down", "Move down", "", act(() => moveItem(p, rows, item, "down").catch(oops)))}{/if} {#if at > 1}{@render row("skip-back", "Move to the top", "", act(() => moveItem(p, rows, item, "top").catch(oops)))}{/if} {/if} diff --git a/src/mobile/Sheet.svelte b/src/mobile/Sheet.svelte index 853e12b..8c36d20 100644 --- a/src/mobile/Sheet.svelte +++ b/src/mobile/Sheet.svelte @@ -13,10 +13,11 @@ import Lane from "./Lane.svelte"; import Cover from "./Cover.svelte"; import { art } from "./art.svelte.js"; - import { player, next, prev, toggle, seekFrac, seekSecs, playQueueAt, toggleShuffle, cycleRepeat } from "./player.svelte.js"; + import { player, next, prev, toggle, seekFrac, seekSecs, playQueueAt, toggleShuffle, cycleRepeat, moveInQueue } from "./player.svelte.js"; import { nav, closeSheet, morphSource, openHalf, openViewer, push } from "./nav.svelte.js"; import { lyr, loadLyrics } from "./lyrics.svelte.js"; import { mmss, drag } from "./util.js"; + import { reorder } from "./reorder.js"; let frameEl = $state(null); let faceEl = $state(null); @@ -144,6 +145,28 @@ if (el && typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center", behavior: "smooth" }); }); function lineSeek(l) { seekSecs(Math.max(0, (l.time_ms + lyr.offset) / 1000)); } + + /* ── the queue, dragged ─────────────────────────────────────────── + The same gesture the playlist page has (./reorder.js). Press and + release without moving and the row's sheet opens exactly as the long + press always opened it — which matters more here than anywhere: a + queue row has no ⋯, so that sheet is the only door to Play now, Move + to next and Remove from queue. */ + let qiAt = -1; + function queueLift() { qiAt = player.qi; } + function queueDrop(from, to) { + /* a track boundary crossed mid-drag moves the whole rendered + window, and these are absolute indices: they would address the + wrong rows. Drop nothing rather than the wrong thing. */ + if (player.qi !== qiAt) return; + moveInQueue(qiAt + 1 + from, qiAt + 1 + to); + } + function queueHold(k) { + const i = player.qi + 1 + k; + const e = player.queue[i]; + if (e) openHalf("track", { album: e.album, track: e.t, queueIndex: i }); + } + const queueDrag = { enabled: true, rowSel: ".row", scrollSel: ".np-face", onLift: queueLift, onDrop: queueDrop, onHold: queueHold };
{:else} -
+

Up next · from {player.from || player.album.title}

diff --git a/src/mobile/player.svelte.js b/src/mobile/player.svelte.js index 72a1010..10cfe05 100644 --- a/src/mobile/player.svelte.js +++ b/src/mobile/player.svelte.js @@ -416,9 +416,11 @@ export function removeFromQueue(i) { armPreload(); writeSession(); } -/** An upcoming row one place up or down, never past the current track. */ +/** An upcoming row one place up or down — or to an absolute index, which + * is what a drop on the queue face hands it — never past the current + * track. The guards were already the right guards for either. */ export function moveInQueue(i, dir) { - const j = i + (dir === "up" ? -1 : 1); + const j = typeof dir === "number" ? dir : i + (dir === "up" ? -1 : 1); if (i <= player.qi || j <= player.qi || i >= player.queue.length || j >= player.queue.length) return; const [row] = player.queue.splice(i, 1); player.queue.splice(j, 0, row); diff --git a/src/mobile/playlists.svelte.js b/src/mobile/playlists.svelte.js index 8a3ab8c..61acf39 100644 --- a/src/mobile/playlists.svelte.js +++ b/src/mobile/playlists.svelte.js @@ -21,6 +21,9 @@ export const pls = $state({ covers: {}, /** the playlist page that is open: { id, items } — the bar's sheet reads it */ current: null, + /** true while a row is in hand on the playlist page: the page's own + * re-read waits for the drop rather than replacing the rows under it */ + dragging: false, /** the last removal: { playlistId, rows: [{ item, position }], label } */ undo: null, }); @@ -225,13 +228,14 @@ export async function exportPlaylist(p, format) { return true; } -/* ── reorder: a row up, down or to the top; the complete id order goes - in one call, as the desktop's drop does ── */ +/* ── reorder: a row up, down, to the top, or to whatever index a drag + dropped it at; the complete id order goes in one call either way, as the + desktop's drop does ── */ export async function moveItem(p, items, item, where) { const ids = items.map((i) => i.id); const from = ids.indexOf(item.id); if (from < 0) return false; - const to = where === "top" ? 0 : where === "up" ? from - 1 : from + 1; + const to = typeof where === "number" ? Math.max(0, Math.min(ids.length - 1, where)) : where === "top" ? 0 : where === "up" ? from - 1 : from + 1; if (to < 0 || to >= ids.length || to === from) return false; ids.splice(from, 1); ids.splice(to, 0, item.id); diff --git a/src/mobile/reorder.js b/src/mobile/reorder.js new file mode 100644 index 0000000..8f5c1a8 --- /dev/null +++ b/src/mobile/reorder.js @@ -0,0 +1,279 @@ +/* Drag to reorder — the playlist page's rows and the player sheet's queue, + one gesture for both. A phone should drag better than a desktop does: + the desktop has a grip and a mouse (src/views/Playlist.svelte), the phone + has a finger that is also how the list is scrolled, so most of this file + is about telling those two apart and then getting out of the scroller's + way. + + The contract, and it is strictly additive: + + hold 320ms the row lifts (and the phone taps back) + move the rows below make room; let go and it lands + release, unmoved the row's sheet opens, exactly as it always did + + That last line is load-bearing. A queue row has no ⋯ — `oncontextmenu` + is its only door to Play now, Move to next and Remove from queue — so a + long press that merely started a drag would strand that sheet. + + A Svelte action on the CONTAINER, in util.js's `drag` idiom: armed on + pointerdown, live only once the hold has landed, every pointer capture + in a try/catch (a synthetic pointer id is not capturable, and that must + not be fatal — it is what the walk drives this with). + + use:reorder={{ enabled, rowSel, skipSel, scrollSel, + onLift(from), onDrop(from, to), onHold(index), onEnd(dropped) }} + + `enabled` may be a value or a FUNCTION, and the playlist passes a + function on purpose: an action's `update` is deprecated in Svelte 5, so + an option object that has to change identity to be heard is one this + cannot lean on. Asked at the press instead, the answer is always the + current one. +*/ + +/** ms of stillness before a press becomes a lift: past a tap, and short of + * Android's own 500ms contextmenu, so the gesture is ours by then */ +const HOLD = 320; +/** px of movement before that: the list is being scrolled, let go of it */ +const SLOP = 8; +/** px from the scroller's edge that pulls the list along */ +const EDGE = 64; +/** px per frame at the very edge */ +const MAXSTEP = 14; + +export function reorder(el, opts) { + let o = opts; + let armed = null; /* { id, x, y, row } — pressed, the hold not yet landed */ + let live = null; /* the lift: the rows, their rects, the slot we are over */ + let holdTimer = 0; + /* a released drag would otherwise fire the row's own click and play the + track, and Android's long press would open the row's sheet in the + middle of one. Both are swallowed at the container in the CAPTURE + phase — which beats the row's handler and Svelte's delegated root + listener alike — and only while a lift is live. */ + let suppressUntil = 0; + + const contentY = (clientY) => (live.scroller ? clientY - live.box.top + live.scroller.scrollTop : clientY); + /** asked at the press, at the lift and at the drop, never cached */ + const ok = () => { const v = o && o.enabled; return typeof v === "function" ? !!v() : !!v; }; + + function attach() { + /* on the window, not the row: the gesture has to survive a capture + that never happened */ + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", finish); + window.addEventListener("pointercancel", finish); + } + function detach() { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", finish); + window.removeEventListener("pointercancel", finish); + } + /* `touch-action` is latched at touchstart and cannot be flipped + mid-gesture, and touch-action: none on a row would cost the list the + flick that scrolls it — most of the page. So the scroll is refused + here instead: after 320ms of stillness the browser has not started + one yet, and eating the first touchmove after the lift cancels it. + Registered imperatively because it must be NON-passive; Svelte + registers touch handlers passively, and a passive listener may not + preventDefault. */ + const eatTouch = (e) => { if (live && e.cancelable) e.preventDefault(); }; + const guard = (e) => { + if (Date.now() > suppressUntil) return; + e.preventDefault(); + e.stopPropagation(); + }; + + function down(e) { + if (live || armed || !ok()) return; + if (e.button > 0) return; + const t = e.target; + if (!t || !t.closest) return; + if (o.skipSel && t.closest(o.skipSel)) return; /* the row's own ⋯ */ + const row = t.closest(o.rowSel); + if (!row || !el.contains(row)) return; + armed = { id: e.pointerId, x: e.clientX, y: e.clientY, row }; + clearTimeout(holdTimer); + holdTimer = setTimeout(lift, HOLD); + attach(); + } + + function lift() { + holdTimer = 0; + if (!armed || live || !ok()) { if (armed) { armed = null; detach(); } return; } + const a = armed; + const rows = [].slice.call(el.querySelectorAll(o.rowSel)); + const from = rows.indexOf(a.row); + if (from < 0 || rows.length < 2) { armed = null; detach(); return; } + const scroller = o.scrollSel ? el.closest(o.scrollSel) : null; + const box = scroller ? scroller.getBoundingClientRect() : { top: 0 }; + const top0 = scroller ? scroller.scrollTop : 0; + /* every row measured once, in the scroller's CONTENT space. That is + what makes the edge pull work: as scrollTop grows the snapshot + stays true and the delta grows on its own, so the row keeps up + with the finger. Measured rather than one assumed row height — + the desktop's `rowH` — because the queue face puts an album + separator between rows. */ + const snap = rows.map((r) => { + const b = r.getBoundingClientRect(); + const top = b.top - box.top + top0; + return { el: r, top, mid: top + b.height / 2 }; + }); + live = { + id: a.id, row: a.row, from, to: from, snap, scroller, box, + grabY: a.y - box.top + top0, y: a.y, y0: a.y, x0: a.x, + moved: false, raf: 0, shift: snap.map(() => 0), + }; + armed = null; + el.classList.add("dragging"); + a.row.classList.add("lifted"); + try { a.row.setPointerCapture(a.id); } catch { /* synthetic ids are not capturable */ } + window.addEventListener("touchmove", eatTouch, { passive: false }); + el.addEventListener("click", guard, true); + el.addEventListener("contextmenu", guard, true); + suppressUntil = Date.now() + 400; + try { if (navigator.vibrate) navigator.vibrate(8); } catch { /* no motor, or none allowed */ } + if (o.onLift) o.onLift(from); + paint(); + } + + function paint() { + if (!live) return; + live.raf = 0; + const s = live.scroller; + if (s) { + const b = s.getBoundingClientRect(); + live.box = b; + /* the edges pull: near the top or the bottom of the scroller the + list comes to the finger, rather than the finger having to + leave the screen to reach the end of a long playlist */ + const above = live.y - b.top; + const below = b.bottom - live.y; + let step = 0; + if (above < EDGE) step = -Math.ceil(((EDGE - Math.max(0, above)) / EDGE) * MAXSTEP); + else if (below < EDGE) step = Math.ceil(((EDGE - Math.max(0, below)) / EDGE) * MAXSTEP); + if (step) { + const was = s.scrollTop; + s.scrollTop = Math.max(0, Math.min(s.scrollHeight - s.clientHeight, was + step)); + /* the list actually moved: keep the frames coming even while + the finger is still */ + if (s.scrollTop !== was) live.raf = requestAnimationFrame(paint); + } + } + const dy = contentY(live.y) - live.grabY; + live.row.style.transform = "translateY(" + Math.round(dy) + "px)"; + /* where the lifted row's own middle now sits decides the slot */ + const mid = live.snap[live.from].mid + dy; + let to = live.from; + for (let i = 0; i < live.snap.length; i++) { + if (i === live.from) continue; + if (i < live.from && mid < live.snap[i].mid) to = Math.min(to, i); + else if (i > live.from && mid > live.snap[i].mid) to = Math.max(to, i); + } + /* each displaced row takes its neighbour's slot — the exact + difference of their measured tops, not a row height. Only the + rows whose shift CHANGED are written to, so a frame that moves + nothing costs nothing. */ + for (let i = 0; i < live.snap.length; i++) { + if (i === live.from) continue; + let sh = 0; + if (to > live.from && i > live.from && i <= to) sh = live.snap[i - 1].top - live.snap[i].top; + else if (to < live.from && i >= to && i < live.from) sh = live.snap[i + 1].top - live.snap[i].top; + if (sh !== live.shift[i]) { + live.shift[i] = sh; + live.snap[i].el.style.transform = sh ? "translateY(" + Math.round(sh) + "px)" : ""; + } + } + live.to = to; + } + + function move(e) { + if (live) { + if (e.pointerId !== live.id) return; + live.y = e.clientY; + /* the same slop the arming uses, not a hair's breadth: a finger + held still for 320ms still wanders a few px, and a press + "released without moving" has to open the row's sheet the way + it always did — on the queue there is no other door to it. */ + if (Math.abs(e.clientY - live.y0) > SLOP || Math.abs(e.clientX - live.x0) > SLOP) live.moved = true; + suppressUntil = Date.now() + 400; + if (e.cancelable) e.preventDefault(); + /* one frame, however many moves arrive in it */ + if (!live.raf) live.raf = requestAnimationFrame(paint); + return; + } + if (!armed || e.pointerId !== armed.id) return; + if (Math.abs(e.clientY - armed.y) > SLOP || Math.abs(e.clientX - armed.x) > SLOP) { + /* it moved before the hold landed: that was a scroll */ + clearTimeout(holdTimer); + holdTimer = 0; + armed = null; + detach(); + } + } + + function finish(e) { + if (e && live && e.pointerId !== live.id) return; + if (e && !live && armed && e.pointerId !== armed.id) return; + clearTimeout(holdTimer); + holdTimer = 0; + detach(); + armed = null; + const was = live; + live = null; + if (!was) return; + if (was.raf) cancelAnimationFrame(was.raf); + window.removeEventListener("touchmove", eatTouch); + /* every mark cleared BEFORE the mutation. The playlist is keyed by + id so a drop MOVES nodes; the queue is keyed by index so it + re-fills them — either way an inline transform left on one is a + bug you can see. */ + el.classList.remove("dragging"); + was.row.classList.remove("lifted"); + was.row.style.transform = ""; + for (let i = 0; i < was.snap.length; i++) was.snap[i].el.style.transform = ""; + try { was.row.releasePointerCapture(was.id); } catch { /* never captured */ } + suppressUntil = Date.now() + 350; + setTimeout(unguard, 420); + /* asked once more here: a filter typed with a finger still down + would make the landing index a lie about the saved order */ + /* a cancelled pointer is not a drop: Android takes the touch away + when it decides the gesture is its own, and landing the row then + would be a reorder nobody asked for. Unmoved it is still the long + press it looks like, so the sheet opens either way. */ + const cancelled = !!e && e.type === "pointercancel"; + const dropped = !cancelled && was.to !== was.from && ok(); + if (dropped) { if (o.onDrop) o.onDrop(was.from, was.to); } + else if (!was.moved) { if (o.onHold) o.onHold(was.from); } + if (o.onEnd) o.onEnd(dropped); + } + function unguard() { + if (live) return; /* another lift has taken the guards over */ + el.removeEventListener("click", guard, true); + el.removeEventListener("contextmenu", guard, true); + } + + el.addEventListener("pointerdown", down); + return { + /* deprecated in Svelte 5 and not relied on — `enabled` is asked for + through a function — but honoured if it ever does arrive */ + update(next) { o = next; }, + destroy() { + clearTimeout(holdTimer); + /* torn down with a row still in hand — a navigation that happened + under the finger. onEnd is what releases the page's deferred + re-read, so it has to be told, or the playlist would stop + re-reading its rows for the rest of the session. */ + if (live) { + if (live.raf) cancelAnimationFrame(live.raf); + live = null; + if (o && o.onEnd) { try { o.onEnd(false); } catch { /* the page is leaving anyway */ } } + } + armed = null; + detach(); + window.removeEventListener("touchmove", eatTouch); + el.removeEventListener("pointerdown", down); + el.removeEventListener("click", guard, true); + el.removeEventListener("contextmenu", guard, true); + }, + }; +} diff --git a/src/mobile/screens/Playlist.svelte b/src/mobile/screens/Playlist.svelte index 52af637..c3561c2 100644 --- a/src/mobile/screens/Playlist.svelte +++ b/src/mobile/screens/Playlist.svelte @@ -13,9 +13,10 @@ import { lib } from "../lib.svelte.js"; import { player } from "../player.svelte.js"; import { openHalf } from "../nav.svelte.js"; - import { pls, bulk, cancelBulk, dismissBulk, coverFor, isSetlist, queueable, unresolvedCount, missingCount, totalSecs, playFrom, shufflePlay, rowClick } from "../playlists.svelte.js"; + import { pls, bulk, cancelBulk, dismissBulk, coverFor, isSetlist, queueable, unresolvedCount, missingCount, totalSecs, playFrom, shufflePlay, rowClick, moveItem } from "../playlists.svelte.js"; import { say } from "../trouble.js"; import { mmss, longDur, stamp } from "../util.js"; + import { reorder } from "../reorder.js"; let { id } = $props(); @@ -32,6 +33,11 @@ if (!p) return; void p.revision; void again; + /* a refetch that lands mid-drag would yank the rows out from under + the gesture; the desktop defers one the same way + (skippedWhileDrag). Reading the flag is what makes this effect + re-run by itself the moment the drag has finished. */ + if (pls.dragging) return; const g = ++gen; api.playlistItems(p.id).then( (r) => { if (g === gen) { items = r || []; loaded = true; loadErr = ""; } }, @@ -65,6 +71,46 @@ function sheet(item) { openHalf("pltrack", { playlist: pl, items, item }); } function tap(item) { if (!rowClick(pl, items, item)) sheet(item); } + + /* ── dragged into place ─────────────────────────────────────────── + Press and hold a row and it lifts, the rows below make room, and + letting go lands it (../reorder.js). A press released without moving + still opens the row's sheet, so nothing that worked yesterday stops + working. The sheet's Move rows stay too: they are the accessible + path, and the one that is still honest under a filter — a filtered + list's visible order is NOT the stored order, so the drag itself is + refused while the search has anything in it, the way the desktop + refuses it ("reordering a partial view would be a lie about the + saved sequence"), and the note under the field says so. */ + const canDrag = $derived(!q.trim() && items.length > 1); + let dragFrom = null; + function dragLift() { pls.dragging = true; dragFrom = items; } + function dragHold(i) { const it = visible[i]; if (it) sheet(it); } + /* a drop clears the flag when the write settles, not when the finger + lifts: releasing it earlier would let the deferred re-read land the + OLD order between the two */ + function dragEnd(dropped) { if (!dropped) pls.dragging = false; } + function dragDrop(from, to) { + /* a refetch that landed mid-drag replaced the rows under us: the + indices the gesture collected address a list that is gone */ + if (items !== dragFrom || q.trim()) { pls.dragging = false; return; } + const snap = items.slice(); + const next = snap.slice(); + const [row] = next.splice(from, 1); + if (!row) { pls.dragging = false; return; } + next.splice(to, 0, row); + /* optimistic: the row is where you put it before the backend has + been asked, and comes back if the answer is no */ + items = next; + moveItem(pl, snap, row, to) + .catch((e) => { items = snap; player.error = String(e); }) + .finally(() => { pls.dragging = false; }); + } + /* `enabled` is a function, not a value: an action's `update` is + deprecated in Svelte 5, so an options object that has to change + identity to be heard is not something to lean on. This one is asked + at the press, every press. */ + const rowDrag = { enabled: () => canDrag, rowSel: ".trk", skipSel: ".r", scrollSel: ".screen", onLift: dragLift, onDrop: dragDrop, onHold: dragHold, onEnd: dragEnd }; {#if pl} @@ -108,11 +154,12 @@ (q = e.currentTarget.value)} type="search" placeholder="Search in playlist" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Search in playlist" /> {#if q}{/if}
+ {#if q && items.length > 1}
Clear the search to drag rows into place.
{/if} {/if} {#if loaded && items.length && !visible.length}
Nothing here matches “{q}”.
{/if} -
+
{#each visible as item, i (item.id)} {@const now = nowItemId != null && nowItemId === item.id}