Files
SoulStream/src/views/Playlist.svelte
T

1719 lines
60 KiB
Svelte

<script>
// One playlist, as an editor: cover (custom or mosaic), inline rename and
// description, play/shuffle, search-within, pointer-drag reorder over
// stable occurrence ids, per-row repair for unresolved imports, setlist
// timestamps, export, delete. Every mutation addresses item IDS — the
// same recording twice is two rows, and only the playing occurrence
// lights up.
import {
ui,
playlistStore,
refreshPlaylists,
playlistCoverFor,
playAlbum,
player,
streamSong,
toggleShuffle,
goToView,
getArt,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
const playlist = $derived(
playlistStore.playlists.find((p) => p.id === ui.playlistId) ?? null,
);
let items = $state([]);
let loaded = $state(false);
let query = $state("");
let menuOpen = $state(false);
let confirmDelete = $state(false);
let renaming = $state(false);
let nameInput = $state("");
let editingDesc = $state(false);
let descInput = $state("");
let matchFor = $state(null); // item id with the Find-match panel open
let matchQuery = $state("");
let matchResults = $state([]);
let matchLoading = $state(false);
// Generation-guarded fetch: quick navigation must never paint another
// playlist's rows, and a slow response must never beat a newer one.
let fetchGen = 0;
let skippedWhileDrag = false;
async function fetchItems(id) {
const gen = ++fetchGen;
try {
const rows = await api.playlistItems(id);
if (gen !== fetchGen || ui.playlistId !== id) return;
if (drag) {
// Never yank the rows out from under a live drag; the drop
// handler refetches once the pointer is released.
skippedWhileDrag = true;
return;
}
items = rows;
loaded = true;
} catch {
if (gen === fetchGen) loaded = true;
}
}
// Navigation: a NEW playlist id resets the page state and fetches.
let shownId = null;
$effect(() => {
const id = ui.playlistId;
if (id == null) return;
if (id !== shownId) {
shownId = id;
loaded = false;
// Cleared SYNCHRONOUSLY: the previous playlist's rows must never
// render under the new playlist's header while the fetch runs.
items = [];
query = "";
matchFor = null;
// The undo toast belongs to the page it was earned on.
undo = null;
clearTimeout(undoTimer);
void fetchItems(id);
}
});
// Refresh: a background mutation bumps the revision and the rows follow —
// without touching the user's filter or scroll.
let shownRev = null;
$effect(() => {
const id = ui.playlistId;
const rev = playlist?.revision;
if (id == null || rev == null) return;
if (shownRev !== `${id}:${rev}`) {
shownRev = `${id}:${rev}`;
void fetchItems(id);
}
});
$effect(() => {
if (playlist) void playlistCoverFor(playlist, 1024);
});
const cover = $derived(playlist ? (playlistStore.covers[playlist.id] ?? null) : null);
const isSetlist = $derived(playlist?.kind === "setlist");
// Row art thumbnails through the shared cache.
let rowArt = $state({});
$effect(() => {
for (const item of items) {
const p = item.art_path;
if (!p || rowArt[p] !== undefined) continue;
rowArt[p] = null;
getArt(p, 128).then((u) => (rowArt[p] = u)).catch(() => {});
}
});
// A stream row saved before covers rode along backfills its artwork ONCE:
// an exact-identity catalog match — normalized artist AND title equality,
// the import ladder's own evidence class — supplies the cover and the
// track id, persisted on the row. Sequential, one search per row, each
// row attempted once per session; no match simply keeps the glyph.
const normId = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
const artAttempted = new Set();
let backfillBusy = false;
$effect(() => {
if (!playlist) return;
const want = items.filter(
(i) =>
i.kind === "stream" &&
!i.art_url &&
i.artist &&
i.title &&
!artAttempted.has(i.id),
);
if (!want.length || backfillBusy) return;
void backfillArt(playlist.id, want);
});
async function backfillArt(plId, rows) {
backfillBusy = true;
try {
for (const row of rows) {
if (ui.playlistId !== plId) return;
if (artAttempted.has(row.id)) continue;
artAttempted.add(row.id);
let hit = null;
try {
const res = await api.catalogSearch(`${row.artist} ${row.title}`);
const same = (t) =>
normId(t.artist) === normId(row.artist) &&
normId(t.title) === normId(row.title);
const hits = (res?.tracks ?? []).filter(same);
hit =
hits.find((t) => row.album && normId(t.album) === normId(row.album)) ??
hits[0] ??
null;
} catch {
// Offline or throttled — let the next page visit retry.
artAttempted.delete(row.id);
return;
}
if (!hit?.cover) continue;
try {
await api.playlistRelinkItem(plId, row.id, {
trackId: hit.id != null ? String(hit.id) : null,
artUrl: hit.cover,
});
const live = items.find((i) => i.id === row.id);
if (live) {
live.art_url = hit.cover;
if (hit.id != null && !live.track_id) live.track_id = String(hit.id);
}
} catch {
// The row vanished mid-backfill — nothing to persist onto.
}
}
} finally {
backfillBusy = false;
}
}
const norm = (s) => (s ?? "").toLowerCase();
const visible = $derived.by(() => {
const q = norm(query.trim());
if (!q) return items;
return items.filter(
(i) =>
norm(i.title).includes(q) ||
norm(i.artist).includes(q) ||
norm(i.album).includes(q),
);
});
// Setlist cue times show only when EVERY row carries one — the import's
// own "coherent or not at all" rule (folder order works the same way). A
// partially stamped list (a track added by hand after import) reads as a
// plain numbered list instead of a column mixing times with indexes.
// Gated on the full list, not the filtered view: hiding the unstamped
// rows must not flip the column.
const stampsCoherent = $derived(items.length > 0 && items.every((i) => i.start_ms != null));
// Rows Play/Shuffle can queue: present local files plus stream rows —
// streams queue as PENDING items that resolve when reached (or a track
// early via the look-ahead), so the playlist plays in order as one queue.
const queueableRows = $derived(
items.filter((i) => (i.path && !i.missing) || i.kind === "stream"),
);
const missingCount = $derived(items.filter((i) => i.missing).length);
const unresolvedCount = $derived(items.filter((i) => i.kind === "unresolved").length);
const totalSecs = $derived(items.reduce((sum, i) => sum + (i.duration_secs ?? 0), 0));
const nowItemId = $derived(player.queue[player.queueIndex]?.plItemId ?? null);
// Where each set-recording slice ends: the NEXT stamped row in the full
// (unfiltered) order bounds it; the last slice runs to the file's end.
const segmentEndOf = $derived.by(() => {
const ends = new Map();
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind !== "segment" || it.start_ms == null) continue;
const next = items.slice(i + 1).find((n) => n.start_ms != null);
ends.set(it.id, next ? next.start_ms : null);
}
return ends;
});
function queueRowOf(item) {
const local = item.path && !item.missing;
const tid = Number(item.track_id);
const endMs = segmentEndOf.get(item.id);
const segment =
item.kind === "segment" && local && item.start_ms != null
? { start: item.start_ms / 1000, end: endMs != null ? endMs / 1000 : null }
: null;
return {
path: local ? item.path : null,
needsResolve: !local,
title: item.title,
artist: item.artist,
album: item.album,
duration_secs:
item.duration_secs ??
(segment && segment.end != null
? Math.round(segment.end - segment.start)
: null),
art_path: local ? item.art_path : null,
artUrl: local ? null : (item.art_url ?? null),
trackId: Number.isFinite(tid) ? tid : null,
plItemId: item.id,
segment,
};
}
function playFrom(item = null) {
const rows = queueableRows;
if (!rows.length) return;
let start = 0;
if (item) {
const index = rows.findIndex((r) => r.id === item.id);
if (index >= 0) start = index;
}
playAlbum(rows.map(queueRowOf), playlist?.name ?? "Playlist", start);
}
function shufflePlay() {
const rows = queueableRows;
if (!rows.length) return;
playFrom(rows[Math.floor(Math.random() * rows.length)]);
// After the queue exists: toggleShuffle seeds its seen-set from the
// CURRENT index, which before playFrom would still be the old queue's.
if (!player.shuffle) toggleShuffle();
}
function rowClick(item) {
if ((item.path && !item.missing) || item.kind === "stream") {
// Play from this row WITHIN the playlist queue — a stream row
// resolves on reach, and what follows it keeps playing.
playFrom(item);
} else if (item.title && item.artist && item.title.toLowerCase() !== "id") {
// A weak row that can still stream one-off (kept for safety; the
// kinds above should cover everything playable).
const tid = Number(item.track_id);
void streamSong(item.artist, item.title, {
cover: item.art_url ?? undefined,
trackId: Number.isFinite(tid) ? tid : undefined,
album: item.album ?? undefined,
expectSecs: item.duration_secs ?? undefined,
});
} else {
// An unknown ("ID") or artist-less placeholder has nothing to
// search for; the repair panel is the useful next step.
openMatch(item);
}
}
// ---- rename / describe ----
function startRename() {
nameInput = playlist?.name ?? "";
renaming = true;
}
async function saveName() {
renaming = false;
const next = nameInput.trim();
if (!playlist || !next || next === playlist.name) return;
await api.renamePlaylist(playlist.id, next);
await refreshPlaylists();
}
function startDesc() {
descInput = playlist?.description ?? "";
editingDesc = true;
}
async function saveDesc() {
editingDesc = false;
if (!playlist) return;
const next = descInput.trim();
if (next === (playlist.description ?? "")) return;
await api.setPlaylistDescription(playlist.id, next || null);
await refreshPlaylists();
}
// ---- menu actions ----
async function togglePin() {
menuOpen = false;
await api.setPlaylistPinned(playlist.id, !playlist.pinned);
await refreshPlaylists();
}
async function duplicate() {
menuOpen = false;
const id = await api.duplicatePlaylist(playlist.id, `${playlist.name} copy`);
await refreshPlaylists();
goToView("playlist", { playlistId: id, label: `${playlist.name} copy` });
}
async function chooseCover() {
menuOpen = false;
const url = await api.playlistCoverPick(playlist.id);
if (url) {
playlistStore.covers[playlist.id] = url;
// The stored cover_path drives the "Remove custom cover" item.
await refreshPlaylists();
}
}
async function clearCover() {
menuOpen = false;
await api.playlistCoverClear(playlist.id);
delete playlistStore.covers[playlist.id];
await refreshPlaylists();
}
async function exportAs(format) {
menuOpen = false;
try {
await api.playlistExport(playlist.id, format);
} catch (e) {
console.error("playlist export failed", e);
}
}
async function del() {
if (!confirmDelete) {
confirmDelete = true;
return;
}
menuOpen = false;
confirmDelete = false;
await api.deletePlaylist(playlist.id);
await refreshPlaylists();
goToView("playlists", { playlistId: null });
}
// ---- remove, with undo ----
// The doc's "editing that feels safe": destructive row edits stash what
// they removed (row + position) and offer one Undo. Restoring re-inserts
// at the original positions — new occurrence ids, same music.
let undo = $state(null); // { playlistId, rows: [{ item, position }], label }
let undoTimer = 0;
function stashUndo(rows, label) {
clearTimeout(undoTimer);
// The stash pins the playlist it came from: navigating away and
// clicking Undo must never restore rows into a DIFFERENT playlist.
undo = { playlistId: playlist.id, rows, label };
undoTimer = setTimeout(() => (undo = null), 7000);
}
async function removeRow(item) {
const position = items.findIndex((i) => i.id === item.id);
await api.playlistRemoveItems(playlist.id, [item.id]);
items = items.filter((i) => i.id !== item.id);
stashUndo([{ item, position }], `Removed “${item.title}”`);
await refreshPlaylists();
}
async function removeUnresolved() {
menuOpen = false;
const rows = items
.map((item, position) => ({ item, position }))
.filter((r) => r.item.kind === "unresolved");
if (!rows.length) return;
await api.playlistRemoveItems(playlist.id, rows.map((r) => r.item.id));
items = items.filter((i) => i.kind !== "unresolved");
stashUndo(
rows,
`Removed ${rows.length} unresolved ${rows.length === 1 ? "entry" : "entries"}`,
);
await refreshPlaylists();
}
async function applyUndo() {
const stash = undo;
undo = null;
clearTimeout(undoTimer);
if (!stash) return;
// Ascending original positions, so each insert-at lands true — into
// the playlist the removal happened in, wherever the user is now.
for (const { item, position } of [...stash.rows].sort((a, b) => a.position - b.position)) {
await api.playlistAddItems(
stash.playlistId,
[
{
title: item.title,
artist: item.artist,
album: item.album,
duration_secs: item.duration_secs,
path: item.path,
art_path: item.art_path,
start_ms: item.start_ms,
source_line: item.source_line,
note: item.note,
track_id: item.track_id,
art_url: item.art_url,
},
],
position,
);
}
await refreshPlaylists();
}
// ---- find match (repair) ----
function openMatch(item) {
matchFor = item.id;
matchQuery = [item.artist, item.title].filter(Boolean).join(" ");
matchResults = [];
void runMatchSearch();
}
let matchGen = 0;
async function runMatchSearch() {
const gen = ++matchGen;
const q = matchQuery.trim();
if (!q) {
matchResults = [];
matchLoading = false; // an in-flight search may never clear it
return;
}
matchLoading = true;
try {
const rows = await api.libraryTrackSearch(q);
if (gen === matchGen) matchResults = rows;
} finally {
if (gen === matchGen) matchLoading = false;
}
}
async function applyMatch(item, candidate) {
matchFor = null;
// An UNKNOWN placeholder ("ID", or no artist at all) adopts the
// matched track's display identity — "this unknown turned out to be
// X" is the whole point of the repair, and lyrics/search need the
// real name. The original line survives in source_line either way.
const unknown =
item.kind === "unresolved" &&
(!item.artist || item.title.trim().toLowerCase() === "id");
await api.playlistRelinkItem(playlist.id, item.id, {
path: candidate.path,
artPath: candidate.art_path,
durationSecs: candidate.duration_secs,
title: unknown ? candidate.title : null,
artist: unknown ? candidate.artist : null,
album: unknown ? candidate.album : null,
});
await refreshPlaylists();
}
// ---- pointer-drag reorder ----
// Grip-driven: the dragged row rides the pointer (translateY), rows it
// crosses shift one slot, the drop persists the COMPLETE id order in one
// call, optimistically applied. Disabled while a filter hides rows —
// reordering a partial view would be a lie about the saved sequence.
let drag = $state(null); // { id, from, to, dy, rowH }
function gripDown(e, index) {
if (query.trim() || !playlist) return;
e.preventDefault();
// An open repair panel sits between rows and would put the slot
// arithmetic off by its height — close it for the drag.
matchFor = null;
const row = e.currentTarget.closest(".trow");
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
// A capture that cannot be taken (exotic/synthetic pointer) still
// drags fine as long as the pointer stays over the list.
}
drag = { id: items[index].id, from: index, to: index, dy: 0, rowH: row.offsetHeight, startY: e.clientY };
}
function gripMove(e) {
if (!drag) return;
const dy = e.clientY - drag.startY;
const offset = Math.round(dy / drag.rowH);
const to = Math.max(0, Math.min(items.length - 1, drag.from + offset));
drag = { ...drag, dy, to };
}
async function gripUp() {
if (!drag) return;
const { from, to } = drag;
drag = null;
if (from === to || !playlist) {
// A refetch that arrived during the drag was deferred; honor it.
if (skippedWhileDrag && playlist) {
skippedWhileDrag = false;
void fetchItems(playlist.id);
}
return;
}
skippedWhileDrag = false;
const next = [...items];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
items = next;
try {
await api.playlistSetOrder(playlist.id, next.map((i) => i.id));
} catch {
await fetchItems(playlist.id);
}
}
function rowShift(index) {
if (!drag || index === drag.from) return 0;
if (drag.to > drag.from && index > drag.from && index <= drag.to) return -drag.rowH;
if (drag.to < drag.from && index >= drag.to && index < drag.from) return drag.rowH;
return 0;
}
function pageClick() {
menuOpen = false;
confirmDelete = false;
}
function fmtDuration(s) {
if (s == null) return "";
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function fmtTotal(totalSeconds) {
const h = Math.floor(totalSeconds / 3600);
const m = Math.round((totalSeconds % 3600) / 60);
return h > 0 ? `${h} hr ${m} min` : `${m} min`;
}
function fmtStamp(ms) {
if (ms == null) return "";
const total = Math.floor(ms / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return h > 0
? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
: `${m}:${String(s).padStart(2, "0")}`;
}
function focusOnMount(node) {
node.focus();
node.select?.();
}
</script>
<svelte:window onclick={pageClick} />
{#if playlist}
<div class="playlist-page">
<header class="hero">
<!-- A blurred copy of the cover bled up behind the glass bar so the
bar has something to frost — the album page's banner recipe,
painted from the playlist's own cover or mosaic. -->
<div class="banner" aria-hidden="true">
{#if cover}
<img src={cover} alt="" draggable="false" />
{/if}
</div>
<div class="identity">
<div class="cover-slot">
{#if cover}
<img class="cover-img" src={cover} alt="" draggable="false" />
{:else}
<div class="cover-fallback"><Icon name="playlist" size={56} /></div>
{/if}
<!-- There is no artwork viewer for playlist covers, so the
hover affordance stays "choose the photo". -->
<button class="cover-edit" title="Choose cover photo" onclick={chooseCover}>
<Icon name="image" size={16} />
<span>Choose photo</span>
</button>
</div>
<div class="copy">
<p class="kind">
{isSetlist ? "Setlist" : "Playlist"}
{#if playlist.pinned}<span class="pin-chip"><Icon name="pin" size={10} /> Pinned</span>{/if}
</p>
{#if renaming}
<input
class="h1-input"
class:long={nameInput.length > 22}
bind:value={nameInput}
onblur={saveName}
onkeydown={(e) => {
if (e.key === "Enter") saveName();
if (e.key === "Escape") renaming = false;
}}
use:focusOnMount
spellcheck="false"
/>
{:else}
<button
class="h1"
class:long={playlist.name.length > 22}
title="Rename"
onclick={startRename}>{playlist.name}</button>
{/if}
{#if editingDesc}
<textarea
class="desc-input"
bind:value={descInput}
onblur={saveDesc}
onkeydown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
saveDesc();
}
if (e.key === "Escape") editingDesc = false;
}}
use:focusOnMount
placeholder="Add a description"
spellcheck="false"
></textarea>
{:else if playlist.description}
<button class="desc" title="Edit description" onclick={startDesc}>
{playlist.description}
</button>
{:else}
<button class="desc add" onclick={startDesc}>
<Icon name="pencil" size={12} /> Add a description
</button>
{/if}
<p class="meta">
{items.length} {items.length === 1 ? "track" : "tracks"}
{#if totalSecs}&thinsp;·&thinsp;{fmtTotal(totalSecs)}{/if}
{#if unresolvedCount > 0}
&thinsp;·&thinsp;<span class="warn">{unresolvedCount} unresolved</span>
{/if}
{#if missingCount > 0}
&thinsp;·&thinsp;<span class="warn">{missingCount} missing</span>
{/if}
</p>
<div class="actions">
<button
class="pill"
onclick={() => playFrom()}
disabled={queueableRows.length === 0}
>
<Icon name="play" size={12} /> Play
</button>
<button
class="iconbtn"
class:on={player.shuffle}
title="Shuffle"
aria-label="Shuffle this playlist"
onclick={shufflePlay}
disabled={queueableRows.length === 0}
>
<Icon name="shuffle" size={17} />
</button>
<div class="menu-wrap">
<button
class="iconbtn"
title="More"
onclick={(e) => {
e.stopPropagation();
confirmDelete = false;
menuOpen = !menuOpen;
}}
>
<Icon name="more-horizontal" size={17} />
</button>
{#if menuOpen}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div class="menu" role="menu" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<button class="mi" onclick={togglePin}>
<Icon name="pin" size={15} /> {playlist.pinned ? "Unpin" : "Pin"}
</button>
<button class="mi" onclick={duplicate}>
<Icon name="layers" size={15} /> Duplicate
</button>
<div class="rule"></div>
<button class="mi" onclick={chooseCover}>
<Icon name="image" size={15} /> Choose cover photo…
</button>
{#if playlist.cover_path}
<button class="mi" onclick={clearCover}>
<Icon name="x" size={15} /> Remove custom cover
</button>
{/if}
<div class="rule"></div>
{#if unresolvedCount > 0}
<button class="mi" onclick={removeUnresolved}>
<Icon name="x" size={15} /> Remove unresolved entries
</button>
<div class="rule"></div>
{/if}
<button class="mi" onclick={() => exportAs("m3u8")}>
<Icon name="export" size={15} /> Export M3U8
</button>
<button class="mi" onclick={() => exportAs("xspf")}>
<Icon name="export" size={15} /> Export XSPF
</button>
<button class="mi" onclick={() => exportAs("pls")}>
<Icon name="export" size={15} /> Export PLS
</button>
<button class="mi" onclick={() => exportAs("csv")}>
<Icon name="export" size={15} /> Export CSV
</button>
<button class="mi" onclick={() => exportAs("json")}>
<Icon name="export" size={15} /> Export backup (JSON)
</button>
<div class="rule"></div>
<button class="mi danger" onclick={del}>
<Icon name="trash" size={15} />
{confirmDelete ? "Really delete?" : "Delete playlist"}
</button>
</div>
{/if}
</div>
<div class="search">
<Icon name="search" size={14} />
<input
bind:value={query}
placeholder="Search in playlist"
spellcheck="false"
/>
{#if query}
<button class="clear" title="Clear" onclick={() => (query = "")}>
<Icon name="x" size={13} />
</button>
{/if}
</div>
</div>
</div>
</div>
</header>
{#if loaded && items.length === 0}
<div class="empty-note">
<p class="et">This playlist is empty.</p>
<p class="es">
Add tracks from an album page, or import a playlist file from the
Playlists screen.
</p>
</div>
{:else if loaded && visible.length === 0}
<p class="empty-note small">Nothing here matches “{query}”.</p>
{:else}
<div class="tracklist" class:dragging={!!drag}>
<!-- The album page's column header, over this page's own
columns: grip gutter, #, then Title spanning the art. -->
<div class="tcols">
<span></span>
<span class="th-no">#</span>
<span class="th-title">Title</span>
<span class="th-dur"><Icon name="clock" size={13} /></span>
</div>
{#each visible as item, index (item.id)}
{@const isNow = nowItemId != null && nowItemId === item.id}
{@const isDragged = drag?.id === item.id}
<div
class="trow"
class:playing={isNow}
class:unresolved={item.kind === "unresolved"}
class:missing={item.missing}
class:dragged={isDragged}
style={isDragged
? `transform: translateY(${drag.dy}px)`
: rowShift(index)
? `transform: translateY(${rowShift(index)}px)`
: ""}
>
<!-- Always rendered: the rows are a grid now, and a
vanishing first cell would shift every column.
While a filter hides rows the grip goes inert
(gripDown already refuses) and stays invisible. -->
<span
class="grip"
class:off={!!query.trim()}
role="button"
tabindex="-1"
aria-label="Drag to reorder"
title={query.trim() ? "" : "Drag to reorder"}
onpointerdown={(e) => gripDown(e, index)}
onpointermove={gripMove}
onpointerup={gripUp}
onpointercancel={gripUp}
>
<Icon name="grip" size={14} />
</span>
<span class="idx">
{#if isNow}
<span
class="eq"
class:active={player.status === "playing" && ui.windowActive}
><b></b><b></b><b></b></span>
{:else if isSetlist && stampsCoherent}
<span class="stamp">{fmtStamp(item.start_ms)}</span>
{:else}
{index + 1}
{/if}
</span>
<span class="thumb">
{#if item.art_path && rowArt[item.art_path]}
<img src={rowArt[item.art_path]} alt="" draggable="false" />
{:else if item.art_url}
<!-- A stream row's cover is a catalog URL — it
has no local file to read artwork from. -->
<img src={item.art_url} alt="" loading="lazy" draggable="false" />
{:else}
<span class="thumb-fallback"><Icon name="music" size={13} /></span>
{/if}
</span>
<button class="who" onclick={() => rowClick(item)} title={item.source_line ?? item.title}>
<span class="ti">
{item.title}
{#if item.kind === "unresolved"}
<span class="tag un">unresolved</span>
{:else if item.kind === "segment" && !item.missing}
<span
class="tag seg"
title="Unidentified — plays this stretch of the set's own recording"
>
set audio
</span>
{:else if item.missing}
<span class="tag miss">file missing</span>
{/if}
</span>
{#if item.artist || item.album}
<span class="ar">
{item.artist}{item.artist && item.album ? " — " : ""}{item.album ?? ""}
</span>
{/if}
</button>
<span class="rowact">
{#if item.kind !== "local" || item.missing}
<button
class="ra"
title="Find a local match"
onclick={() => (matchFor === item.id ? (matchFor = null) : openMatch(item))}
>
<Icon name="link" size={14} />
</button>
{/if}
<button class="ra rm" title="Remove from playlist" onclick={() => removeRow(item)}>
<Icon name="x" size={14} />
</button>
</span>
<span class="du">{fmtDuration(item.duration_secs)}</span>
</div>
{#if matchFor === item.id}
<div class="matchpanel">
<div class="mp-head">
<Icon name="link" size={13} />
<span>Find a local match for “{item.title}”</span>
</div>
<div class="mp-search">
<input
bind:value={matchQuery}
oninput={runMatchSearch}
placeholder="Search your library"
spellcheck="false"
use:focusOnMount
/>
</div>
{#if matchLoading}
<p class="mp-note">Searching…</p>
{:else if matchResults.length === 0}
<p class="mp-note">
No library tracks match. You can stream it instead by clicking the row.
</p>
{:else}
<div class="mp-rows">
{#each matchResults as candidate (candidate.path)}
<button class="mp-row" onclick={() => applyMatch(item, candidate)}>
<span class="mp-t">{candidate.title}</span>
<span class="mp-a">{candidate.artist} — {candidate.album}</span>
<span class="mp-d">{fmtDuration(candidate.duration_secs)}</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
{/each}
</div>
{/if}
</div>
{:else}
<p class="empty-note">Playlist not found.</p>
{/if}
{#if undo}
<!-- The follow-toast pill, carrying an action: what was removed and one
Undo. Unlike a pure confirmation, the pill itself takes the pointer. -->
<div class="undo-layer" aria-live="polite">
<div class="undo-toast">
<span class="undo-label">{undo.label}</span>
<button class="undo-btn" onclick={applyUndo}>Undo</button>
</div>
</div>
{/if}
<style>
/* The glass bar cancels the pane's top padding so the page's box starts
flush with the top of the pane and the banner can run under the frost;
the page takes that inset back for its own content (App.svelte's
BAR_VIEWS rule — this page follows the album page exactly). */
.playlist-page {
padding-top: var(--pane-pad);
}
/* ---------------------------------------------------- hero banner ----
CatalogAlbum's hero, on this page's anatomy. Same scaling system: one
number sizes the artwork, and every length in the hero is derived from
it so the block scales as one piece. */
.hero {
--cover-scale: 1.175; /* 282px, the album page's own size */
--cover: calc(240px * var(--cover-scale));
position: relative;
margin: 0 -28px;
padding: 0 28px;
min-height: calc(var(--cover) + 68px);
display: flex;
align-items: flex-end;
isolation: isolate;
/* The isolation traps the ⋯ menu's z-index inside the hero's own
stacking context, and the track rows below are positioned too —
painted after the hero, they would lie OVER the open menu. Lifting
the hero one step keeps its menu above the rows while staying far
under the glass bar's 40. (The album page never hits this: its hero
has no dropdown.) */
z-index: 1;
}
/* A blurred copy of the cover — custom photo or the four-cover mosaic —
bled up behind the glass bar so the bar has something to frost. */
.banner {
position: absolute;
inset: -70px 0 0 0;
z-index: -2;
overflow: hidden;
}
.banner img {
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(42px) saturate(1.45);
transform: scale(1.25);
opacity: 0.62;
}
.banner::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(10, 10, 12, 0.5) 0%,
rgba(14, 14, 16, 0.42) 42%,
var(--bg-base) 100%
);
}
.identity {
display: flex;
align-items: stretch;
gap: calc(22px * var(--cover-scale));
/* Fixed: the hero's derived height counts on this foot. */
padding-bottom: 22px;
width: 100%;
/* Narrow panes: the copy drops BELOW the cover instead of being
crushed beside it (the title was truncating to one letter at a
~420px content pane). */
flex-wrap: wrap;
}
.cover-slot {
position: relative;
width: var(--cover);
height: var(--cover);
flex: none;
}
.cover-img,
.cover-fallback {
width: 100%;
height: 100%;
border-radius: 10px;
object-fit: cover;
box-shadow: 0 22px 46px -18px rgba(0, 0, 0, 0.8);
}
.cover-fallback {
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: rgba(0, 0, 0, 0.55);
}
.cover-edit {
position: absolute;
inset: 0;
display: grid;
place-items: center;
gap: 0;
border-radius: 10px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 12.5px;
font-weight: 700;
opacity: 0;
transition: opacity 0.16s;
}
.cover-edit span {
margin-top: 6px;
}
.cover-slot:hover .cover-edit,
.cover-edit:focus-visible {
opacity: 1;
}
/* The scaling root — every length below is em, so type, gaps, pills and
the search field scale by the SAME factor (the album page's rule).
Unlike the album's fixed five rows, this column's height varies — a
description can be absent or three lines — so it cannot tune a gap to
fill the cover exactly. It bottom-anchors instead: the actions row
always ends level with the artwork and the copy stacks up from there. */
.copy {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 1.15em;
font-size: calc(14px * var(--cover-scale));
}
.kind {
display: flex;
align-items: center;
gap: 0.6em;
font-size: 0.714em;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.72);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
}
.pin-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.85em;
letter-spacing: 0.05em;
padding: 2px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.13);
color: rgba(255, 255, 255, 0.8);
}
.h1,
.h1-input {
display: block;
font-size: 3em;
font-weight: 800;
line-height: 1.06;
letter-spacing: -0.015em;
text-align: left;
color: var(--text-primary);
max-width: 100%;
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.45);
}
.h1.long,
.h1-input.long {
font-size: 2.286em;
}
.h1 {
border-radius: 6px;
}
.h1:hover {
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 6px;
}
.h1-input {
width: 100%;
background: rgba(12, 12, 14, 0.45);
border: none;
border-radius: 8px;
padding: 0 8px;
outline: 2px solid var(--accent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.desc,
.desc-input {
display: block;
font-size: 0.79em;
color: rgba(255, 255, 255, 0.78);
text-align: left;
max-width: 40em;
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.5);
}
.desc {
border-radius: 5px;
line-height: 1.45;
}
.desc:hover {
color: #fff;
}
.desc.add {
display: inline-flex;
align-items: center;
gap: 6px;
color: rgba(255, 255, 255, 0.55);
font-size: 0.73em;
}
.desc.add:hover {
color: rgba(255, 255, 255, 0.8);
}
.desc-input {
width: 100%;
min-height: 44px;
resize: vertical;
background: rgba(12, 12, 14, 0.45);
border: none;
border-radius: 8px;
padding: 8px 10px;
outline: 2px solid var(--accent);
color: var(--text-primary);
font: inherit;
font-size: 0.79em;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.meta {
font-size: 0.893em;
color: rgba(255, 255, 255, 0.82);
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.5);
}
.meta .warn {
color: #e8a33d;
font-weight: 600;
}
.actions {
display: flex;
align-items: center;
gap: 0.714em;
flex-wrap: wrap;
}
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: var(--accent);
color: #08080a;
font-size: 0.786em;
font-weight: 800;
padding: 0.571em 1.286em;
border-radius: 999px;
white-space: nowrap;
}
.pill:hover:not(:disabled) {
background: var(--accent-hover);
}
.pill:disabled {
background: var(--bg-highlight);
color: var(--text-muted);
cursor: default;
opacity: 0.6;
}
/* 2em is the pill's own height; matching it keeps the action row one
height (the album page's measured rule). */
.iconbtn {
display: grid;
place-items: center;
width: 2em;
height: 2em;
border-radius: 50%;
color: rgba(255, 255, 255, 0.85);
}
.iconbtn:hover:not(:disabled) {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
/* Shuffle stays lit while it is on, so the state it left behind on the
player is visible from here rather than being a silent global. */
.iconbtn.on {
color: var(--accent-hover);
}
.iconbtn:disabled {
color: rgba(255, 255, 255, 0.28);
cursor: default;
}
.menu-wrap {
position: relative;
}
.menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
min-width: 220px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
z-index: 50;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
}
.mi {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px 10px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-align: left;
white-space: nowrap;
}
.mi:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
.mi.danger:hover {
color: #f15e6c;
}
.menu .rule {
height: 1px;
background: var(--border);
margin: 5px 8px;
}
/* The ghost pill's language (translucent white over the banner, blurred),
at the action row's 2em height. */
.search {
display: flex;
align-items: center;
gap: 7px;
margin-left: auto;
padding: 0 0.9em;
height: 2em;
/* Shrinks with the pane rather than forcing the action row to wrap. */
min-width: 8em;
max-width: 17em;
flex: 1;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
color: rgba(255, 255, 255, 0.55);
transition: background 0.14s ease, color 0.14s ease;
}
.search:focus-within {
background: rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.75);
}
.search input {
flex: 1;
min-width: 0;
background: none;
border: none;
outline: none;
color: #fff;
font-size: 0.786em;
font-weight: 600;
}
.search input::placeholder {
color: rgba(255, 255, 255, 0.5);
font-weight: 500;
}
.search .clear {
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.6);
}
.search .clear:hover {
color: #fff;
}
.empty-note {
color: var(--text-secondary);
padding-top: 10vh;
text-align: center;
}
.empty-note.small {
padding-top: 4vh;
font-size: 13px;
}
.empty-note .et {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.empty-note .es {
margin-top: 6px;
font-size: 13px;
color: var(--text-muted);
}
.tracklist {
display: flex;
flex-direction: column;
margin-top: 14px;
padding-bottom: 24px;
}
/* Header and rows share one template so the columns line up — the album
page's rule, over this page's own columns: grip, #, art, title, clock.
The row actions ride the right end as a hover overlay (below) rather
than owning a column. */
.tcols,
.trow {
display: grid;
grid-template-columns: 18px 44px 34px minmax(0, 1fr) 48px;
align-items: center;
gap: 12px;
}
.tcols {
padding: 0 10px 7px;
margin-bottom: 3px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-muted);
}
.th-no {
text-align: right;
}
/* Spans the art column too: that is where a row's content begins. */
.th-title {
grid-column: 3 / 5;
}
.th-dur {
display: flex;
justify-content: flex-end;
}
.trow {
padding: 7px 10px;
border-radius: 6px;
font-size: 13.5px;
transition: transform 0.15s ease;
position: relative;
}
.tracklist.dragging .trow {
/* While a drag is live the moved row must track the pointer with no
easing, and its neighbours take the eased shift. */
transition: transform 0.15s ease;
}
.trow.dragged {
transition: none;
z-index: 5;
background: var(--bg-press);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
}
.trow:hover {
background: var(--bg-highlight);
}
.trow.playing .ti {
color: var(--accent-hover);
}
.trow.unresolved .ti,
.trow.missing .ti {
color: var(--text-secondary);
}
.grip {
display: grid;
place-items: center;
width: 18px;
color: transparent;
cursor: grab;
touch-action: none;
}
.trow:hover .grip,
.trow.dragged .grip {
color: var(--text-muted);
}
.trow.dragged .grip {
cursor: grabbing;
}
/* Reorder is disabled while a filter hides rows; the grip stays for the
grid's geometry but neither shows nor invites. */
.grip.off {
cursor: default;
}
.trow:hover .grip.off {
color: transparent;
}
.idx {
text-align: right;
color: var(--text-muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.stamp {
color: var(--accent-hover);
font-size: 11.5px;
}
.thumb {
width: 34px;
height: 34px;
flex: none;
border-radius: 4px;
overflow: hidden;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumb-fallback {
display: grid;
place-items: center;
width: 100%;
height: 100%;
background: var(--bg-highlight);
color: var(--text-muted);
}
.who {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
text-align: left;
}
.ti {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 8px;
}
.ar {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag {
flex: none;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 7px;
}
.tag.un {
background: rgba(232, 163, 61, 0.15);
color: #e8a33d;
}
.tag.seg {
background: rgba(120, 170, 255, 0.14);
color: #8fb8ff;
}
.tag.miss {
background: rgba(241, 94, 108, 0.13);
color: #f15e6c;
}
/* The album page's hover reveal: the actions slide over the right end of
the row and the duration gets out of the way. */
.rowact {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: none;
gap: 2px;
}
.trow:hover .rowact,
.trow:focus-within .rowact {
display: flex;
}
.ra {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
color: var(--text-secondary);
}
.ra:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
.ra.rm:hover {
color: #f15e6c;
}
.du {
color: var(--text-muted);
font-size: 11.5px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.trow:hover .du,
.trow:focus-within .du {
/* pointer-events too: opacity forms a stacking context, and .du sits
AFTER .rowact in the DOM — invisible, it still painted above the
action buttons and ate their clicks. */
opacity: 0;
pointer-events: none;
}
.eq {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 11px;
}
.eq b {
width: 2.5px;
height: 35%;
background: var(--accent-hover);
border-radius: 1px;
}
.eq.active b {
animation: eq 0.9s ease-in-out infinite;
}
.eq.active b:nth-child(2) {
animation-delay: -0.2s;
}
.eq.active b:nth-child(3) {
animation-delay: -0.55s;
}
@keyframes eq {
0%,
100% {
height: 35%;
}
50% {
height: 100%;
}
}
.matchpanel {
/* Indented to the art column's start, under the row it repairs. */
margin: 2px 10px 8px 96px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-elevated);
padding: 12px 14px;
}
.mp-head {
display: flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
font-weight: 700;
color: var(--text-secondary);
margin-bottom: 9px;
}
.mp-search input {
width: 100%;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 11px;
font-size: 13px;
color: var(--text-primary);
outline: none;
}
.mp-search input:focus {
border-color: var(--accent);
}
.mp-note {
margin-top: 9px;
font-size: 12px;
color: var(--text-muted);
}
.mp-rows {
margin-top: 7px;
max-height: 230px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1px;
}
.mp-row {
display: flex;
align-items: center;
gap: 12px;
padding: 7px 9px;
border-radius: 6px;
text-align: left;
}
.mp-row:hover {
background: var(--bg-highlight);
}
.mp-t {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: none;
max-width: 40%;
}
.mp-a {
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mp-d {
flex: none;
font-size: 11.5px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
/* ── undo toast — the follow pill's geometry, with a live button ── */
.undo-layer {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--player-height, 62px) + 8px + 26px);
display: flex;
justify-content: center;
z-index: 80;
pointer-events: none;
}
.undo-toast {
display: inline-flex;
align-items: center;
gap: 14px;
padding: 9px 10px 9px 16px;
border-radius: 99px;
background: rgba(22, 23, 28, 0.94);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55);
font-size: 12.5px;
font-weight: 700;
color: #fff;
pointer-events: auto;
animation: undo-in 340ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.undo-btn {
padding: 5px 13px;
border-radius: 99px;
background: var(--accent);
color: #000;
font-size: 12px;
font-weight: 800;
}
.undo-btn:hover {
background: var(--accent-hover);
}
@keyframes undo-in {
from {
opacity: 0;
transform: translateY(16px) scale(0.95);
}
to {
opacity: 1;
transform: none;
}
}
/* The album page's narrow-pane step: the whole hero scales down as one
piece, with the title pinned to readable fixed sizes. */
@media (max-width: 820px) {
.hero {
--cover-scale: 0.62;
}
.identity {
gap: 16px;
}
.h1,
.h1-input {
font-size: 32px;
}
.h1.long,
.h1-input.long {
font-size: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.trow {
transition: none;
}
.eq.active b {
animation: none;
}
.undo-toast {
animation-duration: 1ms;
}
}
</style>