Baseline: full tree snapshot after git repository loss
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.
This commit is contained in:
commit
a667b4b28e
161 files changed
+53170
No files matched your search
@@ -0,0 +1,380 @@
|
||||
<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>
|
||||
Reference in new issue
Block a user