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
@@ -30,6 +30,7 @@
|
||||
import { listenerHeat, listenerPeak, listenerShare } from "../lib/listenerBars.js";
|
||||
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import PlaylistMenu from "../components/PlaylistMenu.svelte";
|
||||
|
||||
const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … }
|
||||
const album = $derived(page?.album ?? null);
|
||||
@@ -132,18 +133,23 @@
|
||||
* library keeps albums, not songs, so the owned tracklist has to be read
|
||||
* from the matching local album rather than filtered out of a song list. */
|
||||
let libraryTicks = $state(new Set());
|
||||
/** The same rows with their paths kept, so a playlist add can point at the
|
||||
* owned file instead of a stream placeholder. */
|
||||
let libraryRows = $state([]);
|
||||
let ticksFor = null;
|
||||
$effect(() => {
|
||||
const id = localAlbum?.id ?? null;
|
||||
if (id === ticksFor) return;
|
||||
ticksFor = id;
|
||||
libraryTicks = new Set();
|
||||
libraryRows = [];
|
||||
if (id == null) return;
|
||||
let stale = false;
|
||||
api.albumTracks(id)
|
||||
.then((rows) => {
|
||||
if (!stale && ticksFor === id) {
|
||||
libraryTicks = new Set(rows.map((r) => norm(r.title || "")));
|
||||
libraryRows = rows;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -152,6 +158,40 @@
|
||||
};
|
||||
});
|
||||
|
||||
// ---- add to playlist ----------------------------------------------------
|
||||
/** Local files for this record by normalized title — a local page carries
|
||||
* them in `page.libraryTracks`; an owned catalog page gets them from the
|
||||
* ticks fetch above. */
|
||||
const localByTitle = $derived.by(() => {
|
||||
const m = new Map();
|
||||
for (const lt of page?.libraryTracks ?? []) {
|
||||
const k = norm(lt.title || "");
|
||||
if (k && !m.has(k)) m.set(k, lt);
|
||||
}
|
||||
for (const lt of libraryRows) {
|
||||
const k = norm(lt.title || "");
|
||||
if (k && !m.has(k)) m.set(k, lt);
|
||||
}
|
||||
return m;
|
||||
});
|
||||
/** A playlist row for a track on this record. Owned tracks go in as the
|
||||
* file on disk; the rest are placeholders that stream on click. */
|
||||
function playlistRow(t) {
|
||||
const local = localByTitle.get(norm(t.title || ""));
|
||||
return {
|
||||
title: t.title,
|
||||
artist: album?.artist ?? null,
|
||||
album: album?.title ?? null,
|
||||
duration_secs: t.duration_secs ?? local?.duration_secs ?? null,
|
||||
path: local?.path ?? null,
|
||||
art_path: local ? (page?.artPath ?? localAlbum?.art_path ?? null) : null,
|
||||
// Stream anchors, mirroring what playTrack hands the resolver. A
|
||||
// local record's cover is a file path, not a URL — omit it there.
|
||||
trackId: t.id ?? null,
|
||||
artUrl: isLocal ? null : (t.cover ?? album?.cover ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
// An upcoming album that still has unreleased songs can't be streamed or
|
||||
// saved as a whole — only its already-released singles play (per track).
|
||||
const hasUnreleased = $derived((page?.tracks ?? []).some((t) => t.unreleased));
|
||||
@@ -804,6 +844,12 @@
|
||||
<span class="badge" aria-hidden="true">{previewable}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<PlaylistMenu
|
||||
compact
|
||||
iconSize={17}
|
||||
title="Add this record to a playlist"
|
||||
tracks={() => tracks.filter((t) => !t.unreleased).map(playlistRow)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -900,6 +946,14 @@
|
||||
<span class="tdur">{fmtDuration(t.duration_secs)}</span>
|
||||
{/if}
|
||||
<span class="rowbtns">
|
||||
{#if !locked}
|
||||
<PlaylistMenu
|
||||
compact
|
||||
iconSize={13}
|
||||
align="right"
|
||||
tracks={() => [playlistRow(t)]}
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
class="mini-btn play"
|
||||
disabled={locked}
|
||||
@@ -1364,6 +1418,20 @@
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
cursor: default;
|
||||
}
|
||||
/* The add-to-playlist trigger wears the iconbtn look — the component
|
||||
itself stays unstyled chrome (the Library rail set the precedent). */
|
||||
.actions :global(.plm-trigger) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
border-radius: 50%;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.actions :global(.plm-trigger:hover) {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
/* Count for a record only some of whose tracks have a clip — the same
|
||||
partial-availability report the artist page makes. */
|
||||
.badge {
|
||||
@@ -1745,10 +1813,14 @@
|
||||
}
|
||||
.trow:hover .tdur,
|
||||
.trow:focus-within .tdur {
|
||||
/* pointer-events too: the faded duration is a stacking context and
|
||||
must never eat clicks meant for the buttons over it. */
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.trow.locked:hover .tdur {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tag-unreleased {
|
||||
@@ -1799,6 +1871,40 @@
|
||||
.rowbtns .play {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
/* Three buttons now reach past the duration column into the listeners
|
||||
bar on counted records, so the cluster carries its own fade-in shim —
|
||||
on the hover highlight it reads as the buttons simply taking over. */
|
||||
.rowbtns {
|
||||
padding-left: 14px;
|
||||
background: linear-gradient(to right, transparent, var(--bg-highlight) 14px);
|
||||
}
|
||||
.trow:focus-within {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.rowbtns :global(.plm-trigger) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.rowbtns :global(.plm-trigger:hover) {
|
||||
color: var(--accent-hover);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
/* While its menu is open the pointer is off in the portaled dropdown, so
|
||||
hover alone would drop the cluster mid-interaction. */
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .rowbtns {
|
||||
display: flex;
|
||||
}
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .tdur {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.hero {
|
||||
|
||||
Reference in new issue
Block a user