on click, so keyboard
// shortcuts silently stop working after entering fullscreen unless
// we explicitly refocus the wrapper here.
if (active) wrapRef.current?.focus({ preventScroll: true });
};
document.addEventListener("fullscreenchange", onChange);
document.addEventListener("webkitfullscreenchange", onChange);
return () => {
document.removeEventListener("fullscreenchange", onChange);
document.removeEventListener("webkitfullscreenchange", onChange);
};
}, []);
// CSS pseudo-fullscreen fallback (iOS Safari) — lock page scroll and
// reclaim keyboard focus while it's active, restore on exit/unmount.
useEffect(() => {
if (!pseudoFullscreen) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
wrapRef.current?.focus({ preventScroll: true });
return () => { document.body.style.overflow = prevOverflow; };
}, [pseudoFullscreen]);
const resetHideTimer = useCallback(() => {
setControlsVisible(true);
clearTimeout(hideTimer.current);
if (playing) {
hideTimer.current = setTimeout(() => {
setControlsVisible(false);
setSettingsOpen(false);
}, 3000);
}
}, [playing]);
useEffect(() => {
resetHideTimer();
return () => clearTimeout(hideTimer.current);
}, [playing, resetHideTimer]);
const showFeedback = useCallback((icon, label) => {
setKeyFeedback((prev) => ({ icon, label, key: (prev?.key ?? 0) + 1 }));
clearTimeout(keyFeedbackTimer.current);
keyFeedbackTimer.current = setTimeout(() => setKeyFeedback(null), 800);
}, []);
const togglePlay = useCallback(() => {
const v = vidRef.current;
if (!v) return;
if (v.paused) { v.play(); setPlaying(true); setOverlayVisible(false); setEnded(false); }
else { v.pause(); setPlaying(false); setOverlayVisible(true); }
}, []);
// YouTube-style tap-to-reveal: while playing with the bar auto-hidden, the
// first tap only brings the controls back (doesn't pause); a second tap —
// once controls are already visible — toggles play, same as desktop where
// onMouseMove already keeps controls visible ahead of the click.
const handleWrapperClick = useCallback(() => {
// Safari doesn't focus a
on click, which silently kills
// keyboard shortcuts afterwards — force it explicitly.
wrapRef.current?.focus({ preventScroll: true });
if (!controlsVisible) { resetHideTimer(); return; }
togglePlay();
resetHideTimer();
}, [controlsVisible, resetHideTimer, togglePlay]);
const restart = () => {
const v = vidRef.current;
if (!v) return;
v.currentTime = 0;
v.pause();
setPlaying(false);
setOverlayVisible(true);
};
const handleSeek = (e) => {
const v = vidRef.current;
if (!v || !v.duration) return;
v.currentTime = guard.clampSeekTarget((parseFloat(e.target.value) / 100) * v.duration);
};
const handleVolumeChange = (e) => {
const val = parseFloat(e.target.value);
setVolume(val);
if (vidRef.current) { vidRef.current.volume = val; vidRef.current.muted = val === 0; }
setMuted(val === 0);
};
const toggleMute = () => {
const v = vidRef.current;
if (!v) return;
v.muted = !v.muted;
setMuted(v.muted);
};
const toggleFullscreen = () => {
const el = wrapRef.current;
if (!el) return;
if (isFullscreen) {
if (document.fullscreenElement) document.exitFullscreen();
else if (document.webkitFullscreenElement) document.webkitExitFullscreen();
else setPseudoFullscreen(false);
} else if (el.requestFullscreen) {
el.requestFullscreen();
} else if (el.webkitRequestFullscreen) {
// Desktop Safari — supports element fullscreen via the prefixed API.
el.webkitRequestFullscreen();
} else {
// iOS Safari has no element-level Fullscreen API at all.
setPseudoFullscreen(true);
}
};
const handleKeyDown = useCallback((e) => {
if (e.target.tagName === "INPUT") return;
// Fold single-char keys to lowercase so Shift/Caps-Lock doesn't break
// letter shortcuts (e.g. Shift+F reporting as "F", not "f").
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
switch (key) {
case " ": case "k":
e.preventDefault();
togglePlay();
showFeedback(playing ?
:
, playing ? "Pause" : "Play");
resetHideTimer();
break;
case "ArrowRight":
e.preventDefault();
if (vidRef.current) vidRef.current.currentTime = guard.clampSeekTarget(Math.min(vidRef.current.currentTime + 5, vidRef.current.duration));
showFeedback(
, "+5s");
resetHideTimer();
break;
case "ArrowLeft":
e.preventDefault();
if (vidRef.current) vidRef.current.currentTime = Math.max(vidRef.current.currentTime - 5, 0);
showFeedback(
, "-5s");
resetHideTimer();
break;
case "ArrowUp":
e.preventDefault();
if (vidRef.current) {
const nv = Math.min(volume + 0.1, 1);
vidRef.current.volume = nv; setVolume(nv); setMuted(false);
showFeedback(
, `${Math.round(nv * 100)}%`);
}
break;
case "ArrowDown":
e.preventDefault();
if (vidRef.current) {
const nv = Math.max(volume - 0.1, 0);
vidRef.current.volume = nv; setVolume(nv); setMuted(nv === 0);
showFeedback(
, `${Math.round(nv * 100)}%`);
}
break;
case "m":
e.preventDefault();
toggleMute();
showFeedback(muted ?
:
, muted ? "Unmuted" : "Muted");
break;
case "f":
e.preventDefault();
toggleFullscreen();
showFeedback(isFullscreen ?
:
, isFullscreen ? "Exit fullscreen" : "Fullscreen");
break;
case "Escape":
if (isFullscreen) {
e.preventDefault();
toggleFullscreen();
showFeedback(
, "Exit fullscreen");
}
break;
default: break;
}
}, [togglePlay, toggleMute, toggleFullscreen, resetHideTimer, volume, muted, isFullscreen, playing, showFeedback, guard]);
// ── States ────────────────────────────────────────────────────────────────
if (!assetId) {
return (
No video
);
}
if (fetchLoading) {
return
;
}
if (fetchError || !blobUrl) {
return (
);
}
return (
{ if (playing) setControlsVisible(false); }}
onClick={handleWrapperClick}
onKeyDown={handleKeyDown}
onContextMenu={(e) => e.preventDefault()}
>
);
}