Files
SoulStream/src/mobile/PlaylistShelf.svelte
T
KeelrLeek 6e39dc43f3 android: playlists, the import doors, the share sheet, You → Sources — and three bugs the walks found
The desktop's two playlist pages on the phone: the shelf (pinned first,
mosaic covers, badges, New playlist, Import) and the page (kind, Pinned
chip, description, the meta line's warnings, Play / Shuffle / more, a
filter, rows tagged unresolved / set audio / file missing). Sheets for a
card, the page's overflow, a row, pick-a-playlist, a prompt and a confirm;
Add to playlist on every track and record sheet; a removal undoable for
seven seconds from the neutral toast (notice.svelte.js); stream rows play
through the resolver as path-less entries, a set slice starts and ends at
its cues; Add to library / Download all tracks with the desktop's bulk
rules; nav.replace for a review that became the thing it made.

The import doors that need no file — a link (YouTube · Spotify · Apple
Music · TIDAL · Deezer), a pasted tracklist, a YouTube DJ set — with the
review screen and the desktop's catalog verification pass. A share plugin
crate (crates/tauri-plugin-share): Share → SoulStream from any app keeps
the text in a ShareActivity and the WebView hands it to the import when it
comes back to the front. You → Sources: the source-quality window and the
priority per mode.

Found by the walks and fixed: set_settings takes camelCase arguments, so
the phone's normalization and library-folder saves never landed;
Cover.svelte's non-eager wrapper has no layout box, so its intersection
observer never fired and row thumbnails never loaded (the Artists tab too);
a history push while a history.go was still in flight left the nav mirror
one entry ahead and the page blank after a confirmed delete — the mirror
now applies once per microtask and never pushes during a pending traversal.

The harness names unmocked commands, carries console warnings' frames (the
smoke bundle is built unminified), probes the history mirror on every step,
mocks app_status / play_local / waveform / lyrics_get / player_state and
applies settings patches, runs adb steps on the phone (the share intent),
and walks playlists, the imports, the share and the settings on both the
mock and the phone. ANDROID.md documents all of it.
2026-09-13 17:56:52 -05:00

76 lines
3.4 KiB
Svelte

<script>
/* Library → Playlists: the desktop's Playlists shelf — the stat line,
Pinned then All, the cover or the mosaic on every card, New playlist.
A long press on a card is its sheet (Pin, Rename, Duplicate, Delete);
the import doors stay on the desktop for now. */
import Icon from "../components/Icon.svelte";
import Release from "./Release.svelte";
import { lib } from "./lib.svelte.js";
import { push, openHalf } from "./nav.svelte.js";
import { player } from "./player.svelte.js";
import { coverFor, createPlaylist, shelfTotals } from "./playlists.svelte.js";
import { longDur } from "./util.js";
let { screenEl = null } = $props();
const pinned = $derived(lib.playlists.filter((p) => p.pinned));
const rest = $derived(lib.playlists.filter((p) => !p.pinned));
const totals = $derived(shelfTotals(lib.playlists));
let creating = false;
function newPlaylist() {
openHalf("prompt", {
title: "New playlist",
placeholder: "Name",
value: `New Playlist ${lib.playlists.length + 1}`,
ok: "Create",
onsubmit: async (name) => {
if (creating) return;
creating = true;
try {
const id = await createPlaylist(name);
push("playlist", { id }, screenEl);
} catch (e) {
player.error = String(e);
} finally { creating = false; }
},
});
}
const sub = (p) => `${p.track_count} track${p.track_count === 1 ? "" : "s"}${p.duration_secs ? " · " + longDur(p.duration_secs) : ""}`;
const badge = (p) => (p.kind === "setlist" ? "SETLIST" : p.unresolved_count > 0 ? p.unresolved_count + " UNRESOLVED" : null);
</script>
<div class="plstats">
{#if lib.playlists.length}
<span class="t-sub">{lib.playlists.length} playlist{lib.playlists.length === 1 ? "" : "s"} · {totals.tracks.toLocaleString()} track{totals.tracks === 1 ? "" : "s"}{totals.secs ? " · " + longDur(totals.secs) : ""}{pinned.length ? " · " + pinned.length + " pinned" : ""}</span>
{:else}
<span class="t-sub">No playlists yet</span>
{/if}
</div>
<div class="plbtns">
<button class="btn btn-ghost newpl" onclick={() => openHalf("import")}><Icon name="download" size={15} strokeWidth={3} />Import</button>
<button class="btn btn-ghost newpl" onclick={newPlaylist}><Icon name="plus" size={15} strokeWidth={3} />New playlist</button>
</div>
{#if !lib.playlists.length}
<div class="stub"><b>No playlists on this phone yet.</b>Make one here, or add a track to a new one from its sheet. Playlists are part of the library folder, so the ones the desktop keeps come over with it.</div>
{/if}
{#snippet card(p)}
<Release title={p.name} sub={sub(p)} url={coverFor(p)} badge={badge(p)} mark={p.pinned ? "pin" : null} icon="playlist"
onclick={() => push("playlist", { id: p.id }, screenEl)} onmore={() => openHalf("playlist", { playlist: p })} />
{/snippet}
{#if pinned.length}
<div class="sec plsec pinned"><h2><Icon name="pin" size={12} strokeWidth={3} />Pinned</h2></div>
<div class="grid2 plgrid">
{#each pinned as p (p.id)}{@render card(p)}{/each}
</div>
{#if rest.length}<div class="sec plsec"><h2>All playlists</h2></div>{/if}
{/if}
{#if rest.length}
<div class="grid2 plgrid">
{#each rest as p (p.id)}{@render card(p)}{/each}
</div>
{/if}