mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
21 files changed
+1573
-84
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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+40
-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>
|
||||||
@@ -88,6 +90,11 @@
|
|||||||
<label><input type="radio" name="mode" value="fresh"> fresh</label>
|
<label><input type="radio" name="mode" value="fresh"> fresh</label>
|
||||||
<label><input type="radio" name="mode" value="deep"> deep cuts</label>
|
<label><input type="radio" name="mode" value="deep"> deep cuts</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset class="modes">
|
||||||
|
<legend>visuals</legend>
|
||||||
|
<label><input type="radio" name="visuals" value="liquid"> liquid</label>
|
||||||
|
<label><input type="radio" name="visuals" value="simple"> simple</label>
|
||||||
|
</fieldset>
|
||||||
<label><input type="checkbox" name="free"> free only</label>
|
<label><input type="checkbox" name="free"> free only</label>
|
||||||
<label>bpm <input type="number" name="bpm_min" min="40" max="250" placeholder="min"></label>
|
<label>bpm <input type="number" name="bpm_min" min="40" max="250" placeholder="min"></label>
|
||||||
<label>– <input type="number" name="bpm_max" min="40" max="250" placeholder="max"></label>
|
<label>– <input type="number" name="bpm_max" min="40" max="250" placeholder="max"></label>
|
||||||
@@ -130,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>
|
||||||
@@ -148,10 +182,14 @@
|
|||||||
<dt>r</dt><dd>artist radio</dd>
|
<dt>r</dt><dd>artist radio</dd>
|
||||||
<dt>x</dt><dd>not for me</dd>
|
<dt>x</dt><dd>not for me</dd>
|
||||||
<dt>s</dt><dd>share</dd>
|
<dt>s</dt><dd>share</dd>
|
||||||
|
<dt>v</dt><dd>liquid / simple visuals</dd>
|
||||||
<dt>m</dt><dd>mute</dd>
|
<dt>m</dt><dd>mute</dd>
|
||||||
<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>
|
||||||
@@ -168,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();
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
// The liquid artwork drop: one WebGL2 canvas, moved into the playing card's art slot.
|
||||||
|
// Browsers allow only a handful of WebGL contexts, so there is one for the whole
|
||||||
|
// page; cards around it show their artwork as a plain image.
|
||||||
|
|
||||||
|
import type { Features } from "../features";
|
||||||
|
import { icosphere } from "./mesh";
|
||||||
|
import { FRAGMENT, VERTEX } from "./shaders";
|
||||||
|
import type { Look } from "./style";
|
||||||
|
|
||||||
|
const FOV = (30 * Math.PI) / 180;
|
||||||
|
/// The canvas is this much larger than the art slot, so the liquid can spill out.
|
||||||
|
export const SPILL = 1.5;
|
||||||
|
const DIST = SPILL / Math.tan(FOV / 2);
|
||||||
|
const MORPH_IN = 0.7; // s from flat artwork to liquid
|
||||||
|
const TEXTURE_SIZE = 512;
|
||||||
|
const SLOW_FRAME = 0.02; // s; frames slower than this for SLOW_FOR …
|
||||||
|
const SLOW_FOR = 2; // … seconds step the quality down
|
||||||
|
/// Quality steps: icosphere subdivisions and resolution scale.
|
||||||
|
const QUALITY: [number, number][] = [[5, 1], [4, 1], [4, 0.6], [3, 0.5]];
|
||||||
|
|
||||||
|
const UNIFORMS = [
|
||||||
|
"uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uSnare", "uDrop",
|
||||||
|
"uGain", "uSpike", "uStep", "uTwist", "uRipple", "uShock", "uPointer",
|
||||||
|
"uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export class LiquidBlob {
|
||||||
|
readonly canvas = document.createElement("canvas");
|
||||||
|
/// Called once if the GPU drops the context; the page then falls back to plain art.
|
||||||
|
onLost: (() => void) | null = null;
|
||||||
|
private gl: WebGL2RenderingContext;
|
||||||
|
private u = {} as Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null>;
|
||||||
|
private vao: WebGLVertexArrayObject;
|
||||||
|
private buffers: WebGLBuffer[] = [];
|
||||||
|
private count = 0;
|
||||||
|
private tex: WebGLTexture;
|
||||||
|
private quality: number;
|
||||||
|
private look: Look | null = null;
|
||||||
|
private accent: [number, number, number] = [1, 0.3, 0.43];
|
||||||
|
private ready = false;
|
||||||
|
private gen = 0;
|
||||||
|
private morph = 0;
|
||||||
|
private time = 0;
|
||||||
|
private pointer = [0, 0, 1, 0];
|
||||||
|
private pointerGoal = [0, 0, 1, 0];
|
||||||
|
private slow = 0;
|
||||||
|
|
||||||
|
/// Null where WebGL2 is not available.
|
||||||
|
static create(touch: boolean): LiquidBlob | null {
|
||||||
|
try {
|
||||||
|
return new LiquidBlob(touch);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("liquid visuals off:", (err as Error).message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor(private touch: boolean) {
|
||||||
|
const gl = this.canvas.getContext("webgl2", { antialias: true, premultipliedAlpha: true });
|
||||||
|
if (!gl) throw new Error("no WebGL2");
|
||||||
|
this.gl = gl;
|
||||||
|
this.canvas.className = "liquid-canvas";
|
||||||
|
this.canvas.addEventListener("webglcontextlost", () => {
|
||||||
|
this.ready = false;
|
||||||
|
this.onLost?.();
|
||||||
|
});
|
||||||
|
const prog = gl.createProgram();
|
||||||
|
gl.attachShader(prog, this.shader(gl.VERTEX_SHADER, VERTEX));
|
||||||
|
gl.attachShader(prog, this.shader(gl.FRAGMENT_SHADER, FRAGMENT));
|
||||||
|
gl.bindAttribLocation(prog, 0, "position");
|
||||||
|
gl.linkProgram(prog);
|
||||||
|
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || "link failed");
|
||||||
|
gl.useProgram(prog);
|
||||||
|
for (const name of UNIFORMS) this.u[name] = gl.getUniformLocation(prog, name);
|
||||||
|
this.vao = gl.createVertexArray();
|
||||||
|
this.quality = touch ? 1 : 0;
|
||||||
|
this.buildMesh();
|
||||||
|
this.tex = gl.createTexture();
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, this.tex);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.uniform1i(this.u.uArt, 0);
|
||||||
|
gl.uniformMatrix4fv(this.u.uProj, false, perspective(FOV, 1, 0.1, 20));
|
||||||
|
gl.uniform1f(this.u.uDist, DIST);
|
||||||
|
gl.enable(gl.DEPTH_TEST);
|
||||||
|
gl.clearColor(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private shader(type: number, src: string): WebGLShader {
|
||||||
|
const gl = this.gl;
|
||||||
|
const s = gl.createShader(type)!;
|
||||||
|
gl.shaderSource(s, src);
|
||||||
|
gl.compileShader(s);
|
||||||
|
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) || "compile failed");
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMesh(): void {
|
||||||
|
const gl = this.gl;
|
||||||
|
const mesh = icosphere(QUALITY[this.quality][0]);
|
||||||
|
for (const b of this.buffers) gl.deleteBuffer(b);
|
||||||
|
gl.bindVertexArray(this.vao);
|
||||||
|
const pos = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, pos);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, mesh.positions, gl.STATIC_DRAW);
|
||||||
|
gl.enableVertexAttribArray(0);
|
||||||
|
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
|
||||||
|
const idx = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idx);
|
||||||
|
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.indices, gl.STATIC_DRAW);
|
||||||
|
this.buffers = [pos, idx];
|
||||||
|
this.count = mesh.indices.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show a new track in `slot`. Resolves once its artwork is on the GPU, which is
|
||||||
|
/// when the slot's own image can be hidden.
|
||||||
|
async mount(slot: HTMLElement, look: Look, art: string | null, initial: string): Promise<boolean> {
|
||||||
|
if (this.canvas.parentElement !== slot) slot.append(this.canvas);
|
||||||
|
const gen = ++this.gen;
|
||||||
|
this.look = look;
|
||||||
|
this.ready = false;
|
||||||
|
this.morph = 0;
|
||||||
|
this.slow = 0;
|
||||||
|
const source = await artwork(art, initial).catch(() => artwork(null, initial));
|
||||||
|
if (gen !== this.gen || this.gl.isContextLost()) return false;
|
||||||
|
const gl = this.gl;
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, this.tex);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
|
||||||
|
gl.generateMipmap(gl.TEXTURE_2D);
|
||||||
|
this.ready = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rim colour, as CSS `hsl()` from color.ts or any `#rrggbb`.
|
||||||
|
setAccent(css: string | null): void {
|
||||||
|
this.accent = parseColor(css) ?? [1, 0.3, 0.43];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pointer over the slot: x, y from the centre in units of the art's radius
|
||||||
|
/// (y up); `on` false when it left or was lifted.
|
||||||
|
setPointer(x: number, y: number, on: boolean): void {
|
||||||
|
const r = Math.hypot(x, y);
|
||||||
|
const [dx, dy, dz] = r < 1 ? [x, y, Math.sqrt(1 - r * r)] : [x / r, y / r, 0];
|
||||||
|
this.pointerGoal = [dx, dy, dz, on && r < 1.4 ? 1 : 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw one frame. Returns true while it still moves without music (morphing in,
|
||||||
|
/// settling, following the pointer).
|
||||||
|
frame(f: Features, dt: number, playing: boolean): boolean {
|
||||||
|
const look = this.look;
|
||||||
|
if (!look || !this.ready) return false;
|
||||||
|
const gl = this.gl;
|
||||||
|
this.watch(dt);
|
||||||
|
const scale = Math.min(devicePixelRatio || 1, this.touch ? 1 : 1.5) * QUALITY[this.quality][1];
|
||||||
|
const w = Math.round(this.canvas.clientWidth * scale), h = Math.round(this.canvas.clientHeight * scale);
|
||||||
|
if (!w || !h) return false;
|
||||||
|
if (this.canvas.width !== w || this.canvas.height !== h) {
|
||||||
|
this.canvas.width = w;
|
||||||
|
this.canvas.height = h;
|
||||||
|
}
|
||||||
|
gl.viewport(0, 0, w, h);
|
||||||
|
|
||||||
|
this.morph = Math.min(1, this.morph + dt / MORPH_IN);
|
||||||
|
const morph = 1 - (1 - this.morph) ** 3;
|
||||||
|
// The liquid flows with the tempo, faster when the music moves more.
|
||||||
|
this.time += dt * look.flow * (playing ? 0.6 + 0.8 * f.wobble + 0.6 * f.drop : 0.25);
|
||||||
|
const k = 1 - Math.exp(-dt / 0.15);
|
||||||
|
for (let i = 0; i < 4; i++) this.pointer[i] += (this.pointerGoal[i] - this.pointer[i]) * k;
|
||||||
|
|
||||||
|
const u = this.u;
|
||||||
|
gl.uniform1f(u.uTime, this.time);
|
||||||
|
gl.uniform1f(u.uMorph, morph);
|
||||||
|
gl.uniform1f(u.uLevel, (f.sub + f.low) / 2);
|
||||||
|
gl.uniform1f(u.uWobble, f.wobble);
|
||||||
|
gl.uniform1f(u.uKick, f.kick);
|
||||||
|
gl.uniform1f(u.uKickAge, f.kickAge);
|
||||||
|
gl.uniform1f(u.uSnare, f.snare);
|
||||||
|
gl.uniform1f(u.uDrop, f.drop);
|
||||||
|
gl.uniform1f(u.uGain, look.gain);
|
||||||
|
gl.uniform1f(u.uSpike, look.spike);
|
||||||
|
gl.uniform1f(u.uStep, look.step);
|
||||||
|
gl.uniform1f(u.uTwist, look.twist);
|
||||||
|
gl.uniform1f(u.uRipple, look.ripple);
|
||||||
|
gl.uniform1f(u.uShock, look.shock);
|
||||||
|
gl.uniform1f(u.uIri, look.iri);
|
||||||
|
gl.uniform1f(u.uMetal, look.metal);
|
||||||
|
gl.uniform1f(u.uHeat, look.heat);
|
||||||
|
gl.uniform1f(u.uHue, look.hue);
|
||||||
|
gl.uniform3fv(u.uAccent, this.accent);
|
||||||
|
gl.uniform4fv(u.uPointer, this.pointer);
|
||||||
|
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||||
|
gl.bindVertexArray(this.vao);
|
||||||
|
gl.drawElements(gl.TRIANGLES, this.count, gl.UNSIGNED_SHORT, 0);
|
||||||
|
return this.morph < 1 || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step the quality down when frames keep coming in slow.
|
||||||
|
private watch(dt: number): void {
|
||||||
|
this.slow = dt > SLOW_FRAME ? this.slow + dt : Math.max(0, this.slow - dt);
|
||||||
|
if (this.slow < SLOW_FOR || this.quality >= QUALITY.length - 1) return;
|
||||||
|
this.slow = 0;
|
||||||
|
const before = QUALITY[this.quality][0];
|
||||||
|
this.quality++;
|
||||||
|
if (QUALITY[this.quality][0] !== before) this.buildMesh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The artwork cropped square, or a gradient with the artist's initial.
|
||||||
|
async function artwork(src: string | null, initial: string): Promise<HTMLCanvasElement> {
|
||||||
|
const c = document.createElement("canvas");
|
||||||
|
c.width = c.height = TEXTURE_SIZE;
|
||||||
|
const ctx = c.getContext("2d")!;
|
||||||
|
if (src) {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = src;
|
||||||
|
await img.decode();
|
||||||
|
const side = Math.min(img.naturalWidth, img.naturalHeight);
|
||||||
|
ctx.drawImage(img, (img.naturalWidth - side) / 2, (img.naturalHeight - side) / 2, side, side, 0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
|
||||||
|
} else {
|
||||||
|
const g = ctx.createLinearGradient(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
|
||||||
|
g.addColorStop(0, "#2a2a2e");
|
||||||
|
g.addColorStop(1, "#111");
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
|
||||||
|
ctx.fillStyle = "#ffffff66";
|
||||||
|
ctx.font = `700 ${TEXTURE_SIZE * 0.4}px system-ui, sans-serif`;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText(initial, TEXTURE_SIZE / 2, TEXTURE_SIZE / 2);
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function perspective(fov: number, aspect: number, near: number, far: number): Float32Array {
|
||||||
|
const f = 1 / Math.tan(fov / 2);
|
||||||
|
const nf = 1 / (near - far);
|
||||||
|
return new Float32Array([f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) * nf, -1, 0, 0, 2 * far * near * nf, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `hsl(h s% l%)` (as color.ts makes them) or `#rrggbb`, as 0..1 RGB.
|
||||||
|
function parseColor(css: string | null): [number, number, number] | null {
|
||||||
|
if (!css) return null;
|
||||||
|
const hex = css.match(/^#([0-9a-f]{6})$/i);
|
||||||
|
if (hex) {
|
||||||
|
const n = parseInt(hex[1], 16);
|
||||||
|
return [(n >> 16) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
|
||||||
|
}
|
||||||
|
const m = css.match(/hsl\(\s*([\d.]+)\s+([\d.]+)%\s+([\d.]+)%/);
|
||||||
|
if (!m) return null;
|
||||||
|
const h = +m[1] / 360, s = +m[2] / 100, l = +m[3] / 100;
|
||||||
|
const a = s * Math.min(l, 1 - l);
|
||||||
|
const ch = (n: number) => {
|
||||||
|
const k = (n + h * 12) % 12;
|
||||||
|
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
|
||||||
|
};
|
||||||
|
return [ch(0), ch(8), ch(4)];
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Unit icosphere: an icosahedron with each triangle split into four, `levels` times.
|
||||||
|
// Level 5 has 10 242 vertices, which still fits 16-bit indices.
|
||||||
|
|
||||||
|
export interface Mesh {
|
||||||
|
positions: Float32Array<ArrayBuffer>;
|
||||||
|
indices: Uint16Array<ArrayBuffer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function icosphere(levels: number): Mesh {
|
||||||
|
const t = (1 + Math.sqrt(5)) / 2;
|
||||||
|
const verts: number[][] = [
|
||||||
|
[-1, t, 0], [1, t, 0], [-1, -t, 0], [1, -t, 0],
|
||||||
|
[0, -1, t], [0, 1, t], [0, -1, -t], [0, 1, -t],
|
||||||
|
[t, 0, -1], [t, 0, 1], [-t, 0, -1], [-t, 0, 1],
|
||||||
|
].map(unit);
|
||||||
|
let faces = [
|
||||||
|
[0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
|
||||||
|
[1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
|
||||||
|
[3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
|
||||||
|
[4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1],
|
||||||
|
];
|
||||||
|
for (let l = 0; l < levels; l++) {
|
||||||
|
// Each edge is split once, shared by the two faces on it.
|
||||||
|
const mids = new Map<string, number>();
|
||||||
|
const mid = (a: number, b: number) => {
|
||||||
|
const key = a < b ? `${a},${b}` : `${b},${a}`;
|
||||||
|
let i = mids.get(key);
|
||||||
|
if (i === undefined) {
|
||||||
|
const [p, q] = [verts[a], verts[b]];
|
||||||
|
i = verts.push(unit([p[0] + q[0], p[1] + q[1], p[2] + q[2]])) - 1;
|
||||||
|
mids.set(key, i);
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
};
|
||||||
|
faces = faces.flatMap(([a, b, c]) => {
|
||||||
|
const ab = mid(a, b), bc = mid(b, c), ca = mid(c, a);
|
||||||
|
return [[a, ab, ca], [b, bc, ab], [c, ca, bc], [ab, bc, ca]];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { positions: new Float32Array(verts.flat()), indices: new Uint16Array(faces.flat()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function unit([x, y, z]: number[]): number[] {
|
||||||
|
const l = Math.hypot(x, y, z);
|
||||||
|
return [x / l, y / l, z / l];
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// GLSL for the liquid artwork drop. The vertex shader moves a unit sphere with
|
||||||
|
// noise driven by the music; the fragment shader shows the artwork through the
|
||||||
|
// surface, with a rim in the card's accent colour.
|
||||||
|
|
||||||
|
// 3D simplex noise by Ian McEwan and Stefan Gustavson (Ashima Arts), MIT licence:
|
||||||
|
// https://github.com/ashima/webgl-noise
|
||||||
|
const NOISE = /* glsl */ `
|
||||||
|
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||||
|
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||||
|
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 10.0) * x); }
|
||||||
|
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
|
||||||
|
|
||||||
|
float snoise(vec3 v) {
|
||||||
|
const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);
|
||||||
|
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
|
||||||
|
vec3 i = floor(v + dot(v, C.yyy));
|
||||||
|
vec3 x0 = v - i + dot(i, C.xxx);
|
||||||
|
vec3 g = step(x0.yzx, x0.xyz);
|
||||||
|
vec3 l = 1.0 - g;
|
||||||
|
vec3 i1 = min(g.xyz, l.zxy);
|
||||||
|
vec3 i2 = max(g.xyz, l.zxy);
|
||||||
|
vec3 x1 = x0 - i1 + C.xxx;
|
||||||
|
vec3 x2 = x0 - i2 + C.yyy;
|
||||||
|
vec3 x3 = x0 - D.yyy;
|
||||||
|
i = mod289(i);
|
||||||
|
vec4 p = permute(permute(permute(
|
||||||
|
i.z + vec4(0.0, i1.z, i2.z, 1.0))
|
||||||
|
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
|
||||||
|
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
|
||||||
|
float n_ = 0.142857142857;
|
||||||
|
vec3 ns = n_ * D.wyz - D.xzx;
|
||||||
|
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
|
||||||
|
vec4 x_ = floor(j * ns.z);
|
||||||
|
vec4 y_ = floor(j - 7.0 * x_);
|
||||||
|
vec4 x = x_ * ns.x + ns.yyyy;
|
||||||
|
vec4 y = y_ * ns.x + ns.yyyy;
|
||||||
|
vec4 h = 1.0 - abs(x) - abs(y);
|
||||||
|
vec4 b0 = vec4(x.xy, y.xy);
|
||||||
|
vec4 b1 = vec4(x.zw, y.zw);
|
||||||
|
vec4 s0 = floor(b0) * 2.0 + 1.0;
|
||||||
|
vec4 s1 = floor(b1) * 2.0 + 1.0;
|
||||||
|
vec4 sh = -step(h, vec4(0.0));
|
||||||
|
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
|
||||||
|
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
|
||||||
|
vec3 p0 = vec3(a0.xy, h.x);
|
||||||
|
vec3 p1 = vec3(a0.zw, h.y);
|
||||||
|
vec3 p2 = vec3(a1.xy, h.z);
|
||||||
|
vec3 p3 = vec3(a1.zw, h.w);
|
||||||
|
vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
|
||||||
|
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
|
||||||
|
vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
|
||||||
|
m = m * m;
|
||||||
|
return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const VERTEX = /* glsl */ `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
in vec3 position;
|
||||||
|
|
||||||
|
uniform mat4 uProj;
|
||||||
|
uniform float uDist; // camera distance
|
||||||
|
uniform float uTime; // flow time, runs with the tempo
|
||||||
|
uniform float uMorph; // 0 flat disc (the static artwork), 1 liquid
|
||||||
|
// Music, see features.ts.
|
||||||
|
uniform float uLevel, uWobble, uKick, uKickAge, uSnare, uDrop;
|
||||||
|
// Character of the track, see style.ts.
|
||||||
|
uniform float uGain, uSpike, uStep, uTwist, uRipple, uShock;
|
||||||
|
uniform vec4 uPointer; // direction on the sphere, strength
|
||||||
|
|
||||||
|
out vec3 vPos; // view space
|
||||||
|
out vec3 vNormal; // view space
|
||||||
|
out vec2 vFlat; // front projection of the resting sphere, for the artwork
|
||||||
|
|
||||||
|
${NOISE}
|
||||||
|
|
||||||
|
float disp(vec3 p) {
|
||||||
|
vec3 q = p;
|
||||||
|
float a = uTwist * (p.y + 0.3 * sin(uTime)) * (0.5 + uWobble);
|
||||||
|
q.xz = mat2(cos(a), -sin(a), sin(a), cos(a)) * q.xz;
|
||||||
|
float t = uTime;
|
||||||
|
float lobes = snoise(q * 1.1 + vec3(0.0, t * 0.35, t * 0.2)) * (0.05 + 0.12 * uLevel + 0.12 * uDrop);
|
||||||
|
// Terraces with soft edges: stiff, but no torn shards.
|
||||||
|
float steps = lobes * 10.0;
|
||||||
|
lobes = mix(lobes, (floor(steps) + smoothstep(0.3, 0.7, fract(steps))) / 10.0, uStep);
|
||||||
|
float ripples = snoise(q * 3.2 + vec3(t * 1.3, -t, 0.0)) * (0.012 + 0.07 * uWobble) * uRipple;
|
||||||
|
float n = snoise(q * 2.4 - vec3(0.0, t * 0.6, t * 0.9));
|
||||||
|
float spikes = pow(max(n, 0.0), 3.0) * (0.06 + 0.35 * uSnare + 0.15 * uDrop) * uSpike;
|
||||||
|
// After each kick a ring runs from the front of the drop round to the back.
|
||||||
|
float angle = acos(clamp(p.z, -1.0, 1.0));
|
||||||
|
float ring = exp(-pow((angle - uKickAge * 6.0) * 2.5, 2.0)) * exp(-uKickAge * 3.0) * 0.14 * uShock;
|
||||||
|
float swell = uKick * 0.05;
|
||||||
|
float dent = -0.18 * uPointer.w * exp(-pow(distance(p, uPointer.xyz) * 2.2, 2.0));
|
||||||
|
return clamp((lobes + ripples + spikes + ring + swell) * uGain + dent, -0.3, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 shape(vec3 p) {
|
||||||
|
vec3 s = p * (1.0 + disp(p) * uMorph);
|
||||||
|
s.z *= mix(0.04, 1.0, uMorph);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec3 p = position;
|
||||||
|
// Normal from two displaced neighbours along the surface.
|
||||||
|
vec3 t1 = normalize(cross(p, abs(p.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0)));
|
||||||
|
vec3 t2 = cross(p, t1);
|
||||||
|
const float e = 0.012;
|
||||||
|
vec3 s = shape(p);
|
||||||
|
vec3 n = normalize(cross(shape(normalize(p + t1 * e)) - s, shape(normalize(p + t2 * e)) - s));
|
||||||
|
if (dot(n, p) < 0.0) n = -n;
|
||||||
|
vPos = s - vec3(0.0, 0.0, uDist);
|
||||||
|
vNormal = n;
|
||||||
|
vFlat = p.xy;
|
||||||
|
gl_Position = uProj * vec4(vPos, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FRAGMENT = /* glsl */ `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
in vec3 vPos;
|
||||||
|
in vec3 vNormal;
|
||||||
|
in vec2 vFlat;
|
||||||
|
|
||||||
|
uniform sampler2D uArt;
|
||||||
|
uniform vec3 uAccent;
|
||||||
|
uniform float uTime, uMorph, uKick, uDrop;
|
||||||
|
uniform float uHue; // rim hue offset, radians
|
||||||
|
uniform float uIri; // how much the rim hue drifts
|
||||||
|
uniform float uMetal; // sharper, brighter highlights
|
||||||
|
uniform float uHeat; // pulls the rim towards orange
|
||||||
|
|
||||||
|
out vec4 outColor;
|
||||||
|
|
||||||
|
vec3 hueRotate(vec3 c, float a) {
|
||||||
|
const vec3 k = vec3(0.57735);
|
||||||
|
float ca = cos(a);
|
||||||
|
return c * ca + cross(k, c) * sin(a) + k * dot(k, c) * (1.0 - ca);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec3 n = normalize(vNormal);
|
||||||
|
vec3 v = normalize(-vPos);
|
||||||
|
float fres = pow(1.0 - max(dot(n, v), 0.0), 3.0);
|
||||||
|
// Looking through the surface bends the artwork like water does.
|
||||||
|
vec2 bend = n.xy * (0.22 + 0.1 * uDrop) * uMorph;
|
||||||
|
vec2 uv = 0.5 + vec2(vFlat.x, -vFlat.y) * 0.48 - vec2(bend.x, -bend.y) * 0.5;
|
||||||
|
vec2 split = n.xy * (0.004 + 0.02 * uKick) * uMorph;
|
||||||
|
vec3 art = vec3(texture(uArt, uv + split).r, texture(uArt, uv).g, texture(uArt, uv - split).b);
|
||||||
|
art = mix(art, smoothstep(0.0, 1.0, art), 0.6 * uDrop);
|
||||||
|
|
||||||
|
vec3 light = normalize(vec3(-0.5, 0.7, 0.6));
|
||||||
|
float spec = pow(max(dot(n, normalize(light + v)), 0.0), mix(40.0, 140.0, uMetal)) * mix(0.35, 0.8, uMetal);
|
||||||
|
vec3 rim = hueRotate(uAccent, uHue + uIri * (fres * 3.0 + uTime * 0.3));
|
||||||
|
rim = mix(rim, vec3(1.0, 0.35, 0.1), uHeat * 0.6);
|
||||||
|
|
||||||
|
vec3 col = art * mix(1.0, 0.45, fres * uMorph);
|
||||||
|
col += rim * fres * (0.6 + 0.7 * uKick + 0.4 * uDrop) * uMorph;
|
||||||
|
col += spec * uMorph;
|
||||||
|
outColor = vec4(col, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// The character of the drop for one track, from its tempo, tags and key. Each
|
||||||
|
// field is a shader uniform; tune here.
|
||||||
|
|
||||||
|
import type { Track } from "../api";
|
||||||
|
|
||||||
|
export interface Look {
|
||||||
|
/// Flow speed of the noise.
|
||||||
|
flow: number;
|
||||||
|
/// Overall size of the movement.
|
||||||
|
gain: number;
|
||||||
|
/// Spikes on the snare.
|
||||||
|
spike: number;
|
||||||
|
/// 0 smooth, 1 stepped (stiff, quantised) lobes.
|
||||||
|
step: number;
|
||||||
|
twist: number;
|
||||||
|
/// Fast ripples from the wobble.
|
||||||
|
ripple: number;
|
||||||
|
/// Ring that runs over the surface on each kick.
|
||||||
|
shock: number;
|
||||||
|
/// Rim hue drift.
|
||||||
|
iri: number;
|
||||||
|
metal: number;
|
||||||
|
heat: number;
|
||||||
|
/// Rim hue offset from the key, radians.
|
||||||
|
hue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE: Look = { flow: 1, gain: 1, spike: 0.5, step: 0, twist: 0.15, ripple: 1, shock: 1, iri: 0.1, metal: 0.3, heat: 0, hue: 0 };
|
||||||
|
|
||||||
|
const TAGS: Record<string, (l: Look) => void> = {
|
||||||
|
riddim: (l) => Object.assign(l, { step: 0.8, metal: 0.9, shock: 1.4, spike: 0.6, ripple: 0.7 }),
|
||||||
|
tearout: (l) => Object.assign(l, { spike: 1.4, gain: l.gain * 1.25, heat: 0.7 }),
|
||||||
|
deathstep: (l) => Object.assign(l, { spike: 1.5, gain: l.gain * 1.25, heat: 0.9, metal: 0.7 }),
|
||||||
|
melodic: (l) => Object.assign(l, { iri: 0.8, spike: 0.15, gain: l.gain * 0.9, flow: l.flow * 0.8, metal: 0.1 }),
|
||||||
|
colourbass: (l) => Object.assign(l, { iri: 1, spike: 0.25, metal: 0.15 }),
|
||||||
|
dnb: (l) => Object.assign(l, { ripple: 1.6 }),
|
||||||
|
"170": (l) => Object.assign(l, { ripple: 1.5 }),
|
||||||
|
halftime: (l) => Object.assign(l, { flow: l.flow * 0.7, gain: l.gain * 1.15 }),
|
||||||
|
wonk: (l) => Object.assign(l, { twist: 0.9 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function lookFor(t: Track): Look {
|
||||||
|
// About one noise cycle per bar.
|
||||||
|
const l: Look = { ...BASE, flow: ((t.bpm || 140) / 240) * 0.9 };
|
||||||
|
for (const tag of t.tags ?? []) TAGS[tag]?.(l);
|
||||||
|
l.hue = keyHue(t.key);
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PITCH: Record<string, number> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
|
||||||
|
|
||||||
|
/// Keys a fifth apart get neighbouring hues: up to ±26° around the accent colour.
|
||||||
|
function keyHue(key: string | null): number {
|
||||||
|
const m = key?.trim().match(/^([a-g])\s*([#♯b♭]?)\s*(.*)$/i);
|
||||||
|
if (!m) return 0;
|
||||||
|
let pc = PITCH[m[1].toLowerCase()] + (/[#♯]/.test(m[2]) ? 1 : /[b♭]/.test(m[2]) ? -1 : 0);
|
||||||
|
// A minor key sits with its relative major.
|
||||||
|
if (/^(m(?!aj)|min)/i.test(m[3])) pc += 3;
|
||||||
|
const fifths = (((pc * 7) % 12) + 12) % 12;
|
||||||
|
return (fifths / 12 - 0.5) * 0.9;
|
||||||
|
}
|
||||||
+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;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// `?debug=audio`: live bars of what features.ts hears, for tuning its constants by
|
||||||
|
// ear. Loaded only when asked for.
|
||||||
|
|
||||||
|
import type { Features } from "./features";
|
||||||
|
|
||||||
|
const KEYS: (keyof Features)[] = ["sub", "low", "mid", "high", "kick", "snare", "wobble", "drop"];
|
||||||
|
|
||||||
|
/// Onsets counted since the overlay opened; also on `window.audioDebug` for scripts.
|
||||||
|
const counts = { kicks: 0, snares: 0, frames: 0 };
|
||||||
|
|
||||||
|
export function audioDebug(): (f: Features) => void {
|
||||||
|
(window as unknown as { audioDebug: typeof counts }).audioDebug = counts;
|
||||||
|
let lastAge = 99, lastSnare = 0;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.className = "audio-debug";
|
||||||
|
const w = (canvas.width = 240), h = (canvas.height = 124);
|
||||||
|
document.body.append(canvas);
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
ctx.font = "10px system-ui, sans-serif";
|
||||||
|
const bar = w / KEYS.length;
|
||||||
|
return (f) => {
|
||||||
|
counts.frames++;
|
||||||
|
if (f.kickAge < lastAge) counts.kicks++;
|
||||||
|
if (f.snare > lastSnare + 0.5) counts.snares++;
|
||||||
|
lastAge = f.kickAge;
|
||||||
|
lastSnare = f.snare;
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
ctx.fillStyle = "#fff9";
|
||||||
|
ctx.fillText(`kicks ${counts.kicks} · snares ${counts.snares}`, 4, 11);
|
||||||
|
KEYS.forEach((k, i) => {
|
||||||
|
const v = Math.min(1, Math.max(0, f[k]));
|
||||||
|
ctx.fillStyle = k === "kick" || k === "snare" ? "#ff4d6d" : k === "drop" ? "#ffb02e" : "#7fd4ff";
|
||||||
|
ctx.fillRect(i * bar + 3, h - 14 - v * (h - 30), bar - 6, v * (h - 30));
|
||||||
|
ctx.fillStyle = "#fff9";
|
||||||
|
ctx.fillText(k, i * bar + 3, h - 3);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// What the music is doing right now, in a form visuals can move to.
|
||||||
|
//
|
||||||
|
// Loudness alone is useless for bass music: in a dubstep or riddim drop the sub is
|
||||||
|
// loud and steady, so a loudness meter sits at its maximum and nothing moves. The
|
||||||
|
// movement is in the changes, so besides slow band levels this tracks onsets (kick,
|
||||||
|
// snare), the wobble of the mids, and drops (the low end jumping well above where
|
||||||
|
// it has been).
|
||||||
|
//
|
||||||
|
// All constants that shape the feel are here, in one place, for tuning by ear.
|
||||||
|
|
||||||
|
export interface Features {
|
||||||
|
/// Band levels, 0..1, against the recent range of each band. For slow breathing.
|
||||||
|
sub: number;
|
||||||
|
low: number;
|
||||||
|
mid: number;
|
||||||
|
high: number;
|
||||||
|
/// Onset envelopes: 1 on a hit, then decaying.
|
||||||
|
kick: number;
|
||||||
|
snare: number;
|
||||||
|
/// Seconds since the last kick; large when there was none.
|
||||||
|
kickAge: number;
|
||||||
|
/// How much the mids move (wobble bass, growls), 0..1.
|
||||||
|
wobble: number;
|
||||||
|
/// 0..1, high while the low end sits well above its recent average.
|
||||||
|
drop: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BANDS = { sub: [30, 90], low: [90, 250], mid: [250, 2000], high: [2000, 10000] } as const;
|
||||||
|
type Band = keyof typeof BANDS;
|
||||||
|
|
||||||
|
const SILENCE_DB = -110;
|
||||||
|
const CEIL_FALL = 4; // dB/s; a band's ceiling follows its peaks down this slowly
|
||||||
|
const FLOOR_RISE = 3; // dB/s; and its floor follows the quiet parts up
|
||||||
|
const MIN_RANGE = 14; // dB; below this a band counts as flat, not as full scale
|
||||||
|
|
||||||
|
const ONSET_WINDOW = 0.75; // s of flux history the threshold adapts to
|
||||||
|
const ONSET_SIGMA = 1.5; // a hit is this many deviations above the mean flux
|
||||||
|
const ONSET_MIN_FLUX = 1.2; // dB of mean rise per bin; quieter changes are noise
|
||||||
|
const ONSET_REFRACTORY = 0.11; // s
|
||||||
|
const KICK_DECAY = 0.16; // s
|
||||||
|
const SNARE_DECAY = 0.12; // s
|
||||||
|
|
||||||
|
const WOBBLE_SMOOTH = 0.12; // s
|
||||||
|
const DROP_SHORT = 1; // s of low-end loudness …
|
||||||
|
const DROP_LONG = 12; // … against this much
|
||||||
|
const DROP_HEARD_DB = -70; // the low end counts as sounding above this
|
||||||
|
const DROP_RISE_DB = 2.5; // dB above the long average where a drop starts
|
||||||
|
const DROP_FULL_DB = 5; // dB above the start for a full drop
|
||||||
|
const DROP_ATTACK = 0.25; // s
|
||||||
|
const DROP_RELEASE = 2; // s
|
||||||
|
|
||||||
|
const ease = (dt: number, tau: number) => 1 - Math.exp(-dt / tau);
|
||||||
|
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
|
||||||
|
|
||||||
|
/// Maps a band's dB level into its recent range.
|
||||||
|
class Range {
|
||||||
|
private floor = -60;
|
||||||
|
private ceil = -30;
|
||||||
|
|
||||||
|
update(db: number, dt: number): number {
|
||||||
|
this.ceil = db > this.ceil ? db : this.ceil - CEIL_FALL * dt;
|
||||||
|
this.floor = db < this.floor ? db : this.floor + FLOOR_RISE * dt;
|
||||||
|
const span = Math.max(MIN_RANGE, this.ceil - this.floor);
|
||||||
|
return clamp01((db - (this.ceil - span)) / span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spectral flux against its own running mean and spread.
|
||||||
|
class Onset {
|
||||||
|
private mean = 0;
|
||||||
|
private variance = 1;
|
||||||
|
private last = -1;
|
||||||
|
env = 0;
|
||||||
|
age = 99;
|
||||||
|
|
||||||
|
constructor(private decay: number) {}
|
||||||
|
|
||||||
|
update(flux: number, now: number, dt: number): void {
|
||||||
|
const threshold = this.mean + ONSET_SIGMA * Math.sqrt(this.variance);
|
||||||
|
const hit = flux > threshold && flux > ONSET_MIN_FLUX && now - this.last > ONSET_REFRACTORY;
|
||||||
|
const a = ease(dt, ONSET_WINDOW);
|
||||||
|
const d = flux - this.mean;
|
||||||
|
this.mean += a * d;
|
||||||
|
this.variance += a * (d * d - this.variance);
|
||||||
|
this.age += dt;
|
||||||
|
if (hit) {
|
||||||
|
this.last = now;
|
||||||
|
this.env = 1;
|
||||||
|
this.age = 0;
|
||||||
|
} else {
|
||||||
|
this.env *= Math.exp(-dt / this.decay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FeatureTracker {
|
||||||
|
readonly f: Features = { sub: 0, low: 0, mid: 0, high: 0, kick: 0, snare: 0, kickAge: 99, wobble: 0, drop: 0 };
|
||||||
|
private bins: Float32Array<ArrayBuffer> | null = null;
|
||||||
|
private prev: Float32Array<ArrayBuffer> | null = null;
|
||||||
|
private ranges = { sub: new Range(), low: new Range(), mid: new Range(), high: new Range() };
|
||||||
|
private kick = new Onset(KICK_DECAY);
|
||||||
|
private snare = new Onset(SNARE_DECAY);
|
||||||
|
private lastMid = 0;
|
||||||
|
private wobblePeak = 1;
|
||||||
|
private short = SILENCE_DB;
|
||||||
|
private long = SILENCE_DB;
|
||||||
|
/// Seconds of sound heard so far: the long average covers no more than that, so
|
||||||
|
/// music that starts in the middle of a drop is the baseline, not a drop.
|
||||||
|
private heard = 0;
|
||||||
|
|
||||||
|
/// One frame from the analyser.
|
||||||
|
fromAnalyser(an: AnalyserNode, now: number, dt: number): Features {
|
||||||
|
const n = an.frequencyBinCount;
|
||||||
|
if (this.bins?.length !== n) {
|
||||||
|
this.bins = new Float32Array(n);
|
||||||
|
this.prev = new Float32Array(n).fill(SILENCE_DB);
|
||||||
|
}
|
||||||
|
const bins = this.bins, prev = this.prev!;
|
||||||
|
an.getFloatFrequencyData(bins);
|
||||||
|
const hz = an.context.sampleRate / an.fftSize;
|
||||||
|
const bin = (f: number) => Math.min(n - 1, Math.max(1, Math.round(f / hz)));
|
||||||
|
|
||||||
|
const db: Record<Band, number> = { sub: 0, low: 0, mid: 0, high: 0 };
|
||||||
|
const flux: Record<Band, number> = { sub: 0, low: 0, mid: 0, high: 0 };
|
||||||
|
for (const band of Object.keys(BANDS) as Band[]) {
|
||||||
|
const [lo, hi] = BANDS[band];
|
||||||
|
const a = bin(lo), b = Math.max(a, bin(hi));
|
||||||
|
let power = 0, rise = 0;
|
||||||
|
for (let i = a; i <= b; i++) {
|
||||||
|
const v = Math.max(SILENCE_DB, bins[i]);
|
||||||
|
power += 10 ** (v / 10);
|
||||||
|
rise += Math.max(0, v - prev[i]);
|
||||||
|
prev[i] = v;
|
||||||
|
}
|
||||||
|
const count = b - a + 1;
|
||||||
|
db[band] = Math.max(SILENCE_DB, 10 * Math.log10(power / count));
|
||||||
|
flux[band] = rise / count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const f = this.f;
|
||||||
|
for (const band of Object.keys(BANDS) as Band[]) f[band] = this.ranges[band].update(db[band], dt);
|
||||||
|
this.kick.update((flux.sub + flux.low) / 2, now, dt);
|
||||||
|
this.snare.update((flux.mid + flux.high) / 2, now, dt);
|
||||||
|
f.kick = this.kick.env;
|
||||||
|
f.kickAge = this.kick.age;
|
||||||
|
f.snare = this.snare.env;
|
||||||
|
this.track(f.mid, Math.max(db.sub, db.low), dt);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One frame where there is no analyser: only the loudness from the waveform.
|
||||||
|
fromLoudness(level: number, dt: number): Features {
|
||||||
|
const f = this.f;
|
||||||
|
f.sub = f.low = f.mid = f.high = level;
|
||||||
|
f.kick = f.snare = 0;
|
||||||
|
f.kickAge = 99;
|
||||||
|
this.track(level, level > 0 ? 20 * Math.log10(level) : SILENCE_DB, dt);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nothing playing: let everything settle.
|
||||||
|
idle(dt: number): Features {
|
||||||
|
const f = this.f;
|
||||||
|
const k = Math.exp(-dt / 0.35);
|
||||||
|
f.sub *= k; f.low *= k; f.mid *= k; f.high *= k;
|
||||||
|
f.kick *= k; f.snare *= k; f.wobble *= k; f.drop *= k;
|
||||||
|
f.kickAge += dt;
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wobble from how fast the mids move, drop from the low end against its past.
|
||||||
|
private track(mid: number, lowDb: number, dt: number): void {
|
||||||
|
const f = this.f;
|
||||||
|
const speed = Math.abs(mid - this.lastMid) / Math.max(dt, 1 / 240);
|
||||||
|
this.lastMid = mid;
|
||||||
|
this.wobblePeak = Math.max(1, speed, this.wobblePeak * Math.exp(-dt / 4));
|
||||||
|
f.wobble += (clamp01(speed / this.wobblePeak) - f.wobble) * ease(dt, WOBBLE_SMOOTH);
|
||||||
|
|
||||||
|
if (this.heard === 0) {
|
||||||
|
if (lowDb < DROP_HEARD_DB) return;
|
||||||
|
this.short = this.long = lowDb;
|
||||||
|
}
|
||||||
|
this.heard += dt;
|
||||||
|
this.short += (lowDb - this.short) * ease(dt, DROP_SHORT);
|
||||||
|
this.long += (lowDb - this.long) * ease(dt, Math.min(DROP_LONG, DROP_SHORT + this.heard));
|
||||||
|
const target = clamp01((this.short - this.long - DROP_RISE_DB) / DROP_FULL_DB);
|
||||||
|
f.drop += (target - f.drop) * ease(dt, target > f.drop ? DROP_ATTACK : DROP_RELEASE);
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
-9
@@ -5,7 +5,8 @@ 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 { Visualizer } from "./visual";
|
import { Station } from "./station";
|
||||||
|
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
|
||||||
const SKIP_SECONDS = 8; // leaving earlier than this counts as a skip
|
const SKIP_SECONDS = 8; // leaving earlier than this counts as a skip
|
||||||
@@ -34,9 +35,10 @@ 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);
|
const visual = new Visualizer(player, isTouch);
|
||||||
const cards: Card[] = [];
|
const cards: Card[] = [];
|
||||||
let active = -1;
|
let active = -1;
|
||||||
let loading = false;
|
let loading = false;
|
||||||
@@ -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,15 +541,53 @@ 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" });
|
||||||
|
if (input.name === "visuals") setVisuals(input.value as VisualsSetting);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function setVisuals(v: VisualsSetting): void {
|
||||||
|
visual.visuals = v;
|
||||||
|
for (const input of filtersForm.querySelectorAll<HTMLInputElement>("input[name=visuals]")) input.checked = input.value === v;
|
||||||
|
}
|
||||||
|
setVisuals(visual.visuals);
|
||||||
showMode();
|
showMode();
|
||||||
|
|
||||||
filtersBtn.addEventListener("click", () => togglePanel(filtersForm));
|
filtersBtn.addEventListener("click", () => togglePanel(filtersForm));
|
||||||
filtersForm.addEventListener("submit", (e) => { e.preventDefault(); applyFilters(); });
|
filtersForm.addEventListener("submit", (e) => { e.preventDefault(); applyFilters(); });
|
||||||
$("filters-clear").addEventListener("click", () => { filtersForm.reset(); applyFilters(); });
|
$("filters-clear").addEventListener("click", () => {
|
||||||
|
filtersForm.reset();
|
||||||
|
// reset() also clears the mode and visuals radios, which "clear" leaves alone.
|
||||||
|
showMode();
|
||||||
|
setVisuals(visual.visuals);
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
showFilters();
|
showFilters();
|
||||||
|
|
||||||
// Keyboard --------------------------------------------------------------
|
// Keyboard --------------------------------------------------------------
|
||||||
@@ -540,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(); }
|
||||||
@@ -559,6 +619,7 @@ document.addEventListener("keydown", (e) => {
|
|||||||
else if (k === "r" && card) actions.radio(card);
|
else if (k === "r" && card) actions.radio(card);
|
||||||
else if (k === "x" && card) dislike(card);
|
else if (k === "x" && card) dislike(card);
|
||||||
else if (k === "s" && card) share(card);
|
else if (k === "s" && card) share(card);
|
||||||
|
else if (k === "v") { setVisuals(visual.visuals === "liquid" ? "simple" : "liquid"); toast(`${visual.visuals} visuals`); }
|
||||||
else if (k === "m") { player.muted = !player.muted; toast(player.muted ? "muted" : "sound on"); }
|
else if (k === "m") { player.muted = !player.muted; toast(player.muted ? "muted" : "sound on"); }
|
||||||
else if (k === "?") { helpEl.hidden ? showHelp() : hideHelp(); }
|
else if (k === "?") { helpEl.hidden ? showHelp() : hideHelp(); }
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-2
@@ -148,8 +148,8 @@ export class Player {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const analyser = ctx.createAnalyser();
|
const analyser = ctx.createAnalyser();
|
||||||
analyser.fftSize = 1024;
|
analyser.fftSize = 2048;
|
||||||
analyser.smoothingTimeConstant = 0.7;
|
analyser.smoothingTimeConstant = 0.2; // features.ts smooths per feature; this only takes the edge off the FFT
|
||||||
analyser.connect(ctx.destination);
|
analyser.connect(ctx.destination);
|
||||||
for (const d of this.decks) {
|
for (const d of this.decks) {
|
||||||
const gain = ctx.createGain();
|
const gain = ctx.createGain();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
-4
@@ -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;
|
||||||
@@ -226,7 +239,8 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: min(16rem, 42vh, 70vw);
|
width: min(16rem, 42vh, 70vw);
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 14px;
|
/* Round, so the static artwork matches the liquid drop (blob/) it turns into. */
|
||||||
|
border-radius: 50%;
|
||||||
background: #222 center / cover no-repeat;
|
background: #222 center / cover no-repeat;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 20px 60px #000a,
|
0 20px 60px #000a,
|
||||||
@@ -240,6 +254,28 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
|||||||
}
|
}
|
||||||
.initial { font-size: 4rem; font-weight: 700; color: #fff6; }
|
.initial { font-size: 4rem; font-weight: 700; color: #fff6; }
|
||||||
|
|
||||||
|
/* The liquid drop spills past the art slot; taps go through it to the card. */
|
||||||
|
.liquid-canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: -25%;
|
||||||
|
width: 150%;
|
||||||
|
height: 150%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.card.liquid .art { background-image: none !important; background-color: transparent; box-shadow: none; scale: 1; }
|
||||||
|
.card.liquid .initial { display: none; }
|
||||||
|
.paused-badge, .spinner { z-index: 1; }
|
||||||
|
|
||||||
|
.audio-debug {
|
||||||
|
position: fixed;
|
||||||
|
left: 0.7rem;
|
||||||
|
bottom: 0.7rem;
|
||||||
|
z-index: 30;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background: #000b;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.card.paused .art { transform: scale(0.96); filter: brightness(0.7); }
|
.card.paused .art { transform: scale(0.96); filter: brightness(0.7); }
|
||||||
.paused-badge {
|
.paused-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -282,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; }
|
||||||
@@ -450,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;
|
||||||
@@ -530,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 {
|
||||||
|
|||||||
+113
-36
@@ -1,32 +1,103 @@
|
|||||||
// Per-frame visuals of the playing card: waveform progress, and a bass level that
|
// Per-frame visuals of the playing card: waveform progress, the liquid artwork drop
|
||||||
// the stylesheet turns into a pulse of the artwork and a glow (`--energy`, 0..1).
|
// (blob/), and `--energy` (0..1) for the stylesheet's glow and pulse, which is all
|
||||||
// The level comes from the analyser where audio runs through Web Audio, otherwise
|
// there is where the drop is off or WebGL2 is missing.
|
||||||
// from the track's waveform at the current position.
|
//
|
||||||
|
// What drives them comes from features.ts: from the analyser where audio runs through
|
||||||
|
// Web Audio, otherwise from the track's waveform at the current position.
|
||||||
|
|
||||||
|
import { artistImg } from "./api";
|
||||||
|
import { LiquidBlob, SPILL } from "./blob/blob";
|
||||||
|
import { lookFor } from "./blob/style";
|
||||||
import type { Card } from "./cards";
|
import type { Card } from "./cards";
|
||||||
|
import { accentFor } from "./color";
|
||||||
|
import { type Features, FeatureTracker } from "./features";
|
||||||
import type { Player } from "./player";
|
import type { Player } from "./player";
|
||||||
|
|
||||||
|
export type VisualsSetting = "liquid" | "simple";
|
||||||
|
const VISUALS_KEY = "pl8inf.visuals";
|
||||||
|
|
||||||
export class Visualizer {
|
export class Visualizer {
|
||||||
private raf = 0;
|
private raf = 0;
|
||||||
private bins: Uint8Array<ArrayBuffer> | null = null;
|
private last = 0;
|
||||||
private level = 0;
|
|
||||||
private peak = 0.3;
|
|
||||||
private card: Card | null = null;
|
private card: Card | null = null;
|
||||||
|
private features = new FeatureTracker();
|
||||||
|
/// Undefined until first needed; null where WebGL2 failed.
|
||||||
|
private blob: LiquidBlob | null | undefined;
|
||||||
private reduced = matchMedia("(prefers-reduced-motion: reduce)");
|
private reduced = matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
private setting: VisualsSetting = localStorage.getItem(VISUALS_KEY) === "simple" ? "simple" : "liquid";
|
||||||
|
private debug: ((f: Features) => void) | null = null;
|
||||||
|
|
||||||
constructor(private player: Player) {
|
constructor(
|
||||||
|
private player: Player,
|
||||||
|
private touch: boolean,
|
||||||
|
) {
|
||||||
document.addEventListener("visibilitychange", () => (document.hidden ? this.stop() : this.start()));
|
document.addEventListener("visibilitychange", () => (document.hidden ? this.stop() : this.start()));
|
||||||
|
this.reduced.addEventListener("change", () => this.attach(this.card, true));
|
||||||
|
for (const ev of ["pointermove", "pointerdown"]) document.addEventListener(ev, (e) => this.pointer(e as PointerEvent, true), { passive: true });
|
||||||
|
for (const ev of ["pointerup", "pointercancel", "pointerleave"]) document.addEventListener(ev, (e) => this.pointer(e as PointerEvent, false), { passive: true });
|
||||||
|
if (new URLSearchParams(location.search).get("debug") === "audio") {
|
||||||
|
import("./debug").then((m) => { this.debug = m.audioDebug(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get visuals(): VisualsSetting {
|
||||||
|
return this.setting;
|
||||||
|
}
|
||||||
|
|
||||||
|
set visuals(v: VisualsSetting) {
|
||||||
|
this.setting = v;
|
||||||
|
localStorage.setItem(VISUALS_KEY, v);
|
||||||
|
this.attach(this.card, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the liquid drop runs: wanted, motion allowed, and WebGL2 works.
|
||||||
|
private liquid(): LiquidBlob | null {
|
||||||
|
if (this.setting !== "liquid" || this.reduced.matches || this.blob === null) return null;
|
||||||
|
if (this.blob === undefined) {
|
||||||
|
this.blob = LiquidBlob.create(this.touch);
|
||||||
|
if (this.blob) {
|
||||||
|
this.blob.onLost = () => {
|
||||||
|
this.blob?.canvas.remove();
|
||||||
|
this.blob = null;
|
||||||
|
this.card?.el?.classList.remove("liquid");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.blob;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Follow `card` from now on.
|
/// Follow `card` from now on.
|
||||||
attach(card: Card | null): void {
|
attach(card: Card | null, again = false): void {
|
||||||
if (this.card && this.card !== card) this.card.el?.style.setProperty("--energy", "0");
|
if (this.card === card && !again) return;
|
||||||
|
if (this.card && this.card !== card) {
|
||||||
|
this.card.el?.style.setProperty("--energy", "0");
|
||||||
|
this.card.el?.classList.remove("liquid");
|
||||||
|
}
|
||||||
this.card = card;
|
this.card = card;
|
||||||
|
const blob = this.liquid();
|
||||||
|
const slot = card?.el?.querySelector<HTMLElement>(".art");
|
||||||
|
if (!card || !slot || !blob) {
|
||||||
|
this.blob?.canvas.remove();
|
||||||
|
card?.el?.classList.remove("liquid");
|
||||||
|
} else {
|
||||||
|
const img = artistImg(card.track);
|
||||||
|
blob.setAccent(null);
|
||||||
|
if (img) accentFor(img).then((c) => { if (this.card === card) blob.setAccent(c); });
|
||||||
|
blob.mount(slot, lookFor(card.track), img, (card.track.artist_name || "?")[0].toUpperCase()).then((ok) => {
|
||||||
|
if (ok && this.card === card) {
|
||||||
|
card.el?.classList.add("liquid");
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
this.start();
|
this.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (!this.raf && !document.hidden) this.raf = requestAnimationFrame(this.frame);
|
if (!this.raf && !document.hidden) {
|
||||||
|
this.last = 0;
|
||||||
|
this.raf = requestAnimationFrame(this.frame);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
@@ -34,36 +105,42 @@ export class Visualizer {
|
|||||||
this.raf = 0;
|
this.raf = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private frame = () => {
|
/// Pointer over the drop pushes a dent into it.
|
||||||
|
private pointer(e: PointerEvent, on: boolean): void {
|
||||||
|
const blob = this.blob;
|
||||||
|
if (!blob?.canvas.isConnected) return;
|
||||||
|
const r = blob.canvas.getBoundingClientRect();
|
||||||
|
const radius = r.width / SPILL / 2;
|
||||||
|
blob.setPointer((e.clientX - r.left - r.width / 2) / radius, -(e.clientY - r.top - r.height / 2) / radius, on);
|
||||||
|
if (on) this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private frame = (now: number) => {
|
||||||
this.raf = 0;
|
this.raf = 0;
|
||||||
|
const dt = this.last ? Math.min(0.1, (now - this.last) / 1000) : 1 / 60;
|
||||||
|
this.last = now;
|
||||||
const card = this.card;
|
const card = this.card;
|
||||||
const a = this.player.audio;
|
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
|
const a = this.player.audio;
|
||||||
const progress = a.duration ? a.currentTime / a.duration : 0;
|
const progress = a.duration ? a.currentTime / a.duration : 0;
|
||||||
card.drawWave(progress);
|
card.drawWave(progress);
|
||||||
if (!this.reduced.matches) {
|
const playing = !a.paused;
|
||||||
const raw = a.paused ? 0 : this.bass(card, progress);
|
|
||||||
// Fast attack, slow release, against a slowly falling peak: kicks stand out
|
|
||||||
// the same whether a track is mastered loud or quiet.
|
|
||||||
this.level += (raw - this.level) * (raw > this.level ? 0.55 : 0.12);
|
|
||||||
this.peak = Math.max(this.peak * 0.997, this.level, 0.2);
|
|
||||||
const energy = Math.max(0, (this.level / this.peak - 0.35) / 0.65);
|
|
||||||
card.el?.style.setProperty("--energy", energy.toFixed(3));
|
|
||||||
}
|
|
||||||
// Keep running while playing; one last frame after a pause lets the glow fade.
|
|
||||||
if (!a.paused || this.level > 0.01) this.raf = requestAnimationFrame(this.frame);
|
|
||||||
};
|
|
||||||
|
|
||||||
private bass(card: Card, progress: number): number {
|
|
||||||
const an = this.player.analyser;
|
const an = this.player.analyser;
|
||||||
if (!an || !this.player.mixing) return card.loudnessAt(progress) * 0.8;
|
const mixed = !!an && this.player.mixing;
|
||||||
if (this.bins?.length !== an.frequencyBinCount) this.bins = new Uint8Array(an.frequencyBinCount);
|
const f = !playing
|
||||||
an.getByteFrequencyData(this.bins);
|
? this.features.idle(dt)
|
||||||
// Bins up to ~150 Hz: kick and sub.
|
: mixed
|
||||||
const hz = an.context.sampleRate / an.fftSize;
|
? this.features.fromAnalyser(an, now / 1000, dt)
|
||||||
const top = Math.max(2, Math.round(150 / hz));
|
: this.features.fromLoudness(card.loudnessAt(progress), dt);
|
||||||
let sum = 0;
|
this.debug?.(f);
|
||||||
for (let i = 1; i <= top; i++) sum += this.bins[i];
|
let busy = playing;
|
||||||
return sum / top / 255;
|
if (!this.reduced.matches) {
|
||||||
|
// The kick drives the pulse; without an analyser there are no kicks, only loudness.
|
||||||
|
const energy = Math.min(1, mixed ? 0.65 * f.kick + 0.35 * f.low : 0.7 * f.low);
|
||||||
|
card.el?.style.setProperty("--energy", energy.toFixed(3));
|
||||||
|
busy ||= energy > 0.01;
|
||||||
|
if (this.blob && card.el?.classList.contains("liquid")) busy = this.blob.frame(f, dt, playing) || busy;
|
||||||
}
|
}
|
||||||
|
if (busy) this.raf = requestAnimationFrame(this.frame);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user