mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
Put a 3D play/pause on the liquid drop and keep page swipes working
The flat play/pause badge and the flash icon sat on top of the drop as two separate symbols. With the drop on, both are gone: the icon is stamped out of the drop's front instead, turns with it and catches the light. Pausing pops up the bars, which melt into the play triangle; playing melts it back into the bars as they fade. On touch only a clearly sideways drag turns the drop now, so diagonal swipes turn the page again. A mouse released outside the window, or after turning the drop, no longer leaves the pager dragging. The halo behind the drop drew a hard ring at the art's edge; it is a soft radial glow now.
This commit is contained in:
1 parent
482cfc299b
commit
ca23cf5b7c
5 files changed
+104
-13
No files matched your search
+35
-2
@@ -26,6 +26,10 @@ 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;
|
||||||
|
// playing melts 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;
|
const TEXTURE_SIZE = 512;
|
||||||
const SLOW_FRAME = 0.02; // s; frames slower than this for SLOW_FOR …
|
const SLOW_FRAME = 0.02; // s; frames slower than this for SLOW_FOR …
|
||||||
const SLOW_FOR = 2; // … seconds step the quality down
|
const SLOW_FOR = 2; // … seconds step the quality down
|
||||||
@@ -34,7 +38,7 @@ const QUALITY: [number, number][] = [[5, 1], [4, 1], [4, 0.6], [3, 0.5]];
|
|||||||
|
|
||||||
const UNIFORMS = [
|
const UNIFORMS = [
|
||||||
"uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uSnare", "uDrop", "uBass", "uGrowl",
|
"uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uSnare", "uDrop", "uBass", "uGrowl",
|
||||||
"uGain", "uSpike", "uStep", "uTwist", "uRipple", "uShock", "uScale", "uLift", "uJelly", "uPointer", "uRot", "uFront",
|
"uGain", "uSpike", "uStep", "uTwist", "uRipple", "uShock", "uScale", "uLift", "uJelly", "uPointer", "uRot", "uFront", "uIcon", "uIconA",
|
||||||
"uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat",
|
"uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -64,6 +68,11 @@ export class LiquidBlob {
|
|||||||
private jump = [0, 0];
|
private jump = [0, 0];
|
||||||
private pump = [0, 0];
|
private pump = [0, 0];
|
||||||
private snare = 0;
|
private snare = 0;
|
||||||
|
/// The icon: its shape (0 pause, 1 play), how much it shows, and since when the
|
||||||
|
/// playing state is what it is. Null until the first frame of a track.
|
||||||
|
private icon = [1, 1];
|
||||||
|
private iconFor: boolean | null = null;
|
||||||
|
private iconAge = 0;
|
||||||
|
|
||||||
/// Null where WebGL2 is not available.
|
/// Null where WebGL2 is not available.
|
||||||
static create(touch: boolean): LiquidBlob | null {
|
static create(touch: boolean): LiquidBlob | null {
|
||||||
@@ -146,6 +155,7 @@ export class LiquidBlob {
|
|||||||
this.morph = 0;
|
this.morph = 0;
|
||||||
this.slow = 0;
|
this.slow = 0;
|
||||||
this.spin.reset();
|
this.spin.reset();
|
||||||
|
this.iconFor = null;
|
||||||
const source = await artwork(art, initial).catch(() => artwork(null, initial));
|
const source = await artwork(art, initial).catch(() => artwork(null, initial));
|
||||||
if (gen !== this.gen || this.gl.isContextLost()) return false;
|
if (gen !== this.gen || this.gl.isContextLost()) return false;
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
@@ -192,6 +202,7 @@ export class LiquidBlob {
|
|||||||
const k = 1 - Math.exp(-dt / 0.15);
|
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;
|
for (let i = 0; i < 4; i++) this.pointer[i] += (this.pointerGoal[i] - this.pointer[i]) * k;
|
||||||
const spinning = this.spin.step(dt);
|
const spinning = this.spin.step(dt);
|
||||||
|
const showing = this.showIcon(playing, dt);
|
||||||
const moving = this.bounce(f, dt, look);
|
const moving = this.bounce(f, dt, look);
|
||||||
|
|
||||||
const u = this.u;
|
const u = this.u;
|
||||||
@@ -226,7 +237,7 @@ export class LiquidBlob {
|
|||||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||||
gl.bindVertexArray(this.vao);
|
gl.bindVertexArray(this.vao);
|
||||||
gl.drawElements(gl.TRIANGLES, this.count, gl.UNSIGNED_SHORT, 0);
|
gl.drawElements(gl.TRIANGLES, this.count, gl.UNSIGNED_SHORT, 0);
|
||||||
return this.morph < 1 || moving || spinning || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0;
|
return this.morph < 1 || moving || spinning || showing || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Step the springs and set the jump uniforms. True while they still move.
|
/// Step the springs and set the jump uniforms. True while they still move.
|
||||||
@@ -259,6 +270,28 @@ export class LiquidBlob {
|
|||||||
return Math.abs(jump[0]) + Math.abs(jump[1]) + Math.abs(pump[1]) > 0.002;
|
return Math.abs(jump[0]) + Math.abs(jump[1]) + Math.abs(pump[1]) > 0.002;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Step the play/pause icon and set its uniforms. True while it still moves.
|
||||||
|
private showIcon(playing: boolean, dt: number): boolean {
|
||||||
|
const icon = this.icon;
|
||||||
|
if (this.iconFor === null) {
|
||||||
|
// A new track: no pop, just what holds now.
|
||||||
|
icon[0] = icon[1] = playing ? 0 : 1;
|
||||||
|
this.iconAge = 1;
|
||||||
|
} else if (this.iconFor !== playing) {
|
||||||
|
this.iconAge = 0;
|
||||||
|
if (!playing) icon[0] = 0;
|
||||||
|
icon[1] = 1;
|
||||||
|
}
|
||||||
|
this.iconFor = playing;
|
||||||
|
this.iconAge += dt;
|
||||||
|
const goal = playing ? [0, this.iconAge < ICON_FADE ? 1 : 0] : [this.iconAge < ICON_MELT ? 0 : 1, 1];
|
||||||
|
const k = 1 - Math.exp(-dt / 0.1);
|
||||||
|
for (let i = 0; i < 2; i++) icon[i] += (goal[i] - icon[i]) * k;
|
||||||
|
this.gl.uniform1f(this.u.uIcon, icon[0]);
|
||||||
|
this.gl.uniform1f(this.u.uIconA, icon[1]);
|
||||||
|
return Math.abs(goal[0] - icon[0]) + Math.abs(goal[1] - icon[1]) > 0.005 || this.iconAge < ICON_MELT;
|
||||||
|
}
|
||||||
|
|
||||||
/// Step the quality down when frames keep coming in slow.
|
/// Step the quality down when frames keep coming in slow.
|
||||||
private watch(dt: number): void {
|
private watch(dt: number): void {
|
||||||
this.slow = dt > SLOW_FRAME ? this.slow + dt : Math.max(0, this.slow - dt);
|
this.slow = dt > SLOW_FRAME ? this.slow + dt : Math.max(0, this.slow - dt);
|
||||||
|
|||||||
+41
-1
@@ -54,6 +54,32 @@ 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.
|
||||||
|
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.
|
||||||
|
float sdPlay(vec2 p, float r) {
|
||||||
|
p = vec2(-p.y, p.x + 0.08);
|
||||||
|
const float k = sqrt(3.0);
|
||||||
|
p.x = abs(p.x) - r;
|
||||||
|
p.y = p.y + r / k;
|
||||||
|
if (p.x + k * p.y > 0.0) p = vec2(p.x - k * p.y, -k * p.x - p.y) / 2.0;
|
||||||
|
p.x -= clamp(p.x, -2.0 * r, 0.0);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const VERTEX = /* glsl */ `#version 300 es
|
export const VERTEX = /* glsl */ `#version 300 es
|
||||||
precision highp float;
|
precision highp float;
|
||||||
|
|
||||||
@@ -74,12 +100,15 @@ 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 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.
|
||||||
@@ -103,7 +132,9 @@ 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));
|
||||||
return clamp((lobes + sway + ripples + spikes + wave) * uGain + dent, -0.3, 0.4);
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
vec3 shape(vec3 p) {
|
vec3 shape(vec3 p) {
|
||||||
@@ -142,9 +173,12 @@ 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;
|
||||||
|
|
||||||
out vec4 outColor;
|
out vec4 outColor;
|
||||||
|
|
||||||
|
${ICON}
|
||||||
|
|
||||||
vec3 hueRotate(vec3 c, float a) {
|
vec3 hueRotate(vec3 c, float a) {
|
||||||
const vec3 k = vec3(0.57735);
|
const vec3 k = vec3(0.57735);
|
||||||
float ca = cos(a);
|
float ca = cos(a);
|
||||||
@@ -180,6 +214,12 @@ 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.
|
||||||
|
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 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);
|
||||||
|
|||||||
@@ -151,6 +151,12 @@ export class Pager {
|
|||||||
private move(e: PointerEvent): void {
|
private move(e: PointerEvent): void {
|
||||||
const d = this.drag;
|
const d = this.drag;
|
||||||
if (!d || e.pointerId !== d.id) return;
|
if (!d || e.pointerId !== d.id) return;
|
||||||
|
// The button came up where we did not see it (outside the window, or while
|
||||||
|
// the drop was being turned).
|
||||||
|
if (e.pointerType === "mouse" && !(e.buttons & 1)) {
|
||||||
|
this.up(e, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dx = e.clientX - d.x0;
|
const dx = e.clientX - d.x0;
|
||||||
const dy = e.clientY - d.y0;
|
const dy = e.clientY - d.y0;
|
||||||
if (!d.active) {
|
if (!d.active) {
|
||||||
|
|||||||
+13
-4
@@ -266,18 +266,27 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
|||||||
height: 200%;
|
height: 200%;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
/* The halo stays behind the drop and pulses with it; the text sits on top where
|
/* A halo stays behind the drop and pulses with it; the text sits on top where the
|
||||||
the drop spills over it. */
|
drop spills over it. The drop shows play and pause on itself (blob/shaders.ts). */
|
||||||
.card.liquid .art {
|
.card.liquid .art {
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
background-image: none !important;
|
background-image: none !important;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
box-shadow: 0 0 calc(30px + var(--energy) * 80px) color-mix(in oklab, var(--card-accent) calc(18% + var(--energy) * 40%), transparent);
|
box-shadow: none;
|
||||||
scale: 1;
|
scale: 1;
|
||||||
}
|
}
|
||||||
|
.card.liquid .art::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -40%;
|
||||||
|
background: radial-gradient(closest-side, color-mix(in oklab, var(--card-accent) calc(20% + var(--energy) * 40%), transparent), transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.card.liquid .content > :not(.art) { position: relative; z-index: 1; }
|
.card.liquid .content > :not(.art) { position: relative; z-index: 1; }
|
||||||
.card.liquid .title { text-shadow: 0 2px 16px #000b; }
|
.card.liquid .title { text-shadow: 0 2px 16px #000b; }
|
||||||
.card.liquid .initial { display: none; }
|
.card.liquid .initial, .card.liquid .paused-badge { display: none; }
|
||||||
|
/* Hidden, not removed: it takes itself away when its animation ends. */
|
||||||
|
.card.liquid .flash { visibility: hidden; }
|
||||||
@media (pointer: fine) {
|
@media (pointer: fine) {
|
||||||
.card.liquid .art { cursor: grab; }
|
.card.liquid .art { cursor: grab; }
|
||||||
.card.turning, .card.turning .art { cursor: grabbing; }
|
.card.turning, .card.turning .art { cursor: grabbing; }
|
||||||
|
|||||||
+9
-6
@@ -15,7 +15,7 @@ import type { Player } from "./player";
|
|||||||
|
|
||||||
export type VisualsSetting = "liquid" | "simple";
|
export type VisualsSetting = "liquid" | "simple";
|
||||||
const VISUALS_KEY = "pl8inf.visuals";
|
const VISUALS_KEY = "pl8inf.visuals";
|
||||||
const SLOP = 8; // px before a press on the drop turns it
|
const SLOP = 10; // px before a press on the drop turns it
|
||||||
const REACH = 1.2; // how far past the art's radius the drop can be grabbed
|
const REACH = 1.2; // how far past the art's radius the drop can be grabbed
|
||||||
|
|
||||||
export class Visualizer {
|
export class Visualizer {
|
||||||
@@ -137,8 +137,8 @@ export class Visualizer {
|
|||||||
if (on) this.start();
|
if (on) this.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dragging the drop turns it. On touch only a sideways drag does, as up and
|
// Dragging the drop turns it. On touch only a clearly sideways drag does, as up
|
||||||
// down turn the page; a mouse can turn it any way.
|
// and down turn the page; a mouse can turn it any way (the wheel turns pages).
|
||||||
|
|
||||||
private down(e: PointerEvent): void {
|
private down(e: PointerEvent): void {
|
||||||
this.pointer(e, true);
|
this.pointer(e, true);
|
||||||
@@ -158,16 +158,19 @@ export class Visualizer {
|
|||||||
this.grab = null;
|
this.grab = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The button came up outside the window.
|
||||||
|
if (e.pointerType === "mouse" && !(e.buttons & 1)) {
|
||||||
|
this.up(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dx = e.clientX - g.x, dy = e.clientY - g.y;
|
const dx = e.clientX - g.x, dy = e.clientY - g.y;
|
||||||
if (!g.on) {
|
if (!g.on) {
|
||||||
if (Math.hypot(dx, dy) < SLOP) return;
|
if (Math.hypot(dx, dy) < SLOP) return;
|
||||||
if (e.pointerType !== "mouse" && Math.abs(dy) >= Math.abs(dx)) {
|
if (e.pointerType !== "mouse" && Math.abs(dx) < 2 * Math.abs(dy)) {
|
||||||
this.grab = null;
|
this.grab = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
g.on = true;
|
g.on = true;
|
||||||
// Keep the moves when the mouse leaves the window.
|
|
||||||
document.documentElement.setPointerCapture(e.pointerId);
|
|
||||||
this.blob!.spin.hold(true);
|
this.blob!.spin.hold(true);
|
||||||
this.card?.el?.classList.add("turning");
|
this.card?.el?.classList.add("turning");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user