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.
486 lines
14 KiB
Svelte
486 lines
14 KiB
Svelte
<script>
|
|
import {
|
|
catalog,
|
|
openCatalogArtist,
|
|
streamSong,
|
|
saveSong,
|
|
streamCatalogAlbum,
|
|
saveCatalogAlbum,
|
|
resolver,
|
|
player,
|
|
artBroken,
|
|
markArtBroken,
|
|
} from "../lib/state.svelte.js";
|
|
import Icon from "../components/Icon.svelte";
|
|
|
|
const playSong = (t) =>
|
|
streamSong(t.artist, t.title, {
|
|
cover: t.cover,
|
|
trackId: t.id,
|
|
albumId: t.album_id,
|
|
album: t.album || null,
|
|
expectSecs: t.duration_secs ?? null,
|
|
});
|
|
const nowPlaying = (kind, id) =>
|
|
player.status !== "idle" && player.catalogRef[kind] === id;
|
|
|
|
const top = $derived(catalog.results?.artists[0] ?? null);
|
|
const artists = $derived(catalog.results?.artists.slice(0, 6) ?? []);
|
|
const albums = $derived(catalog.results?.albums.slice(0, 6) ?? []);
|
|
const tracks = $derived(catalog.results?.tracks.slice(0, 5) ?? []);
|
|
|
|
let saved = $state({});
|
|
|
|
// A failed save flashes ✗ on the button (the resolver banner up top is
|
|
// easy to miss) before returning to the idle icon.
|
|
function flagSaveError(key) {
|
|
saved[key] = "err";
|
|
setTimeout(() => {
|
|
if (saved[key] === "err") saved[key] = "";
|
|
}, 3000);
|
|
}
|
|
|
|
async function saveTrack(t) {
|
|
if (saved[`t${t.id}`]) return;
|
|
saved[`t${t.id}`] = "…";
|
|
if (await saveSong(t.artist, t.title)) saved[`t${t.id}`] = "ok";
|
|
else flagSaveError(`t${t.id}`);
|
|
}
|
|
|
|
async function saveAlbum(al) {
|
|
if (saved[`a${al.id}`]) return;
|
|
saved[`a${al.id}`] = "…";
|
|
if (await saveCatalogAlbum(al)) saved[`a${al.id}`] = "ok";
|
|
else flagSaveError(`a${al.id}`);
|
|
}
|
|
|
|
function fmtDuration(s) {
|
|
if (s == null) return "";
|
|
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
}
|
|
|
|
function albumMeta(album) {
|
|
const year = album.release_date?.slice(0, 4);
|
|
const type = album.record_type ? album.record_type[0].toUpperCase() + album.record_type.slice(1) : "Album";
|
|
return [year, type].filter(Boolean).join(" · ");
|
|
}
|
|
</script>
|
|
|
|
<div class="catalog">
|
|
{#if catalog.loading}
|
|
<p class="hint">Searching the catalog for “{catalog.query}”…</p>
|
|
{:else if catalog.error}
|
|
<p class="hint">Catalog search failed: {catalog.error}</p>
|
|
{:else if !catalog.results}
|
|
<p class="hint">Search for an artist, album or song above.</p>
|
|
{:else}
|
|
{#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}
|
|
<div class="lead">
|
|
{#if top}
|
|
<section class="top-result">
|
|
<h2>Top result</h2>
|
|
<button class="top-card" onclick={() => openCatalogArtist(top.id, top)}>
|
|
{#if top.picture}
|
|
<img class="round" src={top.picture} alt="" draggable="false" />
|
|
{:else}
|
|
<div class="round placeholder"><Icon name="music" size={26} /></div>
|
|
{/if}
|
|
<div class="top-name">{top.name}</div>
|
|
<span class="badge">ARTIST</span>
|
|
</button>
|
|
</section>
|
|
{/if}
|
|
{#if tracks.length > 0}
|
|
<section class="songs">
|
|
<h2>Songs</h2>
|
|
<div class="song-list">
|
|
{#each tracks as t (t.id)}
|
|
<div
|
|
class="song"
|
|
class:now={nowPlaying("trackId", t.id)}
|
|
role="button"
|
|
tabindex="0"
|
|
title="Play — streams from the best source"
|
|
onclick={() => playSong(t)}
|
|
onkeydown={(e) => e.key === "Enter" && playSong(t)}
|
|
>
|
|
{#if t.cover && !artBroken[t.cover]}
|
|
<img class="mini-cover" src={t.cover} alt="" draggable="false" onerror={() => markArtBroken(t.cover)} />
|
|
{:else}
|
|
<div class="mini-cover placeholder"></div>
|
|
{/if}
|
|
<div class="song-info">
|
|
<div class="song-title">
|
|
{#if nowPlaying("trackId", t.id)}<span class="eq">♫</span>{/if}
|
|
{t.title}
|
|
</div>
|
|
<div class="song-sub">{t.artist} · {t.album}</div>
|
|
</div>
|
|
<span class="dur">{fmtDuration(t.duration_secs)}</span>
|
|
<button
|
|
class="mini-btn play"
|
|
title="Play"
|
|
onclick={(e) => { e.stopPropagation(); playSong(t); }}
|
|
><Icon name="play" size={13} /></button>
|
|
<button
|
|
class="mini-btn"
|
|
title="Save to library"
|
|
onclick={(e) => { e.stopPropagation(); saveTrack(t); }}
|
|
><Icon name={saved[`t${t.id}`] === "ok" ? "check" : saved[`t${t.id}`] === "err" ? "x" : "download"} size={13} /></button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if artists.length > 0}
|
|
<h2>Artists</h2>
|
|
<div class="artist-row">
|
|
{#each artists as a (a.id)}
|
|
<button class="artist" onclick={() => openCatalogArtist(a.id, a)}>
|
|
{#if a.picture}
|
|
<img class="round sm" src={a.picture} alt="" draggable="false" />
|
|
{:else}
|
|
<div class="round sm placeholder"><Icon name="music" size={20} /></div>
|
|
{/if}
|
|
<div class="artist-name">{a.name}</div>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if albums.length > 0}
|
|
<h2>Albums</h2>
|
|
<div class="album-row">
|
|
{#each albums as al (al.id)}
|
|
<div class="album" class:now={nowPlaying("albumId", al.id)}>
|
|
<div class="cover-wrap">
|
|
{#if nowPlaying("albumId", al.id)}
|
|
<span class="now-badge">♫</span>
|
|
{/if}
|
|
{#if al.cover && !artBroken[al.cover]}
|
|
<img class="cover" src={al.cover} alt="" draggable="false" onerror={() => markArtBroken(al.cover)} />
|
|
{:else}
|
|
<div class="cover placeholder"><Icon name="disc" size={30} /></div>
|
|
{/if}
|
|
<div class="overlay">
|
|
<button
|
|
class="act"
|
|
title="Play — streams from the best source"
|
|
onclick={() => streamCatalogAlbum(al)}
|
|
><Icon name="play" size={15} /></button>
|
|
<button
|
|
class="act"
|
|
title="Save to library"
|
|
onclick={() => saveAlbum(al)}
|
|
><Icon name={saved[`a${al.id}`] === "ok" ? "check" : saved[`a${al.id}`] === "err" ? "x" : "download"} size={15} /></button>
|
|
</div>
|
|
</div>
|
|
<div class="album-title" title={al.title}>{al.title}</div>
|
|
<div class="album-sub">{albumMeta(al)}</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.catalog {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.hint {
|
|
color: var(--text-secondary);
|
|
padding-top: 10vh;
|
|
text-align: center;
|
|
}
|
|
|
|
h2 {
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
margin: 20px 0 10px;
|
|
}
|
|
|
|
.lead {
|
|
display: grid;
|
|
grid-template-columns: 240px 1fr;
|
|
gap: 16px;
|
|
align-items: start;
|
|
}
|
|
|
|
.lead h2 {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.top-card {
|
|
display: block;
|
|
width: 100%;
|
|
text-align: left;
|
|
background: var(--bg-elevated);
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.top-card:hover {
|
|
background: var(--bg-highlight);
|
|
}
|
|
|
|
.round {
|
|
width: 84px;
|
|
height: 84px;
|
|
border-radius: 50%;
|
|
object-fit: cover;
|
|
display: block;
|
|
}
|
|
|
|
.round.sm {
|
|
width: 90px;
|
|
height: 90px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.placeholder {
|
|
display: grid;
|
|
place-items: center;
|
|
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.top-name {
|
|
font-size: 17px;
|
|
font-weight: 700;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-block;
|
|
margin-top: 8px;
|
|
background: var(--bg-highlight);
|
|
color: var(--text-secondary);
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
padding: 4px 10px;
|
|
border-radius: 10px;
|
|
}
|
|
|
|
.song-list {
|
|
background: var(--bg-elevated);
|
|
border-radius: 8px;
|
|
padding: 6px 8px;
|
|
}
|
|
|
|
.song {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 11px;
|
|
padding: 6px;
|
|
border-radius: 5px;
|
|
}
|
|
|
|
.song:hover {
|
|
background: var(--bg-highlight);
|
|
}
|
|
|
|
.mini-cover {
|
|
width: 34px;
|
|
height: 34px;
|
|
border-radius: 4px;
|
|
object-fit: cover;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.song-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.song-title {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.song-sub {
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.dur {
|
|
color: var(--text-muted);
|
|
font-size: 11px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.mini-btn {
|
|
color: var(--text-secondary);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.mini-btn:hover {
|
|
color: var(--accent-hover);
|
|
}
|
|
|
|
.resolving {
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.resolving .pulse {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.resolving.error {
|
|
color: #f15e6c;
|
|
}
|
|
|
|
.song {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.song.now .song-title {
|
|
color: var(--accent-hover);
|
|
}
|
|
|
|
.eq {
|
|
color: var(--accent-hover);
|
|
margin-right: 3px;
|
|
}
|
|
|
|
.album.now .cover {
|
|
outline: 2px solid var(--accent);
|
|
}
|
|
|
|
.now-badge {
|
|
position: absolute;
|
|
right: 7px;
|
|
bottom: 7px;
|
|
z-index: 1;
|
|
display: grid;
|
|
place-items: center;
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 50%;
|
|
background: var(--accent);
|
|
color: #000;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.mini-btn.play {
|
|
color: var(--accent-hover);
|
|
}
|
|
|
|
.act {
|
|
display: grid;
|
|
place-items: center;
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
background: var(--accent);
|
|
color: #000;
|
|
margin: 0 5px;
|
|
}
|
|
|
|
.act:hover {
|
|
background: var(--accent-hover);
|
|
transform: scale(1.06);
|
|
}
|
|
|
|
.artist-row,
|
|
.album-row {
|
|
display: grid;
|
|
/* Fixed tracks — fluid minmax(...,1fr) sawtooths card widths while
|
|
the window or side panels resize (see Home.svelte). */
|
|
grid-template-columns: repeat(auto-fill, 124px);
|
|
gap: 16px;
|
|
}
|
|
|
|
.artist {
|
|
text-align: center;
|
|
padding: 10px 6px;
|
|
border-radius: 8px;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.artist:hover {
|
|
background: var(--bg-highlight);
|
|
}
|
|
|
|
.artist-name {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
margin-top: 8px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.cover-wrap {
|
|
position: relative;
|
|
}
|
|
|
|
.cover {
|
|
display: block;
|
|
width: 100%;
|
|
aspect-ratio: 1;
|
|
object-fit: cover;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(0, 0, 0, 0.55);
|
|
border-radius: 6px;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.cover-wrap:hover .overlay {
|
|
opacity: 1;
|
|
}
|
|
|
|
.album-title {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
margin-top: 7px;
|
|
line-height: 1.25;
|
|
overflow: hidden;
|
|
display: -webkit-box;
|
|
-webkit-box-orient: vertical;
|
|
-webkit-line-clamp: 6;
|
|
}
|
|
|
|
.album-sub {
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
margin-top: 2px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.album {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 245px;
|
|
}
|
|
</style>
|