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

+26 -5
View File
@@ -121,6 +121,7 @@ visitor address reaches pl8list or a CDN.
| drag waveform | drag waveform · ← → | seek | | drag waveform | drag waveform · ← → | seek |
| follow button | f | follow the artist | | follow button | f | follow the artist |
| radio button | r | artist radio (more like them) | | radio button | r | artist radio (more like them) |
| | v | liquid / simple visuals |
| ✕ button | x | not for me (skip, show less) | | ✕ button | x | not for me (skip, show less) |
| share button | s | share / copy link | | share button | s | share / copy link |
| | m | mute | | | m | mute |
@@ -128,9 +129,14 @@ visitor address reaches pl8list or a CDN.
Lock-screen, notification and headphone buttons control playback too (Media Session API). Lock-screen, notification and headphone buttons control playback too (Media Session API).
"likes" lists your likes newest first; ▶ plays one right away. The filters panel picks the "likes" lists your likes newest first; ▶ plays one right away. The filters panel picks the
feed (for you, riddim, fresh, deep cuts); "radio" on a card starts an artist radio, and the chip feed (for you, riddim, fresh, deep cuts) and the visuals. "radio" on a card opens the radio
in the top bar goes back to your feed. Feed and filters (free only, BPM range) are remembered sheet: the artist, their tags and the artists next to them (tap one to walk over to theirs).
per browser. The site can be installed to the home screen as an app. "start radio" jumps to the radio's first track; each radio track says why it is there, and the
chip in the top bar goes back to your feed. Feed and filters (free only, BPM range) are
remembered per browser. The site can be installed to the home screen as an app.
Single-letter keys clash with vim-key browser extensions (Vimium and the like), which take
`r`, `x` and `f` before the page sees them; turn those off for the site.
## Front end ## Front end
@@ -145,8 +151,18 @@ per browser. The site can be installed to the home screen as an app.
starts just before the current one ends. Both decks run through a Web Audio graph with an starts just before the current one ends. Both decks run through a Web Audio graph with an
analyser. Not on iOS: its element volume is read-only and Web Audio stops with the screen analyser. Not on iOS: its element volume is read-only and Web Audio stops with the screen
locked, so there tracks switch with a cut and lock-screen playback keeps working. locked, so there tracks switch with a cut and lock-screen playback keeps working.
- `visual.ts`: per frame, the waveform progress and a bass level (`--energy`) that pulses the - `features.ts`: what the music does, per frame: band levels against their recent range,
artwork and its glow. Without the analyser the level comes from the track's waveform. kick and snare onsets (spectral flux over an adaptive threshold), wobble in the mids, and
drops. A plain bass meter sits at full scale through a whole dubstep drop; these keep
moving. Without the analyser only loudness from the track's waveform is left.
`?debug=audio` shows them live, with onset counts.
- `blob/`: the liquid artwork. One WebGL2 canvas moves into the playing card: an icosphere
displaced by noise driven by those features (lobes, ripples, snare spikes, a ring after each
kick), showing the artwork through its surface. Tempo, tags and key set its character
(`blob/style.ts`). It steps its quality down on slow devices and falls back to the plain
artwork without WebGL2, with reduced motion, or with visuals set to simple.
- `visual.ts`: runs both per frame, plus `--energy` (kick-driven) for the CSS glow.
- `station.ts`: the radio sheet.
- `color.ts`: each card's accent colour (`--card-accent`) from the artist image. - `color.ts`: each card's accent colour (`--card-accent`) from the artist image.
- `cards.ts`, `api.ts`, `main.ts`: cards, server calls, and everything else. - `cards.ts`, `api.ts`, `main.ts`: cards, server calls, and everything else.
@@ -197,6 +213,11 @@ row. Weights live in a `Ranker`, which can also narrow the candidates and add a
| `deep` | tracks with 15 saves or fewer; popularity counts against a track | | `deep` | tracks with 15 saves or fewer; popularity counts against a track |
| `artist&artist=<handle>` | artist radio: the artist and their neighbours in the artist graph (two hops) | | `artist&artist=<handle>` | artist radio: the artist and their neighbours in the artist graph (two hops) |
In an artist radio each track carries `why`: `seed`, `related` (a neighbour), or `via` with the
neighbour that leads to a second-hop artist. `/local/artist?handle=` returns an artist, their
top tags and closest neighbours for the radio sheet, and starts crawling the neighbourhood
(at most every 10 minutes per artist); `exploring` is true while there are fewer than three.
**Tags** (`backend/src/tags.rs`). pl8list has no genre field, so tags come from words in the **Tags** (`backend/src/tags.rs`). pl8list has no genre field, so tags come from words in the
title, description and artist name, and from the BPM: styles (`riddim`, `tearout`, `wonk`, `dnb`, title, description and artist name, and from the BPM: styles (`riddim`, `tearout`, `wonk`, `dnb`,
…), versions (`vip`, `dub`, `dubpack`, `remix`, `flip`, `special`, `collab`, `wip` for v1/v2…) …), versions (`vip`, `dub`, `dubpack`, `remix`, `flip`, `special`, `collab`, `wip` for v1/v2…)
+13 -1
View File
@@ -14,7 +14,7 @@
use std::{ use std::{
cmp::Reverse, cmp::Reverse,
collections::{BinaryHeap, HashSet}, collections::{BinaryHeap, HashMap, HashSet},
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::Duration, time::Duration,
}; };
@@ -50,6 +50,8 @@ const IDLE_DELAY: Duration = Duration::from_secs(20); // extra wait between requ
const MIN_POOL: i64 = 500; // crawl at full pace until the catalogue has this many tracks const MIN_POOL: i64 = 500; // crawl at full pace until the catalogue has this many tracks
const REFRESH_AFTER: f64 = 86400.0; // re-check an artist's newest page for uploads this often const REFRESH_AFTER: f64 = 86400.0; // re-check an artist's newest page for uploads this often
const RESEED_EVERY: Duration = Duration::from_secs(600); const RESEED_EVERY: Duration = Duration::from_secs(600);
/// An artist radio crawls its artist's neighbourhood at most this often.
const EXPLORE_EVERY: Duration = Duration::from_secs(600);
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Debug)] #[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Debug)]
enum Job { enum Job {
@@ -73,6 +75,7 @@ pub struct Crawler {
db: Db, db: Db,
queue: Mutex<Queue>, queue: Mutex<Queue>,
wake: Notify, wake: Notify,
explored: Mutex<HashMap<String, Instant>>,
} }
fn s(v: &Value, k: &str) -> Option<String> { fn s(v: &Value, k: &str) -> Option<String> {
@@ -86,6 +89,7 @@ impl Crawler {
db, db,
queue: Mutex::new(Queue { heap: BinaryHeap::new(), queued: HashSet::new(), seq: 0 }), queue: Mutex::new(Queue { heap: BinaryHeap::new(), queued: HashSet::new(), seq: 0 }),
wake: Notify::new(), wake: Notify::new(),
explored: Mutex::new(HashMap::new()),
}) })
} }
@@ -150,6 +154,14 @@ impl Crawler {
/// An artist radio was opened: crawl the artist, and the related pages of their /// An artist radio was opened: crawl the artist, and the related pages of their
/// best tracks that were not read yet, to fill in the artist graph around them. /// best tracks that were not read yet, to fill in the artist graph around them.
pub async fn explore_artist(&self, handle: String) { pub async fn explore_artist(&self, handle: String) {
{
let mut explored = self.explored.lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
explored.retain(|_, at| now.duration_since(*at) < EXPLORE_EVERY);
if explored.insert(handle.clone(), now).is_some() {
return;
}
}
self.push(P_FEEDBACK, Job::Artist(handle.clone())); self.push(P_FEEDBACK, Job::Artist(handle.clone()));
let h = handle.clone(); let h = handle.clone();
let tracks = self let tracks = self
+30
View File
@@ -278,6 +278,36 @@ pub fn artist_neighbours(conn: &Connection, handle: &str) -> rusqlite::Result<Ve
.collect() .collect()
} }
/// An artist as seen on their tracks.
#[derive(Debug, Serialize)]
pub struct Artist {
pub handle: String,
pub name: String,
pub img: Option<String>,
pub verified: bool,
pub tracks: i64,
}
pub fn artist(conn: &Connection, handle: &str) -> rusqlite::Result<Option<Artist>> {
conn.prepare_cached(
"SELECT handle, artist_name, artist_img, artist_verified, COUNT(*) FROM tracks WHERE handle=? GROUP BY handle",
)?
.query_row([handle], |r| {
Ok(Artist { handle: r.get(0)?, name: r.get(1)?, img: r.get(2)?, verified: r.get(3)?, tracks: r.get(4)? })
})
.optional()
}
/// An artist's most common style tags; tempo tags left out.
pub fn artist_tags(conn: &Connection, handle: &str, n: usize) -> rusqlite::Result<Vec<String>> {
conn.prepare_cached(
"SELECT g.tag FROM track_tags g JOIN tracks t ON t.id = g.track_id \
WHERE t.handle=? AND g.tag NOT GLOB '[0-9]*' GROUP BY g.tag ORDER BY COUNT(*) DESC, g.tag LIMIT ?",
)?
.query_map(params![handle, n as i64], |r| r.get(0))?
.collect()
}
/// How many tracks each artist has with `tag`. /// How many tracks each artist has with `tag`.
pub fn tag_counts(conn: &Connection, tag: &str) -> rusqlite::Result<Vec<(String, i64)>> { pub fn tag_counts(conn: &Connection, tag: &str) -> rusqlite::Result<Vec<(String, i64)>> {
conn.prepare_cached( conn.prepare_cached(
+41
View File
@@ -144,6 +144,47 @@ pub async fn event(State(app): St, Extension(current): Extension<CurrentUser>, J
Ok((no_store(), Json(json!({ "ok": true }))).into_response()) Ok((no_store(), Json(json!({ "ok": true }))).into_response())
} }
/// How many neighbours the radio sheet shows, and how few count as still exploring.
const SHEET_NEIGHBOURS: usize = 12;
const FEW_NEIGHBOURS: usize = 3;
/// An artist and the ones next to them in the artist graph, for the radio sheet.
/// Starts crawling the neighbourhood when it is new, so the sheet can poll while
/// `exploring` is true.
pub async fn artist(State(app): St, Extension(current): Extension<CurrentUser>, Query(q): Query<HashMap<String, String>>) -> Res {
let uid = match current.require() {
Ok(u) => u.id,
Err(r) => return Ok(r),
};
let Some(handle) = q.get("handle").filter(|h| valid_handle(h)).cloned() else {
return Ok(StatusCode::BAD_REQUEST.into_response());
};
app.crawler.explore_artist(handle.clone()).await;
let body = app
.db
.call(move |c| {
let Some(artist) = db::artist(c, &handle)? else { return Ok(None) };
let mut neighbours = vec![];
for (h, weight) in ranker::scaled_neighbours(c, &handle)?.into_iter().take(SHEET_NEIGHBOURS) {
if let Some(a) = db::artist(c, &h)? {
neighbours.push(json!({ "handle": a.handle, "name": a.name, "img": a.img, "weight": weight }));
}
}
Ok(Some(json!({
"followed": db::follows(c, uid)?.contains(&handle),
"tags": db::artist_tags(c, &handle, 4)?,
"exploring": neighbours.len() < FEW_NEIGHBOURS,
"artist": artist,
"neighbours": neighbours,
})))
})
.await?;
Ok(match body {
Some(b) => (no_store(), Json(b)).into_response(),
None => json_error(StatusCode::NOT_FOUND, "unknown artist"),
})
}
/// Valid pl8list handles, as seen in every handle crawled so far. /// Valid pl8list handles, as seen in every handle crawled so far.
fn valid_handle(h: &str) -> bool { fn valid_handle(h: &str) -> bool {
(1..=64).contains(&h.len()) && h.bytes().all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) && h != "." && h != ".." (1..=64).contains(&h.len()) && h.bytes().all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) && h != "." && h != ".."
+1
View File
@@ -316,6 +316,7 @@ pub fn router(app: Arc<App>) -> Router {
.route("/local/me", get(app::me)) .route("/local/me", get(app::me))
.route("/local/feed", get(app::feed)) .route("/local/feed", get(app::feed))
.route("/local/likes", get(app::likes)) .route("/local/likes", get(app::likes))
.route("/local/artist", get(app::artist))
.route("/local/event", post(app::event)) .route("/local/event", post(app::event))
.route("/local/follow", post(app::follow)) .route("/local/follow", post(app::follow))
.route("/account/export", get(account::export)) .route("/account/export", get(account::export))
+47 -1
View File
@@ -1,6 +1,6 @@
//! End-to-end tests of the HTTP API against a fake pl8list. //! End-to-end tests of the HTTP API against a fake pl8list.
use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration};
use axum::{ use axum::{
Router, Router,
@@ -417,3 +417,49 @@ async fn feed_modes_filter_and_tag_tracks() {
let res = get("/local/feed?mode=artist&artist=h").await.unwrap(); let res = get("/local/feed?mode=artist&artist=h").await.unwrap();
assert_eq!(json_body(res).await["tracks"].as_array().unwrap().len(), 1); assert_eq!(json_body(res).await["tracks"].as_array().unwrap().len(), 1);
} }
#[tokio::test]
async fn artist_sheet_and_radio_reasons() {
let (r, app, _) = setup("radio", "http://127.0.0.1:9").await;
let token = app
.db
.call(|c| {
let t = |id, handle: &str, title: &str| crate::db::Track {
id,
handle: handle.into(),
artist_name: handle.to_uppercase(),
slug: format!("s{id}"),
title: title.into(),
bpm: Some(140.0),
full_access: true,
..Default::default()
};
crate::db::upsert_tracks(c, &[t(1, "a", "Riddim one"), t(2, "b", "Two"), t(3, "c", "Three")], None)?;
crate::db::add_related(c, 1, &["b".into()])?;
crate::db::add_related(c, 2, &["c".into()])?;
crate::db::upsert_user(c, 5, "tester", None, None)?;
crate::db::create_session(c, 5)
})
.await
.unwrap();
let cookie = format!("__Host-pl8inf={token}");
let get = |uri: &str| r.clone().oneshot(req("GET", uri, None, Some(&cookie), "203.0.113.1"));
let res = get("/local/artist?handle=a").await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = json_body(res).await;
assert_eq!(body["artist"]["name"], "A");
assert_eq!(body["tags"], serde_json::json!(["riddim"]));
assert_eq!(body["neighbours"][0]["handle"], "b");
assert_eq!(body["exploring"], true);
assert_eq!(get("/local/artist?handle=..").await.unwrap().status(), StatusCode::BAD_REQUEST);
assert_eq!(get("/local/artist?handle=nobody").await.unwrap().status(), StatusCode::NOT_FOUND);
let res = get("/local/feed?mode=artist&artist=a").await.unwrap();
let body = json_body(res).await;
let why: HashMap<i64, serde_json::Value> =
body["tracks"].as_array().unwrap().iter().map(|t| (t["id"].as_i64().unwrap(), t["why"].clone())).collect();
assert_eq!(why[&1], serde_json::json!({ "kind": "seed" }));
assert_eq!(why[&2], serde_json::json!({ "kind": "related" }));
assert_eq!(why[&3], serde_json::json!({ "kind": "via", "handle": "b", "name": "B" }));
}
+66 -22
View File
@@ -48,6 +48,10 @@ pub trait Ranker: Send + Sync {
fn bonus(&self, _track: &Track) -> f64 { fn bonus(&self, _track: &Track) -> f64 {
0.0 0.0
} }
/// Why a picked track is in this feed, sent along as `why`.
fn reason(&self, _track: &Track) -> Option<Value> {
None
}
} }
/// The original mix. /// The original mix.
@@ -164,8 +168,7 @@ impl Ranker for Fresh {
struct ArtistRadio { struct ArtistRadio {
seed: String, seed: String,
/// Neighbours, 0..1 by how closely they are linked. near: HashMap<String, Near>,
near: HashMap<String, f64>,
} }
const RADIO: Weights = Weights { artist: 0.5, related: 0.5, ..CLASSIC }; const RADIO: Weights = Weights { artist: 0.5, related: 0.5, ..CLASSIC };
@@ -179,32 +182,57 @@ impl Ranker for ArtistRadio {
} }
// The artist themselves now and then; mostly the ones around them. // The artist themselves now and then; mostly the ones around them.
fn bonus(&self, t: &Track) -> f64 { fn bonus(&self, t: &Track) -> f64 {
if t.handle == self.seed { 1.0 } else { 2.0 * self.near.get(&t.handle).copied().unwrap_or(0.0) } if t.handle == self.seed { 1.0 } else { 2.0 * self.near.get(&t.handle).map_or(0.0, |n| n.score) }
}
fn reason(&self, t: &Track) -> Option<Value> {
if t.handle == self.seed {
return Some(json!({ "kind": "seed" }));
}
Some(match &self.near.get(&t.handle)?.via {
None => json!({ "kind": "related" }),
Some(via) => json!({ "kind": "via", "handle": via }),
})
} }
} }
/// Neighbours of an artist up to two hops away, scored 0..1. A second-hop artist /// An artist near a radio's seed.
/// scores at most 0.4 of the link that leads to it. #[derive(Debug, PartialEq)]
fn neighbourhood(conn: &Connection, seed: &str) -> rusqlite::Result<HashMap<String, f64>> { pub struct Near {
let scaled = |links: Vec<(String, i64)>| { /// 0..1 by how closely linked.
let max = links.iter().map(|l| l.1).max().unwrap_or(1).max(1) as f64; pub score: f64,
links.into_iter().map(|(h, n)| (h, (n as f64 / max).sqrt())).collect::<Vec<_>>() /// For a second hop: the seed's neighbour that leads here.
}; pub via: Option<String>,
let first = scaled(crate::db::artist_neighbours(conn, seed)?); }
let mut near: HashMap<String, f64> = first.iter().cloned().collect();
let mut by_score = first; /// Neighbours of an artist up to two hops away. A second-hop artist scores at most
by_score.sort_by(|a, b| b.1.total_cmp(&a.1)); /// 0.4 of the link that leads to it.
for (h, w) in by_score.into_iter().take(10) { fn neighbourhood(conn: &Connection, seed: &str) -> rusqlite::Result<HashMap<String, Near>> {
for (h2, w2) in scaled(crate::db::artist_neighbours(conn, &h)?) { let first = scaled_neighbours(conn, seed)?;
if h2 != seed { let mut near: HashMap<String, Near> = first.iter().map(|(h, w)| (h.clone(), Near { score: *w, via: None })).collect();
let s = near.entry(h2).or_default(); for (h, w) in first.into_iter().take(10) {
*s = s.max(0.4 * w * w2); for (h2, w2) in scaled_neighbours(conn, &h)? {
if h2 == seed {
continue;
}
let score = 0.4 * w * w2;
let n = near.entry(h2).or_insert(Near { score: 0.0, via: Some(h.clone()) });
if n.via.is_some() && score > n.score {
*n = Near { score, via: Some(h.clone()) };
} }
} }
} }
Ok(near) Ok(near)
} }
/// Direct neighbours of an artist, closest first, scored 0..1 against the closest.
pub fn scaled_neighbours(conn: &Connection, handle: &str) -> rusqlite::Result<Vec<(String, f64)>> {
let links = crate::db::artist_neighbours(conn, handle)?;
let max = links.iter().map(|l| l.1).max().unwrap_or(1).max(1) as f64;
let mut out: Vec<(String, f64)> = links.into_iter().map(|(h, n)| (h, (n as f64 / max).sqrt())).collect();
out.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Ok(out)
}
/// How strongly each artist is linked to `tag`, 0..1: their own tagged tracks (three /// How strongly each artist is linked to `tag`, 0..1: their own tagged tracks (three
/// or more count fully), or half of what the artists next to them in the graph have. /// or more count fully), or half of what the artists next to them in the graph have.
pub fn tag_affinity(conn: &Connection, tag: &str) -> rusqlite::Result<HashMap<String, f64>> { pub fn tag_affinity(conn: &Connection, tag: &str) -> rusqlite::Result<HashMap<String, f64>> {
@@ -391,12 +419,27 @@ pub fn pick(conn: &Connection, user_id: i64, n: usize, f: Filters, ranker: &dyn
let best = (0..scored.len()).max_by(|&a, &b| penalised(&scored[a]).total_cmp(&penalised(&scored[b]))).unwrap_or(0); let best = (0..scored.len()).max_by(|&a, &b| penalised(&scored[a]).total_cmp(&penalised(&scored[b]))).unwrap_or(0);
let (s, r) = scored.remove(best); let (s, r) = scored.remove(best);
seen.push(r.handle.clone()); seen.push(r.handle.clone());
out.push(serialize(&r, Some(s))); let mut v = serialize(&r, Some(s));
if let Some(why) = ranker.reason(&r) {
v["why"] = why;
}
out.push(v);
} }
add_tags(conn, &mut out)?; add_tags(conn, &mut out)?;
name_reasons(conn, &mut out)?;
Ok(out) Ok(out)
} }
/// Artist names for reasons that point at another artist.
fn name_reasons(conn: &Connection, tracks: &mut [Value]) -> rusqlite::Result<()> {
for t in tracks {
if let Some(h) = t["why"]["handle"].as_str().map(String::from) {
t["why"]["name"] = json!(crate::db::artist(conn, &h)?.map_or(h, |a| a.name));
}
}
Ok(())
}
fn add_tags(conn: &Connection, tracks: &mut [Value]) -> rusqlite::Result<()> { fn add_tags(conn: &Connection, tracks: &mut [Value]) -> rusqlite::Result<()> {
for t in tracks { for t in tracks {
if let Some(id) = t["id"].as_i64() { if let Some(id) = t["id"].as_i64() {
@@ -509,8 +552,9 @@ mod tests {
db::add_related(&mut c, 1, &["b".into()]).unwrap(); db::add_related(&mut c, 1, &["b".into()]).unwrap();
db::add_related(&mut c, 2, &["c".into()]).unwrap(); db::add_related(&mut c, 2, &["c".into()]).unwrap();
let near = neighbourhood(&c, "a").unwrap(); let near = neighbourhood(&c, "a").unwrap();
assert_eq!(near["b"], 1.0); assert_eq!(near["b"], Near { score: 1.0, via: None });
assert!((near["c"] - 0.4).abs() < 1e-9); assert!((near["c"].score - 0.4).abs() < 1e-9);
assert_eq!(near["c"].via.as_deref(), Some("b"));
assert_eq!(ids(&c, Mode::Artist("a".into()), 10), [1, 2, 3]); assert_eq!(ids(&c, Mode::Artist("a".into()), 10), [1, 2, 3]);
} }
+34 -1
View File
@@ -31,7 +31,9 @@
<header id="bar" hidden> <header id="bar" hidden>
<span class="logo">pl8list <b>∞</b></span> <span class="logo">pl8list <b>∞</b></span>
<button id="mode-chip" type="button" title="back to your feed" hidden></button> <button id="mode-chip" type="button" title="back to your feed" hidden>
<span class="chip-avatar" aria-hidden="true"></span><span class="chip-label"></span><span aria-hidden="true">×</span>
</button>
<span id="stats"></span> <span id="stats"></span>
<button id="filters-btn" type="button" aria-controls="filters" aria-expanded="false">filters</button> <button id="filters-btn" type="button" aria-controls="filters" aria-expanded="false">filters</button>
<button id="likes-btn" type="button" aria-controls="likes" aria-expanded="false">likes</button> <button id="likes-btn" type="button" aria-controls="likes" aria-expanded="false">likes</button>
@@ -135,6 +137,33 @@
<button type="button">tap anywhere to start</button> <button type="button">tap anywhere to start</button>
</div> </div>
<!-- Artist radio: who the radio is built around, and who is next to them. -->
<div id="station" hidden>
<section class="station-box" role="dialog" aria-modal="true" aria-labelledby="station-name">
<button type="button" id="station-back" class="link" hidden></button>
<button type="button" id="station-close" class="icon-btn" aria-label="close"><svg><use href="#i-x"/></svg></button>
<div class="station-head">
<div class="station-avatar"><span class="initial"></span></div>
<div>
<p class="station-kicker">artist radio</p>
<h2 id="station-name"></h2>
<p id="station-sub"></p>
<div id="station-tags" class="chips"></div>
</div>
</div>
<h3>around them</h3>
<ol id="station-neighbours"></ol>
<p id="station-exploring" hidden>finding related artists…</p>
<button type="button" id="station-start" class="primary"><svg><use href="#i-play"/></svg> start radio</button>
</section>
</div>
<div id="radio-end" hidden>
<p></p>
<button type="button" id="radio-end-feed">back to your feed</button>
<button type="button" id="radio-end-next" class="primary"></button>
</div>
<div id="help" hidden> <div id="help" hidden>
<div class="help-box"> <div class="help-box">
<h2>controls</h2> <h2>controls</h2>
@@ -158,6 +187,9 @@
<dt>?</dt><dd>this help</dd> <dt>?</dt><dd>this help</dd>
</dl> </dl>
<p>Lock-screen and headphone buttons work too.</p> <p>Lock-screen and headphone buttons work too.</p>
<p class="pointer-only fineprint">Keys like r or x doing something else, such as reloading or
closing the tab? A vim-keys browser extension (Vimium and the like) takes them first:
turn it off for this site.</p>
<button type="button" id="help-close">got it</button> <button type="button" id="help-close">got it</button>
</div> </div>
</div> </div>
@@ -174,6 +206,7 @@
<span class="paused-badge"><svg><use href="#i-play"/></svg></span> <span class="paused-badge"><svg><use href="#i-play"/></svg></span>
<span class="spinner"></span> <span class="spinner"></span>
</div> </div>
<p class="why"></p>
<h1 class="title"></h1> <h1 class="title"></h1>
<div class="byline"> <div class="byline">
<a class="artist" target="_blank" rel="noopener"></a> <a class="artist" target="_blank" rel="noopener"></a>
+29 -2
View File
@@ -24,6 +24,30 @@ export interface Track {
followed?: boolean; followed?: boolean;
/// From titles, descriptions and BPM, see backend/src/tags.rs. /// From titles, descriptions and BPM, see backend/src/tags.rs.
tags?: string[]; 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 { 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. /// that artist and the ones pl8list relates to them.
export type Mode = export type Mode =
| { mode: "classic" | "riddim" | "fresh" | "deep" } | { 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"; 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); 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 likes = () => getJSON<{ tracks: Track[] }>("/local/likes");
export const me = () => getJSON<Me>("/local/me"); 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 trackUrl = (t: Track) => `${PL8}/${t.handle}/${t.slug}`;
export const artistUrl = (t: Track) => `${PL8}/${t.handle}`; export const artistUrl = (t: Track) => `${PL8}/${t.handle}`;
// Images come through our server, so pl8list never sees the visitor's address. // 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 { export function streamUrl(t: Track): string {
const p = new URLSearchParams(); const p = new URLSearchParams();
+17 -1
View File
@@ -31,14 +31,18 @@ export class Card {
private dragging = false; private dragging = false;
private accent: string | null = null; private accent: string | null = null;
/// `station`: the artist radio this card was picked for, if any.
constructor( constructor(
readonly track: Track, readonly track: Track,
private actions: CardActions, private actions: CardActions,
{ liked = false } = {}, { liked = false, station = undefined as string | undefined } = {},
) { ) {
this.liked = liked; this.liked = liked;
this.station = station;
} }
private station: string | undefined;
element(): HTMLElement { element(): HTMLElement {
return this.el ?? this.build(); return this.el ?? this.build();
} }
@@ -70,6 +74,7 @@ export class Card {
$(".initial").textContent = (t.artist_name || "?")[0].toUpperCase(); $(".initial").textContent = (t.artist_name || "?")[0].toUpperCase();
} }
$(".why").textContent = this.whyText();
$(".title").textContent = cleanTitle(t.title); $(".title").textContent = cleanTitle(t.title);
const artist = $<HTMLAnchorElement>(".artist"); const artist = $<HTMLAnchorElement>(".artist");
artist.textContent = t.artist_name + (t.artist_verified ? " ✓" : ""); artist.textContent = t.artist_name + (t.artist_verified ? " ✓" : "");
@@ -112,6 +117,17 @@ export class Card {
return el; 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 { showLike(pop = false): void {
const btn = this.el?.querySelector(".like"); const btn = this.el?.querySelector(".like");
if (!btn) return; 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 { Card, type CardActions, cleanTitle } from "./cards";
import { Pager } from "./pager"; import { Pager } from "./pager";
import { Player } from "./player"; import { Player } from "./player";
import { Station } from "./station";
import { type VisualsSetting, Visualizer } from "./visual"; import { type VisualsSetting, Visualizer } from "./visual";
const PREFETCH_AHEAD = 4; // fetch more when this close to the end const PREFETCH_AHEAD = 4; // fetch more when this close to the end
@@ -34,6 +35,7 @@ const toastEl = $("toast");
const loginEl = $("login"); const loginEl = $("login");
const isTouch = !matchMedia("(hover: hover) and (pointer: fine)").matches; const isTouch = !matchMedia("(hover: hover) and (pointer: fine)").matches;
const radioEnd = $("radio-end");
const player = new Player(); const player = new Player();
const visual = new Visualizer(player, isTouch); const visual = new Visualizer(player, isTouch);
@@ -50,6 +52,8 @@ let preloadTimer = 0;
let filters: Filters = loadFilters(); let filters: Filters = loadFilters();
let mode: Mode = loadMode(); let mode: Mode = loadMode();
let stats: Stats | null = null; 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; const current = () => cards[active] as Card | undefined;
@@ -77,18 +81,25 @@ async function loadMore(): Promise<void> {
if (gen !== feedGen) return; if (gen !== feedGen) return;
showStats(data.stats); showStats(data.stats);
if (!data.tracks.length) { if (!data.tracks.length) {
setStatus(mode.mode === "artist" jumpOnLoad = false;
? `nothing more around ${mode.name} yet. the crawler is looking up who they're related to, try again in a bit.` if (mode.mode === "artist") {
: mode.mode !== "classic" || Object.keys(filters).length 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 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."); : "nothing new right now, the crawler is still digging. try again in a bit.");
setTimeout(() => { if (gen === feedGen) loading = false; }, 8000); setTimeout(() => { if (gen === feedGen) loading = false; }, 8000);
return; return;
} }
setStatus(""); 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(); pager.refresh();
if (active < 0) activate(0); if (active < 0) activate(0);
else if (jumpOnLoad) go(active + 1);
jumpOnLoad = false;
} catch (err) { } catch (err) {
if (gen !== feedGen) return; if (gen !== feedGen) return;
if (err instanceof api.HttpError && err.status === 401) { if (err instanceof api.HttpError && err.status === 401) {
@@ -147,6 +158,7 @@ function activate(idx: number): void {
if (next && started) player.preload(next.track); if (next && started) player.preload(next.track);
}, PRELOAD_AFTER_MS); }, PRELOAD_AFTER_MS);
if (idx >= cards.length - PREFETCH_AHEAD) loadMore(); if (idx >= cards.length - PREFETCH_AHEAD) loadMore();
updateRadioEnd();
cards.forEach((c, i) => { if (c.el && Math.abs(i - idx) > KEEP_BUILT) c.release(); }); 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), share: (c) => share(c),
next: (c) => go(cards.indexOf(c) + 1), next: (c) => go(cards.indexOf(c) + 1),
follow: (c) => setFollow(c, !c.track.followed), 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, canSeek: (c) => c === current() && !!player.audio.duration,
duration: (c) => (c === current() && player.audio.duration) || c.track.duration || 0, duration: (c) => (c === current() && player.audio.duration) || c.track.duration || 0,
seek: (_, s) => player.seek(s), 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 modeName = (m: Mode) => (m.mode === "artist" ? `${m.name} radio` : MODE_NAMES[m.mode]);
const MODE_ICONS = { riddim: "◆", fresh: "✦", deep: "◎" };
function showMode(): void { function showMode(): void {
modeChip.hidden = mode.mode === "classic"; 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; for (const input of filtersForm.querySelectorAll<HTMLInputElement>("input[name=mode]")) input.checked = input.value === mode.mode;
} }
function setMode(m: Mode): void { function setMode(m: Mode): void {
radioDry = false;
updateRadioEnd();
if (JSON.stringify(m) === JSON.stringify(mode)) return; if (JSON.stringify(m) === JSON.stringify(mode)) return;
mode = m; mode = m;
localStorage.setItem(MODE_KEY, JSON.stringify(mode)); localStorage.setItem(MODE_KEY, JSON.stringify(mode));
@@ -521,6 +541,31 @@ function setMode(m: Mode): void {
} }
modeChip.addEventListener("click", () => setMode({ mode: "classic" })); 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) => { filtersForm.addEventListener("change", (e) => {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
if (input.name === "mode") setMode({ mode: input.value as "classic" | "riddim" | "fresh" | "deep" }); if (input.name === "mode") setMode({ mode: input.value as "classic" | "riddim" | "fresh" | "deep" });
@@ -553,8 +598,10 @@ document.addEventListener("keydown", (e) => {
if (k === "Escape") { if (k === "Escape") {
closePanels(); closePanels();
hideHelp(); hideHelp();
station.close();
return; return;
} }
if (station.isOpen) return;
if (!loginEl.hidden) return; if (!loginEl.hidden) return;
if (!started) { if (!started) {
if (k !== "Tab" && k !== "Shift") { e.preventDefault(); start(); } 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); } #bar button[aria-expanded="true"] { background: rgba(255, 255, 255, 0.22); }
#mode-chip { #mode-chip {
max-width: 12rem; max-width: 14rem;
overflow: hidden; display: inline-flex;
text-overflow: ellipsis; align-items: center;
gap: 0.45em;
padding-left: 0.3em;
white-space: nowrap; white-space: nowrap;
background: color-mix(in oklab, var(--accent) 40%, transparent); 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 { .panel {
position: fixed; position: fixed;
@@ -305,6 +318,15 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
overflow-wrap: anywhere; overflow-wrap: anywhere;
text-wrap: balance; 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; } .byline { display: flex; align-items: center; gap: 0.6rem; }
.artist { color: var(--muted); text-decoration: none; } .artist { color: var(--muted); text-decoration: none; }
.artist:hover { text-decoration: underline; } .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; } #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 { #help {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -553,6 +687,11 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
#help-btn { display: none; } #help-btn { display: none; }
#account-btn { max-width: 6.5rem; } #account-btn { max-width: 6.5rem; }
.panel { left: 0.7rem; right: 0.7rem; } .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; } #likes { width: auto; }
.content { width: calc(100vw - 2rem); margin-bottom: 4.5rem; } .content { width: calc(100vw - 2rem); margin-bottom: 4.5rem; }
.actions { .actions {