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.
801 lines
27 KiB
Svelte
801 lines
27 KiB
Svelte
<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>
|