files: the player's volume — mute, a level slider, remembered (as Ham Share's)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-25 15:43:05 -07:00
co-authored by Claude Opus 5.5
parent 1c5cd2b0de
commit 432ddc0df4
3 changed files with 56 additions and 0 deletions
+31
View File
@@ -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 }) => (
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" fill="currentColor" />
{off ? <path d="M16 9.5l5 5M21 9.5l-5 5" /> : <path d={low ? 'M15.5 9.5a3.5 3.5 0 0 1 0 5' : 'M15.5 9.5a3.5 3.5 0 0 1 0 5M18 7a7 7 0 0 1 0 10'} />}
</svg>
)
// Five seconds back or on: a circling arrow with the 5 inside.
const Skip = ({ back }: { back?: boolean }) => (
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
@@ -239,6 +250,17 @@ export default function Player({ path, sudo }: { path: string; sudo: boolean; na
el.addEventListener('change', commit)
return () => el.removeEventListener('change', commit)
})
// Volume: a level and mute, remembered for the next video. iOS lets a page mute but not set
// the level (it reads back as 1), so there the slider isn't shown.
const [vol, setVol] = useState(() => { try { const v = JSON.parse(localStorage.getItem('hamlab.play.volume') ?? ''); return { level: Math.min(1, Math.max(0, Number(v.level) || 0)), muted: !!v.muted } } catch { return { level: 1, muted: false } } })
useEffect(() => {
const v = video.current
if (v) { v.volume = vol.level; v.muted = vol.muted || vol.level === 0 }
try { localStorage.setItem('hamlab.play.volume', JSON.stringify(vol)) } catch { /* private mode */ }
}, [vol, open])
const volBy = useCallback((d: number) => setVol((x) => ({ level: Math.round(Math.min(1, Math.max(0, x.level + d)) * 20) / 20, muted: false })), [])
const muteToggle = useCallback(() => setVol((x) => (x.muted || x.level === 0 ? { level: x.level || 0.5, muted: false } : { ...x, muted: true })), [])
const seekBy = useCallback((d: number) => {
const v = video.current
if (!v) return
@@ -268,6 +290,9 @@ export default function Player({ path, sudo }: { path: string; sudo: boolean; na
else if (e.key === 'ArrowLeft' || e.key === 'j') { e.preventDefault(); seekBy(e.key === 'j' ? -10 : -5) }
else if (e.key === 'ArrowRight' || e.key === 'l') { e.preventDefault(); seekBy(e.key === 'l' ? 10 : 5) }
else if (e.key === 'f') fullscreen()
else if (e.key === 'm') muteToggle()
else if (e.key === 'ArrowUp') { e.preventDefault(); volBy(0.1) }
else if (e.key === 'ArrowDown') { e.preventDefault(); volBy(-0.1) }
else if (e.key === 'Escape') { if (menu) setMenu(false); else if (full && !document.fullscreenElement) setFull(false) }
}
@@ -349,6 +374,12 @@ export default function Player({ path, sudo }: { path: string; sudo: boolean; na
<button className="vp-icon" onClick={toggle} aria-label={paused ? 'Play' : 'Pause'}>{paused ? <I d={PLAY} fill /> : <I d={PAUSE} />}</button>
<button className="vp-icon" onClick={() => seekBy(-5)} aria-label="Back 5 seconds" title="Back 5 seconds"><Skip back /></button>
<button className="vp-icon" onClick={() => seekBy(5)} aria-label="On 5 seconds" title="On 5 seconds"><Skip /></button>
<span className="vp-vol">
<button className="vp-icon" onClick={muteToggle} aria-label={vol.muted || vol.level === 0 ? 'Unmute' : 'Mute'} title={vol.muted || vol.level === 0 ? 'Unmute (m)' : 'Mute (m)'}><Speaker off={vol.muted || vol.level === 0} low={vol.level < 0.5} /></button>
{canLevel && <input type="range" min={0} max={1} step={0.05} value={vol.muted ? 0 : vol.level} aria-label="Volume"
style={{ ['--v' as string]: `${(vol.muted ? 0 : vol.level) * 100}%` }}
onChange={(e) => { const level = Number(e.target.value); setVol({ level, muted: level === 0 }); poke() }} />}
</span>
<span className="vp-time inrow">{clock(shown)} / {clock(dur)}</span>
<span className="spacer" />
{vr && canMotion() && (