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:
co-authored by
Claude Opus 5.5
parent
1c5cd2b0de
commit
432ddc0df4
@@ -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() && (
|
||||
|
||||
Reference in New Issue
Block a user