forked from Keelrleek/SoulStream
dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice
This commit is contained in:
1 parent
4efe85b6bf
commit
1dd8573808
41 files changed
+9288
-647
No files matched your search
+285
-101
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
// 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).
|
||||
// the import doors (playlist file, folder, pasted tracklist, YouTube set).
|
||||
import {
|
||||
playlistStore,
|
||||
refreshPlaylists,
|
||||
@@ -10,6 +10,8 @@
|
||||
playAlbum,
|
||||
} from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import { setlistScan, takeScanDraft } from "../lib/setlistScan.svelte.js";
|
||||
import { fade } from "svelte/transition";
|
||||
import Icon from "./../components/Icon.svelte";
|
||||
import PlaylistImportModal from "./../components/PlaylistImportModal.svelte";
|
||||
|
||||
@@ -34,6 +36,27 @@
|
||||
const pinned = $derived(playlistStore.playlists.filter((p) => p.pinned));
|
||||
const rest = $derived(playlistStore.playlists.filter((p) => !p.pinned));
|
||||
|
||||
// The stat row's numbers — derived from what the store already carries.
|
||||
const totals = $derived.by(() => {
|
||||
let tracks = 0;
|
||||
let secs = 0;
|
||||
for (const p of playlistStore.playlists) {
|
||||
tracks += p.track_count ?? 0;
|
||||
secs += p.duration_secs ?? 0;
|
||||
}
|
||||
return { tracks, secs };
|
||||
});
|
||||
|
||||
// The ambience wash borrows the first pinned playlist's cover, else the
|
||||
// first cover that has resolved. Covers arrive lazily, so this settles a
|
||||
// beat after the cards do — the keyed fade below makes that a dissolve.
|
||||
const ambUrl = $derived.by(() => {
|
||||
const pick =
|
||||
playlistStore.playlists.find((p) => p.pinned && playlistStore.covers[p.id]) ??
|
||||
playlistStore.playlists.find((p) => playlistStore.covers[p.id]);
|
||||
return pick ? playlistStore.covers[pick.id] : null;
|
||||
});
|
||||
|
||||
async function newPlaylist() {
|
||||
if (creating) return;
|
||||
creating = true;
|
||||
@@ -90,6 +113,23 @@
|
||||
importOpen = true;
|
||||
}
|
||||
|
||||
// A finished background listen: the chip's "review" click lands here —
|
||||
// consume the waiting draft into the ordinary review modal.
|
||||
$effect(() => {
|
||||
if (setlistScan.wantsReview && setlistScan.draft && !importOpen) {
|
||||
importDraft = takeScanDraft();
|
||||
importMode = "file";
|
||||
importOpen = true;
|
||||
}
|
||||
});
|
||||
|
||||
function importFromYoutube() {
|
||||
newMenuOpen = false;
|
||||
importDraft = null;
|
||||
importMode = "youtube";
|
||||
importOpen = true;
|
||||
}
|
||||
|
||||
function open(pl) {
|
||||
goToView("playlist", { playlistId: pl.id, label: pl.name });
|
||||
}
|
||||
@@ -102,21 +142,49 @@
|
||||
const gen = ++playCardGen;
|
||||
const rows = await api.playlistItems(pl.id);
|
||||
if (gen !== playCardGen) return;
|
||||
const playable = rows.filter((r) => r.path && !r.missing);
|
||||
// Present local files plus stream rows — streams queue as pending
|
||||
// items that resolve when reached, same as the playlist page's Play.
|
||||
const playable = rows.filter((r) => (r.path && !r.missing) || r.kind === "stream");
|
||||
if (!playable.length) {
|
||||
open(pl); // nothing playable: the page explains why
|
||||
return;
|
||||
}
|
||||
// Set-recording slices: each is bounded by the NEXT stamped row in
|
||||
// the full order (the playlist page derives the same map).
|
||||
const segmentEnds = new Map();
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i];
|
||||
if (r.kind !== "segment" || r.start_ms == null) continue;
|
||||
const next = rows.slice(i + 1).find((n) => n.start_ms != null);
|
||||
segmentEnds.set(r.id, next ? next.start_ms : null);
|
||||
}
|
||||
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,
|
||||
})),
|
||||
playable.map((r) => {
|
||||
const local = r.path && !r.missing;
|
||||
const tid = Number(r.track_id);
|
||||
const endMs = segmentEnds.get(r.id);
|
||||
const segment =
|
||||
r.kind === "segment" && local && r.start_ms != null
|
||||
? { start: r.start_ms / 1000, end: endMs != null ? endMs / 1000 : null }
|
||||
: null;
|
||||
return {
|
||||
path: local ? r.path : null,
|
||||
needsResolve: !local,
|
||||
title: r.title,
|
||||
artist: r.artist,
|
||||
album: r.album,
|
||||
duration_secs:
|
||||
r.duration_secs ??
|
||||
(segment && segment.end != null
|
||||
? Math.round(segment.end - segment.start)
|
||||
: null),
|
||||
art_path: local ? r.art_path : null,
|
||||
artUrl: local ? null : (r.art_url ?? null),
|
||||
trackId: Number.isFinite(tid) ? tid : null,
|
||||
plItemId: r.id,
|
||||
segment,
|
||||
};
|
||||
}),
|
||||
pl.name,
|
||||
0,
|
||||
);
|
||||
@@ -186,53 +254,85 @@
|
||||
<svelte:window onclick={pageClick} />
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<div class="head-text">
|
||||
{#if ambUrl}
|
||||
<!-- The Library's ambience recipe: a blurred cover pinned behind the
|
||||
glass, faded out before the cards. Keyed so a cover change
|
||||
CROSSFADES — background-image is not an animatable property, so
|
||||
the outgoing wash survives as its own element while the incoming
|
||||
one fades up over it. -->
|
||||
{#key ambUrl}
|
||||
<div class="ambience" style="background-image:url({ambUrl})" transition:fade={{ duration: 600 }}></div>
|
||||
{/key}
|
||||
<div class="ambience-glow"></div>
|
||||
{/if}
|
||||
|
||||
<!-- The Library's own header shape: h1 + tools on one line, the stat row
|
||||
under them. An empty shelf keeps the same header — the Import door
|
||||
matters most at the moment there is nothing here. -->
|
||||
<header class="hero">
|
||||
<div class="hero-top">
|
||||
<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} />
|
||||
<div class="tools">
|
||||
<div class="menu-wrap">
|
||||
<button
|
||||
class="tool"
|
||||
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>
|
||||
<button class="mi" onclick={importFromYoutube}>
|
||||
<Icon name="link" size={15} />
|
||||
YouTube DJ set…
|
||||
<span class="hint">tracks from chapters, description or comments</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="tool primary" onclick={newPlaylist} disabled={creating}>
|
||||
<Icon name="plus" size={15} /> New playlist
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
{#if playlistStore.playlists.length}
|
||||
<div class="stats">
|
||||
<span class="stat">
|
||||
<b>{playlistStore.playlists.length}</b>
|
||||
<i>{playlistStore.playlists.length === 1 ? "playlist" : "playlists"}</i>
|
||||
</span>
|
||||
<span class="stat-div"></span>
|
||||
<span class="stat"><b>{totals.tracks.toLocaleString()}</b><i>{totals.tracks === 1 ? "track" : "tracks"}</i></span>
|
||||
{#if totals.secs}
|
||||
<span class="stat-div"></span>
|
||||
<span class="stat"><b>{fmtDuration(totals.secs)}</b><i>of music</i></span>
|
||||
{/if}
|
||||
{#if pinned.length}
|
||||
<span class="stat-div"></span>
|
||||
<span class="stat"><b>{pinned.length}</b><i>pinned</i></span>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="btn primary" onclick={newPlaylist} disabled={creating}>
|
||||
<Icon name="plus" size={15} /> New playlist
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if playlistStore.playlists.length === 0}
|
||||
@@ -353,63 +453,147 @@
|
||||
/>
|
||||
|
||||
<style>
|
||||
/* The Library page's own frame: the blurred ambience pins behind the
|
||||
glass bar, so the page starts at the very top of the pane and carries
|
||||
its own inset instead of taking padding-top from the BAR_VIEWS rule
|
||||
(the Library exception, documented at the set in App.svelte). The
|
||||
isolation keeps the z-indexed washes inside this page's own stacking
|
||||
context — without it they escape past the transparent pane and paint
|
||||
behind the app's opaque black. */
|
||||
.page {
|
||||
padding: 8px 4px 24px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.sub {
|
||||
margin-top: 3px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
--pad-top: calc(var(--bar-h, 46px) + 14px);
|
||||
padding: var(--pad-top) 0 40px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
/* ------------------------------------------------------------ ambience
|
||||
The Library's recipe at this page's colour: blurred cover, faded out
|
||||
before the cards; z-index 0 lies OVER the in-flow header (that is the
|
||||
approved look — the h1 carries a text-shadow for it), pointer-events
|
||||
none so nothing under it goes dead. */
|
||||
.ambience {
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
right: -28px;
|
||||
top: calc(-1 * var(--pad-top));
|
||||
height: 430px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-size: cover;
|
||||
background-position: center 30%;
|
||||
filter: blur(90px) saturate(1.5);
|
||||
opacity: 0.2;
|
||||
transform: translateZ(0);
|
||||
-webkit-mask-image: linear-gradient(180deg, #000 35%, transparent);
|
||||
mask-image: linear-gradient(180deg, #000 35%, transparent);
|
||||
}
|
||||
.ambience-glow {
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
right: -28px;
|
||||
top: calc(-1 * var(--pad-top));
|
||||
height: 300px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
640px 260px at 14% 0%,
|
||||
color-mix(in srgb, var(--accent) 13%, transparent),
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- hero */
|
||||
.hero {
|
||||
padding-top: 4px;
|
||||
}
|
||||
.hero-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 31px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
/* The Library's tool button: translucent elevated glass, one height. */
|
||||
.tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
transition:
|
||||
background 0.14s,
|
||||
color 0.14s,
|
||||
transform 0.14s;
|
||||
}
|
||||
.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);
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--bg-elevated) 74%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(6px);
|
||||
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
|
||||
}
|
||||
.btn.ghost:hover {
|
||||
background: var(--bg-press);
|
||||
.tool:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
.tool:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.tool.primary {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
color: #08080a;
|
||||
}
|
||||
.tool.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
color: #08080a;
|
||||
}
|
||||
|
||||
/* The stat row — the Library's value/label idiom for the collection. */
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
margin: 15px 0 20px;
|
||||
}
|
||||
.stat {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stat b {
|
||||
font-size: 15.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat i {
|
||||
font-style: normal;
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stat-div {
|
||||
width: 1px;
|
||||
height: 17px;
|
||||
flex: none;
|
||||
background: linear-gradient(rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.03));
|
||||
}
|
||||
|
||||
.menu-wrap {
|
||||
|
||||
Reference in new issue
Block a user