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,485 @@
|
||||
<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>
|
||||
Reference in new issue
Block a user