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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 != ".."
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(); }
|
||||||
|
|||||||
@@ -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); }
|
#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 {
|
||||||
|
|||||||
Reference in new issue
Block a user