android: reorder from the sheet — a playlist row up, down or to the top; a queue row up or down

Without a drag: a playlist row's sheet moves it up, down or to the top and
persists the whole order in one call (playlist_set_order, as the desktop's
drop does); a queue row's sheet moves it up or down, never past the track
playing. Also src-tauri/android/ship.sh — one round end to end: the mock
walk, the embedded build installed, the phone walk, the commit, each step
gated on the one before.
This commit is contained in:
KeelrLeek committed 2026-09-13 20:55:50 -05:00
1 parent 991d52d0cc
commit 60303c8a95
7 files changed
+127 -7

No files matched your search

+5 -3
View File
@@ -192,8 +192,8 @@ of its network search.
More long presses: a catalog song in the search results opens the catalog More long presses: a catalog song in the search results opens the catalog
track's sheet (play, save, add to a playlist, the record, the artist); a track's sheet (play, save, add to a playlist, the record, the artist); a
row of the queue face opens the track's sheet with Play now, Move to next row of the queue face opens the track's sheet with Play now, Move to next,
and Remove from queue on top; a raw result's file (and a file on the Browse Move up, Move down and Remove from queue on top; a raw result's file (and a file on the Browse
screen) opens its own — play from here, save this one file, browse; a name screen) opens its own — play from here, save this one file, browse; a name
in a room's list opens the peer sheet. The catalog record page's bar ⋯ is in a room's list opens the peer sheet. The catalog record page's bar ⋯ is
the record's sheet: play, save, add every track to a playlist as streams, the record's sheet: play, save, add every track to a playlist as streams,
@@ -253,7 +253,9 @@ describe / pin / duplicate / delete, `playlist_add_items`,
a file (the desktop's repair panel as a sheet: the library searched by 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`; the row's name, a pick relinks the row through `playlist_relink_item`;
an unknown placeholder adopts the track's identity), Go to artist, an unknown placeholder adopts the track's identity), Go to artist,
Remove from playlist. 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).
A removal is undoable for seven seconds from the neutral toast A removal is undoable for seven seconds from the neutral toast
(`notice.svelte.js` — the desktop's undo pill); the stash pins the (`notice.svelte.js` — the desktop's undo pill); the stash pins the
playlist it came from. playlist it came from.
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# One round, end to end: the mock walk, then the phone (the embedded build,
# installed, walked on the real core), then the commit — each step gated on
# the one before, so nothing lands on the phone the mock did not pass and
# nothing is committed the phone did not pass.
#
# bash src-tauri/android/ship.sh [commit-message-file]
#
# The phone walk needs the phone attached with SoulStream (or the launcher)
# on screen; SS_ALBUM / SS_QUERY name the record and the search it uses.
set -uo pipefail
cd /f/Coding/SoulStream
MSG="${1:-}"
T="$(cygpath -u "$TEMP")"
ADB="$(cygpath -u "$LOCALAPPDATA")/Android/Sdk/platform-tools/adb.exe"
stamp=$(date +%H%M%S)
ALBUM="${SS_ALBUM:-Spiderman}"
QUERY="${SS_QUERY:-daft punk}"
echo "== mock walk $(date +%T)"
if ! node tests/mobile/smoke.mjs > "$T/ss-ship-mock-$stamp.log" 2>&1; then
echo "MOCK WALK FAILED ($T/ss-ship-mock-$stamp.log)"
grep -n "FAIL" "$T/ss-ship-mock-$stamp.log" | cut -c1-160 | head
sed -n '/problem(s):/,$p' "$T/ss-ship-mock-$stamp.log" | cut -c1-300 | head -20
exit 1
fi
echo " green"
echo "== deploy $(date +%T)"
if ! bash src-tauri/android/deploy.sh > "$T/ss-ship-deploy-$stamp.log" 2>&1; then
echo "DEPLOY FAILED ($T/ss-ship-deploy-$stamp.log)"
tail -25 "$T/ss-ship-deploy-$stamp.log"
exit 1
fi
grep -a "^== install\|Success\|not launched" "$T/ss-ship-deploy-$stamp.log" | tail -3
echo "== phone walk $(date +%T)"
if ! node tests/mobile/smoke.mjs --phone --album "$ALBUM" --query "$QUERY" > "$T/ss-ship-phone-$stamp.log" 2>&1; then
echo "PHONE WALK FAILED ($T/ss-ship-phone-$stamp.log)"
grep -n "FAIL" "$T/ss-ship-phone-$stamp.log" | cut -c1-160 | head
sed -n '/problem(s):/,$p' "$T/ss-ship-phone-$stamp.log" | cut -c1-300 | head -20
"$ADB" forward --remove-all
exit 1
fi
grep -n "audio \|no exceptions" "$T/ss-ship-phone-$stamp.log" | cut -c1-120
"$ADB" forward --remove-all
if [ -n "$MSG" ]; then
echo "== commit $(date +%T)"
git add -A
git -c user.name="KeelrLeek" commit -q -F "$MSG" 2>/dev/null
git log -1 --format=' %h %s' | cut -c1-140
fi
echo "== shipped $(date +%T)"
+11
View File
@@ -185,6 +185,17 @@ function mockPlaylists(cmd, args) {
plTouch(p); plTouch(p);
return Promise.resolve(rows.map((r) => r.id)); return Promise.resolve(rows.map((r) => r.id));
} }
if (cmd === "playlist_set_order") {
const p = plOf(args);
if (!p) return Promise.reject(new Error("playlist_set_order: no such playlist"));
const by = new Map((PL_ITEMS[p.id] || []).map((x) => [x.id, x]));
const next = (args?.orderedIds || []).map(Number).map((id) => by.get(id)).filter(Boolean);
for (const x of PL_ITEMS[p.id] || []) if (!next.includes(x)) next.push(x);
PL_ITEMS[p.id] = next;
next.forEach((x, i) => { x.position = i; });
plTouch(p);
return Promise.resolve();
}
if (cmd === "playlist_relink_item") { if (cmd === "playlist_relink_item") {
const p = plOf(args); const p = plOf(args);
const it = p ? (PL_ITEMS[p.id] || []).find((x) => x.id === Number(args?.itemId)) : null; const it = p ? (PL_ITEMS[p.id] || []).find((x) => x.id === Number(args?.itemId)) : null;
+13 -2
View File
@@ -9,10 +9,10 @@
import { speedLabel, groupEntries } from "./net.svelte.js"; import { speedLabel, groupEntries } from "./net.svelte.js";
import * as api from "../lib/api.js"; import * as api from "../lib/api.js";
import { nav, closeHalf, push, top, patch, go, back, openHalf, openSheet } from "./nav.svelte.js"; import { nav, closeHalf, push, top, patch, go, back, openHalf, openSheet } from "./nav.svelte.js";
import { player, playNext, enqueue, playEntries, playQueueAt, removeFromQueue, moveToNext, setSleep, sleepLabel } from "./player.svelte.js"; import { player, playNext, enqueue, playEntries, playQueueAt, removeFromQueue, moveToNext, moveInQueue, setSleep, sleepLabel } from "./player.svelte.js";
import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js"; import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js";
import { catalog, yearOf } from "./catalog.svelte.js"; import { catalog, yearOf } from "./catalog.svelte.js";
import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist } from "./playlists.svelte.js"; import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist, moveItem } from "./playlists.svelte.js";
import { DOORS, start as startImport } from "./importer.svelte.js"; import { DOORS, start as startImport } from "./importer.svelte.js";
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js"; import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js"; import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js";
@@ -282,6 +282,10 @@
{@render row("play", "Play now", "", act(() => playQueueAt(trackArg.queueIndex)))} {@render row("play", "Play now", "", act(() => playQueueAt(trackArg.queueIndex)))}
{#if trackArg.queueIndex > player.qi + 1} {#if trackArg.queueIndex > player.qi + 1}
{@render row("skip-forward", "Move to next", "right after this one", act(() => moveToNext(trackArg.queueIndex)))} {@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")))}
{/if}
{#if trackArg.queueIndex < player.queue.length - 1}
{@render row("chevron-down", "Move down", "", act(() => moveInQueue(trackArg.queueIndex, "down")))}
{/if} {/if}
{@render row("x", "Remove from queue", "", act(() => removeFromQueue(trackArg.queueIndex)))} {@render row("x", "Remove from queue", "", act(() => removeFromQueue(trackArg.queueIndex)))}
<div class="sheet-hair"></div> <div class="sheet-hair"></div>
@@ -408,6 +412,13 @@
{#if item.artist} {#if item.artist}
{@render row("users", "Go to artist", item.artist, act(() => push("artist", { name: item.artist })))} {@render row("users", "Go to artist", item.artist, act(() => push("artist", { name: item.artist })))}
{/if} {/if}
{#if rows.length > 1}
{@const at = rows.findIndex((r) => r.id === item.id)}
<div class="sheet-hair"></div>
{#if at > 0}{@render row("chevron-up", "Move up", "", 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}
<div class="sheet-hair"></div> <div class="sheet-hair"></div>
{@render row("x", "Remove from playlist", "undo for a few seconds after", act(() => removeItems(p, rows, [item]).catch(oops)))} {@render row("x", "Remove from playlist", "undo for a few seconds after", act(() => removeItems(p, rows, [item]).catch(oops)))}
</div> </div>
+9
View File
@@ -412,6 +412,15 @@ export function removeFromQueue(i) {
armPreload(); armPreload();
writeSession(); writeSession();
} }
/** An upcoming row one place up or down, never past the current track. */
export function moveInQueue(i, dir) {
const j = 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);
armPreload();
writeSession();
}
/** Pull an upcoming row up to right after the current track. */ /** Pull an upcoming row up to right after the current track. */
export function moveToNext(i) { export function moveToNext(i) {
if (i <= player.qi + 1 || i >= player.queue.length) return; if (i <= player.qi + 1 || i >= player.queue.length) return;
+15
View File
@@ -184,6 +184,21 @@ export async function deletePlaylist(p) {
await refreshPlaylists(); await refreshPlaylists();
} }
/* ── reorder: a row up, down or to the top; the complete id order goes
in one call, 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;
if (to < 0 || to >= ids.length || to === from) return false;
ids.splice(from, 1);
ids.splice(to, 0, item.id);
await api.playlistSetOrder(p.id, ids);
await refreshPlaylists();
return true;
}
/* ── removal, undoable ───────────────────────────────────────────────── */ /* ── removal, undoable ───────────────────────────────────────────────── */
let undoTimer = 0; let undoTimer = 0;
function stashUndo(playlistId, rows, label) { function stashUndo(playlistId, rows, label) {
+20 -2
View File
@@ -110,6 +110,8 @@ const PROBE = `(function () {
pltags: [].map.call(document.querySelectorAll('.pl-rows .tag'), function (t) { return t.textContent.trim(); }).join('|'), pltags: [].map.call(document.querySelectorAll('.pl-rows .tag'), function (t) { return t.textContent.trim(); }).join('|'),
plkind: (document.querySelector('.rec-head.pl .kind') || {}).textContent || '', plkind: (document.querySelector('.rec-head.pl .kind') || {}).textContent || '',
plfilter: !!document.querySelector('.plfilter input'), plfilter: !!document.querySelector('.plfilter input'),
plfirst: (document.querySelector('.pl-rows .trk .t') || {}).textContent || '',
qfirst: (document.querySelector('.q-face .row .t') || {}).textContent || '',
plthumbs: document.querySelectorAll('.pl-rows .thumb img').length, plthumbs: document.querySelectorAll('.pl-rows .thumb img').length,
imp: document.querySelectorAll('.imp-rows .trk').length, imp: document.querySelectorAll('.imp-rows .trk').length,
impTags: [].map.call(document.querySelectorAll('.imp-rows .tag'), function (t) { return t.textContent.trim(); }).join('|'), impTags: [].map.call(document.querySelectorAll('.imp-rows .tag'), function (t) { return t.textContent.trim(); }).join('|'),
@@ -175,6 +177,9 @@ const MOCK_STEPS = [
/* a queue row's sheet: the row removed */ /* a queue row's sheet: the row removed */
{ do: `document.querySelector('.q-face .row').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "queue-row-sheet", wait: 600 }, { do: `document.querySelector('.q-face .row').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "queue-row-sheet", wait: 600 },
{ do: `__click('.halfsheet .row', 'Remove from queue')`, name: "queue-removed", wait: 600 }, { do: `__click('.halfsheet .row', 'Remove from queue')`, name: "queue-removed", wait: 600 },
/* the second upcoming row moved up */
{ do: `(function () { globalThis.__q2 = document.querySelectorAll('.q-face .row')[1].querySelector('.t').textContent; document.querySelectorAll('.q-face .row')[1].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); })()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Move up')`, name: "queue-moved-up", wait: 600 },
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 900 }, { do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 900 },
/* the lyrics tools: nudged half a second, another candidate chosen */ /* the lyrics tools: nudged half a second, another candidate chosen */
{ do: `__click('.lyr-tools', null)`, name: "lyrics-sheet", wait: 700 }, { do: `__click('.lyr-tools', null)`, name: "lyrics-sheet", wait: 700 },
@@ -277,6 +282,11 @@ const MOCK_STEPS = [
{ do: `document.querySelectorAll('.pl-rows .trk')[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "playlist-row-sheet", wait: 700 }, { do: `document.querySelectorAll('.pl-rows .trk')[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "playlist-row-sheet", wait: 700 },
{ do: `__click('.halfsheet .row', 'Remove from playlist')`, name: "playlist-removed", wait: 900 }, { do: `__click('.halfsheet .row', 'Remove from playlist')`, name: "playlist-removed", wait: 900 },
{ do: `__click('.toast.notice .na', 'Undo')`, name: "playlist-undone", wait: 1000 }, { do: `__click('.toast.notice .na', 'Undo')`, name: "playlist-undone", wait: 1000 },
/* reordered from the sheet: the second row up, then back down */
{ do: `document.querySelectorAll('.pl-rows .trk')[1].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Move up')`, name: "playlist-moved-up", wait: 900 },
{ do: `document.querySelectorAll('.pl-rows .trk')[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Move down')`, name: "playlist-moved-down", wait: 900 },
/* the bulk actions from the page's sheet: Add to library files the /* the bulk actions from the page's sheet: Add to library files the
streamed row (its cache is complete); Download all finds it cached too */ streamed row (its cache is complete); Download all finds it cached too */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 }, { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
@@ -405,6 +415,7 @@ const MOCK_EXPECT = {
queue: (i) => { globalThis.__qrows = i.qrows; return i.sheet.includes("up") && i.qrows >= 2; }, queue: (i) => { globalThis.__qrows = i.qrows; return i.sheet.includes("up") && i.qrows >= 2; },
"queue-row-sheet": (i) => i.half.includes("up") && /Remove from queue/.test(i.halfText) && /Play now/.test(i.halfText), "queue-row-sheet": (i) => i.half.includes("up") && /Remove from queue/.test(i.halfText) && /Play now/.test(i.halfText),
"queue-removed": (i) => !i.half.includes("up") && i.qrows === globalThis.__qrows - 1, "queue-removed": (i) => !i.half.includes("up") && i.qrows === globalThis.__qrows - 1,
"queue-moved-up": (i) => !i.half.includes("up") && !!i.qfirst && i.qrows === globalThis.__qrows - 1,
"library-again": (i) => i.screen.includes("tr-pop"), "library-again": (i) => i.screen.includes("tr-pop"),
/* after the follow: the feed's lead ("Coming · ALBUM" — the upcoming /* after the follow: the feed's lead ("Coming · ALBUM" — the upcoming
record leads), the news, the back catalogue, recommendations, record leads), the news, the back catalogue, recommendations,
@@ -448,6 +459,8 @@ const MOCK_EXPECT = {
"playlist-row-sheet": (i) => i.half.includes("up") && /Remove from playlist/.test(i.halfText), "playlist-row-sheet": (i) => i.half.includes("up") && /Remove from playlist/.test(i.halfText),
"playlist-removed": (i) => i.plrows === 5 && /Removed/.test(i.notice), "playlist-removed": (i) => i.plrows === 5 && /Removed/.test(i.notice),
"playlist-undone": (i) => i.plrows === 6 && !i.notice, "playlist-undone": (i) => i.plrows === 6 && !i.notice,
"playlist-moved-up": (i) => i.plfirst.startsWith("Track 2"),
"playlist-moved-down": (i) => i.plfirst.startsWith("Track 1"),
"playlist-bulk-cached": (i) => /1 saved to library/.test(i.bulkline) && !i.half.includes("up"), "playlist-bulk-cached": (i) => /1 saved to library/.test(i.bulkline) && !i.half.includes("up"),
"playlist-bulk-download": (i) => /1 saved to library/.test(i.bulkline), "playlist-bulk-download": (i) => /1 saved to library/.test(i.bulkline),
"playlist-unknown-sheet": (i) => i.half.includes("up") && /Find a local match/.test(i.halfText), "playlist-unknown-sheet": (i) => i.half.includes("up") && /Find a local match/.test(i.halfText),
@@ -907,8 +920,13 @@ async function main() {
const topName = (await cdp.evaluate(HERO)).top; const topName = (await cdp.evaluate(HERO)).top;
await cdp.evaluate(HELPERS); await cdp.evaluate(HELPERS);
await cdp.evaluate(`__click('.top .btn-ghost', null)`); await cdp.evaluate(`__click('.top .btn-ghost', null)`);
await sleep(4500); /* the catalog page lands when the provider answers — polled, since a fixed wait read a slow answer as a failure */
const hero = await cdp.evaluate(HERO); let hero = null;
for (let i = 0; i < 24; i++) {
await sleep(500);
hero = await cdp.evaluate(HERO);
if (!hero.screen.includes("has-hero") || hero.tabs >= 1 || hero.pop >= 1) break;
}
await still(path.join(OUT, `${prefix}cat-hero-page.png`)); await still(path.join(OUT, `${prefix}cat-hero-page.png`));
const heroOk = hero.screen.includes("tr-push") && (!hero.screen.includes("has-hero") || hero.tabs >= 1 || hero.pop >= 1); const heroOk = hero.screen.includes("tr-push") && (!hero.screen.includes("has-hero") || hero.tabs >= 1 || hero.pop >= 1);
if (!heroOk) problems.push("EXPECT cat-hero-page " + JSON.stringify(hero)); if (!heroOk) problems.push("EXPECT cat-hero-page " + JSON.stringify(hero));