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)
}
/// 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 {
(no_store(), Json(json!({ "ok": true }))).into_response()
}
@@ -305,6 +310,7 @@ pub fn router(app: Arc<App>) -> Router {
Router::new()
.route("/", get(index))
.route("/static/{*path}", get(static_route))
.route("/sw.js", get(service_worker))
.route("/healthz", get(healthz))
.route("/impressum", get(legal::impressum))
.route("/datenschutz", get(legal::datenschutz))
+1
View File
@@ -2,6 +2,7 @@
"name": "pl8list ∞",
"short_name": "pl8 ∞",
"description": "Endless scroll through pl8list, tuned to what you like.",
"id": "/",
"start_url": "/",
"scope": "/",
"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 SNARE_ATTACK = 0.03; // s; spikes grow in rather than pop
const SNARE_RELEASE = 0.15; // s
// The play/pause icon: pausing pops up the bars, which melt into the play triangle;
// playing melts the triangle back into the bars as they fade.
// The play/pause icon: pausing shows the bars, which turn into the play triangle;
// 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_FADE = 0.3; // s the bars show after playing starts
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
// (negative inside). `shape` 0 is the pause bars, 1 the play triangle; between them
// one melts into the other.
// The play/pause icon on the front of the drop, as distances in units of the radius
// (negative inside), with rounded corners like the page's icons.
const ICON = /* glsl */ `
float sdBox(vec2 p, vec2 b) {
vec2 d = abs(p) - b;
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) {
p = vec2(-p.y, p.x + 0.08);
p = vec2(-p.y, p.x - 0.035);
const float k = sqrt(3.0);
p.x = abs(p.x) - r;
p.y = p.y + r / k;
@@ -74,9 +74,12 @@ float sdPlay(vec2 p, float r) {
return -length(p) * sign(p.y);
}
float icon(vec2 p, float shape) {
float bars = sdBox(vec2(abs(p.x) - 0.15, p.y), vec2(0.07, 0.24));
return mix(bars, sdPlay(p, 0.24), shape) - 0.03;
float sdPause(vec2 p) {
return sdBox(vec2(abs(p.x) - 0.1, p.y), vec2(0.035, 0.15)) - 0.025;
}
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 mat3 uRot; // turned by hand, object to view space
uniform vec3 uFront; // towards the viewer, in object space
uniform float uIcon; // play/pause, see ICON
uniform float uIconA; // how much it shows
uniform float uIconA; // how much the play/pause icon shows, see ICON
out vec3 vPos; // view space
out vec3 vNormal; // view space
out vec3 vObj; // the resting sphere; its front projection carries the artwork
${NOISE}
${ICON}
// 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.
@@ -132,9 +133,8 @@ float disp(vec3 p) {
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 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.
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) + 0.09 * stamp;
// Calmer while the icon shows, so it stays readable.
return clamp((lobes + sway + ripples + spikes + wave) * uGain + dent, -0.3, 0.4) * (1.0 - 0.6 * uIconA);
}
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 uMetal; // sharper, brighter highlights
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;
@@ -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));
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;
// The icon, crisp per pixel on top of the stamp, with a soft shadow round it.
float d = icon(vObj.xy, uIcon);
float aa = fwidth(d);
float front = smoothstep(0.3, 0.6, vObj.z) * uIconA;
col *= 1.0 - 0.5 * front * (1.0 - smoothstep(0.0, 0.12, d));
col = mix(col, vec3(0.96) + 0.4 * spec, front * (1.0 - smoothstep(-aa, aa, d)));
// The icon sits on a dark glass disc, like the page's round buttons, painted onto
// the surface so it turns with the drop. Each shape grows in as it fades in.
vec2 ip = vObj.xy;
float front = smoothstep(0.35, 0.6, vObj.z) * uIconA;
float disc = length(ip) - 0.36;
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.
float a = 1.0 - 0.9 * smoothstep(0.8, 1.0, fres) * uMorph;
outColor = vec4(col * a, a);
+5
View File
@@ -39,6 +39,11 @@ const radioEnd = $("radio-end");
const player = new Player();
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[] = [];
let active = -1;
let loading = false;