mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
Turn artist radio into a station sheet with reasons on each track
The radio button (r) opens a sheet with the artist, their tags and their closest neighbours instead of switching the feed at once; tapping a neighbour walks the artist graph. Starting jumps to the radio's first track, the top-bar chip shows the station, each radio card says why it is there, and an end card offers the way back when the radio runs dry. GET /local/artist feeds the sheet and starts crawling a new artist's neighbourhood (at most every 10 minutes per artist). Artist-radio tracks carry why: seed, related, or via the neighbour that leads to them.
This commit is contained in:
1 parent
e8c0f40128
commit
76cfbb12ac
13 files changed
+654
-42
No files matched your search
+29
-2
@@ -24,6 +24,30 @@ export interface Track {
|
||||
followed?: boolean;
|
||||
/// From titles, descriptions and BPM, see backend/src/tags.rs.
|
||||
tags?: string[];
|
||||
/// In an artist radio: why this track is in it.
|
||||
why?: Why;
|
||||
}
|
||||
|
||||
/// `seed`: by the radio's artist; `related`: by an artist next to them;
|
||||
/// `via`: one step further, through the artist `handle`.
|
||||
export type Why = { kind: "seed" } | { kind: "related" } | { kind: "via"; handle: string; name: string };
|
||||
|
||||
export interface Artist {
|
||||
handle: string;
|
||||
name: string;
|
||||
img: string | null;
|
||||
verified: boolean;
|
||||
tracks: number;
|
||||
}
|
||||
|
||||
/// An artist and their neighbours in the artist graph, for the radio sheet.
|
||||
export interface ArtistInfo {
|
||||
artist: Artist;
|
||||
neighbours: { handle: string; name: string; img: string | null; weight: number }[];
|
||||
tags: string[];
|
||||
followed: boolean;
|
||||
/// Still crawling for neighbours; ask again in a moment.
|
||||
exploring: boolean;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
@@ -55,7 +79,7 @@ export type Filters = Partial<Record<"free" | "bpm_min" | "bpm_max", string>>;
|
||||
/// that artist and the ones pl8list relates to them.
|
||||
export type Mode =
|
||||
| { mode: "classic" | "riddim" | "fresh" | "deep" }
|
||||
| { mode: "artist"; artist: string; name: string };
|
||||
| { mode: "artist"; artist: string; name: string; img?: string | null };
|
||||
|
||||
export type EventKind = "play" | "like" | "unlike" | "skip" | "complete" | "dislike";
|
||||
|
||||
@@ -99,6 +123,8 @@ export function feed(filters: Filters, mode: Mode) {
|
||||
return getJSON<{ tracks: Track[]; stats: Stats }>("/local/feed?" + p);
|
||||
}
|
||||
|
||||
export const artist = (handle: string) => getJSON<ArtistInfo>("/local/artist?" + new URLSearchParams({ handle }));
|
||||
|
||||
export const likes = () => getJSON<{ tracks: Track[] }>("/local/likes");
|
||||
|
||||
export const me = () => getJSON<Me>("/local/me");
|
||||
@@ -107,7 +133,8 @@ export const PL8 = "https://pl8list.com";
|
||||
export const trackUrl = (t: Track) => `${PL8}/${t.handle}/${t.slug}`;
|
||||
export const artistUrl = (t: Track) => `${PL8}/${t.handle}`;
|
||||
// Images come through our server, so pl8list never sees the visitor's address.
|
||||
export const artistImg = (t: Track) => (t.artist_img ? `/img/${encodeURIComponent(t.artist_img)}` : null);
|
||||
export const imgUrl = (id: string | null) => (id ? `/img/${encodeURIComponent(id)}` : null);
|
||||
export const artistImg = (t: Track) => imgUrl(t.artist_img);
|
||||
|
||||
export function streamUrl(t: Track): string {
|
||||
const p = new URLSearchParams();
|
||||
|
||||
+17
-1
@@ -31,14 +31,18 @@ export class Card {
|
||||
private dragging = false;
|
||||
private accent: string | null = null;
|
||||
|
||||
/// `station`: the artist radio this card was picked for, if any.
|
||||
constructor(
|
||||
readonly track: Track,
|
||||
private actions: CardActions,
|
||||
{ liked = false } = {},
|
||||
{ liked = false, station = undefined as string | undefined } = {},
|
||||
) {
|
||||
this.liked = liked;
|
||||
this.station = station;
|
||||
}
|
||||
|
||||
private station: string | undefined;
|
||||
|
||||
element(): HTMLElement {
|
||||
return this.el ?? this.build();
|
||||
}
|
||||
@@ -70,6 +74,7 @@ export class Card {
|
||||
$(".initial").textContent = (t.artist_name || "?")[0].toUpperCase();
|
||||
}
|
||||
|
||||
$(".why").textContent = this.whyText();
|
||||
$(".title").textContent = cleanTitle(t.title);
|
||||
const artist = $<HTMLAnchorElement>(".artist");
|
||||
artist.textContent = t.artist_name + (t.artist_verified ? " ✓" : "");
|
||||
@@ -112,6 +117,17 @@ export class Card {
|
||||
return el;
|
||||
}
|
||||
|
||||
/// Where a radio track comes from, e.g. "Lower radio · via Motus".
|
||||
private whyText(): string {
|
||||
const { station } = this;
|
||||
const why = this.track.why;
|
||||
if (!station) return "";
|
||||
if (!why) return `${station} radio`;
|
||||
if (why.kind === "seed") return `${station} radio`;
|
||||
if (why.kind === "related") return `${station} radio · related artist`;
|
||||
return `${station} radio · via ${why.name}`;
|
||||
}
|
||||
|
||||
showLike(pop = false): void {
|
||||
const btn = this.el?.querySelector(".like");
|
||||
if (!btn) return;
|
||||
|
||||
+53
-6
@@ -5,6 +5,7 @@ import type { Filters, Mode, Pl8Status, Stats, Track, User } from "./api";
|
||||
import { Card, type CardActions, cleanTitle } from "./cards";
|
||||
import { Pager } from "./pager";
|
||||
import { Player } from "./player";
|
||||
import { Station } from "./station";
|
||||
import { type VisualsSetting, Visualizer } from "./visual";
|
||||
|
||||
const PREFETCH_AHEAD = 4; // fetch more when this close to the end
|
||||
@@ -34,6 +35,7 @@ const toastEl = $("toast");
|
||||
const loginEl = $("login");
|
||||
|
||||
const isTouch = !matchMedia("(hover: hover) and (pointer: fine)").matches;
|
||||
const radioEnd = $("radio-end");
|
||||
|
||||
const player = new Player();
|
||||
const visual = new Visualizer(player, isTouch);
|
||||
@@ -50,6 +52,8 @@ let preloadTimer = 0;
|
||||
let filters: Filters = loadFilters();
|
||||
let mode: Mode = loadMode();
|
||||
let stats: Stats | null = null;
|
||||
let jumpOnLoad = false; // a radio just started: go to its first track as soon as it is here
|
||||
let radioDry = false; // the artist radio found nothing more; say so on its last card
|
||||
|
||||
const current = () => cards[active] as Card | undefined;
|
||||
|
||||
@@ -77,18 +81,25 @@ async function loadMore(): Promise<void> {
|
||||
if (gen !== feedGen) return;
|
||||
showStats(data.stats);
|
||||
if (!data.tracks.length) {
|
||||
setStatus(mode.mode === "artist"
|
||||
? `nothing more around ${mode.name} yet. the crawler is looking up who they're related to, try again in a bit.`
|
||||
: mode.mode !== "classic" || Object.keys(filters).length
|
||||
jumpOnLoad = false;
|
||||
if (mode.mode === "artist") {
|
||||
radioDry = true;
|
||||
updateRadioEnd();
|
||||
} else setStatus(mode.mode !== "classic" || Object.keys(filters).length
|
||||
? "nothing new matches this feed and your filters right now. loosen them, or check back in a bit."
|
||||
: "nothing new right now, the crawler is still digging. try again in a bit.");
|
||||
setTimeout(() => { if (gen === feedGen) loading = false; }, 8000);
|
||||
return;
|
||||
}
|
||||
setStatus("");
|
||||
cards.push(...data.tracks.map((t) => new Card(t, actions)));
|
||||
radioDry = false;
|
||||
updateRadioEnd();
|
||||
const station = mode.mode === "artist" ? mode.name : undefined;
|
||||
cards.push(...data.tracks.map((t) => new Card(t, actions, { station })));
|
||||
pager.refresh();
|
||||
if (active < 0) activate(0);
|
||||
else if (jumpOnLoad) go(active + 1);
|
||||
jumpOnLoad = false;
|
||||
} catch (err) {
|
||||
if (gen !== feedGen) return;
|
||||
if (err instanceof api.HttpError && err.status === 401) {
|
||||
@@ -147,6 +158,7 @@ function activate(idx: number): void {
|
||||
if (next && started) player.preload(next.track);
|
||||
}, PRELOAD_AFTER_MS);
|
||||
if (idx >= cards.length - PREFETCH_AHEAD) loadMore();
|
||||
updateRadioEnd();
|
||||
cards.forEach((c, i) => { if (c.el && Math.abs(i - idx) > KEEP_BUILT) c.release(); });
|
||||
}
|
||||
|
||||
@@ -172,7 +184,7 @@ const actions: CardActions = {
|
||||
share: (c) => share(c),
|
||||
next: (c) => go(cards.indexOf(c) + 1),
|
||||
follow: (c) => setFollow(c, !c.track.followed),
|
||||
radio: (c) => setMode({ mode: "artist", artist: c.track.handle, name: c.track.artist_name }),
|
||||
radio: (c) => station.open(c.track.handle, c.track.artist_name, api.artistImg(c.track)),
|
||||
canSeek: (c) => c === current() && !!player.audio.duration,
|
||||
duration: (c) => (c === current() && player.audio.duration) || c.track.duration || 0,
|
||||
seek: (_, s) => player.seek(s),
|
||||
@@ -504,13 +516,21 @@ function loadMode(): Mode {
|
||||
|
||||
const modeName = (m: Mode) => (m.mode === "artist" ? `${m.name} radio` : MODE_NAMES[m.mode]);
|
||||
|
||||
const MODE_ICONS = { riddim: "◆", fresh: "✦", deep: "◎" };
|
||||
|
||||
function showMode(): void {
|
||||
modeChip.hidden = mode.mode === "classic";
|
||||
modeChip.textContent = `${modeName(mode)} ×`;
|
||||
modeChip.querySelector(".chip-label")!.textContent = modeName(mode);
|
||||
const avatar = modeChip.querySelector<HTMLElement>(".chip-avatar")!;
|
||||
const img = mode.mode === "artist" ? mode.img : null;
|
||||
avatar.style.backgroundImage = img ? `url("${img}")` : "";
|
||||
avatar.textContent = img ? "" : mode.mode === "artist" ? mode.name[0]?.toUpperCase() ?? "" : mode.mode === "classic" ? "" : MODE_ICONS[mode.mode];
|
||||
for (const input of filtersForm.querySelectorAll<HTMLInputElement>("input[name=mode]")) input.checked = input.value === mode.mode;
|
||||
}
|
||||
|
||||
function setMode(m: Mode): void {
|
||||
radioDry = false;
|
||||
updateRadioEnd();
|
||||
if (JSON.stringify(m) === JSON.stringify(mode)) return;
|
||||
mode = m;
|
||||
localStorage.setItem(MODE_KEY, JSON.stringify(mode));
|
||||
@@ -521,6 +541,31 @@ function setMode(m: Mode): void {
|
||||
}
|
||||
|
||||
modeChip.addEventListener("click", () => setMode({ mode: "classic" }));
|
||||
|
||||
const station = new Station((m) => {
|
||||
if (!started) start();
|
||||
jumpOnLoad = true;
|
||||
setMode(m);
|
||||
});
|
||||
|
||||
/// On the last card of a radio that has played everything known around its artist.
|
||||
function updateRadioEnd(): void {
|
||||
const m = mode;
|
||||
radioEnd.hidden = !(radioDry && m.mode === "artist" && active >= cards.length - 1);
|
||||
if (radioEnd.hidden || m.mode !== "artist") return;
|
||||
const text = radioEnd.querySelector("p")!;
|
||||
const next = $("radio-end-next");
|
||||
text.textContent = `that's all around ${m.name} for now. the crawler keeps looking.`;
|
||||
// Hand over to the artist playing now, if the radio wandered away from its seed.
|
||||
const card = current();
|
||||
const other = card && card.track.handle !== m.artist ? card.track : null;
|
||||
next.hidden = !other;
|
||||
if (other) {
|
||||
next.textContent = `${other.artist_name} radio`;
|
||||
next.onclick = () => station.open(other.handle, other.artist_name, api.artistImg(other));
|
||||
}
|
||||
}
|
||||
$("radio-end-feed").addEventListener("click", () => setMode({ mode: "classic" }));
|
||||
filtersForm.addEventListener("change", (e) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.name === "mode") setMode({ mode: input.value as "classic" | "riddim" | "fresh" | "deep" });
|
||||
@@ -553,8 +598,10 @@ document.addEventListener("keydown", (e) => {
|
||||
if (k === "Escape") {
|
||||
closePanels();
|
||||
hideHelp();
|
||||
station.close();
|
||||
return;
|
||||
}
|
||||
if (station.isOpen) return;
|
||||
if (!loginEl.hidden) return;
|
||||
if (!started) {
|
||||
if (k !== "Tab" && k !== "Shift") { e.preventDefault(); start(); }
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// The radio sheet: who an artist radio is built around and who is next to them.
|
||||
// Opening it changes nothing yet; "start radio" does. Tapping a neighbour shows
|
||||
// their sheet, so the artist graph can be walked before picking a station.
|
||||
//
|
||||
// A new artist may have no neighbours yet. The server then starts crawling them
|
||||
// and says `exploring`; the sheet asks again every few seconds and fades new ones in.
|
||||
|
||||
import * as api from "./api";
|
||||
import type { ArtistInfo, Mode } from "./api";
|
||||
|
||||
const POLL_MS = 2000;
|
||||
const POLL_FOR_MS = 20000;
|
||||
|
||||
interface Shown {
|
||||
handle: string;
|
||||
name: string;
|
||||
img: string | null;
|
||||
}
|
||||
|
||||
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
|
||||
export class Station {
|
||||
private root = $("station");
|
||||
private nameEl = $("station-name");
|
||||
private subEl = $("station-sub");
|
||||
private tagsEl = $("station-tags");
|
||||
private avatar = this.root.querySelector<HTMLElement>(".station-avatar")!;
|
||||
private list = $("station-neighbours");
|
||||
private exploringEl = $("station-exploring");
|
||||
private startBtn = $<HTMLButtonElement>("station-start");
|
||||
private backBtn = $<HTMLButtonElement>("station-back");
|
||||
private shown: Shown | null = null;
|
||||
/// Artists walked through to get here, for the back button.
|
||||
private trail: Shown[] = [];
|
||||
private gen = 0;
|
||||
private timer = 0;
|
||||
private known = new Set<string>();
|
||||
|
||||
constructor(private start: (m: Mode) => void) {
|
||||
$("station-close").addEventListener("click", () => this.close());
|
||||
this.root.addEventListener("click", (e) => { if (e.target === this.root) this.close(); });
|
||||
this.backBtn.addEventListener("click", () => {
|
||||
const prev = this.trail.pop();
|
||||
if (prev) this.show(prev);
|
||||
});
|
||||
this.startBtn.addEventListener("click", () => {
|
||||
const s = this.shown;
|
||||
if (!s) return;
|
||||
this.close();
|
||||
this.start({ mode: "artist", artist: s.handle, name: s.name, img: s.img });
|
||||
});
|
||||
}
|
||||
|
||||
get isOpen(): boolean {
|
||||
return !this.root.hidden;
|
||||
}
|
||||
|
||||
open(handle: string, name: string, img: string | null): void {
|
||||
this.trail = [];
|
||||
this.root.hidden = false;
|
||||
this.show({ handle, name, img });
|
||||
this.startBtn.focus();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.root.hidden = true;
|
||||
this.gen++;
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
private show(s: Shown): void {
|
||||
this.shown = s;
|
||||
this.known.clear();
|
||||
this.list.replaceChildren();
|
||||
this.tagsEl.replaceChildren();
|
||||
this.nameEl.textContent = s.name;
|
||||
this.subEl.textContent = "";
|
||||
this.setAvatar(this.avatar, s.img, s.name);
|
||||
this.startBtn.lastChild!.textContent = ` start ${s.name} radio`;
|
||||
const prev = this.trail[this.trail.length - 1];
|
||||
this.backBtn.hidden = !prev;
|
||||
if (prev) this.backBtn.textContent = `← ${prev.name}`;
|
||||
this.exploringEl.hidden = true;
|
||||
const gen = ++this.gen;
|
||||
clearTimeout(this.timer);
|
||||
this.load(s.handle, gen, performance.now() + POLL_FOR_MS);
|
||||
}
|
||||
|
||||
private async load(handle: string, gen: number, until: number): Promise<void> {
|
||||
let info: ArtistInfo;
|
||||
try {
|
||||
info = await api.artist(handle);
|
||||
} catch (err) {
|
||||
if (gen === this.gen) this.subEl.textContent = (err as Error).message;
|
||||
return;
|
||||
}
|
||||
if (gen !== this.gen) return;
|
||||
this.render(info);
|
||||
const again = info.exploring && performance.now() < until;
|
||||
this.exploringEl.hidden = !again;
|
||||
if (again) this.timer = window.setTimeout(() => this.load(handle, gen, until), POLL_MS);
|
||||
else if (!info.neighbours.length) this.list.textContent = "nobody linked to them yet. the radio plays their own tracks and fills up as more are found.";
|
||||
}
|
||||
|
||||
private render(info: ArtistInfo): void {
|
||||
const { artist, neighbours, tags } = info;
|
||||
const s = this.shown!;
|
||||
if (!s.img && artist.img) {
|
||||
s.img = api.imgUrl(artist.img);
|
||||
this.setAvatar(this.avatar, s.img, s.name);
|
||||
}
|
||||
const count = neighbours.length;
|
||||
this.subEl.textContent =
|
||||
`${artist.tracks} track${artist.tracks === 1 ? "" : "s"} here` +
|
||||
(count ? ` · plus ${count}${count >= 12 ? "+" : ""} related artist${count === 1 ? "" : "s"}` : "");
|
||||
this.tagsEl.replaceChildren(...tags.map((t) => {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = t;
|
||||
return span;
|
||||
}));
|
||||
if (count && this.list.firstChild?.nodeType === Node.TEXT_NODE) this.list.replaceChildren();
|
||||
for (const n of neighbours) {
|
||||
if (this.known.has(n.handle)) continue;
|
||||
this.known.add(n.handle);
|
||||
const li = document.createElement("li");
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.title = `${n.name}'s radio`;
|
||||
const av = document.createElement("span");
|
||||
av.className = "station-avatar small";
|
||||
const img = api.imgUrl(n.img);
|
||||
this.setAvatar(av, img, n.name);
|
||||
const label = document.createElement("span");
|
||||
label.textContent = n.name;
|
||||
btn.append(av, label);
|
||||
btn.addEventListener("click", () => {
|
||||
this.trail.push(this.shown!);
|
||||
this.show({ handle: n.handle, name: n.name, img });
|
||||
});
|
||||
li.append(btn);
|
||||
this.list.append(li);
|
||||
}
|
||||
}
|
||||
|
||||
private setAvatar(el: HTMLElement, img: string | null, name: string): void {
|
||||
el.style.backgroundImage = img ? `url("${img}")` : "";
|
||||
let initial = el.querySelector(".initial");
|
||||
if (!initial) {
|
||||
initial = document.createElement("span");
|
||||
initial.className = "initial";
|
||||
el.append(initial);
|
||||
}
|
||||
initial.textContent = img ? "" : (name || "?")[0].toUpperCase();
|
||||
}
|
||||
}
|
||||
+142
-3
@@ -83,12 +83,25 @@ button {
|
||||
}
|
||||
#bar button[aria-expanded="true"] { background: rgba(255, 255, 255, 0.22); }
|
||||
#mode-chip {
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 14rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45em;
|
||||
padding-left: 0.3em;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in oklab, var(--accent) 40%, transparent);
|
||||
}
|
||||
#mode-chip .chip-label { overflow: hidden; text-overflow: ellipsis; }
|
||||
.chip-avatar {
|
||||
flex: none;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.8em;
|
||||
background: #0006 center / cover no-repeat;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
@@ -305,6 +318,15 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
||||
overflow-wrap: anywhere;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.why {
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in oklab, var(--card-accent) 70%, #fff);
|
||||
}
|
||||
.why:empty { display: none; }
|
||||
.byline { display: flex; align-items: center; gap: 0.6rem; }
|
||||
.artist { color: var(--muted); text-decoration: none; }
|
||||
.artist:hover { text-decoration: underline; }
|
||||
@@ -473,6 +495,118 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
||||
}
|
||||
#start button { font-size: 1.3rem; padding: 0.7em 1.6em; background: var(--accent); border: none; color: #fff; }
|
||||
|
||||
/* Radio sheet: a bottom sheet on phones, a centred box elsewhere. */
|
||||
#station {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 26;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: #000b;
|
||||
animation: fade 0.2s ease;
|
||||
}
|
||||
.station-box {
|
||||
position: relative;
|
||||
width: min(26rem, 100%);
|
||||
max-height: calc(100dvh - 2rem);
|
||||
overflow-y: auto;
|
||||
background: var(--panel);
|
||||
border: 1px solid #333;
|
||||
border-radius: var(--radius-l);
|
||||
padding: 1.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
animation: rise 0.35s var(--ease-out);
|
||||
}
|
||||
@keyframes rise { from { transform: translateY(2rem); opacity: 0; } }
|
||||
#station-back { align-self: flex-start; }
|
||||
#station-close { position: absolute; top: 0.8rem; right: 0.8rem; }
|
||||
.icon-btn { width: 2.2rem; height: 2.2rem; padding: 0; display: grid; place-items: center; }
|
||||
.icon-btn svg { width: 1rem; height: 1rem; }
|
||||
.station-head { display: flex; gap: 1rem; align-items: center; }
|
||||
.station-head h2 { margin: 0; font: 800 1.4rem/1.1 var(--font-display); overflow-wrap: anywhere; }
|
||||
.station-kicker { margin: 0 0 0.2rem; font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); }
|
||||
#station-sub { margin: 0.3rem 0 0.4rem; color: var(--muted); font-size: 0.85rem; }
|
||||
#station-tags { justify-content: flex-start; }
|
||||
#station-tags span { background: rgba(255, 255, 255, 0.12); }
|
||||
.station-avatar {
|
||||
flex: none;
|
||||
width: 5.5rem;
|
||||
height: 5.5rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #222 center / cover no-repeat;
|
||||
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 60%, transparent), 0 10px 30px #000a;
|
||||
}
|
||||
.station-avatar .initial { font-size: 2rem; }
|
||||
.station-avatar.small { width: 3.4rem; height: 3.4rem; box-shadow: none; }
|
||||
.station-avatar.small .initial { font-size: 1.2rem; }
|
||||
.station-box h3 { margin: 0; font-size: 0.8rem; font-weight: 600; color: var(--muted); }
|
||||
#station-neighbours {
|
||||
list-style: none;
|
||||
margin: 0 -1.4rem;
|
||||
padding: 0 1.4rem 0.3rem;
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
#station-neighbours li { animation: fade 0.5s ease; }
|
||||
#station-neighbours button {
|
||||
width: 4.2rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--fg);
|
||||
}
|
||||
#station-neighbours button > span:last-child { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#station-neighbours button:hover .station-avatar { box-shadow: 0 0 0 2px var(--accent); }
|
||||
#station-exploring { margin: 0; color: var(--muted); font-size: 0.85rem; animation: breathe 1.4s ease-in-out infinite; }
|
||||
@keyframes breathe { 50% { opacity: 0.35; } }
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
padding: 0.7em 1.2em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4em;
|
||||
}
|
||||
button.primary svg { width: 1em; height: 1em; }
|
||||
|
||||
/* The end of an artist radio, over its last card. */
|
||||
#radio-end {
|
||||
position: fixed;
|
||||
z-index: 6;
|
||||
left: 50%;
|
||||
bottom: max(6rem, calc(env(safe-area-inset-bottom) + 5rem));
|
||||
transform: translateX(-50%);
|
||||
width: min(24rem, calc(100vw - 2rem));
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--panel);
|
||||
border: 1px solid #333;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
animation: rise 0.35s var(--ease-out);
|
||||
}
|
||||
#radio-end p { flex-basis: 100%; margin: 0 0 0.3rem; color: var(--muted); font-size: 0.9rem; }
|
||||
|
||||
#help {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -553,6 +687,11 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
||||
#help-btn { display: none; }
|
||||
#account-btn { max-width: 6.5rem; }
|
||||
.panel { left: 0.7rem; right: 0.7rem; }
|
||||
#station { place-items: end stretch; padding: 0; }
|
||||
.station-box { width: 100%; border-radius: var(--radius-l) var(--radius-l) 0 0; padding-bottom: max(1.4rem, env(safe-area-inset-bottom)); }
|
||||
#mode-chip { max-width: 9rem; }
|
||||
/* A station chip says more than the logo; there is no room for both. */
|
||||
#bar:has(#mode-chip:not([hidden])) .logo { display: none; }
|
||||
#likes { width: auto; }
|
||||
.content { width: calc(100vw - 2rem); margin-bottom: 4.5rem; }
|
||||
.actions {
|
||||
|
||||
Reference in new issue
Block a user