playlists + lyrics: setlists with pages of their own, and the words timed to the voice

This commit is contained in:
KeelrLeek committed 2026-08-17 18:05:31 -05:00
1 parent c4ff6269a3
commit df3d15553a
49 files changed
+14419 -599

No files matched your search

+591 -62
View File
@@ -1,132 +1,661 @@
<script>
// The sidebar's Playlists row needs a destination. Playlists used to be
// listed in the sidebar itself; with the sidebar showing listening history
// instead, this is where they live.
import { playlistStore, refreshPlaylists, goToView } from "../lib/state.svelte.js";
// The playlist shelf: every playlist as a cover card — custom art or the
// composed four-cover mosaic — with pinning, duplication, export, and the
// two import doors (a playlist file, a pasted tracklist).
import {
playlistStore,
refreshPlaylists,
playlistCoverFor,
goToView,
playAlbum,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./../components/Icon.svelte";
import PlaylistImportModal from "./../components/PlaylistImportModal.svelte";
let importOpen = $state(false);
let importMode = $state("file"); // "file" | "paste"
let importDraft = $state(null);
let menuFor = $state(null); // playlist id whose ⋯ menu is open
let confirmDelete = $state(null); // playlist id armed for deletion
let creating = $state(false);
let newMenuOpen = $state(false);
let probing = $state(false);
$effect(() => {
if (!playlistStore.loaded) refreshPlaylists();
});
// Covers resolve lazily through the store cache; re-render never refetches.
$effect(() => {
for (const pl of playlistStore.playlists) void playlistCoverFor(pl);
});
const pinned = $derived(playlistStore.playlists.filter((p) => p.pinned));
const rest = $derived(playlistStore.playlists.filter((p) => !p.pinned));
async function newPlaylist() {
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
const id = await api.createPlaylist(name);
await refreshPlaylists();
goToView("playlist", { playlistId: id, label: name });
if (creating) return;
creating = true;
try {
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
const id = await api.createPlaylist(name);
await refreshPlaylists();
goToView("playlist", { playlistId: id, label: name });
} finally {
creating = false;
}
}
async function importFromFile() {
newMenuOpen = false;
if (probing) return;
probing = true;
try {
const draft = await api.playlistImportProbe();
if (draft) {
importDraft = draft;
importMode = "file";
importOpen = true;
}
} catch (e) {
console.error("playlist import probe failed", e);
} finally {
probing = false;
}
}
async function importFromFolder() {
newMenuOpen = false;
if (probing) return;
probing = true;
try {
const draft = await api.playlistImportFolder();
if (draft) {
importDraft = draft;
importMode = "file";
importOpen = true;
}
} catch (e) {
console.error("folder import failed", e);
} finally {
probing = false;
}
}
function importFromText() {
newMenuOpen = false;
importDraft = null;
importMode = "paste";
importOpen = true;
}
function open(pl) {
goToView("playlist", { playlistId: pl.id, label: pl.name });
}
// Hover-play, straight from the card — the Library's own affordance.
// Generation-guarded: a slower fetch from an EARLIER click must not
// override the playlist clicked after it.
let playCardGen = 0;
async function playCard(pl) {
const gen = ++playCardGen;
const rows = await api.playlistItems(pl.id);
if (gen !== playCardGen) return;
const playable = rows.filter((r) => r.path && !r.missing);
if (!playable.length) {
open(pl); // nothing playable: the page explains why
return;
}
playAlbum(
playable.map((r) => ({
path: r.path,
title: r.title,
artist: r.artist,
album: r.album,
duration_secs: r.duration_secs,
art_path: r.art_path,
plItemId: r.id,
})),
pl.name,
0,
);
}
function toggleMenu(e, pl) {
e.stopPropagation();
confirmDelete = null;
menuFor = menuFor === pl.id ? null : pl.id;
}
async function togglePin(pl) {
menuFor = null;
await api.setPlaylistPinned(pl.id, !pl.pinned);
await refreshPlaylists();
}
async function duplicate(pl) {
menuFor = null;
const id = await api.duplicatePlaylist(pl.id, `${pl.name} copy`);
await refreshPlaylists();
goToView("playlist", { playlistId: id, label: `${pl.name} copy` });
}
async function exportAs(pl, format) {
menuFor = null;
try {
await api.playlistExport(pl.id, format);
} catch (e) {
console.error("playlist export failed", e);
}
}
async function remove(pl) {
if (confirmDelete !== pl.id) {
confirmDelete = pl.id;
return;
}
menuFor = null;
confirmDelete = null;
await api.deletePlaylist(pl.id);
await refreshPlaylists();
}
function onImported(id, name) {
importOpen = false;
importDraft = null;
refreshPlaylists();
goToView("playlist", { playlistId: id, label: name });
}
function fmtDuration(totalSecs) {
if (!totalSecs) return "";
const h = Math.floor(totalSecs / 3600);
const m = Math.round((totalSecs % 3600) / 60);
return h > 0 ? `${h} hr ${m} min` : `${m} min`;
}
// Close any open menu on a page click.
function pageClick() {
menuFor = null;
newMenuOpen = false;
confirmDelete = null;
}
</script>
<svelte:window onclick={pageClick} />
<div class="page">
<header>
<h1>Playlists</h1>
<button class="new" onclick={newPlaylist}>
<Icon name="plus" size={15} /> New playlist
</button>
<div class="head-text">
<h1>Playlists</h1>
{#if playlistStore.playlists.length}
<p class="sub">
{playlistStore.playlists.length}
{playlistStore.playlists.length === 1 ? "playlist" : "playlists"}
</p>
{/if}
</div>
<div class="head-actions">
<div class="menu-wrap">
<button
class="btn ghost"
onclick={(e) => {
e.stopPropagation();
menuFor = null;
newMenuOpen = !newMenuOpen;
}}
>
<Icon name="download" size={15} /> Import
<Icon name="chevron-down" size={13} />
</button>
{#if newMenuOpen}
<div class="menu" role="menu">
<button class="mi" onclick={importFromFile} disabled={probing}>
<Icon name="file-text" size={15} />
Playlist file…
<span class="hint">M3U · XSPF · PLS · CSV · JSON</span>
</button>
<button class="mi" onclick={importFromFolder} disabled={probing}>
<Icon name="folder-open" size={15} />
Folder of audio files…
<span class="hint">tag or natural order</span>
</button>
<button class="mi" onclick={importFromText}>
<Icon name="pencil" size={15} />
Paste a tracklist…
<span class="hint">plain lines or timestamped setlist</span>
</button>
</div>
{/if}
</div>
<button class="btn primary" onclick={newPlaylist} disabled={creating}>
<Icon name="plus" size={15} /> New playlist
</button>
</div>
</header>
{#if playlistStore.playlists.length === 0}
<p class="empty">No playlists yet.</p>
<div class="empty">
<div class="empty-icon"><Icon name="playlist" size={44} /></div>
<p class="empty-title">No playlists yet</p>
<p class="empty-sub">
Create one, or import an M3U/XSPF file or a pasted tracklist.
</p>
</div>
{:else}
<ul>
{#each playlistStore.playlists as pl (pl.id)}
<li>
<button class="row" onclick={() => open(pl)}>
<span class="ic"><Icon name="playlist" size={18} /></span>
<span class="name">{pl.name}</span>
<span class="count">
{pl.track_count}
{pl.track_count === 1 ? "track" : "tracks"}
</span>
</button>
</li>
{#if pinned.length}
<div class="lab"><Icon name="pin" size={12} /> Pinned</div>
<div class="grid">
{#each pinned as pl (pl.id)}
{@render card(pl)}
{/each}
</div>
{#if rest.length}
<div class="lab all">All playlists</div>
{/if}
{/if}
<div class="grid">
{#each rest as pl (pl.id)}
{@render card(pl)}
{/each}
</ul>
</div>
{/if}
</div>
{#snippet card(pl)}
<div class="card-wrap">
<button class="card" type="button" onclick={() => open(pl)}>
<span class="artwrap">
{#if playlistStore.covers[pl.id]}
<img class="art" src={playlistStore.covers[pl.id]} alt="" draggable="false" />
{:else}
<div class="art fallback">
<Icon name="playlist" size={38} />
</div>
{/if}
{#if pl.track_count > pl.unresolved_count}
<span
class="hoverplay"
role="button"
tabindex="0"
title="Play"
onclick={(e) => {
e.stopPropagation();
playCard(pl);
}}
onkeydown={(e) => e.key === "Enter" && (e.stopPropagation(), playCard(pl))}
><Icon name="play" size={13} /></span>
{/if}
</span>
<div class="card-title" title={pl.name}>
{pl.name}
</div>
<div class="card-sub">
{pl.track_count} {pl.track_count === 1 ? "track" : "tracks"}
{#if pl.duration_secs}&thinsp;·&thinsp;{fmtDuration(pl.duration_secs)}{/if}
</div>
{#if pl.kind === "setlist" || pl.unresolved_count > 0}
<div class="badges">
{#if pl.kind === "setlist"}<span class="badge set">Setlist</span>{/if}
{#if pl.unresolved_count > 0}
<span class="badge un">{pl.unresolved_count} unresolved</span>
{/if}
</div>
{/if}
</button>
{#if pl.pinned}
<span class="pinned-mark" title="Pinned"><Icon name="pin" size={12} /></span>
{/if}
<button class="more" title="More" onclick={(e) => toggleMenu(e, pl)}>
<Icon name="more-horizontal" size={17} />
</button>
{#if menuFor === pl.id}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div class="menu card-menu" role="menu" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<button class="mi" onclick={() => togglePin(pl)}>
<Icon name="pin" size={15} /> {pl.pinned ? "Unpin" : "Pin"}
</button>
<button class="mi" onclick={() => duplicate(pl)}>
<Icon name="layers" size={15} /> Duplicate
</button>
<div class="rule"></div>
<button class="mi" onclick={() => exportAs(pl, "m3u8")}>
<Icon name="export" size={15} /> Export M3U8
</button>
<button class="mi" onclick={() => exportAs(pl, "xspf")}>
<Icon name="export" size={15} /> Export XSPF
</button>
<button class="mi" onclick={() => exportAs(pl, "pls")}>
<Icon name="export" size={15} /> Export PLS
</button>
<button class="mi" onclick={() => exportAs(pl, "csv")}>
<Icon name="export" size={15} /> Export CSV
</button>
<button class="mi" onclick={() => exportAs(pl, "json")}>
<Icon name="export" size={15} /> Export backup (JSON)
</button>
<div class="rule"></div>
<button class="mi danger" onclick={() => remove(pl)}>
<Icon name="trash" size={15} />
{confirmDelete === pl.id ? "Really delete?" : "Delete"}
</button>
</div>
{/if}
</div>
{/snippet}
<PlaylistImportModal
bind:open={importOpen}
mode={importMode}
draft={importDraft}
onimported={onImported}
/>
<style>
.page {
padding: 8px 4px 24px;
}
header {
display: flex;
align-items: center;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
margin-bottom: 22px;
}
h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
}
.new {
.sub {
margin-top: 3px;
font-size: 12.5px;
color: var(--text-muted);
}
.head-actions {
display: flex;
align-items: center;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
border-radius: 8px;
padding: 9px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-highlight);
font-weight: 700;
transition:
background 0.14s,
color 0.14s;
color 0.14s,
transform 0.14s;
}
.new:hover {
.btn.primary {
background: var(--accent);
color: #000;
}
.btn.primary:hover:not(:disabled) {
background: var(--accent-hover);
transform: scale(1.02);
}
.btn.ghost {
background: var(--bg-highlight);
color: var(--text-secondary);
}
.btn.ghost:hover {
background: var(--bg-press);
color: var(--text-primary);
}
ul {
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
.btn:disabled {
opacity: 0.55;
}
.row {
.menu-wrap {
position: relative;
}
.menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 230px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
z-index: 40;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
}
.mi {
display: flex;
align-items: center;
gap: 13px;
gap: 10px;
width: 100%;
padding: 11px 12px;
padding: 9px 10px;
border-radius: 7px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
transition: background 0.14s;
text-align: left;
white-space: nowrap;
}
.row:hover {
.mi:hover:not(:disabled) {
background: var(--bg-highlight);
color: var(--text-primary);
}
.ic {
.mi:disabled {
opacity: 0.5;
}
.mi.danger:hover {
color: #f15e6c;
}
.mi .hint {
margin-left: auto;
font-size: 10.5px;
font-weight: 600;
color: var(--text-muted);
padding-left: 14px;
}
.menu .rule {
height: 1px;
background: var(--border);
margin: 5px 8px;
}
.lab {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin: 4px 2px 12px;
}
.lab.all {
margin-top: 26px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 16px;
}
.card-wrap {
position: relative;
}
.card {
display: flex;
flex-direction: column;
text-align: left;
background: var(--bg-elevated);
border-radius: var(--radius);
padding: 13px;
width: 100%;
height: 100%;
transition: background 0.2s;
}
.card:hover {
background: var(--bg-highlight);
}
.artwrap {
position: relative;
display: block;
margin-bottom: 11px;
}
.art {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 6px;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35);
}
/* The Library card's own hover-play, verbatim in spirit: accent circle
riding the art's corner, revealed by the card hover. */
.hoverplay {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 2;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 5px;
background: var(--bg-highlight);
color: var(--text-muted);
flex: none;
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--accent);
color: #000;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.5);
opacity: 0;
transform: translateY(4px);
transition:
opacity 0.18s ease,
transform 0.18s ease,
background 0.18s ease;
}
.name {
flex: 1;
min-width: 0;
font-size: 14px;
.hoverplay:hover {
background: var(--accent-hover);
}
.card-wrap:hover .hoverplay,
.hoverplay:focus-visible {
opacity: 1;
transform: none;
}
.art.fallback {
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--accent-soft), var(--bg-press) 72%);
color: var(--text-secondary);
}
.card-title {
font-weight: 600;
font-size: 14px;
line-height: 1.3;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.card-sub {
font-size: 12px;
color: var(--text-secondary);
margin-top: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.count {
font-size: 12px;
color: var(--text-muted);
flex: none;
.badges {
display: flex;
gap: 5px;
margin-top: 7px;
flex-wrap: wrap;
}
.badge {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 2.5px 7px;
border-radius: 8px;
}
.badge.set {
background: var(--accent-soft);
color: var(--accent-hover);
}
.badge.un {
background: rgba(232, 163, 61, 0.15);
color: #e8a33d;
}
.pinned-mark {
position: absolute;
top: 20px;
left: 20px;
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
backdrop-filter: blur(6px);
pointer-events: none;
}
.more {
position: absolute;
top: 20px;
right: 20px;
display: grid;
place-items: center;
width: 27px;
height: 27px;
border-radius: 50%;
color: #fff;
background: rgba(0, 0, 0, 0.45);
opacity: 0;
transition:
opacity 0.15s,
background 0.15s;
}
.card-wrap:hover .more,
.more:focus-visible {
opacity: 1;
}
.more:hover {
background: rgba(0, 0, 0, 0.7);
}
.card-menu {
top: 48px;
right: 14px;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 14vh;
text-align: center;
}
.empty-icon {
display: grid;
place-items: center;
width: 92px;
height: 92px;
border-radius: 24px;
background: var(--bg-elevated);
color: var(--text-muted);
margin-bottom: 18px;
}
.empty-title {
font-size: 17px;
font-weight: 700;
}
.empty-sub {
margin-top: 5px;
font-size: 13px;
color: var(--text-muted);
max-width: 380px;
}
</style>