1719 lines
60 KiB
Svelte
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} · {fmtTotal(totalSecs)}{/if}
|
|
{#if unresolvedCount > 0}
|
|
 · <span class="warn">{unresolvedCount} unresolved</span>
|
|
{/if}
|
|
{#if missingCount > 0}
|
|
 · <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>
|