Files
SoulStream/src/views/CatalogSearch.svelte
T
KeelrLeek a667b4b28e 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.
2026-07-22 17:57:00 -05:00

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>