android: the artist page, redesigned — the editorial shape
Kyle called the artist page "super glitchy" and asked for a rework in the
style of other music apps on mobile; from three mockups he picked the
editorial one (research/mobile-mockup, look B). What made it glitchy: six
sections each popped in on their own as their data landed, so the page
reflowed six times; the hero dissolved again every time a better picture
arrived; a cloud of stat chips wrapped to three lines; and the discography
tab jumped when the identity merge re-filed releases.
The page now: a 300dp portrait with the name on it and the play disc beside
it, Shuffle · Follow · one line of facts, the latest release as a card, Top
Songs as swipeable columns of four with dots ("See All" opens the list),
Albums and Singles & EPs as carousels with a link to the full discography,
On this phone, Similar Artists, and About with the bio, tags and socials.
The first sharp picture is the picture, locked; a cold visit renders
skeletons the size of the sections that are coming and swaps once; there
are no tabs on the page, the rails simply grow. No band over the hero — the
shell's two floating discs are the only chrome, as everywhere else. The
hero and its copy behind the scroller are both 300px now, as they must be.
This commit is contained in:
1 parent
bb7f1f1b2d
commit
69bb722590
2 files changed
+309
-183
No files matched your search
+227
-173
@@ -1,15 +1,25 @@
|
||||
<script>
|
||||
/* The artist page, the desktop's (CatalogArtist.svelte + ArtistExtras)
|
||||
at phone scale: the banner with the wordmark or the name on it, the
|
||||
identity row — Play popular, Follow, releases, fans, years active,
|
||||
the genre, the socials — Popular with its listener bars, the latest
|
||||
release, the discography filed the desktop's way (tabs, editions
|
||||
folded into families, what the library holds marked), releases by
|
||||
year, fans also like, what is on this phone, and About.
|
||||
/* The artist page — Kyle's pick from three redesigns (2026-09-18,
|
||||
research/mobile-mockup `?look=b`): the editorial shape. A 300dp
|
||||
portrait with the name ON it and the play disc beside it, one line of
|
||||
facts, the LATEST RELEASE card first, Top Songs as swipeable columns
|
||||
of four, then Albums / Singles & EPs / On this phone as carousels,
|
||||
Similar Artists, and About.
|
||||
|
||||
Opens cold: the library's own shell at once (its portrait blurred as
|
||||
a seed when it is only a cover), the catalog lookup dissolving the
|
||||
rest in — dissolve, never pop. */
|
||||
What made the old page "super glitchy" (his word) and what this one
|
||||
does about it:
|
||||
- six sections each popped in on their own as their data landed, so
|
||||
the page reflowed six times — now the cold page renders skeletons
|
||||
the size of what is coming and swaps once when the catalog lands;
|
||||
- the hero re-dissolved every time a better picture arrived — now the
|
||||
first sharp picture is the picture, locked;
|
||||
- a cloud of stat chips wrapped to three lines — now one line, cut
|
||||
with an ellipsis;
|
||||
- the discography tab jumped when the identity merge re-filed
|
||||
releases — there are no tabs here; the rails just grow.
|
||||
|
||||
No bar over the hero: a pushed page floats its two controls (the
|
||||
shell's .fchrome discs) and the title is the page's own. */
|
||||
import { untrack } from "svelte";
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import Release from "../Release.svelte";
|
||||
@@ -22,14 +32,14 @@
|
||||
import { push, openHalf } from "../nav.svelte.js";
|
||||
import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, loadPopular, loadHeroSeed, loadOverview, picture } from "../catalog.svelte.js";
|
||||
import { resolver, streamSong, streamCatalogAlbum } from "../resolve.svelte.js";
|
||||
import { discoTabs, activeYears, latestOf, timelineOf, isUpcoming, SOCIAL_LABEL } from "../disco.js";
|
||||
import { discoTabs, activeYears, latestOf, isUpcoming, SOCIAL_LABEL } from "../disco.js";
|
||||
import { shuffled, mmss, ago, fmtCount } from "../util.js";
|
||||
|
||||
/** `seed`: the search's own row (a CatalogArtist), when the page was
|
||||
* opened from one — the identity is then known, no name search. */
|
||||
let { name, seed = null, screenEl = null } = $props();
|
||||
|
||||
/* the desktop's play-stats chips: this phone's own listening to the artist */
|
||||
/* this phone's own listening to the artist, for the About paragraph */
|
||||
let plays = $state({ key: "", seconds: 0, last: null, topTitle: null, topCount: 0 });
|
||||
$effect(() => {
|
||||
const n = name;
|
||||
@@ -38,7 +48,7 @@
|
||||
api.artistPlayStats(n).then((st) => {
|
||||
if (plays.key !== n) return;
|
||||
plays = { key: n, seconds: st?.seconds ?? 0, last: st?.last_played_at ?? null, topTitle: st?.top_title ?? null, topCount: st?.top_count ?? 0 };
|
||||
}, () => { /* the chips stay hidden */ });
|
||||
}, () => { /* the line stays out */ });
|
||||
});
|
||||
const playTime = $derived(!plays.seconds || plays.seconds < 60 ? "" : plays.seconds < 3600 ? Math.round(plays.seconds / 60) + " min" : (plays.seconds / 3600).toFixed(1) + " hours");
|
||||
|
||||
@@ -52,18 +62,11 @@
|
||||
const entry = $derived(catalog.artists[artistKey(name)] ?? null);
|
||||
const page = $derived(entry?.page ?? null);
|
||||
const artistId = $derived(page?.artist?.id ?? entry?.id ?? seed?.id ?? null);
|
||||
/* the two fast lanes the page does not wait for: the banner on its own
|
||||
(a hero chosen on this phone is read off the file, so it is up before
|
||||
any network answers) and the one-round content stage. Both sit strictly
|
||||
BELOW the page everywhere they are read — what the exhaustive pass
|
||||
returns always wins. */
|
||||
const heroSeed = $derived(artistId != null ? catalog.heroSeeds[artistId]?.banner ?? null : null);
|
||||
const overview = $derived(artistId != null ? catalog.overviews[artistId] ?? null : null);
|
||||
const shownName = $derived(page?.artist?.name || overview?.artist?.name || seed?.name || name);
|
||||
const heroUrl = $derived(page?.banner || heroSeed || picture(page?.artist?.picture) || picture(entry?.seed?.picture) || picture(seed?.picture) || null);
|
||||
const logoUrl = $derived(page?.logos?.[0] ?? null);
|
||||
/* once per name — untracked, or the entry the lookup writes would re-arm
|
||||
this effect, and a lookup that fails would fail forever, hot */
|
||||
$effect(() => { const n = name, sd = seed; untrack(() => lookupArtist(n, sd)); });
|
||||
$effect(() => { if (seedPath) wantArt(seedPath); });
|
||||
$effect(() => { if (heroUrl) wantArt(heroUrl); });
|
||||
@@ -72,21 +75,24 @@
|
||||
const nm = page?.artist?.name || name;
|
||||
if (id != null) untrack(() => loadExtras(id, nm));
|
||||
});
|
||||
/* three lanes off the same id, all of them ahead of the page: Popular's
|
||||
ranked rows, the banner on its own, and the first page of releases.
|
||||
Asked the moment an id is known — from the search's own row, or from
|
||||
the identity search — so the page can paint before it is built */
|
||||
$effect(() => { const id = artistId; if (id != null) untrack(() => { loadPopular(id); loadHeroSeed(id); loadOverview(id); }); });
|
||||
const seedSrc = $derived(seedPath ? art[seedPath] : "");
|
||||
const heroSrc = $derived(heroUrl ? art[heroUrl] : "");
|
||||
/* the copy behind the scroller, which is the one allowed under the clock */
|
||||
$effect(() => { heroArt.src = heroSrc || seedSrc || ""; return () => { heroArt.src = ""; }; });
|
||||
/* the first sharp picture is THE picture. It used to follow `heroSrc`,
|
||||
and every better banner that arrived — the seed, the portrait, the
|
||||
page's own — dissolved in over the last one: three dissolves on one
|
||||
visit read as the page flickering. */
|
||||
let heroShown = $state("");
|
||||
$effect(() => { const s = heroSrc; if (s && !heroShown) heroShown = s; });
|
||||
$effect(() => { heroArt.src = heroShown || seedSrc || ""; return () => { heroArt.src = ""; }; });
|
||||
const extras = $derived(artistId != null ? catalog.extras[artistId] ?? null : null);
|
||||
const socials = $derived(page?.socials?.length ? page.socials : (extras?.socials ?? []));
|
||||
/* nothing from the catalog yet: the shell, with skeletons where the
|
||||
sections will be, and one swap when it lands */
|
||||
const loading = $derived(entry?.status === "loading" && !page);
|
||||
|
||||
/* the library's rows for this artist, by loose title, so Popular and the
|
||||
discography can say what is already here */
|
||||
let libByTitle = $state(new Map());
|
||||
const libByTitle = $state(new Map());
|
||||
let libMap = $state(new Map());
|
||||
$effect(() => {
|
||||
const as = albums;
|
||||
let live = true;
|
||||
@@ -97,56 +103,56 @@
|
||||
try { tr = await tracksOf(a.id); } catch { tr = []; }
|
||||
for (const t of tr) if (!m.has(looseTitle(t.title))) m.set(looseTitle(t.title), { album: a, track: t });
|
||||
}
|
||||
if (live) libByTitle = m;
|
||||
if (live) libMap = m;
|
||||
})();
|
||||
return () => { live = false; };
|
||||
});
|
||||
void libByTitle;
|
||||
|
||||
/* ── Popular ─────────────────────────────────────────────────────── */
|
||||
/* the page's own list once it lands (the same Last.fm order, from the
|
||||
same inputs); the fast lane's until then, and for an artist whose
|
||||
page came back with no Popular at all */
|
||||
const fastPopular = $derived(artistId != null ? catalog.popular[artistId]?.tracks ?? [] : []);
|
||||
const popularAll = $derived(page?.popular_tracks?.length ? page.popular_tracks : fastPopular);
|
||||
let showAllPopular = $state(false);
|
||||
const popular = $derived((showAllPopular ? popularAll : popularAll.slice(0, 5)).map((t) => ({ t, hit: libByTitle.get(looseTitle(t.title)) || null })));
|
||||
const popPeak = $derived(popularAll.reduce((m, t) => Math.max(m, t.listeners || 0), 0));
|
||||
const share = (t) => (popPeak ? Math.max(0.04, (t.listeners || 0) / popPeak) : 0);
|
||||
|
||||
/* ── the discography, filed ──────────────────────────────────────── */
|
||||
/* the overview's raw first page holds the discography up until the full
|
||||
page lands with the classified one — never the other way round: the
|
||||
page's list is a strict upgrade, folded and filed */
|
||||
/* ── the facts line ──────────────────────────────────────────────── */
|
||||
const fanCount = $derived(page?.artist?.fans || overview?.artist?.fans || seed?.fans || 0);
|
||||
const discoAll = $derived(page?.albums ?? overview?.albums ?? []);
|
||||
const years = $derived(activeYears(discoAll));
|
||||
const facts = $derived([fans(fanCount), years, page?.top_genre].filter(Boolean).join(" · "));
|
||||
|
||||
/* ── Top Songs ───────────────────────────────────────────────────── */
|
||||
const fastPopular = $derived(artistId != null ? catalog.popular[artistId]?.tracks ?? [] : []);
|
||||
const popularAll = $derived((page?.popular_tracks?.length ? page.popular_tracks : fastPopular).map((t) => ({ t, hit: libMap.get(looseTitle(t.title)) || null })));
|
||||
let showAllPopular = $state(false);
|
||||
const PAGE = 4;
|
||||
const topPages = $derived.by(() => {
|
||||
const top = popularAll.slice(0, 12);
|
||||
const out = [];
|
||||
for (let i = 0; i < top.length; i += PAGE) out.push(top.slice(i, i + PAGE));
|
||||
return out;
|
||||
});
|
||||
let pageIdx = $state(0);
|
||||
let pagesEl = $state(null);
|
||||
function onPages() {
|
||||
const el = pagesEl;
|
||||
const col = el?.firstElementChild;
|
||||
if (!el || !col) return;
|
||||
pageIdx = Math.max(0, Math.min(topPages.length - 1, Math.round(el.scrollLeft / (col.offsetWidth + 12))));
|
||||
}
|
||||
|
||||
/* ── the discography, as rails ───────────────────────────────────── */
|
||||
const tabs = $derived(discoTabs(discoAll));
|
||||
let tab = $state("album");
|
||||
let tabFor = null;
|
||||
$effect(() => {
|
||||
const id = artistId;
|
||||
const ts = tabs;
|
||||
if (!ts.length) return;
|
||||
untrack(() => {
|
||||
const albumsFirst = () => ts.find((t) => t.key === "album")?.key ?? ts[0].key;
|
||||
if (tabFor !== id) { tabFor = id; tab = albumsFirst(); }
|
||||
else if (!ts.some((t) => t.key === tab)) tab = albumsFirst();
|
||||
});
|
||||
});
|
||||
/* the grid is a SLICE — the whole tab lives on the discography page,
|
||||
which the head's "All" and the link under the grid both open. Before
|
||||
this an artist with 140 releases rendered 140 cards here. */
|
||||
const GRID_CAP = 12;
|
||||
const activeTab = $derived(tabs.find((t) => t.key === tab) ?? null);
|
||||
const families = $derived((activeTab?.items ?? []).slice(0, GRID_CAP));
|
||||
const tabCount = $derived(activeTab?.items.length ?? 0);
|
||||
const RAIL_CAP = 12;
|
||||
const albumFamilies = $derived((tabs.find((t) => t.key === "album")?.items ?? []).slice(0, RAIL_CAP));
|
||||
const shortTabs = $derived(tabs.filter((t) => t.key === "ep" || t.key === "single"));
|
||||
const shortFamilies = $derived(
|
||||
shortTabs.flatMap((t) => t.items.map((f) => ({ f, kind: t.key === "ep" ? "EP" : "Single" })))
|
||||
.sort((a, b) => String(b.f.release_date ?? "").localeCompare(String(a.f.release_date ?? "")))
|
||||
.slice(0, RAIL_CAP),
|
||||
);
|
||||
const inLib = (a) => albums.find((x) => looseTitle(x.title) === looseTitle(a.title)) || null;
|
||||
const familyLib = (f) => (f.editions || [f]).map(inLib).find(Boolean) || null;
|
||||
const familyKeyOf = (f) => f.familyKey ?? f.key ?? f.id;
|
||||
const latest = $derived(latestOf(discoAll));
|
||||
const timeline = $derived(timelineOf(discoAll));
|
||||
const years = $derived(activeYears(discoAll));
|
||||
const releasedCount = $derived(discoAll.filter((a) => !isUpcoming(a)).length);
|
||||
const latestKind = $derived(latest ? ((latest.record_type || "album").toLowerCase() === "single" ? "Single" : (latest.record_type || "album").toLowerCase() === "ep" ? "EP" : "Album") : "");
|
||||
|
||||
/* ── the identity row ────────────────────────────────────────────── */
|
||||
/* ── identity ────────────────────────────────────────────────────── */
|
||||
const followed = $derived(isFollowed(shownName));
|
||||
let followBusy = $state(false);
|
||||
async function follow() {
|
||||
@@ -155,11 +161,13 @@
|
||||
try { await toggleFollow(artistId, shownName, page?.artist?.picture ?? seed?.picture ?? null); } catch (e) { player.error = String(e); }
|
||||
followBusy = false;
|
||||
}
|
||||
/** The opener plugin (an Android intent); the desktop command as the
|
||||
* fallback for a build without it. */
|
||||
function openLink(url) {
|
||||
openUrl(url).catch(() => api.openExternal(url).catch((e) => { player.error = String(e); }));
|
||||
}
|
||||
const SOCIAL_ICON = {
|
||||
bandcamp: "brand-bandcamp", instagram: "brand-instagram", youtube: "brand-youtube", twitter: "brand-x", x: "brand-x",
|
||||
facebook: "brand-facebook", soundcloud: "brand-soundcloud", tiktok: "brand-tiktok", website: "globe",
|
||||
};
|
||||
|
||||
/* ── playing ─────────────────────────────────────────────────────── */
|
||||
const current = (t) => player.track?.id === "cat:" + t.id;
|
||||
@@ -176,7 +184,11 @@
|
||||
});
|
||||
} catch (e) { player.error = String(e); }
|
||||
}
|
||||
function playTop() { const p = popular[0]; if (p) playPopular(p); }
|
||||
async function playTop() {
|
||||
const p = popularAll[0];
|
||||
if (p) { await playPopular(p); return; }
|
||||
if (albums.length) { try { await playAlbum(albums[0], await tracksOf(albums[0].id), 0); } catch (e) { player.error = String(e); } }
|
||||
}
|
||||
function morePopular(p) {
|
||||
if (p.hit) { openHalf("track", { album: p.hit.album, track: p.hit.track }); return; }
|
||||
const t = p.t;
|
||||
@@ -192,11 +204,16 @@
|
||||
await streamCatalogAlbum({ ...a, artist: shownName }, 0);
|
||||
} catch (e) { player.error = String(e); }
|
||||
}
|
||||
async function shuffleAll() {
|
||||
/** Shuffle: the records on this phone when there are any; a random
|
||||
* popular song otherwise, so the button always does something. */
|
||||
async function shuffle() {
|
||||
try {
|
||||
if (albums.length) {
|
||||
const es = [];
|
||||
for (const a of albums) for (const t of await tracksOf(a.id)) es.push({ t, album: a });
|
||||
if (es.length) await playEntries(shuffled(es), 0, shownName);
|
||||
if (es.length) { await playEntries(shuffled(es), 0, shownName); return; }
|
||||
}
|
||||
if (popularAll.length) await playPopular(popularAll[Math.floor(Math.random() * Math.min(popularAll.length, 10))]);
|
||||
} catch (e) { player.error = String(e); }
|
||||
}
|
||||
function openRecord(a) {
|
||||
@@ -211,132 +228,170 @@
|
||||
}
|
||||
function openRelated(a) { push("artist", { name: a.name, seed: a }, screenEl); }
|
||||
let bioOpen = $state(false);
|
||||
const empty = $derived(!albums.length && !popularAll.length && !tabs.length && !loading);
|
||||
</script>
|
||||
|
||||
<div class="hero">
|
||||
<div class="hero ar">
|
||||
<div class="hero-img seed" style:background-image={seedSrc ? `url("${seedSrc}")` : null}></div>
|
||||
{#if heroSrc}
|
||||
<div class="hero-img dissolve" style:background-image={`url("${heroSrc}")`}></div>
|
||||
{#if heroShown}
|
||||
<div class="hero-img dissolve" style:background-image={`url("${heroShown}")`}></div>
|
||||
{/if}
|
||||
<div class="hero-scrim"></div>
|
||||
<div class="hero-in">
|
||||
<div class="hero-in row2">
|
||||
<div class="hero-id">
|
||||
<div class="ar-kick">Artist</div>
|
||||
{#if logoUrl}
|
||||
<img class="hero-logo" src={logoUrl} alt={shownName} />
|
||||
{:else}
|
||||
<div class="hero-name">{shownName}</div>
|
||||
<h1 class="hero-name">{shownName}</h1>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="disc" onclick={playTop} disabled={!popularAll.length && !albums.length} aria-label="Play {shownName}"><Icon name="play" size={22} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-acts">
|
||||
<button class="disc lg" onclick={playTop} disabled={!popular.length} aria-label="Play popular songs"><Icon name="play" size={26} /></button>
|
||||
<div class="stats">
|
||||
<button class="s fb" class:on={followed} onclick={follow} disabled={artistId == null || followBusy} aria-pressed={followed}>
|
||||
<Icon name={followed ? "check" : "plus"} size={12} strokeWidth={3} />{followed ? "Following" : "Follow"}
|
||||
<div class="ar-acts">
|
||||
<button class="btn btn-ghost" onclick={shuffle} disabled={!albums.length && !popularAll.length}><Icon name="shuffle" size={16} />Shuffle</button>
|
||||
<button class="btn btn-line" class:on={followed} onclick={follow} disabled={artistId == null || followBusy} aria-pressed={followed}>
|
||||
<Icon name={followed ? "check" : "plus"} size={15} />{followed ? "Following" : "Follow"}
|
||||
</button>
|
||||
{#if releasedCount}<span class="s"><Icon name="disc" size={12} strokeWidth={3} />{releasedCount} releases</span>{/if}
|
||||
{#if page?.artist?.fans || overview?.artist?.fans || seed?.fans}<span class="s"><Icon name="users" size={12} strokeWidth={3} />{fans(page?.artist?.fans || overview?.artist?.fans || seed?.fans)}</span>{/if}
|
||||
{#if years}<span class="s"><Icon name="calendar" size={12} strokeWidth={3} />{years}</span>{/if}
|
||||
{#if page?.top_genre}<span class="s"><Icon name="tag" size={12} strokeWidth={3} />{page.top_genre}</span>{/if}
|
||||
{#if playTime}<span class="s played"><Icon name="history" size={12} strokeWidth={3} />{playTime} played</span>{/if}
|
||||
{#if albums.length}<button class="s quiet" onclick={shuffleAll}><Icon name="shuffle" size={12} strokeWidth={3} />{albums.length} here · shuffle</button>{/if}
|
||||
{#each socials as link (link.kind + link.url)}
|
||||
<button class="s quiet soc" onclick={() => openLink(link.url)}><Icon name={link.kind === "website" ? "globe" : "link"} size={12} strokeWidth={3} />{SOCIAL_LABEL[link.kind] || link.kind}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if facts}<span class="ar-facts">{facts}</span>{/if}
|
||||
</div>
|
||||
|
||||
{#if entry?.status === "loading"}
|
||||
<div class="sec-note" style="padding-top: 12px">Looking up the rest on the catalog…</div>
|
||||
{:else if entry?.status === "error"}
|
||||
<div class="sec-note" style="padding-top: 12px">The catalog did not answer — only what this phone holds is here.</div>
|
||||
{#if latest}
|
||||
<div class="ar-latest">
|
||||
<button class="ar-latest-art" onclick={() => openRecord(latest)} aria-label="Open {latest.title}">
|
||||
{#if latest.cover}<img src={latest.cover} alt="" />{:else}<span class="ph"><Icon name="disc" size={28} /></span>{/if}
|
||||
</button>
|
||||
<div class="ar-latest-copy">
|
||||
<div class="k">Latest release{yearOf(latest.release_date) ? ` · ${yearOf(latest.release_date)}` : ""}</div>
|
||||
<div class="ar-latest-t">{latest.title}</div>
|
||||
<div class="m">{latestKind}{latest.track_count ? ` · ${latest.track_count} ${latest.track_count === 1 ? "song" : "songs"}` : ""}{inLib(latest) ? " · on this phone" : ""}</div>
|
||||
<div class="ar-latest-acts">
|
||||
<button class="btn btn-fill sm" onclick={() => playRecord(latest)}><Icon name="play" size={14} />Play</button>
|
||||
<button class="btn btn-ghost sm" onclick={() => openRecord(latest)}>Open</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if loading}
|
||||
<div class="ar-latest skel-card" aria-hidden="true">
|
||||
<span class="ar-latest-art skel"></span>
|
||||
<div class="ar-latest-copy"><span class="skel ln" style="width: 42%; height: 9px"></span><span class="skel ln" style="width: 70%; margin-top: 9px"></span><span class="skel ln" style="width: 50%; height: 9px; margin-top: 8px"></span></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if popular.length}
|
||||
<div class="sec"><h2>Popular</h2></div>
|
||||
{#each popular as p, i (p.t.id)}
|
||||
<button class="row pop" class:playing={p.hit ? player.track?.path === p.hit.track.path : current(p.t)} onclick={() => playPopular(p)} oncontextmenu={(e) => { e.preventDefault(); morePopular(p); }}>
|
||||
<span class="rank">{i + 1}</span>
|
||||
{#if popularAll.length}
|
||||
<div class="sec">
|
||||
<h2>Top Songs</h2>
|
||||
{#if popularAll.length > PAGE}<button class="all" onclick={() => (showAllPopular = !showAllPopular)}>{showAllPopular ? "Less" : "See All"}<Icon name="chevron-right" size={14} /></button>{/if}
|
||||
</div>
|
||||
{#if showAllPopular}
|
||||
{#each popularAll as p, i (p.t.id)}
|
||||
<button class="row ar-pop" class:playing={p.hit ? player.track?.path === p.hit.track.path : current(p.t)} onclick={() => playPopular(p)} oncontextmenu={(e) => { e.preventDefault(); morePopular(p); }}>
|
||||
<span class="ar-n">{i + 1}</span>
|
||||
<span class="art">{#if p.t.cover}<img src={p.t.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={20} />{/if}</span>
|
||||
<span class="b">
|
||||
<span class="t">{p.t.title}</span>
|
||||
<span class="m">{p.t.album}{p.hit ? " · in your library" : ""}{current(p.t) && resolver.active ? " · finding the best source…" : ""}</span>
|
||||
{#if popPeak}<span class="lbar"><i style="transform: scaleX({share(p.t).toFixed(3)})"></i><em>{fmtCount(p.t.listeners)}</em></span>{/if}
|
||||
<span class="m">{fmtCount(p.t.listeners) ? `${fmtCount(p.t.listeners)} listeners` : (p.t.album || "")}{p.hit ? " · on this phone" : ""}{current(p.t) && resolver.active ? " · finding a copy…" : ""}</span>
|
||||
</span>
|
||||
<span class="r">{mmss(p.t.duration_secs)}</span>
|
||||
<span class="r"><span class="ar-dur">{mmss(p.t.duration_secs)}</span></span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if popularAll.length > 5}
|
||||
<button class="more-link" onclick={() => (showAllPopular = !showAllPopular)}>{showAllPopular ? "Show less" : "Show more"}</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if latest}
|
||||
<div class="sec"><h2>Latest release</h2></div>
|
||||
<div class="feat" style="margin: 0 var(--pad)">
|
||||
<div class="feat-bg" style:background-image={latest.cover ? `url("${latest.cover}")` : null}></div>
|
||||
<div class="feat-in">
|
||||
<button class="feat-art" onclick={() => openRecord(latest)} aria-label="Open {latest.title}">
|
||||
{#if latest.cover}<img src={latest.cover} alt="" />{:else}<span class="ph"><Icon name="disc" size={28} /></span>{/if}
|
||||
{:else}
|
||||
<div class="ar-pages" bind:this={pagesEl} onscroll={onPages}>
|
||||
{#each topPages as col, ci (ci)}
|
||||
<div class="ar-col">
|
||||
{#each col as p (p.t.id)}
|
||||
<button class="row ar-pop tight" class:playing={p.hit ? player.track?.path === p.hit.track.path : current(p.t)} onclick={() => playPopular(p)} oncontextmenu={(e) => { e.preventDefault(); morePopular(p); }}>
|
||||
<span class="art">{#if p.t.cover}<img src={p.t.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={20} />{/if}</span>
|
||||
<span class="b">
|
||||
<span class="t">{p.t.title}</span>
|
||||
<span class="m">{p.t.album || (fmtCount(p.t.listeners) ? `${fmtCount(p.t.listeners)} listeners` : "")}{p.hit ? " · on this phone" : ""}{current(p.t) && resolver.active ? " · finding a copy…" : ""}</span>
|
||||
</span>
|
||||
<span class="r" role="button" tabindex="0" aria-label="More for {p.t.title}" onkeydown={() => {}} onclick={(e) => { e.stopPropagation(); morePopular(p); }}><Icon name="more" size={18} /></span>
|
||||
</button>
|
||||
<div class="feat-copy">
|
||||
<div class="k">Latest · {(latest.record_type || "album").toUpperCase()}</div>
|
||||
<div class="feat-name">{latest.title}</div>
|
||||
<div class="m">{yearOf(latest.release_date)}{latest.track_count ? ` · ${latest.track_count} tracks` : ""}{inLib(latest) ? " · in your library" : ""}</div>
|
||||
<div class="feat-acts">
|
||||
<button class="btn btn-fill" onclick={() => playRecord(latest)}><Icon name="play" size={14} />Play</button>
|
||||
<button class="btn btn-ghost" onclick={() => openRecord(latest)}>Open</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if topPages.length > 1}
|
||||
<div class="ar-dots" aria-hidden="true">{#each topPages as _, i (i)}<i class:on={i === pageIdx}></i>{/each}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if loading}
|
||||
<div class="sec"><h2>Top Songs</h2></div>
|
||||
<div class="ar-pages" aria-hidden="true"><div class="ar-col">
|
||||
{#each [58, 44, 66, 50] as w (w)}
|
||||
<div class="skel-row"><span class="skel art"></span><span class="b"><span class="skel ln" style="width: {w}%"></span><span class="skel ln" style="width: {w - 14}%; height: 9px; margin-top: 7px"></span></span></div>
|
||||
{/each}
|
||||
</div></div>
|
||||
{/if}
|
||||
|
||||
{#if tabs.length}
|
||||
{#if albumFamilies.length}
|
||||
<div class="sec">
|
||||
<h2>Discography <em>· {releasedCount} on the catalog</em></h2>
|
||||
<button class="all" onclick={() => push("disco", { name }, screenEl)} aria-label="All releases">All<Icon name="chevron-right" size={14} /></button>
|
||||
<h2>Albums <em>· {tabs.find((t) => t.key === "album")?.items.length ?? albumFamilies.length}</em></h2>
|
||||
<button class="all" onclick={() => push("disco", { name, tab: "album" }, screenEl)} aria-label="All albums">See All<Icon name="chevron-right" size={14} /></button>
|
||||
</div>
|
||||
{#if tabs.length > 1}
|
||||
<div class="segtabs disco-tabs" role="tablist" aria-label="Discography">
|
||||
{#each tabs as t (t.key)}
|
||||
<button class:on={tab === t.key} onclick={() => (tab = t.key)} role="tab" aria-selected={tab === t.key} aria-label={t.label + ", " + t.items.length}>{t.label}<span class="count" aria-hidden="true">{t.items.length}</span></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid2">
|
||||
{#each families as f (familyKeyOf(f))}
|
||||
<div class="rail ar-rail">
|
||||
{#each albumFamilies as f (familyKeyOf(f))}
|
||||
{@const l = familyLib(f)}
|
||||
<Release
|
||||
title={f.title}
|
||||
sub={tab === "upcoming" ? "Upcoming" : [yearOf(f.release_date), f.track_count ? `${f.track_count} tracks` : ""].filter(Boolean).join(" · ")}
|
||||
sub={[yearOf(f.release_date), f.track_count ? `${f.track_count} tracks` : ""].filter(Boolean).join(" · ")}
|
||||
url={f.cover}
|
||||
path={l?.art_path ?? null}
|
||||
badge={(f.editions?.length ?? 1) > 1 ? `${f.editions.length} editions` : (l ? "in your library" : null)}
|
||||
badge={(f.editions?.length ?? 1) > 1 ? `${f.editions.length} editions` : (l ? "on this phone" : null)}
|
||||
onclick={() => openFamily(f)}
|
||||
onmore={() => openHalf("catalogAlbum", { album: { ...(f.editions?.[0] ?? f), artist: name } })}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if tabCount > GRID_CAP}
|
||||
<button class="more-link dg-all" onclick={() => push("disco", { name, tab }, screenEl)}>Show all {tabCount} {activeTab?.label ?? "releases"}</button>
|
||||
{/if}
|
||||
{:else if loading}
|
||||
<div class="sec"><h2>Albums</h2></div>
|
||||
<div class="rail ar-rail" aria-hidden="true">
|
||||
{#each [72, 58, 80] as w (w)}
|
||||
<div class="release skel-card"><span class="cover-wrap skel"></span><span class="skel ln" style="width: {w}%; margin-top: 10px"></span><span class="skel ln" style="width: {w - 30}%; height: 9px; margin-top: 7px"></span></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if timeline}
|
||||
<div class="sec"><h2>Releases by year <em>· {timeline.last - timeline.first + 1} years · {timeline.total} releases</em></h2></div>
|
||||
<div class="tl">
|
||||
{#each timeline.cols as c (c.year)}
|
||||
<div class="tl-col" class:empty={!c.n} title="{c.year} · {c.n ? c.n + (c.n === 1 ? ' release' : ' releases') : 'nothing released'}">
|
||||
<span class="tl-bar" style="height:{c.h}px"></span>
|
||||
<span class="tl-yr">{String(c.year).slice(2)}</span>
|
||||
{#if shortFamilies.length}
|
||||
<div class="sec">
|
||||
<h2>Singles & EPs <em>· {shortTabs.reduce((n, t) => n + t.items.length, 0)}</em></h2>
|
||||
<button class="all" onclick={() => push("disco", { name, tab: shortTabs[0].key }, screenEl)} aria-label="All singles and EPs">See All<Icon name="chevron-right" size={14} /></button>
|
||||
</div>
|
||||
<div class="rail ar-rail sm">
|
||||
{#each shortFamilies as { f, kind } (familyKeyOf(f))}
|
||||
{@const l = familyLib(f)}
|
||||
<Release
|
||||
title={f.title}
|
||||
sub={[yearOf(f.release_date), kind].filter(Boolean).join(" · ")}
|
||||
url={f.cover}
|
||||
path={l?.art_path ?? null}
|
||||
badge={l ? "on this phone" : null}
|
||||
onclick={() => openFamily(f)}
|
||||
onmore={() => openHalf("catalogAlbum", { album: { ...(f.editions?.[0] ?? f), artist: name } })}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tabs.length && releasedCount > albumFamilies.length + shortFamilies.length}
|
||||
<button class="more-link ar-disco" onclick={() => push("disco", { name }, screenEl)}>The full discography · {releasedCount} releases<Icon name="chevron-right" size={14} /></button>
|
||||
{/if}
|
||||
|
||||
{#if albums.length}
|
||||
<div class="sec"><h2>On this phone <em>· {albums.length} record{albums.length === 1 ? "" : "s"}{tracks ? ` · ${tracks} tracks` : ""}</em></h2></div>
|
||||
{#if plays.topTitle}<div class="sec-note">Most played here: {plays.topTitle} · {plays.topCount}×{plays.last ? " · last " + ago(plays.last) : ""}</div>{/if}
|
||||
<div class="rail ar-rail sm">
|
||||
{#each albums as a (a.id)}
|
||||
<Release title={a.title} sub={String(a.year || "")} path={a.art_path} onclick={() => push("album", { id: a.id }, screenEl)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if extras?.related?.length}
|
||||
<div class="sec"><h2>Fans also like</h2></div>
|
||||
<div class="sec"><h2>Similar Artists</h2></div>
|
||||
<div class="rail">
|
||||
{#each extras.related.slice(0, 12) as a (a.id)}
|
||||
<button class="acard" onclick={() => openRelated(a)}>
|
||||
@@ -348,35 +403,34 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if albums.length}
|
||||
<div class="sec"><h2>On this phone <em>· {albums.length} record{albums.length === 1 ? "" : "s"}{tracks ? ` · ${tracks} tracks` : ""}</em></h2></div>
|
||||
{#if plays.topTitle}<div class="sec-note">Most played here: {plays.topTitle} · {plays.topCount}×{plays.last ? " · last " + ago(plays.last) : ""}</div>{/if}
|
||||
<div class="grid2">
|
||||
{#each albums as a (a.id)}
|
||||
<Release title={a.title} sub={String(a.year || "")} path={a.art_path} onclick={() => push("album", { id: a.id }, screenEl)} />
|
||||
{#if extras?.bio || extras?.profile?.listeners || extras?.profile?.tags?.length || socials.length || playTime}
|
||||
<div class="sec"><h2>About</h2></div>
|
||||
<div class="ar-about">
|
||||
{#if extras?.profile?.listeners || extras?.profile?.plays || playTime}
|
||||
<div class="ar-facts wide">
|
||||
{[extras?.profile?.listeners ? `${fmtCount(extras.profile.listeners)} listeners` : "", extras?.profile?.plays ? `${fmtCount(extras.profile.plays)} plays` : "", playTime ? `${playTime} played here` : ""].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
{/if}
|
||||
{#if extras?.bio}
|
||||
<p class="ar-bio" class:open={bioOpen} onclick={() => (bioOpen = !bioOpen)} role="button" tabindex="0" aria-expanded={bioOpen} onkeydown={() => {}}>{extras.bio}</p>
|
||||
{/if}
|
||||
{#if extras?.profile?.tags?.length}
|
||||
<div class="chips ar-tags">{#each extras.profile.tags.slice(0, 6) as t (t)}<span class="chip quiet">{t}</span>{/each}</div>
|
||||
{/if}
|
||||
{#if socials.length}
|
||||
<div class="ar-socials">
|
||||
{#each socials as link (link.kind + link.url)}
|
||||
<button class="iconbtn muted sm" onclick={() => openLink(link.url)} aria-label={SOCIAL_LABEL[link.kind] || link.kind} title={SOCIAL_LABEL[link.kind] || link.kind}><Icon name={SOCIAL_ICON[link.kind] || "link"} size={18} /></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if extras?.bio || extras?.profile?.listeners || extras?.profile?.tags?.length}
|
||||
<div class="sec"><h2>About</h2></div>
|
||||
<div class="about">
|
||||
{#if extras.profile?.listeners || extras.profile?.plays}
|
||||
<div class="stats">
|
||||
{#if extras.profile.listeners}<span class="s"><Icon name="users" size={12} strokeWidth={3} />{fmtCount(extras.profile.listeners)} listeners</span>{/if}
|
||||
{#if extras.profile.plays}<span class="s"><Icon name="play" size={12} strokeWidth={3} />{fmtCount(extras.profile.plays)} plays</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if extras.bio}
|
||||
<p class="bio" class:open={bioOpen} onclick={() => (bioOpen = !bioOpen)} role="button" tabindex="0" aria-expanded={bioOpen} onkeydown={() => {}}>{extras.bio}</p>
|
||||
{/if}
|
||||
{#if extras.profile?.tags?.length}
|
||||
<div class="tags">{#each extras.profile.tags.slice(0, 6) as t (t)}<span class="chip quiet">{t}</span>{/each}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !albums.length && !popular.length && !tabs.length && entry?.status !== "loading"}
|
||||
{#if entry?.status === "error" && !albums.length && !popularAll.length}
|
||||
<div class="sec-note" style="padding-top: 12px">The catalog did not answer — only what this phone holds is here.</div>
|
||||
{/if}
|
||||
{#if empty}
|
||||
<div class="stub"><b>Nothing here yet.</b>No records by {shownName} on this phone, and the catalog {entry?.status === "error" ? "didn't answer" : "had nothing to add"}.</div>
|
||||
{/if}
|
||||
|
||||
|
||||
+82
-10
@@ -555,7 +555,7 @@ html[data-material="solid"] .card, html[data-material="solid"] .group { backgrou
|
||||
/* ── the artist hero ──────────────────────────────────────────────────────
|
||||
The desktop hero is a wide banner beside the identity block. A phone has
|
||||
no beside, so the portrait goes full-bleed and the name sits ON it. */
|
||||
.hero { position: relative; height: 258px; display: flex; align-items: flex-end; overflow: hidden; margin-bottom: -6px; }
|
||||
.hero { position: relative; height: 300px; display: flex; align-items: flex-end; overflow: hidden; margin-bottom: -6px; }
|
||||
.hero-img { position: absolute; inset: 0; background-size: cover; background-position: center 24%; transform: scale(1.04); }
|
||||
/* the blurred SEED: the picture the library already has, soft and
|
||||
oversized, until a sharp one exists */
|
||||
@@ -568,18 +568,18 @@ html[data-material="solid"] .card, html[data-material="solid"] .group { backgrou
|
||||
}
|
||||
/* The hero's picture again, behind the scroller and outside its mask, so the
|
||||
photograph is allowed under the clock while the words are not. Its geometry
|
||||
has to match .hero/.hero-img exactly — 258px tall, the same 24% focal point,
|
||||
has to match .hero/.hero-img exactly — 300px tall, the same 24% focal point,
|
||||
the same 1.04 scale — or the seam shows. */
|
||||
.hero-behind {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 258px;
|
||||
height: 300px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
/* the clip .hero gives its own picture; without it the 1.04 below renders
|
||||
268px in a 258px box and five pixels of photograph appear under the
|
||||
312px in a 300px box and twelve pixels of photograph appear under the
|
||||
hero's foot, below the black the scrim fades to */
|
||||
overflow: hidden;
|
||||
transform: translateY(var(--hero-shift, 0px));
|
||||
@@ -1039,17 +1039,11 @@ html[data-material="solid"] .halfsheet { background: #1c1c20; backdrop-filter: n
|
||||
release on the app's featured surface (the artwork blurred behind itself);
|
||||
releases by year; About. */
|
||||
.hero-logo { display: block; max-width: 78%; max-height: 96px; object-fit: contain; object-position: left bottom; filter: drop-shadow(0 2px 12px rgba(0, 0, 0, 0.6)); }
|
||||
.hero-acts { display: flex; align-items: center; gap: 12px; padding: 12px var(--pad) 0; }
|
||||
.hero-acts .stats { flex: 1; min-width: 0; }
|
||||
.stats button.s { cursor: pointer; }
|
||||
.stats .s.fb { background: rgba(255, 255, 255, 0.11); color: var(--text); }
|
||||
.stats .s.fb.on { background: var(--accent); color: #04121a; }
|
||||
.stats .s.fb:disabled { opacity: 0.5; }
|
||||
.stats .s.quiet { color: var(--text-secondary); }
|
||||
.row.pop .rank { width: 16px; text-align: right; font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; flex: none; }
|
||||
.lbar { display: flex; align-items: center; gap: 8px; margin-top: 5px; height: 10px; }
|
||||
.lbar i { display: block; flex: 1; height: 3px; border-radius: 2px; background: var(--accent); transform-origin: left center; opacity: 0.85; }
|
||||
.lbar em { font-style: normal; font-size: 10.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; min-width: 32px; text-align: right; }
|
||||
.more-link { display: block; padding: 8px var(--pad) 2px; font-size: 12.5px; font-weight: 750; color: var(--accent-hover); }
|
||||
.feat { position: relative; border-radius: var(--r-card); overflow: hidden; background: var(--surface); box-shadow: var(--ring); }
|
||||
.feat-bg { position: absolute; inset: -30px; background-size: cover; background-position: center; filter: blur(28px) saturate(1.2); opacity: 0.55; }
|
||||
@@ -1683,3 +1677,81 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
|
||||
}
|
||||
.dg-eds { display: flex; flex-direction: column; gap: 6px; padding: 0 var(--pad) 12px calc(var(--pad) + 58px); }
|
||||
.dg-eds .ed { width: 100%; }
|
||||
|
||||
/* ── the artist page, B (2026-09-18) ──────────────────────────────────────
|
||||
The editorial shape Kyle picked from three mockups: the name ON the
|
||||
300dp portrait with the play disc beside it, one line of facts, the
|
||||
latest release first, Top Songs as swipeable columns of four, the
|
||||
records as carousels, About last. The bar over the hero is the shell's
|
||||
two floating discs; nothing else. */
|
||||
.hero.ar .hero-in { padding-bottom: 14px; }
|
||||
.hero-in.row2 { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; }
|
||||
.hero-in.row2 .hero-id { min-width: 0; flex: 1; }
|
||||
.hero-in.row2 .disc { flex: none; margin-bottom: 2px; }
|
||||
.hero.ar .hero-name { margin: 0; overflow-wrap: anywhere; }
|
||||
.ar-kick { font-size: 11px; font-weight: 800; letter-spacing: 0.9px; text-transform: uppercase; color: rgba(255, 255, 255, 0.7); margin-bottom: 5px; text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5); }
|
||||
.ar-acts { display: flex; align-items: center; gap: 10px; padding: 14px var(--pad) 6px; }
|
||||
.ar-facts { margin-left: auto; min-width: 0; font-size: 12.5px; font-weight: 600; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ar-facts.wide { margin: 0 0 8px; white-space: normal; }
|
||||
.btn-line { box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, 0.22); color: #fff; background: transparent; }
|
||||
.btn-line.on { box-shadow: inset 0 0 0 1.5px var(--accent); color: var(--accent-hover); }
|
||||
.btn-line:disabled { opacity: 0.5; }
|
||||
.btn.sm { height: 34px; padding: 0 14px; font-size: 13px; }
|
||||
|
||||
/* the latest release */
|
||||
.ar-latest { display: flex; gap: 14px; margin: 6px var(--pad) 4px; padding: 12px; border-radius: var(--r-card); background: var(--surface); box-shadow: var(--ring); }
|
||||
.ar-latest-art { flex: none; width: 96px; height: 96px; border-radius: 10px; overflow: hidden; background: rgba(255, 255, 255, 0.06); display: block; padding: 0; }
|
||||
.ar-latest-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.ar-latest-art .ph { display: flex; width: 100%; height: 100%; align-items: center; justify-content: center; color: var(--text-muted); }
|
||||
.ar-latest-copy { min-width: 0; flex: 1; display: flex; flex-direction: column; justify-content: center; }
|
||||
.ar-latest-copy .k { font-size: 10.5px; letter-spacing: 0.8px; text-transform: uppercase; font-weight: 800; color: var(--accent-hover); }
|
||||
.ar-latest-t { font-size: 15px; font-weight: 800; letter-spacing: -0.2px; line-height: 1.2; margin: 3px 0 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ar-latest-copy .m { font-size: 12px; color: var(--text-secondary); font-weight: 600; }
|
||||
.ar-latest-acts { display: flex; gap: 8px; margin-top: 10px; }
|
||||
|
||||
/* top songs, in columns of four */
|
||||
.row.ar-pop .ar-n { width: 18px; text-align: right; color: var(--text-muted); font-weight: 700; font-variant-numeric: tabular-nums; font-size: 13px; flex: none; }
|
||||
.row.ar-pop .r { display: inline-flex; align-items: center; gap: 10px; color: var(--text-muted); }
|
||||
.row.ar-pop .ar-dur { font-variant-numeric: tabular-nums; font-size: 12.5px; }
|
||||
.ar-pages { display: flex; gap: 12px; overflow-x: auto; scroll-snap-type: x mandatory; padding: 0 var(--pad); scrollbar-width: none; scroll-padding-inline: var(--pad); }
|
||||
.ar-pages::-webkit-scrollbar { display: none; }
|
||||
.ar-col { flex: none; width: 86%; scroll-snap-align: start; }
|
||||
.ar-col .row { margin: 0 calc(-1 * var(--pad)); }
|
||||
.ar-col .row.tight { padding-top: 6px; padding-bottom: 6px; }
|
||||
.ar-dots { display: flex; justify-content: center; gap: 6px; padding: 6px 0 2px; }
|
||||
.ar-dots i { width: 6px; height: 6px; border-radius: 50%; background: rgba(255, 255, 255, 0.22); transition: background var(--m-fast); }
|
||||
.ar-dots i.on { background: #fff; }
|
||||
|
||||
/* the carousels */
|
||||
.rail { scroll-padding-inline: var(--pad); }
|
||||
.ar-rail .release { width: 156px; }
|
||||
.ar-rail.sm .release { width: 132px; }
|
||||
.more-link.ar-disco { display: inline-flex; align-items: center; gap: 4px; padding: 14px var(--pad) 4px; }
|
||||
|
||||
/* about */
|
||||
.ar-about { padding: 0 var(--pad); }
|
||||
.ar-bio { font-size: 13.5px; line-height: 1.6; color: var(--text-secondary); display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; margin: 0; }
|
||||
.ar-bio.open { display: block; }
|
||||
.ar-tags { padding: 0; margin-top: 12px; }
|
||||
.ar-socials { display: flex; gap: 6px; margin-top: 12px; }
|
||||
.ar-socials .iconbtn { background: rgba(255, 255, 255, 0.055); }
|
||||
|
||||
/* skeletons: the shape of what is coming, so the page never jumps when it
|
||||
arrives (the mockup's .skel, at the app's radii) */
|
||||
.skel { display: block; background: rgba(255, 255, 255, 0.07); border-radius: 6px; animation: skel-breathe 1.4s ease-in-out infinite; }
|
||||
.skel.ln { height: 11px; }
|
||||
.skel.art { width: 46px; height: 46px; border-radius: 8px; flex: none; }
|
||||
.skel.cover-wrap { aspect-ratio: 1; width: 100%; border-radius: var(--r-art); }
|
||||
.skel-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
|
||||
.skel-row .b { flex: 1; min-width: 0; }
|
||||
.skel-card { pointer-events: none; }
|
||||
.ar-latest.skel-card .ar-latest-art { background: rgba(255, 255, 255, 0.07); }
|
||||
@keyframes skel-breathe { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
|
||||
|
||||
/* the listener / progress bars other screens draw under a row (CatalogAlbum,
|
||||
Transfers, Diagnostics) — the artist page's Popular used them too, once */
|
||||
.lbar { display: flex; align-items: center; gap: 8px; margin-top: 5px; height: 10px; }
|
||||
.lbar i { display: block; flex: 1; height: 3px; border-radius: 2px; background: var(--accent); transform-origin: left center; opacity: 0.85; }
|
||||
.lbar em { font-style: normal; font-size: 10.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; min-width: 32px; text-align: right; }
|
||||
/* a rail's cards line up at the TOP — a two-line title must not lift its cover */
|
||||
.ar-rail { align-items: flex-start; }
|
||||
Reference in new issue
Block a user