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:
nvrl committed 2026-11-01 14:32:51 +01:00
1 parent e8c0f40128
commit 76cfbb12ac
13 files changed
+654 -42

No files matched your search

+29 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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(); }
+155
View File
@@ -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
View File
@@ -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 {