The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts.
381 lines
12 KiB
Svelte
381 lines
12 KiB
Svelte
<script>
|
|
import {
|
|
catalog,
|
|
libraryStore,
|
|
streamSong,
|
|
saveSong,
|
|
streamCatalogAlbum,
|
|
saveCatalogAlbum,
|
|
resolver,
|
|
player,
|
|
openArtistByName,
|
|
artBroken,
|
|
coverUpgrades,
|
|
resolveCovers,
|
|
coverLoadError,
|
|
} from "../lib/state.svelte.js";
|
|
import Icon from "../components/Icon.svelte";
|
|
|
|
const page = $derived(catalog.albumPage); // { album, tracks }
|
|
const album = $derived(page?.album ?? null);
|
|
|
|
// Upgrade this album's cover to the highest verified-same-artwork source.
|
|
let coveredId = null;
|
|
$effect(() => {
|
|
if (album?.cover && album.id !== coveredId) {
|
|
coveredId = album.id;
|
|
resolveCovers([album], album.artist);
|
|
}
|
|
});
|
|
|
|
const nowPlaying = (kind, id) =>
|
|
player.status !== "idle" && player.catalogRef[kind] === id;
|
|
|
|
function handleCoverError(node) {
|
|
const fallback = coverLoadError(
|
|
album?.cover,
|
|
node?.currentSrc || node?.src || null,
|
|
album?.artist ?? "",
|
|
album?.title ?? "",
|
|
);
|
|
if (fallback && node?.src !== fallback) node.src = fallback;
|
|
}
|
|
|
|
const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
|
|
const inLibrary = $derived.by(() => {
|
|
if (!album) return false;
|
|
const a = norm(album.artist);
|
|
const t = norm(album.title);
|
|
return libraryStore.albums.some((x) => norm(x.artist) === a && norm(x.title) === t);
|
|
});
|
|
|
|
// 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));
|
|
|
|
// Multi-disc releases (e.g. anniversary box sets) get "Disc N" headers, and
|
|
// each disc's tracks are numbered from 1 (Deezer's per-disc track_position).
|
|
const multiDisc = $derived(
|
|
new Set((page?.tracks ?? []).map((t) => t.disk_number ?? 1)).size > 1,
|
|
);
|
|
|
|
let saved = $state({});
|
|
function flagSaveError(key) {
|
|
saved[key] = "err";
|
|
setTimeout(() => saved[key] === "err" && (saved[key] = ""), 3000);
|
|
}
|
|
async function saveAlbumAction() {
|
|
if (!album || saved.album) return;
|
|
saved.album = "…";
|
|
if (await saveCatalogAlbum(album)) saved.album = "ok";
|
|
else flagSaveError("album");
|
|
}
|
|
async function saveTrackAction(t) {
|
|
if (saved[`t${t.id}`]) return;
|
|
saved[`t${t.id}`] = "…";
|
|
if (await saveSong(album.artist, t.title)) saved[`t${t.id}`] = "ok";
|
|
else flagSaveError(`t${t.id}`);
|
|
}
|
|
|
|
const playTrack = (t) =>
|
|
streamSong(album.artist, t.title, {
|
|
cover: t.cover ?? album.cover,
|
|
trackId: t.id,
|
|
albumId: album.id,
|
|
album: album.title || null,
|
|
expectSecs: t.duration_secs ?? null,
|
|
});
|
|
|
|
function year(a) {
|
|
return a?.release_date?.slice(0, 4) ?? "";
|
|
}
|
|
function fmtDuration(s) {
|
|
if (s == null) return "";
|
|
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
}
|
|
|
|
// Scroll to (and briefly flash) the track the now-playing song link targets.
|
|
let flashId = $state(null);
|
|
$effect(() => {
|
|
const tid = catalog.pendingTrackId;
|
|
if (tid == null || !page) return;
|
|
requestAnimationFrame(() => {
|
|
const el = document.getElementById(`catrack-${tid}`);
|
|
if (el) el.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
});
|
|
flashId = tid;
|
|
const t = setTimeout(() => (flashId === tid) && (flashId = null), 1800);
|
|
return () => clearTimeout(t);
|
|
});
|
|
</script>
|
|
|
|
<div class="album-page">
|
|
<button class="back" onclick={() => openArtistByName(album?.artist)}>
|
|
<Icon name="chevron-left" size={16} /> {album?.artist ?? "Back"}
|
|
</button>
|
|
|
|
{#if resolver.active}
|
|
<p class="resolving"><span class="pulse">●</span> {resolver.label} — finding the best source…</p>
|
|
{:else if resolver.error}
|
|
<p class="resolving error">{resolver.error}</p>
|
|
{/if}
|
|
|
|
{#if catalog.albumLoading}
|
|
<p class="hint">Loading album…</p>
|
|
{:else if !page}
|
|
<p class="hint">{catalog.error || "Album not found."}</p>
|
|
{:else}
|
|
<header class="hero">
|
|
{#if album.cover && !artBroken[album.cover]}
|
|
<img class="cover" src={coverUpgrades[album.cover] ?? album.cover} alt="" draggable="false" onerror={(event) => handleCoverError(event.currentTarget)} />
|
|
{:else}
|
|
<div class="cover placeholder"><Icon name="disc" size={40} /></div>
|
|
{/if}
|
|
<div class="hero-info">
|
|
<p class="kind">{(album.record_type ?? "album").toUpperCase()}</p>
|
|
<h1>{album.title}</h1>
|
|
<p class="meta">
|
|
<button class="artist-link" onclick={() => openArtistByName(album.artist)}>{album.artist}</button>
|
|
{#if year(album)}· {year(album)}{/if}
|
|
{#if album.track_count}· {album.track_count} tracks{/if}
|
|
{#if inLibrary}· <span class="owned">in your library</span>{/if}
|
|
</p>
|
|
<div class="actions">
|
|
<button
|
|
class="pill"
|
|
disabled={hasUnreleased}
|
|
title={hasUnreleased ? "Not released yet" : ""}
|
|
onclick={() => streamCatalogAlbum(album)}
|
|
>▶ Play</button>
|
|
<button class="pill ghost" disabled={hasUnreleased} onclick={saveAlbumAction}>
|
|
{saved.album === "ok" ? "✓ Saved" : saved.album === "err" ? "✗ No sources" : "⤓ Save"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="tracklist">
|
|
{#each page.tracks as t, i (t.id)}
|
|
{#if multiDisc && t.disk_number !== (page.tracks[i - 1]?.disk_number ?? (i === 0 ? null : 1))}
|
|
<div class="disc-head">Disc {t.disk_number ?? 1}</div>
|
|
{/if}
|
|
{@const locked = t.unreleased}
|
|
{@const play = () => { if (!locked) playTrack(t); }}
|
|
<div
|
|
id="catrack-{t.id}"
|
|
class="trow"
|
|
class:now={nowPlaying("trackId", t.id)}
|
|
class:flash={flashId === t.id}
|
|
class:locked
|
|
role="button"
|
|
tabindex="0"
|
|
title={locked ? "Not released yet" : "Play — streams from the best source"}
|
|
onclick={play}
|
|
onkeydown={(e) => e.key === "Enter" && play()}
|
|
>
|
|
<span class="tno">{nowPlaying("trackId", t.id) ? "♫" : (t.track_no ?? "·")}</span>
|
|
<span class="ttitle">{t.title}</span>
|
|
{#if locked}
|
|
<span class="tag-unreleased">Unreleased</span>
|
|
{:else}
|
|
<span class="tdur">{fmtDuration(t.duration_secs)}</span>
|
|
{/if}
|
|
<button class="mini-btn play" disabled={locked} title={locked ? "Not released yet" : "Play"} onclick={(e) => { e.stopPropagation(); play(); }}>
|
|
<Icon name="play" size={13} />
|
|
</button>
|
|
<button class="mini-get" disabled={locked} title={locked ? "Not released yet" : "Save to library"} onclick={(e) => { e.stopPropagation(); saveTrackAction(t); }}>
|
|
<Icon name={saved[`t${t.id}`] === "ok" ? "check" : saved[`t${t.id}`] === "err" ? "x" : "download"} size={13} />
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.hint {
|
|
color: var(--text-secondary);
|
|
padding-top: 10vh;
|
|
text-align: center;
|
|
}
|
|
|
|
.back {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
color: var(--text-secondary);
|
|
font-weight: 600;
|
|
margin-bottom: 16px;
|
|
}
|
|
.back:hover { color: var(--text-primary); }
|
|
|
|
.hero {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 20px;
|
|
background: var(--bg-elevated);
|
|
border-radius: 10px;
|
|
padding: 20px;
|
|
}
|
|
|
|
.cover {
|
|
width: 150px;
|
|
height: 150px;
|
|
border-radius: 8px;
|
|
object-fit: cover;
|
|
flex-shrink: 0;
|
|
box-shadow: 0 12px 28px -12px rgba(0, 0, 0, 0.7);
|
|
}
|
|
.placeholder {
|
|
display: grid;
|
|
place-items: center;
|
|
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.hero-info { min-width: 0; }
|
|
.kind {
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.08em;
|
|
color: var(--text-secondary);
|
|
}
|
|
h1 {
|
|
font-size: 30px;
|
|
font-weight: 800;
|
|
margin: 4px 0 6px;
|
|
}
|
|
.meta {
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
}
|
|
.artist-link {
|
|
color: var(--text-primary);
|
|
font-weight: 600;
|
|
}
|
|
.artist-link:hover { text-decoration: underline; }
|
|
.owned { color: var(--accent-hover); }
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 14px;
|
|
}
|
|
.pill {
|
|
background: var(--accent);
|
|
color: #000;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
padding: 7px 16px;
|
|
border-radius: 14px;
|
|
white-space: nowrap;
|
|
}
|
|
.pill:hover { background: var(--accent-hover); }
|
|
.pill.ghost {
|
|
background: var(--bg-highlight);
|
|
color: var(--text-primary);
|
|
}
|
|
.pill.ghost:hover { background: var(--bg-press, #333); }
|
|
.pill:disabled {
|
|
background: var(--bg-highlight);
|
|
color: var(--text-muted);
|
|
cursor: default;
|
|
opacity: 0.6;
|
|
}
|
|
.pill:disabled:hover { background: var(--bg-highlight); }
|
|
|
|
.tracklist {
|
|
margin-top: 18px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.disc-head {
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--text-secondary);
|
|
padding: 14px 8px 6px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
margin-bottom: 4px;
|
|
}
|
|
.disc-head:first-child { padding-top: 4px; }
|
|
|
|
.trow {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 8px 8px;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
}
|
|
.trow:hover { background: var(--bg-highlight); }
|
|
.trow.now .ttitle,
|
|
.trow.now .tno { color: var(--accent-hover); }
|
|
.trow.locked { cursor: default; }
|
|
.trow.locked:hover { background: transparent; }
|
|
.trow.locked .ttitle { color: var(--text-muted); }
|
|
.tag-unreleased {
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.02em;
|
|
color: var(--text-muted);
|
|
border: 1px solid var(--border, #3a3a3a);
|
|
border-radius: 4px;
|
|
padding: 1px 6px;
|
|
flex: 0 0 auto;
|
|
}
|
|
.mini-btn:disabled,
|
|
.mini-get:disabled {
|
|
color: var(--text-muted);
|
|
opacity: 0.4;
|
|
cursor: default;
|
|
}
|
|
.mini-btn:disabled:hover,
|
|
.mini-get:disabled:hover { color: var(--text-muted); }
|
|
.trow.flash { animation: flash 1.8s ease; }
|
|
@keyframes flash {
|
|
0%, 30% { background: color-mix(in srgb, var(--accent) 22%, transparent); }
|
|
100% { background: transparent; }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.trow.flash { animation: none; }
|
|
}
|
|
|
|
.tno {
|
|
width: 22px;
|
|
text-align: right;
|
|
color: var(--text-muted);
|
|
font-size: 12px;
|
|
flex: 0 0 auto;
|
|
}
|
|
.ttitle {
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-size: 13.5px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.tdur {
|
|
color: var(--text-muted);
|
|
font-size: 11px;
|
|
flex: 0 0 auto;
|
|
}
|
|
.mini-btn.play { color: var(--accent-hover); }
|
|
.mini-btn,
|
|
.mini-get { color: var(--text-secondary); flex: 0 0 auto; }
|
|
.mini-btn:hover,
|
|
.mini-get:hover { color: var(--accent-hover); }
|
|
|
|
.resolving {
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
.resolving .pulse { color: var(--accent); }
|
|
.resolving.error { color: #f15e6c; }
|
|
</style>
|