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 }) => (