diff --git a/web/scripts/e2e-player.mjs b/web/scripts/e2e-player.mjs index 5576a40..fb5faae 100644 --- a/web/scripts/e2e-player.mjs +++ b/web/scripts/e2e-player.mjs @@ -95,6 +95,22 @@ await page.screenshot({ path: `${SHOTS}/h1-auto.png` }) await page.evaluate((t) => t ? document.documentElement.setAttribute('data-theme', t) : document.documentElement.removeAttribute('data-theme'), was) } +// Volume: the speaker mutes and unmutes, the slider sets the level, and it is remembered. +{ + await controls() + const vs = () => page.evaluate(() => { const v = document.querySelector('.vp video'); return { muted: v.muted, volume: Math.round(v.volume * 100) / 100 } }) + await page.getByRole('button', { name: 'Mute' }).click() + const a = await vs() + await page.getByRole('button', { name: 'Unmute' }).click() + const b = await vs() + let c = b + if (await page.getByLabel('Volume').count()) { await page.getByLabel('Volume').fill('0.3'); c = await vs() } + const kept = await page.evaluate(() => Object.entries(localStorage).find(([k]) => /volume/.test(k))?.[1] ?? '') + check('volume: mute, unmute, level, remembered', a.muted && !b.muted && (c.volume === 0.3 || !(await page.getByLabel('Volume').count())) && /level/.test(kept), + `${JSON.stringify(a)} → ${JSON.stringify(b)} → ${JSON.stringify(c)}, kept ${kept}`) + if (await page.getByLabel('Volume').count()) await page.getByLabel('Volume').fill('1') +} + // The 5-second buttons: back and on, the ball with them. { await controls() diff --git a/web/src/Player.tsx b/web/src/Player.tsx index 150b0d3..68dc5e6 100644 --- a/web/src/Player.tsx +++ b/web/src/Player.tsx @@ -53,6 +53,9 @@ class PatientAbr extends Base { } } +// Whether the browser lets a page set the volume level (iOS doesn't: only mute). +const canLevel = (() => { try { const a = document.createElement('audio'); a.volume = 0.5; return a.volume === 0.5 } catch { return false } })() + const mbps = (b: number) => `${b >= 10e6 ? Math.round(b / 1e6) : (b / 1e6).toFixed(1)} Mb/s` const clock = (sec: number) => { const t = Math.max(0, Math.floor(sec)), h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), x = t % 60; return `${h ? `${h}:${String(m).padStart(2, '0')}` : m}:${String(x).padStart(2, '0')}` } const size = (w?: number, h?: number, vr?: boolean) => { @@ -68,6 +71,14 @@ const I = ({ d, size: sz = 20, fill }: { d: string; size?: number; fill?: boolea const PLAY = 'M7 4.5v15l12.5-7.5z', PAUSE = 'M8 5v14M16 5v14', CHECK = 'M5 12.5l4.5 4.5L19 7.5', TUNE = 'M4 7h10M18 7h2M4 17h4M12 17h8M16 5v4M10 15v4' const FULL = 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5', SMALL = 'M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5', PHONE = 'M8 3h8a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM3 9a9 9 0 0 0 0 6M21 9a9 9 0 0 1 0 6' +// The speaker: waves by the level, a cross when muted. +const Speaker = ({ off, low }: { off?: boolean; low?: boolean }) => ( + +) + // Five seconds back or on: a circling arrow with the 5 inside. const Skip = ({ back }: { back?: boolean }) => ( : } + + + {canLevel && { const level = Number(e.target.value); setVol({ level, muted: level === 0 }); poke() }} />} + {clock(shown)} / {clock(dur)} {vr && canMotion() && ( diff --git a/web/src/styles.css b/web/src/styles.css index b08e8e6..680e586 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -988,3 +988,12 @@ html:has(.vp)::before, html:has(.vp)::after, body:has(.vp)::before, body:has(.vp .vp video, .vp canvas { filter: none !important; mix-blend-mode: normal !important; } .vp { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-weight: 400; letter-spacing: normal; text-shadow: none; } .vp button, .vp input, .vp select { font-family: inherit !important; } + +/* volume: the speaker mutes; the slider beside it (not on iOS, which only mutes) sets the level */ +.vp-vol { display: inline-flex; align-items: center; } +.vp-vol input { width: 72px; height: 24px; margin: 0 6px 0 0; padding: 0; background: transparent; border: 0; -webkit-appearance: none; appearance: none; cursor: pointer; } +.vp-vol input::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: linear-gradient(to right, #fff var(--v), rgba(255, 255, 255, .3) var(--v)); } +.vp-vol input::-moz-range-track { height: 4px; border-radius: 2px; background: linear-gradient(to right, #fff var(--v), rgba(255, 255, 255, .3) var(--v)); } +.vp-vol input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; margin-top: -4px; border-radius: 50%; background: #fff; border: 0; } +.vp-vol input::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: #fff; border: 0; } +@media (max-width: 560px) { .vp-vol input { display: none; } }