now playing: the transfer moves into the bar, and the panel speaks the app's headings
The hover popover is deleted. It showed the peer, the rate, a percentage and a second progress bar — every one of which the status line and the hairline beside it already carry — so it was a duplicate you had to hover for. The two things unique to it move up: the save action into the bar, and nothing else. Deleted with it, the reveal action, its 250ms close grace and the `.np-statuswrap` hit area, all of which existed only to make a popover reachable across the 25px gap between the status line and itself. Release sections were the one heading in the app that was 13px, Title Case, full white on an opaque sticky bar; every other one — `.lab` here, `.disc-head` and `.rail-lab` on the catalog pages — is 11px uppercase muted. They now match, with the hairline rule the catalog pages use, and they no longer pin: the bar existed only to hide rows sliding under a sticky heading. Those headings sit on the blurred record, not a flat page, so `--text-muted` and #5c5c5c were chosen against a background that is not there. Each carries a dark plate now, years get the album page's over-artwork treatment, and the hairline goes 0.07 -> 0.14, which over a bright cover was the difference between a faint line and no line. "From the album" rendered `player.queue`, which the album fill grows ONE TRACK AT A TIME as each Soulseek source resolves — a nine-track record read "5 tracks" while you watched the rest arrive. It renders the record's own tracklist now, matched to queue items by provider id, and a row with no source yet resolves on demand when clicked. The About pane gains a record head, a lede, stat tiles, pills and a source line, all re-laid-out from data already in hand. The artist page's expander gets the same lede and pills — no record head or tiles there, because its rail already carries them.
This commit is contained in:
1 parent
a6b585303e
commit
3f4f5c305a
2 files changed
+459
-185
No files matched your search
+375
-177
@@ -7,6 +7,7 @@
|
||||
getArt,
|
||||
getCatalogArt,
|
||||
jumpToQueueIndex,
|
||||
streamSong,
|
||||
discovery,
|
||||
loadDiscovery,
|
||||
openCatalogArtist,
|
||||
@@ -29,6 +30,7 @@
|
||||
import SaveMenu from "./SaveMenu.svelte";
|
||||
import * as api from "../lib/api.js";
|
||||
import { floatScroll } from "../lib/scrollbar.js";
|
||||
import { splitDescription } from "../lib/editions.js";
|
||||
import { accentFromImage } from "../lib/color.js";
|
||||
|
||||
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
|
||||
@@ -81,9 +83,11 @@
|
||||
const quality = $derived(currentItem?.quality ?? null);
|
||||
|
||||
// Album context: the queue is a catalog album/continuation with >1 track.
|
||||
const albumMode = $derived(
|
||||
!!player.catalogRef.albumId && player.queue.length > 1,
|
||||
);
|
||||
// Knowing the record is enough. It used to also require a queue longer than
|
||||
// one, which meant the album section could not appear until the background
|
||||
// fill had sourced a SECOND track — so playing a single track showed
|
||||
// nothing, then a partial list.
|
||||
const albumMode = $derived(!!player.catalogRef.albumId);
|
||||
|
||||
// Artist name for the Save action: album items carry "artist — album".
|
||||
const artistName = $derived.by(() => {
|
||||
@@ -245,13 +249,31 @@
|
||||
let previous = null;
|
||||
const rows = items.map((album) => {
|
||||
const year = key === "upcoming" ? "Upcoming" : albumYear(album) || "—";
|
||||
const heading = year === previous ? null : year;
|
||||
// A divider that only repeats the section it sits under is
|
||||
// noise: Upcoming headed its rows with the word "Upcoming"
|
||||
// again, directly below a heading already reading Upcoming.
|
||||
const heading =
|
||||
year === previous || year.toLowerCase() === label.toLowerCase()
|
||||
? null
|
||||
: year;
|
||||
previous = year;
|
||||
return { album, heading };
|
||||
});
|
||||
return [{ key, label, count: items.length, rows }];
|
||||
});
|
||||
});
|
||||
/** The artwork this row is already painting, handed to the album page so
|
||||
* its skeleton opens with the record's cover rather than a grey block. */
|
||||
function albumSeed(album) {
|
||||
if (!album?.cover || artBroken[album.cover]) return null;
|
||||
return {
|
||||
cover: album.cover,
|
||||
title: album.title ?? null,
|
||||
artist: album.artist ?? discovery?.name ?? null,
|
||||
kind: album.record_type ?? null,
|
||||
tracks: album.track_count ?? null,
|
||||
};
|
||||
}
|
||||
function fmtFans(n) {
|
||||
if (!n) return "—";
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
|
||||
@@ -416,6 +438,15 @@
|
||||
/** The hairline has no percentage to show until bytes start arriving. */
|
||||
const hairlineSeeking = $derived(isStream && player.streamTotal === 0);
|
||||
|
||||
// The hover-revealed transfer popover is gone. It showed the peer, the
|
||||
// rate, a percentage and a second progress bar — every one of which the
|
||||
// status line and the hairline beside it already carry — so it was a
|
||||
// duplicate that had to be hovered for. What was unique to it now lives in
|
||||
// the bar: the peer as a monogram, and the save action. Deleted with it:
|
||||
// the reveal action, its 250ms close grace, and the `.np-statuswrap` hit
|
||||
// area, all of which existed only to make a popover reachable across the
|
||||
// 25px gap between the status line and itself.
|
||||
|
||||
// ---- drag to resize ----------------------------------------------------
|
||||
// Pointer capture, like the player bar's scrubber: the pointer keeps
|
||||
// delivering moves after it leaves the 9px grip, which a mousemove
|
||||
@@ -576,29 +607,133 @@
|
||||
const listenersOf = (item) =>
|
||||
item?.trackId != null ? (listenersById.get(String(item.trackId)) ?? null) : null;
|
||||
|
||||
/** "7 October 2014 · 13 tracks · 42 min", from whichever parts are known. */
|
||||
const creditsLine = $derived.by(() => {
|
||||
const parts = [];
|
||||
const date = catalogPage?.album?.release_date ?? null;
|
||||
if (date) {
|
||||
const d = new Date(date);
|
||||
parts.push(
|
||||
isNaN(d)
|
||||
? date
|
||||
: d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }),
|
||||
);
|
||||
/** The RECORD's tracklist, not the queue's progress through it.
|
||||
*
|
||||
* This section rendered `player.queue` directly, and the queue is grown one
|
||||
* track at a time by the album fill — each row appeared only once its
|
||||
* Soulseek source had resolved. So a nine-track record read "5 tracks"
|
||||
* while you watched the rest arrive one by one. `catalogPage.tracks` is the
|
||||
* whole record in a single fetch, and it now lands early (see the
|
||||
* `catalogRef` assignment in `streamSong`), so the list is complete
|
||||
* immediately and the queue is demoted to what it actually is: playback
|
||||
* state layered on top.
|
||||
*
|
||||
* A row with no queue item yet is still playable — clicking it resolves a
|
||||
* source on demand, exactly as the fill would have. Falls back to the queue
|
||||
* whenever the record is unknown (a local album, a plain queue). */
|
||||
const albumRows = $derived.by(() => {
|
||||
const queue = player.queue ?? [];
|
||||
const tracks = catalogPage?.tracks ?? [];
|
||||
if (!albumMode || !tracks.length) {
|
||||
return queue.map((item, index) => ({
|
||||
key: `q${index}`,
|
||||
title: item.title,
|
||||
duration: item.duration ?? null,
|
||||
listeners: listenersOf(item),
|
||||
queueIndex: index,
|
||||
trackId: item.trackId ?? null,
|
||||
}));
|
||||
}
|
||||
const n = catalogPage?.tracks?.length ?? player.queue.length;
|
||||
if (n) parts.push(`${n} track${n === 1 ? "" : "s"}`);
|
||||
const secs = (catalogPage?.tracks ?? player.queue).reduce(
|
||||
// Match by provider id first; a title match is the fallback for items
|
||||
// queued before an id was known.
|
||||
const byTrackId = new Map();
|
||||
const byTitle = new Map();
|
||||
queue.forEach((item, index) => {
|
||||
if (item.trackId != null) byTrackId.set(String(item.trackId), index);
|
||||
const key = norm(item.title);
|
||||
if (key && !byTitle.has(key)) byTitle.set(key, index);
|
||||
});
|
||||
return tracks.map((t, i) => ({
|
||||
key: `t${t.id ?? i}`,
|
||||
title: t.title,
|
||||
duration: t.duration_secs ?? t.duration ?? null,
|
||||
listeners: t.listeners ?? null,
|
||||
queueIndex:
|
||||
(t.id != null ? byTrackId.get(String(t.id)) : undefined)
|
||||
?? byTitle.get(norm(t.title))
|
||||
?? -1,
|
||||
trackId: t.id ?? null,
|
||||
}));
|
||||
});
|
||||
|
||||
/** Play a row. Already sourced → jump to it; not yet → resolve it now. */
|
||||
function playAlbumRow(row) {
|
||||
if (row.queueIndex >= 0) {
|
||||
jumpToQueueIndex(row.queueIndex);
|
||||
return;
|
||||
}
|
||||
streamSong(artistName, row.title, {
|
||||
cover: catalogPage?.album?.cover ?? currentItem?.artUrl ?? null,
|
||||
trackId: row.trackId,
|
||||
albumId: player.catalogRef.albumId,
|
||||
album: albumTitle || null,
|
||||
expectSecs: row.duration ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- About pane facts ---------------------------------------------------
|
||||
// Everything below is re-laid-out from data already in hand — the record's
|
||||
// catalog payload, its editorial info, and the artist's own releases. None
|
||||
// of it costs a request the panel was not already making.
|
||||
|
||||
/** "My Bloody Valentine · 1991 · 11 tracks", under the record's name. */
|
||||
const recordSub = $derived.by(() => {
|
||||
const year = catalogPage?.album?.release_date?.slice(0, 4) ?? null;
|
||||
const n = catalogPage?.tracks?.length ?? 0;
|
||||
return [artistName, year, n ? `${n} track${n === 1 ? "" : "s"}` : null]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
});
|
||||
|
||||
/** Lede + body, from the shared splitter — the album page and the artist
|
||||
* page's expander promote their descriptions with the same function. */
|
||||
const descParts = $derived(splitDescription(albumInfo?.description));
|
||||
const descLede = $derived(descParts.lede);
|
||||
const descRest = $derived(descParts.rest);
|
||||
|
||||
/** Year / tracks / runtime / listeners, from the record's own payload. */
|
||||
const recordTiles = $derived.by(() => {
|
||||
const tiles = [];
|
||||
const year = catalogPage?.album?.release_date?.slice(0, 4);
|
||||
if (year) tiles.push({ label: "Released", value: year });
|
||||
const n = catalogPage?.tracks?.length ?? 0;
|
||||
if (n) tiles.push({ label: "Tracks", value: String(n) });
|
||||
const secs = (catalogPage?.tracks ?? []).reduce(
|
||||
(sum, t) => sum + (t.duration_secs ?? t.duration ?? 0),
|
||||
0,
|
||||
);
|
||||
if (secs) {
|
||||
const mins = Math.round(secs / 60);
|
||||
parts.push(mins >= 60 ? `${Math.floor(mins / 60)} hr ${mins % 60} min` : `${mins} min`);
|
||||
tiles.push({
|
||||
label: "Runtime",
|
||||
value: mins >= 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`,
|
||||
});
|
||||
}
|
||||
return parts.join(" · ");
|
||||
const listeners = catalogPage?.album_listeners ?? null;
|
||||
if (listeners) tiles.push({ label: "Listeners", value: fmtListeners(listeners) });
|
||||
return tiles;
|
||||
});
|
||||
|
||||
/** "3 November 1991" for the pill row. */
|
||||
const releasedOn = $derived.by(() => {
|
||||
const value = catalogPage?.album?.release_date;
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return isNaN(date)
|
||||
? value
|
||||
: date.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
|
||||
});
|
||||
|
||||
/** "1988–2013" — the span of the artist's own releases, nothing fetched. */
|
||||
const activeYears = $derived.by(() => {
|
||||
const years = (discovery.albums ?? [])
|
||||
.map((a) => Number(a.release_date?.slice(0, 4)))
|
||||
.filter((y) => Number.isFinite(y) && y > 1900);
|
||||
if (!years.length) return "";
|
||||
const first = Math.min(...years);
|
||||
const last = Math.max(...years);
|
||||
if (first === last) return String(first);
|
||||
return last >= new Date().getFullYear() - 1 ? `${first}–present` : `${first}–${last}`;
|
||||
});
|
||||
|
||||
// Width-responsive: the pill's stats spread rather than bunch, and the
|
||||
@@ -846,38 +981,16 @@
|
||||
{metaParts[1] || player.track?.title || "Nothing playing"}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Saving what you are streaming lived in the popover. It is the
|
||||
only ACTION that did, so it comes up here rather than being
|
||||
lost with it. -->
|
||||
{#if isStream && currentItem?.stream}
|
||||
<SaveMenu align="down" />
|
||||
{/if}
|
||||
{#if isStream}
|
||||
<span class="hairline" class:seek={hairlineSeeking} class:done={cached} style="--buf:{bufferPct}%"><i></i></span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- The whole transfer, one hover away: everything the old source card
|
||||
showed permanently, without spending a permanent line on it. This
|
||||
is also where saving what you are streaming lives now. -->
|
||||
{#if isStream && currentItem?.stream}
|
||||
<div class="xpop">
|
||||
<div class="xrow">
|
||||
<div class="xav">{currentItem.stream.username.slice(0, 1).toUpperCase()}</div>
|
||||
<div class="xwho">
|
||||
<span title={currentItem.stream.username}>{currentItem.stream.username}</span>
|
||||
<small>
|
||||
{#if cached}
|
||||
complete · {fmtSize(player.streamTotal)}
|
||||
{:else if player.streamTotal > 0}
|
||||
{fmtSize(player.streamReceived)} / {fmtSize(player.streamTotal)}{rateText ? ` · ${rateText}` : ""}
|
||||
{:else}
|
||||
connecting…
|
||||
{/if}
|
||||
</small>
|
||||
</div>
|
||||
<SaveMenu align="down" />
|
||||
</div>
|
||||
{#if player.streamTotal > 0}
|
||||
<div class="xbar"><i style="width:{bufferPct}%"></i></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="np-scroll" use:floatScroll>
|
||||
@@ -1007,38 +1120,39 @@
|
||||
<span class="un-dur">{fmtTime(upNext.duration)}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if player.queue.length > 1}
|
||||
{#if albumRows.length > 1}
|
||||
<div class="lab">
|
||||
<span>{albumMode ? "From the album" : "Queue"}</span>
|
||||
<span class="cnt">{player.queue.length} tracks</span>
|
||||
<span class="cnt">{albumRows.length} tracks</span>
|
||||
</div>
|
||||
<div class="list">
|
||||
{#each player.queue as item, i}
|
||||
{@const lis = listenersOf(item)}
|
||||
{#each albumRows as row, i (row.key)}
|
||||
{@const isNow =
|
||||
row.queueIndex >= 0 && row.queueIndex === player.queueIndex}
|
||||
<button
|
||||
class="row"
|
||||
class:now={i === player.queueIndex}
|
||||
class:now={isNow}
|
||||
class:wide={showListeners}
|
||||
onclick={() => jumpToQueueIndex(i)}
|
||||
onclick={() => playAlbumRow(row)}
|
||||
>
|
||||
<span class="n">
|
||||
{#if i === player.queueIndex}
|
||||
{#if isNow}
|
||||
<span
|
||||
class="eq"
|
||||
class:active={player.status === "playing" && ui.windowActive}
|
||||
><b></b><b></b><b></b></span>
|
||||
{:else}{i + 1}{/if}
|
||||
</span>
|
||||
<span class="nm" title={item.title}>
|
||||
{item.title}
|
||||
{#if libraryTicks.has(norm(item.title))}
|
||||
<span class="nm" title={row.title}>
|
||||
{row.title}
|
||||
{#if libraryTicks.has(norm(row.title))}
|
||||
<span class="own" title="Saved in your library"><Icon name="check" size={11} /></span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if showListeners}
|
||||
<span class="lis">{fmtListeners(lis) ?? "—"}</span>
|
||||
<span class="lis">{fmtListeners(row.listeners) ?? "—"}</span>
|
||||
{/if}
|
||||
<span class="du">{fmtTime(item.duration)}</span>
|
||||
<span class="du">{fmtTime(row.duration)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1080,6 +1194,7 @@
|
||||
{#each releaseSections as section (section.key)}
|
||||
<div class="rel-sec">
|
||||
<span>{section.label}</span>
|
||||
<span class="rel-rule"></span>
|
||||
<span class="n">{section.count}</span>
|
||||
</div>
|
||||
<div class="rel-rows">
|
||||
@@ -1090,7 +1205,7 @@
|
||||
<button
|
||||
class="rel-row"
|
||||
title={row.album.title}
|
||||
onclick={() => openCatalogAlbum(row.album.id)}
|
||||
onclick={() => openCatalogAlbum(row.album.id, { seed: albumSeed(row.album) })}
|
||||
>
|
||||
<span class="rel-th">
|
||||
{#if row.album.cover && !artBroken[row.album.cover]}
|
||||
@@ -1108,7 +1223,7 @@
|
||||
<button
|
||||
class="mini"
|
||||
title={al.title}
|
||||
onclick={() => openCatalogAlbum(al.id)}
|
||||
onclick={() => openCatalogAlbum(al.id, { seed: albumSeed(al) })}
|
||||
onpointerenter={(e) => tintFromCover(e, al)}
|
||||
>
|
||||
<div class="art">
|
||||
@@ -1143,25 +1258,56 @@
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
{#if albumInfo?.description}
|
||||
<div class="lab"><span>About this record</span></div>
|
||||
<p class="bio" class:clamped={!descExpanded}>{albumInfo.description}</p>
|
||||
<button class="more" class:open={descExpanded} onclick={() => (descExpanded = !descExpanded)}>
|
||||
{descExpanded ? "Show less" : "Show more"}
|
||||
<Icon name="chevron-down" size={13} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if creditsLine || albumInfo?.label || albumInfo?.genre}
|
||||
<div class="credits">
|
||||
<span class="clab">Released</span>
|
||||
{creditsLine}
|
||||
{#if albumInfo?.label || albumInfo?.genre}
|
||||
<br />{albumInfo.label ?? ""}{albumInfo.label && albumInfo.genre ? " · " : ""}{albumInfo.genre ?? ""}
|
||||
<div class="lab"><span>About this record</span></div>
|
||||
{#if albumTitle}
|
||||
<!-- The record names itself, so the copy below does
|
||||
not have to carry the identity as well. -->
|
||||
<div class="rechead">
|
||||
{#if artUrl}
|
||||
<img class="cv" src={artUrl} alt="" draggable="false" />
|
||||
{/if}
|
||||
<div class="rh-copy">
|
||||
<div class="rt">{albumTitle}</div>
|
||||
{#if recordSub}<div class="rs">{recordSub}</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if albumInfo?.description}
|
||||
<!-- The lede is the description's own FIRST SENTENCE,
|
||||
set larger — not a written hook. "Loveless is the
|
||||
second studio album by…" already reads like one,
|
||||
and splitting it costs no source and invents
|
||||
nothing. The remainder clamps as before. -->
|
||||
{#if descLede}
|
||||
<p class="lede">{descLede}</p>
|
||||
{/if}
|
||||
{#if descRest}
|
||||
<p class="bio" class:clamped={!descExpanded}>{descRest}</p>
|
||||
<button class="more" class:open={descExpanded} onclick={() => (descExpanded = !descExpanded)}>
|
||||
{descExpanded ? "Show less" : "Show more"}
|
||||
<Icon name="chevron-down" size={13} />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if recordTiles.length}
|
||||
<div class="tiles">
|
||||
{#each recordTiles as tile (tile.label)}
|
||||
<div class="tile"><b>{tile.value}</b><i>{tile.label}</i></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if albumInfo?.genre || albumInfo?.label || releasedOn}
|
||||
<div class="pills">
|
||||
{#if albumInfo?.genre}<span class="pill accent">{albumInfo.genre}</span>{/if}
|
||||
{#if albumInfo?.label}<span class="pill">{albumInfo.label}</span>{/if}
|
||||
{#if releasedOn}<span class="pill">{releasedOn}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="lab"><span>About the artist</span></div>
|
||||
{@render artistChip()}
|
||||
{#if activeYears}
|
||||
<div class="pills"><span class="pill">Active {activeYears}</span></div>
|
||||
{/if}
|
||||
{#if discovery.bio}
|
||||
<p class="bio" class:clamped={!bioExpanded}>{discovery.bio}</p>
|
||||
<button class="more" class:open={bioExpanded} onclick={() => (bioExpanded = !bioExpanded)}>
|
||||
@@ -1172,6 +1318,17 @@
|
||||
{#if !albumInfo?.description && !discovery.bio}
|
||||
<p class="pane-empty">Nothing written about this record yet.</p>
|
||||
{/if}
|
||||
<!-- The pane asserts dates, counts and prose from three
|
||||
different providers; saying which is whose costs a
|
||||
line and matches how carefully the rest of the app
|
||||
anchors what it claims. -->
|
||||
{#if albumInfo?.description || discovery.bio || recordTiles.length}
|
||||
<p class="srcs">
|
||||
{#if albumInfo?.description || discovery.bio}<b>Words</b> Wikipedia · {/if}
|
||||
{#if recordTiles.length}<b>Counts</b> Last.fm · {/if}
|
||||
<b>Catalogue</b> Deezer + MusicBrainz
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
@@ -1782,20 +1939,31 @@
|
||||
/* source card */
|
||||
/* save action row */
|
||||
/* tracklist */
|
||||
/* Every heading in this panel rides the blurred record, not a flat dark
|
||||
page: `.bleed-fade` is only rgba(24,24,24,0.15) at the top and does not
|
||||
reach solid until 78% down. `--text-muted` and #5c5c5c were chosen
|
||||
against --bg-base and simply vanish into a bright cover. Each heading
|
||||
carries its own ground instead — a dark plate fading out to the right,
|
||||
so the label end is always backed and the row end still shows the
|
||||
artwork through. */
|
||||
.lab {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 7px;
|
||||
margin-inline: -8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(90deg, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.14));
|
||||
}
|
||||
.lab .cnt {
|
||||
letter-spacing: 0;
|
||||
color: #5c5c5c;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
/* Section spacing belongs to the label, not to whatever happens to sit
|
||||
above it — an artist with no social links used to leave the releases
|
||||
@@ -2089,24 +2257,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The album page's credits block, verbatim, so a record reads the same in
|
||||
both places. */
|
||||
.credits {
|
||||
margin-top: 18px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.credits .clab {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #5e5e5e;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
p.bio {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
@@ -2136,7 +2286,96 @@
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* ---- transfer detail, on hovering the header ---- */
|
||||
/* ---- About pane ---- */
|
||||
/* The record naming itself, so the copy below need not carry the identity. */
|
||||
.rechead { display: flex; gap: 11px; margin-bottom: 9px; }
|
||||
.rechead .cv {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
border-radius: 6px;
|
||||
flex: none;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 8px 18px -8px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.rh-copy { min-width: 0; }
|
||||
.rechead .rt {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.rechead .rs {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-top: 3px;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
/* The description's own first sentence, promoted — see `descLede`. */
|
||||
.lede {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 0 0 8px;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
/* Auto-fit, because this pane is resizable: the row goes two-up rather than
|
||||
crushing four tiles into 272px. */
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(74px, 1fr));
|
||||
gap: 6px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.tile {
|
||||
padding: 8px 9px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
border: 1px solid rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
.tile b {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tile i {
|
||||
font-style: normal;
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 8.5px;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
.pills { display: flex; flex-wrap: wrap; gap: 5px; margin: 8px 0 2px; }
|
||||
.pill {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.pill.accent {
|
||||
background: color-mix(in srgb, var(--accent) 30%, rgba(10, 10, 12, 0.4));
|
||||
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
|
||||
color: #fff;
|
||||
}
|
||||
.srcs {
|
||||
margin-top: 14px;
|
||||
font-size: 9.5px;
|
||||
line-height: 1.6;
|
||||
color: #5e5e5e;
|
||||
}
|
||||
.srcs b { color: #7a7a7a; font-weight: 700; }
|
||||
|
||||
.headzone {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -2144,69 +2383,6 @@
|
||||
right: 0;
|
||||
z-index: 6;
|
||||
}
|
||||
.xpop {
|
||||
position: absolute;
|
||||
top: 46px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: -1;
|
||||
padding: 10px 12px 12px;
|
||||
background: rgba(22, 22, 26, 0.94);
|
||||
backdrop-filter: blur(16px) saturate(1.5);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.5);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45), 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
pointer-events: none;
|
||||
transition: opacity 150ms ease, transform 300ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.headzone:hover .xpop,
|
||||
.headzone:focus-within .xpop {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.xrow { display: flex; align-items: center; gap: 9px; }
|
||||
.xav {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: color-mix(in srgb, var(--accent) 42%, #333);
|
||||
color: #fff;
|
||||
}
|
||||
.xwho { flex: 1; min-width: 0; font-size: 12px; font-weight: 600; }
|
||||
.xwho span {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.xwho small {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.xbar {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #2b2b2b;
|
||||
overflow: hidden;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.xbar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--accent-hover);
|
||||
transition: width 240ms linear;
|
||||
}
|
||||
.list { display: flex; flex-direction: column; }
|
||||
.row {
|
||||
display: flex;
|
||||
@@ -2298,31 +2474,53 @@
|
||||
Dense rows rather than a grid: at the panel's widths a row fits a whole
|
||||
title where a card clamps it to two lines, and twice as many releases
|
||||
fit on a screen. E4 of research/nowplaying-releases/dividers.html. */
|
||||
/* The app has exactly one section heading and this is now it: 11px,
|
||||
uppercase, 0.1em, weight 800, muted, with a hairline running out to the
|
||||
edge — `.disc-head` on both catalog pages, and a sibling of this panel's
|
||||
own `.lab`. It used to be 13px Title Case in full white on an opaque
|
||||
full-bleed bar, the only heading in the app shaped that way, which is
|
||||
why it read as a design of its own.
|
||||
|
||||
It is no longer sticky. The bar existed only to hide rows sliding under
|
||||
a pinned heading; painted where it sits it scrolls away like every other
|
||||
element, which is the same call the segmented control above it got. */
|
||||
.rel-sec {
|
||||
position: sticky;
|
||||
top: 46px;
|
||||
z-index: 3;
|
||||
margin: 14px -14px 4px;
|
||||
padding: 10px 14px 6px;
|
||||
background: linear-gradient(var(--bg-base) 74%, transparent);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
margin: 18px -8px 7px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(90deg, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.14));
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
}
|
||||
.rel-sec .n { font-size: 11px; font-weight: 700; color: var(--text-muted); }
|
||||
/* Deliberately quieter than the category above it, so the two never read
|
||||
as the same level of heading. */
|
||||
/* 0.07 is the hairline's value on a flat page. Over a bright record it is
|
||||
not a faint line, it is no line — this one has to survive the cover. */
|
||||
.rel-rule {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.rel-sec .n { letter-spacing: 0; font-weight: 700; color: rgba(255, 255, 255, 0.5); }
|
||||
/* Quieter than the section above it, so the two never read as the same
|
||||
level of heading. A year that only repeats its section is dropped
|
||||
upstream, in `releaseSections`.
|
||||
|
||||
No plate on this one: a year is a minor divider, and a third band of
|
||||
dark under it made the list read as stripes. It takes the treatment the
|
||||
album page already gives text standing on artwork — brighter ink and a
|
||||
drop shadow (`.kind`, `.stat`, `.np-from` all do exactly this). */
|
||||
.rel-year {
|
||||
margin: 12px 2px 3px;
|
||||
margin: 10px 2px 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.75;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rel-rows { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
editionLabel,
|
||||
isEdition,
|
||||
releaseFamilies,
|
||||
splitDescription,
|
||||
} from "../lib/editions.js";
|
||||
import { listenerHeat, listenerPeak, listenerShare } from "../lib/listenerBars.js";
|
||||
import { buildArtistManifest } from "../lib/preview/manifest.js";
|
||||
@@ -2450,6 +2451,27 @@
|
||||
return sized;
|
||||
}
|
||||
|
||||
/** Lede + body for the open record, from the shared splitter. */
|
||||
const railDesc = $derived(splitDescription(openInfo?.description));
|
||||
|
||||
/** What the expander is already showing, handed to the album page so its
|
||||
* skeleton opens holding this record's artwork instead of a grey block.
|
||||
* `railCover` on purpose: that exact URL is the one already decoded and in
|
||||
* memory for the rail, so the seeded cover costs no request and cannot
|
||||
* flash on its way in. */
|
||||
function albumSeed(album) {
|
||||
if (!album?.cover || artBroken[album.cover]) return null;
|
||||
return {
|
||||
cover: railCover(album.cover),
|
||||
title: album.title ?? null,
|
||||
artist: album.artist ?? page?.artist?.name ?? null,
|
||||
kind: album.record_type ?? null,
|
||||
// The open expander has already fetched the real tracks; its count
|
||||
// beats the card's metadata, which is missing on some rows.
|
||||
tracks: openTracks.length || album.track_count || null,
|
||||
};
|
||||
}
|
||||
|
||||
function handleCoverError(node, originalCover, title = "") {
|
||||
const fallback = coverLoadError(
|
||||
originalCover,
|
||||
@@ -4746,7 +4768,10 @@
|
||||
<button
|
||||
class="rail-title-link"
|
||||
title="Open album"
|
||||
onclick={() => openAlbumId != null && openCatalogAlbum(openAlbumId)}
|
||||
onclick={() => openAlbumId != null
|
||||
&& openCatalogAlbum(openAlbumId, {
|
||||
seed: albumSeed(railArtAlbum ?? openAlbum),
|
||||
})}
|
||||
>{openAlbum?.title ?? openFamily?.baseTitle ?? ""}</button>
|
||||
</h3>
|
||||
<p class="rail-meta">
|
||||
@@ -4787,13 +4812,31 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if openInfo?.description}
|
||||
<p class="rail-desc" class:clamped={!descExpanded} in:partIn={{ delay: 60 }}>{openInfo.description}</p>
|
||||
<button
|
||||
class="rail-more"
|
||||
class:open={descExpanded}
|
||||
in:partIn={{ delay: 85 }}
|
||||
onclick={() => (descExpanded = !descExpanded)}
|
||||
>{descExpanded ? "Show less" : "Show more"} <Icon name="chevron-down" size={13} /></button>
|
||||
<!-- The description's own first
|
||||
sentence, promoted. Same
|
||||
splitter the album page and
|
||||
the Now Playing panel use;
|
||||
nothing is written. The body
|
||||
keeps its 4-line clamp and
|
||||
its mask fade. -->
|
||||
{#if railDesc.lede}
|
||||
<p class="rail-lede" in:partIn={{ delay: 55 }}>{railDesc.lede}</p>
|
||||
{/if}
|
||||
{#if railDesc.rest}
|
||||
<p class="rail-desc" class:clamped={!descExpanded} in:partIn={{ delay: 60 }}>{railDesc.rest}</p>
|
||||
<button
|
||||
class="rail-more"
|
||||
class:open={descExpanded}
|
||||
in:partIn={{ delay: 85 }}
|
||||
onclick={() => (descExpanded = !descExpanded)}
|
||||
>{descExpanded ? "Show less" : "Show more"} <Icon name="chevron-down" size={13} /></button>
|
||||
{/if}
|
||||
{#if openInfo.genre || openInfo.label}
|
||||
<div class="rail-pills" in:partIn={{ delay: 100 }}>
|
||||
{#if openInfo.genre}<span class="gpill accent">{openInfo.genre}</span>{/if}
|
||||
{#if openInfo.label}<span class="gpill">{openInfo.label}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if openFamily && openFamily.editions.length > 1}
|
||||
<div class="rail-eds">
|
||||
@@ -7904,6 +7947,39 @@
|
||||
|
||||
/* Clamped description dissolves at its bottom edge; the quiet text button
|
||||
below expands it, its chevron rotating. */
|
||||
/* The promoted first sentence. Only a little larger than the body: the rail
|
||||
is 250px wide and the title above it is already 17px, so a bigger lede
|
||||
would compete with the record's own name. */
|
||||
.rail-lede {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
/* Genre and label only — the date is already in `.rail-meta` directly
|
||||
above, and repeating it cost a second wrapped row in a 210px column. */
|
||||
.rail-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
.gpill {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.gpill.accent {
|
||||
background: color-mix(in srgb, var(--accent) 30%, rgba(10, 10, 12, 0.4));
|
||||
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
|
||||
color: #fff;
|
||||
}
|
||||
.rail-desc {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
|
||||
Reference in new issue
Block a user