Draw a clean play/pause on the drop and make the app installable

The raised icon was cut into a mesh too coarse for its edges and did not line up
with the icon painted over it, so it came out doubled and jagged, and melting one
shape into the other went through blobby in-betweens. The icon is now only painted:
crisp and white on a dark disc like the page's round buttons, still turning with the
drop, and pause and play fade into each other as the new one grows in.

Some browsers only offer to install an app with a service worker. A minimal one at
/sw.js (served from the root so it covers the whole app) caches nothing and only
shows a short offline note instead of the browser's error page. The manifest gets
an id.
This commit is contained in:
nvrl committed 2026-11-01 16:08:59 +01:00
1 parent ca23cf5b7c
commit 065c9980dc
6 files changed
+59 -23

No files matched your search

+6
View File
@@ -294,6 +294,11 @@ async fn static_route(Path(path): Path<String>) -> Response {
static_file(&path) static_file(&path)
} }
/// The service worker, at the root so its scope covers the whole app.
async fn service_worker() -> Response {
static_file("sw.js")
}
async fn healthz() -> Response { async fn healthz() -> Response {
(no_store(), Json(json!({ "ok": true }))).into_response() (no_store(), Json(json!({ "ok": true }))).into_response()
} }
@@ -305,6 +310,7 @@ pub fn router(app: Arc<App>) -> Router {
Router::new() Router::new()
.route("/", get(index)) .route("/", get(index))
.route("/static/{*path}", get(static_route)) .route("/static/{*path}", get(static_route))
.route("/sw.js", get(service_worker))
.route("/healthz", get(healthz)) .route("/healthz", get(healthz))
.route("/impressum", get(legal::impressum)) .route("/impressum", get(legal::impressum))
.route("/datenschutz", get(legal::datenschutz)) .route("/datenschutz", get(legal::datenschutz))
+1
View File
@@ -2,6 +2,7 @@
"name": "pl8list ∞", "name": "pl8list ∞",
"short_name": "pl8 ∞", "short_name": "pl8 ∞",
"description": "Endless scroll through pl8list, tuned to what you like.", "description": "Endless scroll through pl8list, tuned to what you like.",
"id": "/",
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
+18
View File
@@ -0,0 +1,18 @@
// Makes the app installable from the browser (some browsers want a service worker
// for that) and shows a short note instead of the browser's error page when the
// phone is offline. Nothing is cached: audio, artwork and the feed always come
// from the network, and every other request passes straight through.
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim()));
const OFFLINE = `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>pl8list ∞</title></head>
<body bgcolor="#000000" text="#f2f2f2"><p>You are offline. pl8list ∞ needs a connection to play.</p></body></html>`;
self.addEventListener("fetch", (e) => {
if (e.request.mode !== "navigate") return;
e.respondWith(
fetch(e.request).catch(() => new Response(OFFLINE, { headers: { "Content-Type": "text/html; charset=utf-8" } })),
);
});
+2 -2
View File
@@ -26,8 +26,8 @@ const THROB = 0.09; // size swing with the bass line's envelope
const WHIRL = 15; // rad/s of hand spin at which it bulges and sloshes the most const WHIRL = 15; // rad/s of hand spin at which it bulges and sloshes the most
const SNARE_ATTACK = 0.03; // s; spikes grow in rather than pop const SNARE_ATTACK = 0.03; // s; spikes grow in rather than pop
const SNARE_RELEASE = 0.15; // s const SNARE_RELEASE = 0.15; // s
// The play/pause icon: pausing pops up the bars, which melt into the play triangle; // The play/pause icon: pausing shows the bars, which turn into the play triangle;
// playing melts the triangle back into the bars as they fade. // playing turns the triangle back into the bars as they fade.
const ICON_MELT = 0.35; // s the bars show before turning into the triangle const ICON_MELT = 0.35; // s the bars show before turning into the triangle
const ICON_FADE = 0.3; // s the bars show after playing starts const ICON_FADE = 0.3; // s the bars show after playing starts
const TEXTURE_SIZE = 512; const TEXTURE_SIZE = 512;
+27 -21
View File
@@ -54,18 +54,18 @@ float snoise(vec3 v) {
} }
`; `;
// The play/pause icon on the front of the drop, as a distance in units of the radius // The play/pause icon on the front of the drop, as distances in units of the radius
// (negative inside). `shape` 0 is the pause bars, 1 the play triangle; between them // (negative inside), with rounded corners like the page's icons.
// one melts into the other.
const ICON = /* glsl */ ` const ICON = /* glsl */ `
float sdBox(vec2 p, vec2 b) { float sdBox(vec2 p, vec2 b) {
vec2 d = abs(p) - b; vec2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0); return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
} }
// Inigo Quilez's equilateral triangle, MIT licence; turned to point right. // Inigo Quilez's equilateral triangle, MIT licence; turned to point right and
// moved right a little, so it looks centred.
float sdPlay(vec2 p, float r) { float sdPlay(vec2 p, float r) {
p = vec2(-p.y, p.x + 0.08); p = vec2(-p.y, p.x - 0.035);
const float k = sqrt(3.0); const float k = sqrt(3.0);
p.x = abs(p.x) - r; p.x = abs(p.x) - r;
p.y = p.y + r / k; p.y = p.y + r / k;
@@ -74,9 +74,12 @@ float sdPlay(vec2 p, float r) {
return -length(p) * sign(p.y); return -length(p) * sign(p.y);
} }
float icon(vec2 p, float shape) { float sdPause(vec2 p) {
float bars = sdBox(vec2(abs(p.x) - 0.15, p.y), vec2(0.07, 0.24)); return sdBox(vec2(abs(p.x) - 0.1, p.y), vec2(0.035, 0.15)) - 0.025;
return mix(bars, sdPlay(p, 0.24), shape) - 0.03; }
float sdPlayIcon(vec2 p) {
return sdPlay(p, 0.15) - 0.03;
} }
`; `;
@@ -100,15 +103,13 @@ uniform float uJelly; // extra wobble while a jump rings out, 0..1
uniform vec4 uPointer; // direction on the sphere, strength uniform vec4 uPointer; // direction on the sphere, strength
uniform mat3 uRot; // turned by hand, object to view space uniform mat3 uRot; // turned by hand, object to view space
uniform vec3 uFront; // towards the viewer, in object space uniform vec3 uFront; // towards the viewer, in object space
uniform float uIcon; // play/pause, see ICON uniform float uIconA; // how much the play/pause icon shows, see ICON
uniform float uIconA; // how much it shows
out vec3 vPos; // view space out vec3 vPos; // view space
out vec3 vNormal; // view space out vec3 vNormal; // view space
out vec3 vObj; // the resting sphere; its front projection carries the artwork out vec3 vObj; // the resting sphere; its front projection carries the artwork
${NOISE} ${NOISE}
${ICON}
// Every layer keeps a floor, so the drop never goes still and nothing snaps in from // Every layer keeps a floor, so the drop never goes still and nothing snaps in from
// nothing; the music only turns each layer up and down. // nothing; the music only turns each layer up and down.
@@ -132,9 +133,8 @@ float disp(vec3 p) {
float angle = acos(clamp(dot(p, uFront), -1.0, 1.0)); float angle = acos(clamp(dot(p, uFront), -1.0, 1.0));
float wave = exp(-pow((angle - uKickAge * 5.0) * 1.8, 2.0)) * exp(-uKickAge * 2.8) * 0.09 * uShock; float wave = exp(-pow((angle - uKickAge * 5.0) * 1.8, 2.0)) * exp(-uKickAge * 2.8) * 0.09 * uShock;
float dent = -0.18 * uPointer.w * exp(-pow(distance(p, uPointer.xyz) * 2.2, 2.0)); float dent = -0.18 * uPointer.w * exp(-pow(distance(p, uPointer.xyz) * 2.2, 2.0));
// The icon stands out of the front like a stamp, and turns with the drop. // Calmer while the icon shows, so it stays readable.
float stamp = (1.0 - smoothstep(-0.02, 0.05, icon(p.xy, uIcon))) * smoothstep(0.3, 0.6, p.z) * uIconA; return clamp((lobes + sway + ripples + spikes + wave) * uGain + dent, -0.3, 0.4) * (1.0 - 0.6 * uIconA);
return clamp((lobes + sway + ripples + spikes + wave) * uGain + dent, -0.3, 0.4) * (1.0 - 0.6 * uIconA) + 0.09 * stamp;
} }
vec3 shape(vec3 p) { vec3 shape(vec3 p) {
@@ -173,7 +173,8 @@ uniform float uHue; // rim hue offset, radians
uniform float uIri; // how much the rim hue drifts uniform float uIri; // how much the rim hue drifts
uniform float uMetal; // sharper, brighter highlights uniform float uMetal; // sharper, brighter highlights
uniform float uHeat; // pulls the rim towards orange uniform float uHeat; // pulls the rim towards orange
uniform float uIcon, uIconA; uniform float uIcon; // 0 pause, 1 play; between them one fades into the other
uniform float uIconA;
out vec4 outColor; out vec4 outColor;
@@ -214,12 +215,17 @@ void main() {
vec3 env = mix(vec3(0.015), uAccent * 0.5, pow(smoothstep(-0.4, 1.0, r.y), 2.0)); vec3 env = mix(vec3(0.015), uAccent * 0.5, pow(smoothstep(-0.4, 1.0, r.y), 2.0));
env += 0.5 * smoothstep(0.92, 0.985, dot(r, normalize(vec3(-0.5, 0.7, 0.5)))); env += 0.5 * smoothstep(0.92, 0.985, dot(r, normalize(vec3(-0.5, 0.7, 0.5))));
col += env * (0.1 + 0.5 * fres) * uMorph; col += env * (0.1 + 0.5 * fres) * uMorph;
// The icon, crisp per pixel on top of the stamp, with a soft shadow round it. // The icon sits on a dark glass disc, like the page's round buttons, painted onto
float d = icon(vObj.xy, uIcon); // the surface so it turns with the drop. Each shape grows in as it fades in.
float aa = fwidth(d); vec2 ip = vObj.xy;
float front = smoothstep(0.3, 0.6, vObj.z) * uIconA; float front = smoothstep(0.35, 0.6, vObj.z) * uIconA;
col *= 1.0 - 0.5 * front * (1.0 - smoothstep(0.0, 0.12, d)); float disc = length(ip) - 0.36;
col = mix(col, vec3(0.96) + 0.4 * spec, front * (1.0 - smoothstep(-aa, aa, d))); col = mix(col, col * 0.3, front * (1.0 - smoothstep(-0.01, 0.01, disc)));
float playIn = smoothstep(0.0, 1.0, uIcon);
float dPause = sdPause(ip / mix(1.0, 0.7, playIn)), dPlay = sdPlayIcon(ip / mix(0.7, 1.0, playIn));
float ink = max((1.0 - smoothstep(-fwidth(dPause), fwidth(dPause), dPause)) * (1.0 - playIn),
(1.0 - smoothstep(-fwidth(dPlay), fwidth(dPlay), dPlay)) * playIn);
col = mix(col, vec3(0.96), front * ink);
// The very edge fades out, so the drop sits in the page instead of on it. // The very edge fades out, so the drop sits in the page instead of on it.
float a = 1.0 - 0.9 * smoothstep(0.8, 1.0, fres) * uMorph; float a = 1.0 - 0.9 * smoothstep(0.8, 1.0, fres) * uMorph;
outColor = vec4(col * a, a); outColor = vec4(col * a, a);
+5
View File
@@ -39,6 +39,11 @@ const radioEnd = $("radio-end");
const player = new Player(); const player = new Player();
const visual = new Visualizer(player, isTouch); const visual = new Visualizer(player, isTouch);
// Installable as an app; see public/sw.js. The dev server has no /sw.js.
if ("serviceWorker" in navigator && import.meta.env.PROD) {
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch((err) => console.warn("no service worker:", err));
}
const cards: Card[] = []; const cards: Card[] = [];
let active = -1; let active = -1;
let loading = false; let loading = false;