This is the whole build of the liquid glass video player, in the order it was written: the player of a made-up brand, Aether Video. A mountain film plays edge to edge; over its lower edge floats a bar of thick glass that bends the film into its rim. The scrubber is a glass rod with an amber liquid thread inside it, and the playhead is a glass bead: a small lens that shows the frame, and swells into a big one when you hover the rod. The volume is water in a glass tube. Each stage below is the page as it stood at that step. It shows a still, and Run it here starts the live page in its place. The code under each heading is only what that stage adds. The finished source is at the end, and on GitHub under the MIT licence.
There is no framework, no build step and no player library: one HTML page, one style sheet and three ES modules, and
a real <video> element. The glass is one WebGL 2 shader on one canvas; every icon and every word is
real DOM above it, so it stays sharp and accessible.
1 · The film and the layout
Start with the markup: the film, and every control as a real element. The buttons are buttons, the rod is a
role=slider, the tube is a vertical slider. Nothing positions them in CSS; the layout does that in
JavaScript, so the DOM and (later) the glass are placed from the same numbers.
<div class="controls" role="group" aria-label="Video controls">
<button class="dome ui-el" data-k="close" data-dome="close" type="button" aria-label="Close player"></button>
<button class="dome ui-el" data-k="prev" data-dome="prev" type="button" aria-label="Previous film"></button>
<button class="dome ui-el" data-k="play" data-dome="play" type="button" aria-label="Play" aria-pressed="false"></button>
<button class="dome ui-el" data-k="pause" data-dome="pause" type="button" aria-label="Pause" aria-pressed="false"></button>
<button class="dome ui-el" data-k="toggle" data-dome="toggle" type="button" aria-label="Pause"></button>
<button class="dome ui-el" data-k="next" data-dome="next" type="button" aria-label="Next film"></button>
<div id="scrub" class="scrub ui-el" data-k="scrub" role="slider" tabindex="0" aria-label="Seek" aria-valuemin="0" aria-valuemax="7" aria-valuenow="0" aria-valuetext="0 seconds of 7"></div>
<div class="vol ui-el" data-k="vol" role="slider" tabindex="0" aria-label="Volume" aria-orientation="vertical" aria-valuemin="0" aria-valuemax="100" aria-valuenow="60" aria-valuetext="60 percent"><i class="liq" aria-hidden="true"></i></div>
<button class="glyph ui-el" data-k="mute" type="button" aria-label="Unmute" aria-pressed="true"></button>
<button class="dome ui-el" data-k="fs" data-dome="fs" type="button" aria-label="Full screen"></button>
<button class="dome ui-el" data-k="gear" data-dome="gear" type="button" aria-label="Settings"></button>
<p class="lbl time ui-el" data-k="time"><span class="t-now">00:00</span><span class="t-sep"> / </span><span class="t-dur">00:00</span></p>
<p class="lbl title ui-el" data-k="title">Cinematic Mountain Vista</p>
<p class="lbl quality ui-el" data-k="quality">HD 720p</p>
<p class="lbl gear-lbl ui-el" data-k="gearLbl" aria-hidden="true"><b>AETHER</b><br>VIDEO</p>
<p class="lbl t-left ui-el" data-k="elapsed" aria-hidden="true">00:00</p>
<p class="lbl t-right ui-el" data-k="remain" aria-hidden="true">-00:00</p>
</div>
The film opens halfway through and plays muted (browsers only autoplay without sound). With reduced motion it waits on that frame.
// showcase: open halfway through the film, playing (paused on the same frame with reduced motion)
await loadFilm(0, { play: false });
await new Promise((r) => { if (video.duration) r(); else video.addEventListener('loadedmetadata', r, { once: true }); });
video.currentTime = video.duration * 0.5;
await once(video, 'seeked');
if (!st.reduced) { try { await video.play(); } catch { /* stays paused */ } }
A scrubber needs a film that can seek. Without HTTP Range on the server, Chrome reports nothing seekable and setting
currentTime silently does nothing, so the player checks, and falls back to loading the film as a Blob.
async function ensureSource(v, f) {
const want = f.src[rendition()];
if (v.dataset.src === want && v.readyState >= 1) return;
v.dataset.src = want;
v.src = want;
await once(v, 'loadedmetadata');
if (!seekableOK(v)) {
// the server did not answer Range: load the film as a Blob so seeking still works
const blob = await (await fetch(want)).blob();
v.src = URL.createObjectURL(blob);
await once(v, 'loadedmetadata');
v.dataset.mode = 'blob';
if (!seekableOK(v)) throw new Error('film is not seekable even from a Blob');
} else v.dataset.mode = 'range';
}
The layout is a few numbers in one unit, U, that scales with the screen: the bar, its domes, the rod,
the tube and every label.
if (mode === 'bar') {
const x = 30 * U + s.l, w = W - 60 * U - s.l - s.r, h = 140 * U, y = H - 26 * U - s.b - h;
L.slab = { x, y, w, h, r: 28 * U, bevel: 11 * U };
const r1 = y + 50 * U, r2 = y + 107 * U;
D('play', x + 56 * U, r1, 30 * U, 25 * U);
D('pause', x + 56 * U + 76 * U, r1, 30 * U, 23 * U);
D('next', x + 132 * U + 68 * U, r1, 24 * U, 19 * U);
D('gear', x + w - 56 * U, r1, 30 * U, 25 * U);
const tr = 29 * U, tcx = x + w - 56 * U - 30 * U - 30 * U - tr;
L.tube = { cx: tcx, top: y - 58 * U, bottom: y + h + 4 * U, r: tr };
L.rod = { x0: x + 200 * U + 24 * U + 36 * U, x1: tcx - tr - 34 * U, y: r1, r: 9 * U };
L.beadR = [34 * U, 50 * U];
D('fs', L.rod.x1 - 12 * U, r2, 0, 19 * U);
L.labels.mute = { x: L.rod.x1 - 52 * U, y: r2 };
L.labels.time = { x: x + 30 * U, y: r2 };
L.labels.title = { x: (L.rod.x0 + L.rod.x1) / 2, y: r2 };
L.labels.quality = { x: L.rod.x1 - 80 * U, y: r2 };
L.labels.gearLbl = { x: x + w - 56 * U, y: r1 + 36 * U };
L.labels.brand = { x: 32 * U + s.l, y: 28 * U + s.t };
L.labels.brandRight = { x: W - 34 * U - s.r, y: 36 * U + s.t };
L.preview = { w: 214 * U };
}
Every control is placed with a 2D translate rounded to whole device pixels, so the text stays as sharp as static text.
const snap = (v) => Math.round(v * devicePixelRatio) / devicePixelRatio;
function place(el, x, y, extra = '') {
x = snap(x); y = snap(y);
const key = `${x}|${y}|${extra}`;
if (placed.get(el) === key) return;
placed.set(el, key);
el.style.transform = `translate(${x}px, ${y}px)${extra}`;
}
Until the WebGL glass arrives in stage 4, the bar is frosted in CSS. The finished player keeps this as its fallback.
.no-gl .slab { border-radius: var(--r, 26px); background: linear-gradient(180deg, rgba(60, 52, 54, .42), rgba(24, 20, 22, .5));
border: 1px solid rgba(255, 255, 255, .32); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .6), inset 0 -1px 0 rgba(255, 255, 255, .15), 0 14px 32px rgba(0, 0, 0, .35);
-webkit-backdrop-filter: blur(18px) saturate(1.35); backdrop-filter: blur(18px) saturate(1.35); pointer-events: none; }

2 · The controls work
Every film has its own <video>, created and loaded at the start, so ⏭ never waits for a new
source. The switch plays the next film hidden, swaps the two layers in one frame, and only then pauses the old one.
v.muted = true;
if (play) { try { await v.play(); } catch { /* stays paused */ } }
await nextFrame();
if (seq !== loadSeq) return;
video = v;
v.classList.add('on'); prev.classList.remove('on');
await nextFrame();
prev.pause(); prev.muted = true;
applyVolume();
syncPlayState();
The rod: a hover shows the frame under the pointer from the thumbnail strip, and a drag seeks live. Only one seek is in flight at a time; the latest position waits its turn, so a fast drag never piles up seeks.
function scrubSeek(t) { // while dragging: one seek in flight at a time, the latest wins
seekQueued = t;
if (video.seeking) return;
video.currentTime = seekQueued; seekQueued = null;
}
EL.scrub.addEventListener('pointerenter', (e) => { if (e.pointerType === 'mouse') { st.hoverX = localX(e); st.peek = false; kick(); } });
EL.scrub.addEventListener('pointermove', (e) => {
if (e.pointerType === 'mouse' || st.dragging) { st.hoverX = clamp(localX(e), st.L.rod.x0, st.L.rod.x1); kick(); }
if (st.dragging) {
// a real drag starts: hold the film while scrubbing (a plain click never pauses, never seeks twice)
if (!st.moved && Math.abs(st.hoverX - st.downX) > 3) { st.moved = true; if (st.wasPlaying) video.pause(); }
if (st.moved) scrubSeek(xTime(st.hoverX));
}
});
The tube is a slider too: drag the water, roll the wheel, or use the arrow keys.
const tubeLevelAt = (e) => { const L = st.L, r = root.getBoundingClientRect(); const y = e.clientY - r.top;
const top = L.tube.top + L.tube.r * 0.9, bot = L.tube.bottom - L.tube.r * 0.5; return clamp((bot - y) / (bot - top), 0, 1); };
let volDrag = false;
EL.vol.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; e.preventDefault(); EL.vol.setPointerCapture(e.pointerId); EL.vol.focus({ preventScroll: true, focusVisible: false }); volDrag = true; setVolume(tubeLevelAt(e)); });
EL.vol.addEventListener('pointermove', (e) => { if (volDrag) setVolume(tubeLevelAt(e)); });
const volEnd = () => { if (volDrag) { volDrag = false; say(`Volume ${Math.round(st.volume * 100)} percent`); } };
EL.vol.addEventListener('pointerup', volEnd); EL.vol.addEventListener('pointercancel', volEnd);
EL.vol.addEventListener('wheel', (e) => { e.preventDefault(); setVolume(st.volume - Math.sign(e.deltaY) * 0.05); }, { passive: false });
The film started muted, so the first click or key anywhere on the page turns the sound on, unless that click was the mute control itself.
const firstGesture = (e) => {
if (!st.autoMuted || !e.isTrusted) return;
if (e.target.closest && e.target.closest('[data-k="mute"]')) return;
if (e.type === 'keydown' && ['Tab', 'Shift', 'Alt', 'Meta', 'Control', 'Escape', 'm', 'M'].includes(e.key)) return;
st.autoMuted = false; st.muted = false; applyVolume();
};
document.addEventListener('pointerdown', firstGesture, true);
document.addEventListener('keydown', firstGesture, true);
switch (k) {
case ' ': case 'k': case 'K': e.preventDefault(); togglePlay(); say(video.paused ? 'Paused' : 'Playing'); break;
case 'ArrowRight': e.preventDefault(); seek(video.currentTime + seekStep(), { announce: true }); break;
case 'ArrowLeft': e.preventDefault(); seek(video.currentTime - seekStep(), { announce: true }); break;
case 'ArrowUp': e.preventDefault(); setVolume((st.muted ? 0 : st.volume) + 0.05, { announce: true }); break;
case 'ArrowDown': e.preventDefault(); setVolume((st.muted ? 0 : st.volume) - 0.05, { announce: true }); break;
case 'm': case 'M': setMuted(!st.muted); break;
case 'n': nextFilm(1); break;
case 'N': nextFilm(-1); break;
case 'Home': e.preventDefault(); seek(0, { announce: true }); break;
case 'End': e.preventDefault(); seek(d, { announce: true }); break;
case ',': if (video.paused) seek(video.currentTime - 1 / (film().fps || 24)); break;
case '.': if (video.paused) seek(video.currentTime + 1 / (film().fps || 24)); break;
default:
if (/^[0-9]$/.test(k)) seek(d * (+k) / 10, { announce: true });
}

3 · Full screen, settings and the phone
Full screen is the real Fullscreen API, with a CSS fallback where it is missing (an iPhone in Safari).
async function toggleFs() {
closeMenu(false);
const enter = !st.fs;
if (enter) {
const req = root.requestFullscreen || root.webkitRequestFullscreen;
try { if (!req) throw 0; await req.call(root, { navigationUI: 'hide' }); return; }
catch { st.pseudoFs = true; setFs(true); }
} else {
if (fsEl()) { try { await (document.exitFullscreen || document.webkitExitFullscreen).call(document); return; } catch { /* fall through */ } }
st.pseudoFs = false; setFs(false);
}
}
In full screen the bar becomes a vertical rail on the right: the same domes, laid out again. The tube stands beside it and the scrubber becomes a slim thread along the bottom.
const w = 84 * U, h = 364 * U, x = W - 30 * U - w - s.r, y = (H - h) / 2;
L.slab = { x, y, w, h, r: w / 2, bevel: 11 * U };
['play', 'pause', 'next', 'fs', 'gear'].forEach((k, i) => D(k, x + w / 2, y + 42 * U + i * 70 * U, 27 * U, (k === 'fs' ? 20 : 22) * U));
const tr = 25 * U;
L.tube = { cx: x - 22 * U - tr, top: y + h - 190 * U, bottom: y + h, r: tr };
const ry = H - 54 * U - s.b;
L.rod = { x0: 48 * U + s.l + 126 * U, x1: W - 160 * U - s.r, y: ry, r: 6 * U };
L.beadR = [19 * U, 40 * U];
L.labels.time = { x: 48 * U + s.l, y: ry };
L.labels.title = { x: 48 * U + s.l, y: ry - 46 * U };
L.labels.quality = { x: L.rod.x1 + 24 * U, y: ry };
L.labels.mute = { x: W - 52 * U - s.r, y: ry };
After 2.5 seconds without input everything fades and the cursor hides; any movement brings it back.
function resetIdle() {
clearTimeout(idleTimer);
if (st.idle) { st.idle = false; root.classList.remove('is-idle'); G.vis.t = 1; kick(); }
if (st.fs && !video.paused) idleTimer = setTimeout(goIdle, 2500);
}
function goIdle() {
if (!st.fs || video.paused || st.menuOpen || st.dragging || root.querySelector('.controls :focus-visible')) return;
st.idle = true; root.classList.add('is-idle'); G.vis.t = 0; kick();
}
On a phone a tap shows or hides the controls, and a double tap on the left or right third seeks a second.
const side = e.clientX < st.W / 3 ? -1 : e.clientX > st.W * 2 / 3 ? 1 : 0;
const now = performance.now();
if (side && lastTap.side === side && now - lastTap.t < 320) {
clearTimeout(tapTimer); seek(video.currentTime + side * seekStep(), { announce: true }); lastTap.t = 0; return;
}
lastTap = { t: now, side };
clearTimeout(tapTimer);
tapTimer = setTimeout(() => { st.tapHidden = !st.tapHidden; root.classList.toggle('is-idle', st.tapHidden); G.vis.t = st.tapHidden ? 0 : 1; kick(); }, side ? 300 : 0);
The gear opens a settings menu: quality, speed, loop and sound. The choices are saved, and every storage access is wrapped, so a blocked storage just forgets.
const store = {
get(k, d) { try { const v = localStorage.getItem(KEY + k); return v === null ? d : JSON.parse(v); } catch { return d; } },
set(k, v) { try { localStorage.setItem(KEY + k, JSON.stringify(v)); } catch { /* private mode */ } },
};

4 · The glass, in WebGL
One canvas over the film draws all the glass. It needs the film as a texture: each new decoded frame is uploaded once, with mipmaps, so frosting is just a blurrier mip level.
uploadVideo(video) {
const gl = this.gl; if (!this.ok || video.readyState < 2) return;
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.tVid);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video);
gl.generateMipmap(gl.TEXTURE_2D);
this.vidW = video.videoWidth; this.vidH = video.videoHeight; this.vidReady = true;
}
New frames come from requestVideoFrameCallback, so the glass uploads exactly when the film has a frame,
never in between.
if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
for (const v of vids) { const onFrame = () => { if (v === video) { frameDirty = true; kick(); } v.requestVideoFrameCallback(onFrame); }; v.requestVideoFrameCallback(onFrame); }
} else for (const v of vids) v.addEventListener('timeupdate', () => { if (v === video) { frameDirty = true; kick(); } });
Every piece of glass is a signed-distance shape lifted into a height field, so each pixel has a real normal. The bar's bevel bends the film outside it into the rim; the body is frosted.
// the bevel bends the outside film into the rim (thick glass); the body is frosted
vec3 sharp = film(p - n.xy * B * 1.9 * edge - g * 2. * uDpr, 0.8);
vec3 fr = frost(p - n.xy * B * 0.5, 4.0);
vec3 c = mix(fr * 0.47 + vec3(0.028, 0.025, 0.03), sharp * 0.92 + 0.04, smoothstep(0.08, 0.7, edge));
float vy = clamp((lp.y + uSlab.w) / (2. * uSlab.w), 0., 1.);
c += vec3(0.05, 0.048, 0.052) * (1. - vy) * hh;
The bead is a ball lens: the frame stands upright inside it, magnified in the middle and squeezed towards the rim. While you hover, it shows the thumbnail-strip frame under the pointer instead of the live film.
// ball lens: the frame stands upright, magnified in the middle and compressed towards the rim
vec2 q = (p - uBead.xy) / uBead.z;
vec2 s = q * (0.42 + 0.4 * dot(q, q)) + n.xy * 0.18;
vec2 live = vec2(0.5) + s * uBeadAsp;
vec2 cell = mix(uCell.xy, uCell.zw, clamp(vec2(0.5) + s * uBeadAsp * vec2(1.0, 1.0), 0.03, 0.97));
vec3 img = mix(textureLod(uVid, live, uBeadLod).rgb, textureLod(uSpr, cell, max(uBeadLod - 1.4, 0.)).rgb, uBeadSrc);
// the played part: an amber liquid thread glowing inside the rod
vec3 liq = mix(vec3(0.95, 0.5, 0.24), vec3(1.0, 0.86, 0.66), z * z) * (0.5 + 0.48 * z);
liq += 0.5 * exp(-pow((dv.y + 0.5) / 0.15, 2.));
c = mix(c, liq, played * 0.9);
The tube's water is a cylinder of glass: it flips and magnifies the film behind it, with a cool tint, and its surface is a wave.
// surface of the water
float ys = uLiq.x + uLiq.y * sin(u * 2.4 + uLiq.z) + uLiq.y * 0.4 * sin(u * 5.1 - uLiq.z * 1.7) + uLiq.w * u * r;
float inLiq = smoothstep(ys - 0.8, ys + 0.8, p.y);
// empty glass: nearly clear, slight lensing
vec3 air = film(p - vec2(u * r * 0.22, 0.), 0.6) * 0.9 + 0.035;
// water: the cylinder flips and magnifies the film horizontally, cool tint
vec3 wat = film(vec2(uTube.x - u * r * 1.35, p.y - r * 0.6), 1.4);
wat = wat * vec3(0.8, 0.92, 1.0) * 0.95 + vec3(0.12, 0.15, 0.19);
float depth = clamp((p.y - ys) / max(uTube.z - ys, 1.), 0., 1.);
wat *= 1. - 0.12 * depth;
vec3 c = mix(air, wat, inLiq);
The canvas covers only the band the glass can reach, never the whole screen.
function sizeCanvas() {
if (!glass || !st.L) return;
const L = st.L, W = st.W, H = st.H;
let top = 0;
if (L.mode === 'bar') top = Math.max(0, Math.floor(Math.min(L.tube ? L.tube.top : H, L.rod.y - L.beadR[1] * 1.6, L.slab.y) - 40));
canvas.style.top = top + 'px'; canvas.style.height = (H - top) + 'px';
glass.resize(W, H - top, devicePixelRatio, 0, top);
}

5 · The glass moves
One kind of spring moves everything: the bar, the domes, the rod, the tube and the labels. The DOM and the glass read the same springs every frame, so they never disagree.
class Sp {
constructor(v = 0, k = 170, z = 0.78) { this.v = v; this.t = v; this.vel = 0; this.k = k; this.c = 2 * Math.sqrt(k) * z; }
step(h) { this.vel += (this.k * (this.t - this.v) - this.c * this.vel) * h; this.v += this.vel * h; }
get rest() { return Math.abs(this.t - this.v) < 0.02 && Math.abs(this.vel) < 0.05; }
snap() { this.v = this.t; this.vel = 0; }
}
The bead becomes a drop of liquid glass. Its head springs to the pointer, a tail springs after the head, and the shader joins them with a neck; the faster it moves, the more it stretches.
const kH = st.dragging ? 900 : 330, cH = 2 * Math.sqrt(kH) * (st.dragging ? 0.8 : 0.6), kT = 150, cT = 2 * Math.sqrt(kT) * 0.78;
for (let i = 0; i < sub; i++) {
bead.vx += (kH * (tx - bead.x) - cH * bead.vx) * h; bead.vy += (kH * (ty - bead.y) - cH * bead.vy) * h;
bead.x += bead.vx * h; bead.y += bead.vy * h;
bead.tvx += (kT * (bead.x - bead.tx) - cT * bead.tvx) * h; bead.tvy += (kT * (bead.y - bead.ty) - cT * bead.tvy) * h;
bead.tx += bead.tvx * h; bead.ty += bead.tvy * h;
const spd = Math.hypot(bead.vx, bead.vy);
const sT = clamp(spd / 2400, 0, 0.3);
bead.sv += (420 * (sT - bead.s) - 9 * bead.sv) * h; bead.s += bead.sv * h;
}
The water sloshes against every change of level and keeps a slow breath.
wave.amp = wave.amp * Math.exp(-dt * 1.6) + Math.abs(G.level.vel) * 0.8 * dt * 60 * 0.05;
wave.amp = Math.min(wave.amp, 9);
wave.phase += dt * (2.0 + Math.min(wave.amp, 6) * 0.5);
G.tilt.t = clamp(-G.level.vel * 0.9, -0.5, 0.5);
Full screen settles the film from a slightly smaller rounded pane to the edges, with a glass rim riding its edge.
function transitionState(now) {
if (!trans) return null;
const k = clamp((now - trans.t0) / trans.dur, 0, 1);
const e = 1 - Math.pow(1 - k, 3);
const sc = lerp(0.9, 1, e) + Math.sin(k * Math.PI) * 0.012;
const rad = 30 * (st.L ? st.L.U : 1) * (1 - e);
if (k >= 1) { trans = null; stage.style.transform = ''; stage.style.borderRadius = ''; sizeCanvas(); return null; }
stage.style.transform = `scale(${sc})`;
stage.style.borderRadius = rad / sc + 'px';
return { sc, rad, a: 1 - k * k };
}
Switching films dissolves through a veil: the outgoing frame is copied on the GPU and faded out in WebGL, because two
<video> layers cross-fading in CSS made Chrome drop frames.
snapshot() {
const gl = this.gl; if (!this.ok || !this.vidReady) return false;
gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.tVid, 0);
gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, this.tOld);
gl.copyTexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 0, 0, this.vidW, this.vidH, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return true;
}

6 · The finish
A <video>'s own audio costs a dropped frame on every seek, play and switch on a slow phone. So the
films stay muted for good, and the soundtrack plays through Web Audio from a decoded buffer, restarted at the picture's
position whenever the picture jumps.
function soundSync(force = false) {
if (!sound.ok) return;
const buf = sound.ready[st.film];
const want = !!buf && !st.muted && !video.paused && !video.ended && !video.seeking && sound.ctx.state === 'running';
if (sound.src && (!want || force || sound.film !== st.film)) { try { sound.src.stop(); } catch { /* ended */ } sound.src.disconnect(); sound.src = null; }
if (!want || sound.src) return;
const s = sound.ctx.createBufferSource();
s.buffer = buf; s.loop = st.loop; s.loopEnd = Math.min(buf.duration, video.duration || buf.duration);
s.playbackRate.value = st.speed; s.connect(sound.gain);
sound.off = Math.min(video.currentTime, buf.duration - 0.01); sound.t0 = sound.ctx.currentTime; sound.rate = st.speed; sound.film = st.film;
s.start(0, sound.off);
sound.src = s;
}
Last, the saved keys are versioned (dv59a.*), and anything older is wiped before it can confuse the page.
try { for (const k of Object.keys(localStorage)) if (k.startsWith('dv59') && !k.startsWith(KEY)) localStorage.removeItem(k); } catch { /* */ }


Make it yours
- Your films go in
media/, with their posters and thumbnail strips;media/manifest.jslists them (title, length, renditions, the strip's grid). Any number of films works. - Your glass is
glass.js: the tint of the bar's body, the colour of the liquid thread and the water. - Accessibility is already there: real buttons, a seek slider and a volume slider with keys and value text, a
live region for what changed, visible focus rings, 44 px targets,
prefers-reduced-motionhonoured, and a CSS frosted fallback without WebGL 2.
The code is on GitHub at UlviShabanbayli/domvybe-liquid-video, under the MIT licence: use it in your own projects, commercial ones included. Aether Video is made up; the two films were generated with Google Veo, and everything else was made in code for this project.
Put the DOM and the glass on the same springs. Every control is a real element placed from the same numbers the shader draws from, so the glass you see and the button you press are always in the same place, even mid-animation.