albums: one page for every record, and streamed history rows that actually open

Three things were wrong with opening an album, and only one of them was the
one that got reported.

The reported half: clicking a local record in the sidebar's history went to
the old in-library album view. Local albums carry no catalog identity of any
kind -- `albums.mbid` is null and is not even in the DTO -- so there was
nothing to route with. openAlbumByName already existed for exactly this
(the now-playing links use it, and it requires BOTH artist and title to match
normalized, which is the namesake guard); it now reports whether it resolved
and caches settled answers, so a second click is instant.

The unreported half, and the reason "half of them are not working": every
STREAMED row was dead. catalog_album_id is TEXT in SQLite, so it arrives as a
string, and `catalog_album` takes a u64 -- the invoke was rejected outright
with "invalid type: string ... expected u64". Terrestrials, OK Human and 9 all
failed silently on click. The id is coerced in checkedAlbumPayload now, where
both other string sources (page seeds) pass through too.

And the part that made the first half unfixable: a record no catalog carries
had nowhere to go. Panda Bear's "A ? of WHEN" is not on Deezer, so no amount
of resolving would ever open it. Local albums now render on the SAME page,
composed from the library's own data -- its tracks, durations, bitrates and
the cover already on disk -- under a namespaced `lib:` id that can never
collide with a Deezer one. Playback stays local: Play and any row queue the
files you already have rather than sending a record you own back through the
resolver. Save is not offered (it is already saved), previews are absent
(there is nothing to preview), and the attribution footer credits your
library rather than claiming Deezer + MusicBrainz.

With that, the old view has no callers and is deleted: the album-page block,
its CSS, and the members that only it used. Nothing anywhere routes to it.
Verified live: all six history rows open the new page, a Home card opens it,
Play queues 14 local FLACs without streaming, and the back arrow returns.
This commit is contained in:
KeelrLeek committed 2026-08-01 07:43:50 -05:00
1 parent 3c5bccf1b6
commit dde8c7510b
9 files changed
+252 -365

No files matched your search

+7 -1
View File
@@ -53,7 +53,7 @@
cancels the pane's top padding so a page's artwork can run underneath it,
and the page restores that inset for its own content. Without it the page
renders 24px too high; with it but not listed here, 24px too low. */
const BAR_VIEWS = new Set(["catalogArtist", "catalogAlbum"]);
const BAR_VIEWS = new Set(["catalogArtist", "catalogAlbum", "catalog"]);
initEvents();
autoConnect();
@@ -180,6 +180,12 @@
it: the glass bar bleeds up over it, cancels it for what follows, and
each page under the bar restores it as its own padding-top. */
--pane-pad: 24px;
/* The glass bar's height, declared HERE rather than on the bar so pages
can clear it. A page with a hero (artist, album) wants its artwork to
run underneath the bar and only takes --pane-pad back; a page with no
artwork behind the bar (search) has to start below it, or its first
heading sits under the frost at rest. */
--bar-h: 46px;
border-radius: var(--radius);
overflow-y: auto;
overflow-anchor: none;
+4 -3
View File
@@ -1,5 +1,5 @@
<script>
import { getArt, ui } from "../lib/state.svelte.js";
import { getArt, openLibraryAlbum } from "../lib/state.svelte.js";
import Icon from "./Icon.svelte";
let { album, onselect = null } = $props();
@@ -15,8 +15,9 @@
onselect(album);
return;
}
ui.view = "library";
ui.albumId = album.id;
// One album page for everything — the old in-library album view is
// gone, so a local record opens the same page a catalog record does.
void openLibraryAlbum(album.id);
}
</script>
+6 -2
View File
@@ -125,8 +125,12 @@
<style>
.glassbar {
--bar-h: 46px;
/* Sticky rather than absolute, so the bar pins to the top of the pane
/* --bar-h is INHERITED from the scroll pane (App.svelte), not declared
here: pages need the same number to decide whether to clear the bar
or run their artwork under it, and two copies would drift. Safe
because this component is only ever mounted inside that pane — see
the note at the top of the file.
Sticky rather than absolute, so the bar pins to the top of the pane
and stays there as the page scrolls, and `top` pins it flush against
the very top of the pane over that padding.
+14 -2
View File
@@ -12,6 +12,8 @@
toggleSidebar,
goToView,
openCatalogAlbum,
openAlbumByName,
openLibraryAlbum,
getArt,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
@@ -137,7 +139,7 @@
return !!a.art_path && a.art_path === playingArt;
}
function openRecent(a) {
async function openRecent(a) {
if (a.kind === "catalog" && a.catalog_album_id) {
// The catalog page, not the old library album view — streamed
// records live in the catalog. The seed paints title/artist/cover
@@ -152,7 +154,17 @@
});
return;
}
goToView("library", { albumId: a.album_id });
// A library row has no catalog id to open with — local albums carry no
// catalog identity at all (`albums.mbid` is null and never surfaced) —
// so the record is resolved by artist + title, where BOTH must match
// normalized or nothing opens. That namesake guard lives in
// openAlbumByName and is the reason this can't just take a first hit.
//
// Either way it is the SAME album page: a record no catalog has (Panda
// Bear's "A ? of WHEN", verified live) opens from the library's own
// data instead. There is no second album view to fall back to.
if (await openAlbumByName(a.artist, a.title)) return;
void openLibraryAlbum(a.album_id);
}
$effect(() => {
+15 -2
View File
@@ -33,13 +33,26 @@ const checkedArtistIdentity = (artistId, artist) =>
return payload;
});
const checkedAlbumPayload = (albumId) =>
invoke("catalog_album", { albumId }).then((payload) => {
// The id is COERCED to a number here, not merely passed along. Deezer album
// ids reach the frontend as strings from two places — SQLite stores the
// recently-played anchor as TEXT, and page seeds carry `String(id)` — and the
// Rust command takes a u64, so a string argument is rejected outright with
// "invalid type: string … expected u64". That made every streamed row in the
// sidebar's history dead on click (2026-08-01). The identity check below still
// compares through `sameArtistId`, which normalizes both sides, so a numeric
// id and its string form remain the same album.
const checkedAlbumPayload = (albumId) => {
const numeric = typeof albumId === "number" ? albumId : Number(String(albumId).trim());
if (!Number.isFinite(numeric)) {
return Promise.reject(new Error(`catalog_album: not an album id: ${albumId}`));
}
return invoke("catalog_album", { albumId: numeric }).then((payload) => {
if (!sameArtistId(payload?.album?.id, albumId)) {
throw new Error("catalog_album returned a mismatched album identity");
}
return payload;
});
};
export const appStatus = () => invoke("app_status");
export const getCredentials = () => invoke("get_credentials");
+146 -8
View File
@@ -1279,6 +1279,21 @@ export async function openEntry(entry) {
case "catalogArtist":
return openCatalogArtist(entry.artistId);
case "catalogAlbum":
// A local record lives on this same view but is composed from the
// library rather than fetched — the back arrow has to route on the
// id's realm or it would ask the catalog for a `lib:` id.
if (isLibraryAlbumId(entry.albumId)) {
return openLibraryAlbum(libraryAlbumIdOf(entry.albumId), {
seed: entry.art
? {
cover: entry.art,
title: entry.label ?? null,
artist: entry.artist ?? null,
kind: null,
}
: null,
});
}
// `recordVisit` stored this record's cover, so going back paints
// the skeleton with the artwork rather than a grey block. Older
// entries predate that field and simply seed nothing.
@@ -2413,26 +2428,149 @@ export async function openArtistByName(name) {
}
}
/** Local library albums are rendered by the SAME page as catalog records —
* there is no second album view. Their ids are namespaced so they can never
* collide with a Deezer album id in the page cache or an identity check.
* `openCatalogAlbum` refuses these; they go through `openLibraryAlbum`. */
const LIB_ALBUM_PREFIX = "lib:";
export const isLibraryAlbumId = (id) =>
typeof id === "string" && id.startsWith(LIB_ALBUM_PREFIX);
export const libraryAlbumIdOf = (id) =>
isLibraryAlbumId(id) ? Number(id.slice(LIB_ALBUM_PREFIX.length)) : null;
/**
* Open a LOCAL album on the album page. Same view, same shape — a library
* record simply has no catalog counterpart to fetch, so the page is composed
* from what the library itself knows: its tracks, their durations and
* bitrates, and the cover already on disk.
*
* The remote-only sections (listeners, editions, prose) are absent rather than
* empty; the page hides what it has no data for. Playback stays LOCAL — the
* point of a library record is the file you already have.
*/
export async function openLibraryAlbum(albumId, opts = {}) {
const id = Number(albumId);
if (!Number.isFinite(id)) return;
captureScroll();
// A verified navigation supersedes any still-running name search, exactly
// as openCatalogAlbum does — otherwise a slow resolve can land on top.
albumResolveRequest += 1;
const request = ++albumPageRequest;
ui.view = "catalogAlbum";
ui.albumId = null;
catalog.albumPage = null;
catalog.albumLoading = true;
catalog.albumSeed = opts.seed ?? null;
catalog.error = "";
const active = () => request === albumPageRequest && ui.view === "catalogAlbum";
try {
if (!libraryStore.loaded) await refreshLibrary();
const meta = libraryStore.albums.find((a) => a.id === id) ?? null;
if (!meta) throw new Error("album is not in your library");
const [tracks, cover] = await Promise.all([
api.albumTracks(id),
getArt(meta.art_path).catch(() => null),
]);
if (!active()) return;
catalog.albumPage = {
local: true,
libraryAlbumId: id,
libraryTracks: tracks,
artPath: meta.art_path ?? null,
album: {
id: `${LIB_ALBUM_PREFIX}${id}`,
title: meta.title,
artist: meta.artist,
cover: cover ?? null,
release_date: meta.year ? String(meta.year) : null,
record_type: "album",
track_count: tracks.length,
is_soundtrack: false,
},
// Shaped like catalog tracks so the tracklist renders unchanged;
// `path`/`bitrate_kbps` ride along for the local row and playback.
tracks: tracks.map((t) => ({
id: `${LIB_ALBUM_PREFIX}t${t.id}`,
title: t.title,
artist: t.artist || meta.artist,
album: meta.title,
album_id: null,
cover: cover ?? null,
duration_secs: t.duration_secs ?? null,
preview: null,
track_no: t.track_no ?? null,
unreleased: false,
path: t.path,
bitrate_kbps: t.bitrate_kbps ?? null,
})),
album_plays: null,
album_listeners: null,
totals_summed: false,
};
recordVisit({
view: "catalogAlbum",
label: meta.title,
albumId: `${LIB_ALBUM_PREFIX}${id}`,
artist: meta.artist,
art: cover ?? null,
});
} catch (error) {
if (active()) {
catalog.error = String(error);
catalog.albumPage = null;
}
} finally {
if (active()) catalog.albumLoading = false;
}
}
/** Confirmed answers from `openAlbumByName`, keyed on the normalized pair:
* an album id, or `null` for a record no catalog has. Both are worth keeping —
* a local-only album misses every time, and remembering the miss is what makes
* a second click instant instead of re-running the search. Only SETTLED
* answers land here; a transport failure stays retryable. */
const albumNameResolves = new Map();
/** Resolve an album by artist + title (via catalog search) and open its page —
* used by the now-playing album/song links when there's no catalog albumId
* (e.g. a local library track). */
* used by the now-playing album/song links and the sidebar's recently-played
* rows when there's no catalog albumId (e.g. a local library track, which
* carries no catalog identity of any kind).
*
* Returns the album id it opened, or `null` when nothing matched — callers
* that have a fallback destination need to know which happened. */
export async function openAlbumByName(artist, title, opts = {}) {
const request = ++albumResolveRequest;
if (opts.albumId != null) return openCatalogAlbum(opts.albumId, opts);
if (!artist || !title) return;
if (opts.albumId != null) {
openCatalogAlbum(opts.albumId, opts);
return opts.albumId;
}
if (!artist || !title) return null;
const na = catNorm(artist);
const nt = catNorm(title);
const key = `${na}|${nt}`;
if (albumNameResolves.has(key)) {
const id = albumNameResolves.get(key);
if (id != null) openCatalogAlbum(id, opts);
return id;
}
try {
const res = await api.catalogSearch(`${artist} ${title}`);
if (request !== albumResolveRequest) return;
const na = catNorm(artist);
const nt = catNorm(title);
if (request !== albumResolveRequest) return null;
// A title-only or first-result fallback can cross into a namesake's
// catalog. Without a verified id, both artist and title must match.
const match = res.albums?.find(
(album) => catNorm(album.artist) === na && catNorm(album.title) === nt,
);
if (match) openCatalogAlbum(match.id, opts);
// A successful search that matched nothing is an answer ABOUT THE
// RECORD: this artist has no such album in the catalog. Negative-cache
// it. A throw below is an answer about the network, and is not cached.
albumNameResolves.set(key, match ? match.id : null);
if (!match) return null;
openCatalogAlbum(match.id, opts);
return match.id;
} catch {
// no album match — leave the view as-is
return null;
}
}
+51 -10
View File
@@ -19,6 +19,7 @@
openCatalogAlbum,
openAlbumFromPreviewItem,
toggleShuffle,
playAlbum,
ui,
} from "../lib/state.svelte.js";
import { openPreview } from "../lib/preview/previewSession.svelte.js";
@@ -32,10 +33,17 @@
const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … }
const album = $derived(page?.album ?? null);
/** A LOCAL library record on this same page. Everything remote is simply
* absent — no listeners, no editions, no prose, no cover upgrade to chase
* — and playback goes to the files on disk instead of the resolver. */
const isLocal = $derived(Boolean(page?.local));
// Upgrade this album's cover to the highest verified-same-artwork source.
let coveredId = null;
$effect(() => {
// A local cover is already the file on disk — there is no better
// source to go looking for, and the upgrade path expects a URL.
if (isLocal) return;
if (album?.cover && album.id !== coveredId) {
coveredId = album.id;
resolveCovers([album], album.artist);
@@ -566,7 +574,20 @@
else flagSaveError(`t${t.id}`);
}
const playTrack = (t) =>
const playTrack = (t) => {
// A local record plays the file you already have — queueing the whole
// album from the clicked row, the way the library always did — rather
// than sending a track you own back through the resolver.
if (isLocal) {
const at = page.libraryTracks.findIndex((lt) => lt.path === t.path);
playAlbum(
page.libraryTracks,
`${album.artist} — ${album.title}`,
at < 0 ? 0 : at,
page.artPath,
);
return;
}
streamSong(album.artist, t.title, {
cover: t.cover ?? album.cover,
trackId: t.id,
@@ -574,6 +595,7 @@
album: album.title || null,
expectSecs: t.duration_secs ?? null,
});
};
function year(a) {
return a?.release_date?.slice(0, 4) ?? "";
@@ -736,17 +758,30 @@
class="pill"
disabled={hasUnreleased}
title={hasUnreleased ? "Not released yet" : ""}
onclick={() => streamCatalogAlbum(album)}
onclick={() =>
isLocal
? playAlbum(
page.libraryTracks,
`${album.artist} — ${album.title}`,
0,
page.artPath,
)
: streamCatalogAlbum(album)}
>
<Icon name="play" size={12} /> Play
</button>
<button class="pill ghost" disabled={hasUnreleased} onclick={saveAlbumAction}>
<Icon
name={saved.album === "ok" ? "check" : saved.album === "err" ? "x" : "download"}
size={13}
/>
{saved.album === "ok" ? "Saved" : saved.album === "err" ? "No sources" : "Save"}
</button>
<!-- Save is "fetch this from the network into your
library". A local record is already there, so the
control has nothing to do and is not offered. -->
{#if !isLocal}
<button class="pill ghost" disabled={hasUnreleased} onclick={saveAlbumAction}>
<Icon
name={saved.album === "ok" ? "check" : saved.album === "err" ? "x" : "download"}
size={13}
/>
{saved.album === "ok" ? "Saved" : saved.album === "err" ? "No sources" : "Save"}
</button>
{/if}
<button
class="iconbtn"
class:on={player.shuffle}
@@ -936,10 +971,16 @@
{#if nTracks}<span class="gpill">{nTracks}</span>{/if}
{#if runtime}<span class="gpill">{runtime}</span>{/if}
</div>
<!-- Attribution has to be true of THIS page: a local record's
facts were read off the files, not fetched from a catalogue. -->
<p class="srcs">
{#if info?.description}<b>Words</b> Wikipedia · {/if}
{#if albumPlays || albumListeners}<b>Counts</b> Last.fm · {/if}
<b>Catalogue</b> Deezer + MusicBrainz
{#if isLocal}
<b>Catalogue</b> your library
{:else}
<b>Catalogue</b> Deezer + MusicBrainz
{/if}
</p>
{/if}
{/if}
+8
View File
@@ -194,6 +194,14 @@
.catalog {
display: flex;
flex-direction: column;
/* The glass bar cancels the pane's top padding so a page's artwork can
run underneath it, and each page under the bar hands that inset back.
Unlike the artist and album pages there is no hero here for the bar to
float over, so the results also have to CLEAR the bar's height —
without it the "Top result" heading sits under the frost at rest.
They still slide under it on scroll, which is what the frost is for.
See the note by BAR_VIEWS and --bar-h in App.svelte. */
padding-top: calc(var(--pane-pad) + var(--bar-h));
}
.hint {
+1 -337
View File
@@ -3,19 +3,14 @@
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([]);
@@ -238,66 +233,13 @@
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));
});
// Still used by the inline album expander in the artist rows below.
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}
@@ -369,87 +311,6 @@
</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 {
@@ -600,201 +461,4 @@
.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>