diff --git a/web/src/blob/blob.ts b/web/src/blob/blob.ts index f4d8c93..b0f2794 100644 --- a/web/src/blob/blob.ts +++ b/web/src/blob/blob.ts @@ -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 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. +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 SLOW_FRAME = 0.02; // s; frames slower than this for SLOW_FOR … 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 = [ "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", ] as const; @@ -64,6 +68,11 @@ export class LiquidBlob { private jump = [0, 0]; private pump = [0, 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. static create(touch: boolean): LiquidBlob | null { @@ -146,6 +155,7 @@ export class LiquidBlob { this.morph = 0; this.slow = 0; this.spin.reset(); + this.iconFor = null; const source = await artwork(art, initial).catch(() => artwork(null, initial)); if (gen !== this.gen || this.gl.isContextLost()) return false; const gl = this.gl; @@ -192,6 +202,7 @@ export class LiquidBlob { 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 spinning = this.spin.step(dt); + const showing = this.showIcon(playing, dt); const moving = this.bounce(f, dt, look); const u = this.u; @@ -226,7 +237,7 @@ export class LiquidBlob { 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 || 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. @@ -259,6 +270,28 @@ export class LiquidBlob { 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. private watch(dt: number): void { this.slow = dt > SLOW_FRAME ? this.slow + dt : Math.max(0, this.slow - dt); diff --git a/web/src/blob/shaders.ts b/web/src/blob/shaders.ts index fed077f..a53ed28 100644 --- a/web/src/blob/shaders.ts +++ b/web/src/blob/shaders.ts @@ -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 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 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 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. @@ -103,7 +132,9 @@ 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)); - 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) { @@ -142,9 +173,12 @@ 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; out vec4 outColor; +${ICON} + vec3 hueRotate(vec3 c, float a) { const vec3 k = vec3(0.57735); 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)); 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 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); diff --git a/web/src/pager.ts b/web/src/pager.ts index 3fea14e..4312d2a 100644 --- a/web/src/pager.ts +++ b/web/src/pager.ts @@ -151,6 +151,12 @@ export class Pager { private move(e: PointerEvent): void { const d = this.drag; 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 dy = e.clientY - d.y0; if (!d.active) { diff --git a/web/src/style.css b/web/src/style.css index 7ecd6f5..b632c4d 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -266,18 +266,27 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; height: 200%; pointer-events: none; } -/* The halo stays behind the drop and pulses with it; the text sits on top where - the drop spills over it. */ +/* A halo stays behind the drop and pulses with it; the text sits on top where the + drop spills over it. The drop shows play and pause on itself (blob/shaders.ts). */ .card.liquid .art { z-index: 0; background-image: none !important; 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; } +.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 .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) { .card.liquid .art { cursor: grab; } .card.turning, .card.turning .art { cursor: grabbing; } diff --git a/web/src/visual.ts b/web/src/visual.ts index 47a1dc3..3187ec4 100644 --- a/web/src/visual.ts +++ b/web/src/visual.ts @@ -15,7 +15,7 @@ import type { Player } from "./player"; export type VisualsSetting = "liquid" | "simple"; 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 export class Visualizer { @@ -137,8 +137,8 @@ export class Visualizer { if (on) this.start(); } - // Dragging the drop turns it. On touch only a sideways drag does, as up and - // down turn the page; a mouse can turn it any way. + // Dragging the drop turns it. On touch only a clearly sideways drag does, as up + // and down turn the page; a mouse can turn it any way (the wheel turns pages). private down(e: PointerEvent): void { this.pointer(e, true); @@ -158,16 +158,19 @@ export class Visualizer { this.grab = null; 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; if (!g.on) { 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; return; } g.on = true; - // Keep the moves when the mouse leaves the window. - document.documentElement.setPointerCapture(e.pointerId); this.blob!.spin.hold(true); this.card?.el?.classList.add("turning"); }