SURFACES: panes add black, surfaces INSIDE panes add white. Every pane stands on 0.7075 black over the acrylic (.content 0.35 over .app 0.55), so a translucent black card nets 0.81 - darker than its own pane and inside the ~0.85 band already rejected once on the artist page. Solid mode's ladder is measured UPWARD from #121212 (+6/+18/+24), so glass elevates with WHITE: new --glass-surface/-hover/-press/-hairline/-well/-ring under :root.glass (0.03/0.08/0.11/0.10/0.18), which is independently the vocabulary .btn-ghost:hover and .ax-ghost:hover already shipped. The four solid tokens are NEVER redefined - they are consumed inside color-mix() and gradient stops where an alpha multiplies through and kills the call site (0.74 x 0.03 = an invisible chip; the album row's text-overflow fade works by BEING opaque), and they paint modal chrome that has to stay solid. 129 additive :global(:root.glass) declarations across 18 files, all 129 verified inside a glass block by walking the diff - solid mode byte-identical, confirmed structurally, by computed style with the class off, and visually. Three of them are not colour swaps: the album row's .rowbtns shim was OCCLUDING the last ~19px of the listeners bar and nothing translucent can occlude, so .tcount masks out just before the buttons instead; Playlist's banner scrim and the discography's pinned ambience both landed on opaque --bg-base and now dissolve like the album banner; and the 0.9 black hero tint on Home/CatalogSearch/Discography paints straight onto the pane when there is no artwork, so :first-child (the {#if} renders no element) flips it to white. Menus, dialogs, toasts and the import surfaces stay opaque deliberately, and the Settings copy for the mode now says so instead of claiming cards do. Measured with a known ground substituted behind the transparent window, since CDP never sees the acrylic: library toolbar chroma +2 as shipped, +16 with a white fill AND its backdrop-filter dropped, +30 for the bare pane beside it. RESIZE: Windows drops the legacy accent-policy acrylic for the whole of a live resize and the window stays transparent through it, so the desktop punches through sharp - measured by toggling window_glass alone, desktop icon labels readable through the titlebar. The blur cannot be kept (documented since Win10 2004, and the modern DWM backdrop was already rejected for the overlay-promotion blink), so armGlassResizeGuard grounds the canvas to --bg-sidebar for the length of the gesture. STALLS: "choppy but the FPS says 144" was 200-750ms MAIN-THREAD TASKS, not frame pacing - idle-scheduled work fires at scroll pauses by definition. Twin bake moved to display resolution and off the ready task (was a same-task 4K WebGL readback plus software blur, 100-170ms per rotation AND per artist entry); gl.finish() -> gl.flush() in buildMaster, which had been draining the whole 4K EASU/RCAS/grade chain on the main thread; copyCanvas realloc guarded; one app-wide scroll clock in scrollbar.js (markAppScroll/appScrollIdleFor) so all three periscopes stop cloning mid-gesture on ANY pane rather than only their own; bounded reclone with the querySelectorAll census deleted, the gate widened 250->1000ms, and the build/attach split across an idle callback and a rAF behind a generation guard; the hero rotation tick gated on scroll recency and its payload cache sized to the working set (2->4 entries, 24->32MiB, 32->64MiB); sessionTick no longer force-rewrites every 12s while merely PAUSED. Scroll storm went from 109-616ms long tasks to zero. Hover recalc and GC were measured innocent - do not re-chase them. 98/98 tests throughout.
1958 lines
70 KiB
Svelte
1958 lines
70 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,
|
|
cachedStreamArt,
|
|
playlistBulk,
|
|
runPlaylistBulk,
|
|
cancelPlaylistBulk,
|
|
dismissPlaylistBulk,
|
|
} from "../lib/state.svelte.js";
|
|
import { untrack } from "svelte";
|
|
import * as api from "../lib/api.js";
|
|
import { findCatalogTrack } from "../lib/catalogMatch.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 exportOpen = $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 artAttempted = new Set();
|
|
let backfillBusy = false;
|
|
$effect(() => {
|
|
if (!playlist) return;
|
|
const want = items.filter(
|
|
(i) =>
|
|
i.kind === "stream" &&
|
|
!i.art_url &&
|
|
!i.art_path &&
|
|
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);
|
|
// The file BEFORE the catalog: a row already streamed once
|
|
// holds the peer's own copy, and underground rows — the ones
|
|
// with no catalog cover to find, which is why they are in
|
|
// this walk at all — are exactly the ones whose files carry
|
|
// artwork nothing else has.
|
|
const embedded = await cachedStreamArt(row.track_id, row.artist, row.title);
|
|
if (ui.playlistId !== plId) return;
|
|
if (embedded) {
|
|
try {
|
|
await api.playlistRelinkItem(plId, row.id, { artPath: embedded });
|
|
const mine = items.find((i) => i.id === row.id);
|
|
if (mine) mine.art_path = embedded;
|
|
} catch {
|
|
// The row vanished mid-walk — nothing to persist onto.
|
|
}
|
|
continue;
|
|
}
|
|
// The SAME lookup the import review uses — one definition of
|
|
// "this row is that record", so a row that matched on import
|
|
// matches here too.
|
|
let hit = null;
|
|
try {
|
|
hit = await findCatalogTrack(
|
|
(query) => api.catalogSearchTracks(query),
|
|
{
|
|
title: row.title,
|
|
artist: row.artist,
|
|
duration_secs: row.duration_secs ?? null,
|
|
},
|
|
(artist, title) => api.catalogArtistTrackCandidates(artist, title),
|
|
);
|
|
} 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;
|
|
}
|
|
}
|
|
|
|
// A row the walk above could not help: its file was not cached when the
|
|
// page opened, so nothing local existed to read. The player extracts the
|
|
// cover the moment it streams one — if that row is one of ours, keep it,
|
|
// rather than making the row blank again on the next visit.
|
|
$effect(() => {
|
|
const q = player.queue[player.queueIndex];
|
|
const found = q?.art;
|
|
const rowId = q?.plItemId;
|
|
if (!found || rowId == null || !playlist) return;
|
|
const mine = items.find((i) => i.id === rowId);
|
|
if (!mine || mine.art_path === found) return;
|
|
const plId = playlist.id;
|
|
// Writing back into `items` is what this effect READS; untrack keeps
|
|
// the assignment from re-entering it, and the equality guard above
|
|
// makes a second pass a no-op either way.
|
|
untrack(() => {
|
|
mine.art_path = found;
|
|
artAttempted.add(rowId);
|
|
api.playlistRelinkItem(plId, rowId, { artPath: found }).catch(() => {});
|
|
});
|
|
});
|
|
|
|
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();
|
|
}
|
|
// The two collection actions — stream rows with full identity are the
|
|
// material; the runner dedupes namesakes and narrates via playlistBulk.
|
|
const bulkableCount = $derived(
|
|
items.filter((i) => i.kind === "stream" && i.artist && i.title).length,
|
|
);
|
|
function addCachedToLibrary() {
|
|
menuOpen = false;
|
|
void runPlaylistBulk(playlist.id, items, "cached");
|
|
}
|
|
function downloadAllTracks() {
|
|
menuOpen = false;
|
|
void runPlaylistBulk(playlist.id, items, "download");
|
|
}
|
|
|
|
async function exportAs(format) {
|
|
menuOpen = false;
|
|
exportOpen = 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;
|
|
exportOpen = 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>
|
|
{/if}
|
|
<div class="rule"></div>
|
|
<button
|
|
class="mi"
|
|
onclick={addCachedToLibrary}
|
|
disabled={playlistBulk.active || bulkableCount === 0}
|
|
title="File every already-streamed track into the library — nothing is downloaded"
|
|
>
|
|
<Icon name="library" size={15} /> Add to library
|
|
</button>
|
|
<button
|
|
class="mi"
|
|
onclick={downloadAllTracks}
|
|
disabled={playlistBulk.active || bulkableCount === 0}
|
|
title="Find and download every track that isn't in the library yet"
|
|
>
|
|
<Icon name="download" size={15} /> Download all tracks
|
|
</button>
|
|
<div class="rule"></div>
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="sub-wrap"
|
|
onmouseenter={() => (exportOpen = true)}
|
|
onmouseleave={() => (exportOpen = false)}
|
|
>
|
|
<button
|
|
class="mi"
|
|
onclick={() => (exportOpen = !exportOpen)}
|
|
aria-haspopup="menu"
|
|
aria-expanded={exportOpen}
|
|
>
|
|
<Icon name="export" size={15} /> Export
|
|
<span class="sub-arrow"><Icon name="chevron-right" size={13} /></span>
|
|
</button>
|
|
{#if exportOpen}
|
|
<div class="menu sub" role="menu">
|
|
<button class="mi" onclick={() => exportAs("m3u8")}>M3U8</button>
|
|
<button class="mi" onclick={() => exportAs("xspf")}>XSPF</button>
|
|
<button class="mi" onclick={() => exportAs("pls")}>PLS</button>
|
|
<button class="mi" onclick={() => exportAs("csv")}>CSV</button>
|
|
<button class="mi" onclick={() => exportAs("json")}>Backup (JSON)</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<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>
|
|
{#if playlistBulk.playlistId === playlist.id && (playlistBulk.active || playlistBulk.summary)}
|
|
<div class="bulkline">
|
|
{#if playlistBulk.active}
|
|
<span class="ring"></span>
|
|
<span>
|
|
{playlistBulk.mode === "cached"
|
|
? "Adding streamed tracks to the library"
|
|
: "Downloading tracks"}
|
|
— {playlistBulk.done}/{playlistBulk.total}
|
|
{#if playlistBulk.organized} · {playlistBulk.organized} saved{/if}
|
|
{#if playlistBulk.queued} · {playlistBulk.queued} downloading{/if}
|
|
{#if playlistBulk.missed} · {playlistBulk.missed} missed{/if}
|
|
</span>
|
|
<button class="bulkx" title="Stop" onclick={cancelPlaylistBulk}>
|
|
<Icon name="x" size={13} />
|
|
</button>
|
|
{:else}
|
|
<Icon name="check" size={13} />
|
|
<span>{playlistBulk.summary}</span>
|
|
<button class="bulkx" title="Dismiss" onclick={dismissPlaylistBulk}>
|
|
<Icon name="x" size={13} />
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</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;
|
|
}
|
|
.sub-wrap {
|
|
position: relative;
|
|
}
|
|
.sub-wrap > .mi {
|
|
width: 100%;
|
|
}
|
|
.sub-arrow {
|
|
margin-left: auto;
|
|
display: inline-flex;
|
|
color: var(--text-tertiary);
|
|
}
|
|
/* The flyout: the same glass card, berthed beside its row. The bridge
|
|
(::before) covers the 6px gap so the pointer can cross without the
|
|
mouseleave closing it. */
|
|
.menu.sub {
|
|
position: absolute;
|
|
left: calc(100% + 4px);
|
|
top: -6px;
|
|
min-width: 130px;
|
|
}
|
|
.menu.sub::before {
|
|
content: "";
|
|
position: absolute;
|
|
left: -8px;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 8px;
|
|
}
|
|
.mi:disabled {
|
|
opacity: 0.45;
|
|
pointer-events: none;
|
|
}
|
|
.bulkline {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-top: 10px;
|
|
padding: 7px 12px;
|
|
border-radius: 999px;
|
|
width: fit-content;
|
|
max-width: 100%;
|
|
background: var(--bg-elevated);
|
|
border: 1px solid var(--border);
|
|
font-size: 12.5px;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
}
|
|
.bulkline .ring {
|
|
width: 12px;
|
|
height: 12px;
|
|
flex: none;
|
|
border-radius: 50%;
|
|
border: 2px solid var(--border);
|
|
border-top-color: var(--accent);
|
|
animation: bulkspin 0.9s linear infinite;
|
|
}
|
|
@keyframes bulkspin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
.bulkline .bulkx {
|
|
display: inline-flex;
|
|
color: var(--text-tertiary);
|
|
padding: 2px;
|
|
border-radius: 50%;
|
|
}
|
|
.bulkline .bulkx:hover {
|
|
color: var(--text-primary);
|
|
background: var(--bg-highlight);
|
|
}
|
|
.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;
|
|
}
|
|
}
|
|
|
|
/* ── dark glass ────────────────────────────────────────────────────────
|
|
Ladder in global.css. Solid mode is untouched throughout.
|
|
|
|
The banner is the album page's problem, verbatim: its ::after scrim
|
|
lands on opaque --bg-base, which keeps the whole hero zone solid over
|
|
the frost, and a thinner landing COLOUR cannot fix it — a translucent
|
|
tail can never match frost and draws a hard line at the box edge
|
|
(CatalogAlbum.svelte carries Kyle's note from 2026-08-26). So the layer
|
|
dissolves instead: img and scrim fade out together and end on frost at
|
|
frost's own value. Same stops as the album banner, for the same reason. */
|
|
:global(:root.glass) .banner {
|
|
mask-image: linear-gradient(to bottom, #000 55%, transparent 98%);
|
|
-webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent 98%);
|
|
}
|
|
|
|
/* Rows. .dragged keeps its lift by staying one rung ABOVE hover, which is
|
|
the relationship it has in solid mode (--bg-press over --bg-highlight). */
|
|
:global(:root.glass) .trow:hover,
|
|
:global(:root.glass) .mp-row:hover {
|
|
background: var(--glass-hover);
|
|
}
|
|
:global(:root.glass) .trow.dragged {
|
|
background: var(--glass-press);
|
|
}
|
|
|
|
/* Title and description editors, and the track filter: their backdrops
|
|
reach the see-through pane, where a backdrop sample black-flattens
|
|
(measured — Sidebar.svelte holds the numbers). The fills already carry
|
|
the look on their own. */
|
|
:global(:root.glass) .h1-input,
|
|
:global(:root.glass) .desc-input,
|
|
:global(:root.glass) .search {
|
|
backdrop-filter: none;
|
|
-webkit-backdrop-filter: none;
|
|
}
|
|
|
|
:global(:root.glass) .bulkline {
|
|
background: var(--glass-surface);
|
|
border-color: var(--glass-hairline);
|
|
}
|
|
:global(:root.glass) .bulkline .bulkx:hover {
|
|
background: var(--glass-hover);
|
|
}
|
|
:global(:root.glass) .pill:disabled,
|
|
:global(:root.glass) .thumb-fallback {
|
|
background: var(--glass-surface);
|
|
}
|
|
|
|
/* Menus stay solid on purpose — see the note in Playlists.svelte. */
|
|
</style>
|