forked from Keelrleek/SoulStream
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,800 @@
|
||||
<script>
|
||||
import { tick } from "svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
import {
|
||||
libraryStore,
|
||||
ui,
|
||||
getArt,
|
||||
evictArt,
|
||||
playAlbum,
|
||||
player,
|
||||
playlistStore,
|
||||
refreshPlaylists,
|
||||
} from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import AlbumCard from "../components/AlbumCard.svelte";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
|
||||
let showPlaylistMenu = $state(false);
|
||||
let expandedArtist = $state(null);
|
||||
let inlineAlbumId = $state(null);
|
||||
let inlineTracks = $state([]);
|
||||
let artistArtUrls = $state({});
|
||||
const artistArtOrder = [];
|
||||
const artistArtInFlight = new Map();
|
||||
const visibleArtistArts = new Set();
|
||||
const artistArtPaths = new Map();
|
||||
const ARTIST_ART_URL_MAX = 64;
|
||||
const ARTIST_ART_CHAR_MAX = 24 * 1024 * 1024;
|
||||
let artistArtChars = 0;
|
||||
|
||||
const artists = $derived.by(() => {
|
||||
const grouped = new Map();
|
||||
for (const album of libraryStore.albums) {
|
||||
const key = album.artist || "Unknown Artist";
|
||||
const entry = grouped.get(key) ?? { name: key, albums: [], artist_art_path: null };
|
||||
entry.albums.push(album);
|
||||
if (!entry.artist_art_path) entry.artist_art_path = album.artist_art_path || album.art_path;
|
||||
grouped.set(key, entry);
|
||||
}
|
||||
return [...grouped.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
|
||||
function touchArtistArt(name) {
|
||||
const index = artistArtOrder.indexOf(name);
|
||||
if (index >= 0) artistArtOrder.splice(index, 1);
|
||||
artistArtOrder.push(name);
|
||||
}
|
||||
|
||||
function forgetArtistArt(name) {
|
||||
const url = artistArtUrls[name];
|
||||
if (typeof url === "string") artistArtChars -= url.length;
|
||||
delete artistArtUrls[name];
|
||||
artistArtPaths.delete(name);
|
||||
const index = artistArtOrder.indexOf(name);
|
||||
if (index >= 0) artistArtOrder.splice(index, 1);
|
||||
}
|
||||
|
||||
function rememberArtistArt(name, path, url) {
|
||||
const previous = artistArtUrls[name];
|
||||
if (typeof previous === "string") artistArtChars -= previous.length;
|
||||
artistArtUrls[name] = url;
|
||||
artistArtPaths.set(name, path);
|
||||
artistArtChars += url.length;
|
||||
touchArtistArt(name);
|
||||
// Keep at least the just-painted image even if one unusual payload is
|
||||
// larger than the budget; all older card references can be reloaded
|
||||
// lazily from the separately bounded shared art cache.
|
||||
while (
|
||||
artistArtOrder.length > 1
|
||||
&& (
|
||||
artistArtOrder.length > ARTIST_ART_URL_MAX
|
||||
|| artistArtChars > ARTIST_ART_CHAR_MAX
|
||||
)
|
||||
) {
|
||||
const evictIndex = artistArtOrder.findIndex(
|
||||
(artistName) => !visibleArtistArts.has(artistName),
|
||||
);
|
||||
// Preserve every currently painted card; the byte budget is a
|
||||
// soft ceiling only when an unusually large visible row exceeds
|
||||
// it by itself.
|
||||
if (evictIndex < 0) break;
|
||||
const [oldest] = artistArtOrder.splice(evictIndex, 1);
|
||||
const oldUrl = artistArtUrls[oldest];
|
||||
if (typeof oldUrl === "string") artistArtChars -= oldUrl.length;
|
||||
delete artistArtUrls[oldest];
|
||||
artistArtPaths.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
// Load only cards near the scroll viewport. The old whole-library effect
|
||||
// eagerly retained every base64 image and, because it read/write the same
|
||||
// reactive object, reattached callbacks to every still-pending promise on
|
||||
// each completion.
|
||||
function loadArtistArt(node, artist) {
|
||||
let current = artist;
|
||||
let alive = true;
|
||||
let intersecting = false;
|
||||
const load = () => {
|
||||
const { name, artist_art_path: path } = current;
|
||||
if (!path) return;
|
||||
if (
|
||||
artistArtUrls[name] !== undefined
|
||||
&& artistArtPaths.get(name) === path
|
||||
) {
|
||||
touchArtistArt(name);
|
||||
return;
|
||||
}
|
||||
if (artistArtUrls[name] !== undefined) forgetArtistArt(name);
|
||||
let flight = artistArtInFlight.get(name);
|
||||
if (!flight || flight.path !== path) {
|
||||
const pending = getArt(path);
|
||||
flight = { path, pending };
|
||||
artistArtInFlight.set(name, flight);
|
||||
pending.finally(() => {
|
||||
if (artistArtInFlight.get(name) === flight) {
|
||||
artistArtInFlight.delete(name);
|
||||
}
|
||||
});
|
||||
}
|
||||
flight.pending.then((url) => {
|
||||
if (
|
||||
alive
|
||||
&& current.name === name
|
||||
&& current.artist_art_path === path
|
||||
&& url
|
||||
) {
|
||||
rememberArtistArt(name, path, url);
|
||||
}
|
||||
});
|
||||
};
|
||||
const observer = typeof IntersectionObserver === "undefined"
|
||||
? null
|
||||
: new IntersectionObserver(
|
||||
(entries) => {
|
||||
intersecting = entries.some((entry) => entry.isIntersecting);
|
||||
if (intersecting) {
|
||||
visibleArtistArts.add(current.name);
|
||||
load();
|
||||
} else {
|
||||
visibleArtistArts.delete(current.name);
|
||||
}
|
||||
},
|
||||
{ root: node.closest(".content"), rootMargin: "300px" },
|
||||
);
|
||||
if (observer) observer.observe(node);
|
||||
else load();
|
||||
return {
|
||||
update(next) {
|
||||
const previousName = current.name;
|
||||
const pathChanged = current.artist_art_path !== next.artist_art_path;
|
||||
current = next;
|
||||
if (intersecting && previousName !== next.name) {
|
||||
visibleArtistArts.delete(previousName);
|
||||
visibleArtistArts.add(next.name);
|
||||
}
|
||||
if (pathChanged) {
|
||||
forgetArtistArt(next.name);
|
||||
if (intersecting) load();
|
||||
} else if (artistArtUrls[next.name] !== undefined) {
|
||||
touchArtistArt(next.name);
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
alive = false;
|
||||
visibleArtistArts.delete(current.name);
|
||||
observer?.disconnect();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toggleArtist(name) {
|
||||
expandedArtist = expandedArtist === name ? null : name;
|
||||
inlineAlbumId = null;
|
||||
}
|
||||
|
||||
function selectInlineAlbum(album) {
|
||||
inlineAlbumId = inlineAlbumId === album.id ? null : album.id;
|
||||
if (inlineAlbumId !== null) revealInlineTracklist();
|
||||
}
|
||||
|
||||
const inlineAlbum = $derived(
|
||||
libraryStore.albums.find((a) => a.id === inlineAlbumId) ?? null,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!inlineAlbum) {
|
||||
inlineTracks = [];
|
||||
return;
|
||||
}
|
||||
const id = inlineAlbum.id;
|
||||
// Keep the previous album's rows rendered while the new ones load:
|
||||
// blanking them collapses the open panel, which shrinks .content's
|
||||
// scrollHeight and makes the browser clamp scrollTop upward — the
|
||||
// "jump to the top" when switching albums while scrolled deep.
|
||||
api.albumTracks(id).then((tracks) => {
|
||||
if (inlineAlbumId !== id) return; // album changed while loading
|
||||
inlineTracks = tracks;
|
||||
revealInlineTracklist();
|
||||
});
|
||||
});
|
||||
|
||||
let revealRaf = null;
|
||||
|
||||
// Scroll .content down just enough to keep the inline tracklist in view
|
||||
// while it grows (slide transition, track rows arriving). One-shot
|
||||
// measurement doesn't work here: at tick() the panel is still ~0px tall,
|
||||
// so we follow it frame by frame until its size settles. Never scrolls
|
||||
// up, and never pushes the tracklist heading above the top of the pane.
|
||||
async function revealInlineTracklist() {
|
||||
await tick();
|
||||
if (revealRaf) cancelAnimationFrame(revealRaf);
|
||||
const scroller = document.querySelector(".content");
|
||||
const target = document.querySelector(".inline-tracklist");
|
||||
if (!scroller || !target) return;
|
||||
const margin = 16;
|
||||
const start = performance.now();
|
||||
let lastBottom = -1;
|
||||
let stableSince = start;
|
||||
const step = (now) => {
|
||||
revealRaf = null;
|
||||
if (!target.isConnected) return;
|
||||
const viewport = scroller.getBoundingClientRect();
|
||||
const rect = target.getBoundingClientRect();
|
||||
const delta = Math.min(
|
||||
rect.bottom - viewport.bottom + margin,
|
||||
rect.top - viewport.top - margin,
|
||||
);
|
||||
if (delta > 0) scroller.scrollBy({ top: delta });
|
||||
if (rect.bottom !== lastBottom) {
|
||||
lastBottom = rect.bottom;
|
||||
stableSince = now;
|
||||
}
|
||||
// Keep following briefly after the last size change, bounded hard.
|
||||
if (now - stableSince < 150 && now - start < 900) {
|
||||
revealRaf = requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
revealRaf = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
async function addAlbumToPlaylist(playlistId) {
|
||||
showPlaylistMenu = false;
|
||||
const payload = tracks.map((t) => ({
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
duration_secs: t.duration_secs ?? null,
|
||||
path: t.path,
|
||||
art_path: album?.art_path ?? null,
|
||||
}));
|
||||
await api.addToPlaylist(playlistId, payload);
|
||||
await refreshPlaylists();
|
||||
}
|
||||
|
||||
async function newPlaylistWithAlbum() {
|
||||
const id = await api.createPlaylist(album.title);
|
||||
await addAlbumToPlaylist(id);
|
||||
}
|
||||
|
||||
let tracks = $state([]);
|
||||
let art = $state(null);
|
||||
let refreshingArt = $state(false);
|
||||
let artError = $state("");
|
||||
|
||||
async function refreshArt() {
|
||||
if (!album || refreshingArt) return;
|
||||
refreshingArt = true;
|
||||
artError = "";
|
||||
try {
|
||||
const path = await api.refreshAlbumArt(album.id);
|
||||
evictArt(path);
|
||||
forgetArtistArt(album.artist);
|
||||
art = await getArt(path);
|
||||
} catch (e) {
|
||||
artError = String(e);
|
||||
} finally {
|
||||
refreshingArt = false;
|
||||
}
|
||||
}
|
||||
|
||||
const album = $derived(libraryStore.albums.find((a) => a.id === ui.albumId) ?? null);
|
||||
|
||||
$effect(() => {
|
||||
if (!album) return;
|
||||
tracks = [];
|
||||
art = null;
|
||||
api.albumTracks(album.id).then((t) => (tracks = t));
|
||||
getArt(album.art_path).then((url) => (art = url));
|
||||
});
|
||||
|
||||
function fmtDuration(s) {
|
||||
if (s == null) return "";
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const nowPlayingPath = $derived(
|
||||
player.queueIndex >= 0 ? player.queue[player.queueIndex]?.path : null,
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if !album}
|
||||
<div class="library">
|
||||
<h1>Artists</h1>
|
||||
{#if libraryStore.albums.length === 0}
|
||||
<div class="empty">
|
||||
<p class="big">Your music will live here</p>
|
||||
<p class="sub">Download an album from Search and it lands here, tagged with art.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="artist-count">{artists.length} artists</p>
|
||||
<div class="artist-grid">
|
||||
{#each artists as artist (artist.name)}
|
||||
<button
|
||||
class="artist-card"
|
||||
use:loadArtistArt={artist}
|
||||
class:selected={expandedArtist === artist.name}
|
||||
aria-expanded={expandedArtist === artist.name}
|
||||
onclick={() => toggleArtist(artist.name)}
|
||||
>
|
||||
{#if artistArtUrls[artist.name]}
|
||||
<img class="artist-art" src={artistArtUrls[artist.name]} alt="" draggable="false" />
|
||||
{:else}
|
||||
<div class="artist-art placeholder"><Icon name="music" size={30} /></div>
|
||||
{/if}
|
||||
<span class="artist-name">{artist.name}</span>
|
||||
<span class="artist-meta">{artist.albums.length} {artist.albums.length === 1 ? "album" : "albums"}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{@const selected = artists.find((a) => a.name === expandedArtist)}
|
||||
<section class="artist-albums" class:open={!!selected} aria-hidden={!selected}>
|
||||
{#if selected}
|
||||
<div class="artist-albums-head">
|
||||
<div><h2>{selected.name}</h2><p>{selected.albums.length} albums</p></div>
|
||||
<button class="close-expand" onclick={() => (expandedArtist = null)}>×</button>
|
||||
</div>
|
||||
<div class="album-strip">
|
||||
{#each selected.albums as a (a.id)}
|
||||
<AlbumCard album={a} onselect={selectInlineAlbum} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if inlineAlbum && selected.albums.some((a) => a.id === inlineAlbum.id)}
|
||||
<div class="inline-tracklist" transition:slide={{ duration: 180 }}>
|
||||
<div class="inline-track-head">
|
||||
<div>
|
||||
<h3>{inlineAlbum.title}</h3>
|
||||
<p>{inlineAlbum.track_count} tracks{inlineAlbum.year ? ` · ${inlineAlbum.year}` : ""}</p>
|
||||
</div>
|
||||
<button
|
||||
class="inline-play"
|
||||
onclick={() => playAlbum(inlineTracks, `${inlineAlbum.artist} — ${inlineAlbum.title}`, 0, inlineAlbum.art_path)}
|
||||
disabled={inlineTracks.length === 0}
|
||||
>▶ Play</button>
|
||||
</div>
|
||||
<div class="inline-tracks">
|
||||
{#each inlineTracks as t, i (t.id)}
|
||||
<button
|
||||
class="inline-track"
|
||||
onclick={() => playAlbum(inlineTracks, `${inlineAlbum.artist} — ${inlineAlbum.title}`, i, inlineAlbum.art_path)}
|
||||
>
|
||||
<span class="inline-tno">{t.track_no ?? "·"}</span>
|
||||
<span class="inline-ttitle">{t.title}</span>
|
||||
<span class="inline-tmeta">{fmtDuration(t.duration_secs)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="album-page">
|
||||
<button class="back" onclick={() => (ui.albumId = null)}>
|
||||
<Icon name="chevron-left" size={16} /> Library
|
||||
</button>
|
||||
|
||||
<header>
|
||||
{#if art}
|
||||
<img class="cover" src={art} alt="" draggable="false" />
|
||||
{:else}
|
||||
<div class="cover placeholder"><Icon name="disc" size={56} /></div>
|
||||
{/if}
|
||||
<div class="head-meta">
|
||||
<p class="kind">Album</p>
|
||||
<h1>{album.title}</h1>
|
||||
<p class="line">
|
||||
<strong>{album.artist}</strong>
|
||||
{album.year ? ` · ${album.year}` : ""} · {album.track_count} tracks
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="play-all"
|
||||
onclick={() =>
|
||||
playAlbum(tracks, `${album.artist} — ${album.title}`, 0, album.art_path)}
|
||||
disabled={tracks.length === 0}
|
||||
>▶ Play</button>
|
||||
<div class="add-menu">
|
||||
<button
|
||||
class="refresh-art"
|
||||
title="Add album to a playlist"
|
||||
onclick={() => (showPlaylistMenu = !showPlaylistMenu)}
|
||||
disabled={tracks.length === 0}
|
||||
><Icon name="list-plus" size={15} /> Playlist</button>
|
||||
{#if showPlaylistMenu}
|
||||
<div class="menu">
|
||||
<button class="menu-item new" onclick={newPlaylistWithAlbum}>
|
||||
+ New playlist
|
||||
</button>
|
||||
{#each playlistStore.playlists as pl (pl.id)}
|
||||
<button
|
||||
class="menu-item"
|
||||
onclick={() => addAlbumToPlaylist(pl.id)}
|
||||
>{pl.name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="refresh-art"
|
||||
title="Wrong cover? Refetch the album art"
|
||||
onclick={refreshArt}
|
||||
disabled={refreshingArt}
|
||||
>
|
||||
<Icon name="refresh" size={14} />
|
||||
{refreshingArt ? "fetching…" : "art"}
|
||||
</button>
|
||||
</div>
|
||||
{#if artError}
|
||||
<p class="art-error">{artError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="tracklist">
|
||||
{#each tracks as t, i (t.id)}
|
||||
<button
|
||||
class="trow"
|
||||
class:playing={nowPlayingPath === t.path}
|
||||
onclick={() =>
|
||||
playAlbum(tracks, `${album.artist} — ${album.title}`, i, album.art_path)}
|
||||
>
|
||||
<span class="tno">{t.track_no ?? "·"}</span>
|
||||
<span class="ttitle">{t.title}</span>
|
||||
<span class="tmeta">
|
||||
{t.bitrate_kbps ? `${t.bitrate_kbps} kbps · ` : ""}{fmtDuration(t.duration_secs)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 15vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sub {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.artist-count {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
margin-top: -16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.artist-grid {
|
||||
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, 156px);
|
||||
gap: 22px 18px;
|
||||
}
|
||||
|
||||
.artist-card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: background 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.artist-card:hover,
|
||||
.artist-card.selected {
|
||||
background: var(--bg-highlight);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.artist-art {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1.28;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
|
||||
}
|
||||
|
||||
.artist-art.placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.artist-name,
|
||||
.artist-meta {
|
||||
display: block;
|
||||
padding: 0 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.artist-name { margin-top: 9px; font-weight: 600; }
|
||||
.artist-meta { margin-top: 4px; color: var(--text-secondary); font-size: 12px; padding-bottom: 10px; }
|
||||
|
||||
.artist-albums {
|
||||
max-height: 0;
|
||||
margin-top: 0;
|
||||
padding: 0 18px;
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
transition: max-height 260ms ease, margin-top 260ms ease, padding 260ms ease, opacity 180ms ease, transform 260ms ease;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--radius);
|
||||
border-top: 0 solid transparent;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.artist-albums.open {
|
||||
max-height: 900px;
|
||||
margin-top: 28px;
|
||||
padding: 18px;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
border-top: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.artist-albums-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.artist-albums-head h2 { font-size: 20px; margin: 0 0 4px; }
|
||||
.artist-albums-head p { color: var(--text-secondary); font-size: 12px; }
|
||||
.close-expand { color: var(--text-secondary); font-size: 24px; line-height: 1; padding: 0 4px; }
|
||||
.close-expand:hover { color: var(--text-primary); }
|
||||
.album-strip { display: grid; grid-template-columns: repeat(auto-fill, 146px); gap: 14px; }
|
||||
|
||||
.album-strip :global(.card) { padding: 10px; }
|
||||
|
||||
.inline-tracklist {
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.inline-track-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.inline-track-head h3 { font-size: 16px; margin: 0 0 3px; }
|
||||
.inline-track-head p { color: var(--text-secondary); font-size: 12px; }
|
||||
.inline-play { padding: 7px 15px; border-radius: 18px; background: var(--accent); color: #000; font-size: 12px; font-weight: 700; }
|
||||
.inline-play:disabled { opacity: 0.45; }
|
||||
.inline-tracks { display: flex; flex-direction: column; }
|
||||
.inline-track { display: flex; align-items: center; gap: 12px; width: 100%; padding: 7px 8px; border-radius: 5px; text-align: left; }
|
||||
.inline-track:hover { background: var(--bg-highlight); }
|
||||
.inline-tno { width: 22px; text-align: right; color: var(--text-muted); font-size: 12px; }
|
||||
.inline-ttitle { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.inline-tmeta { color: var(--text-muted); font-size: 12px; }
|
||||
|
||||
/* album page */
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cover.placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
|
||||
color: var(--text-muted);
|
||||
font-size: 56px;
|
||||
}
|
||||
|
||||
.kind {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.head-meta h1 {
|
||||
margin: 6px 0 10px;
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.line {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.play-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 10px 26px;
|
||||
border-radius: 24px;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.refresh-art {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 24px;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.refresh-art:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.refresh-art:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.add-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
min-width: 200px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-press);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 9px 12px;
|
||||
border-radius: 5px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
.menu-item.new {
|
||||
color: var(--accent-hover);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 5px 5px 0 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.art-error {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #f15e6c;
|
||||
}
|
||||
|
||||
.play-all:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.play-all:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tracklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.trow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.trow:hover {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
.trow.playing .ttitle {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.tno {
|
||||
width: 22px;
|
||||
text-align: right;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ttitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tmeta {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user