feat: YouTube's keyboard shortcuts in the player

This is a video player, so the keys fingers already know should work.

  space / k   play, pause
  j / l       ten seconds back, forward
  ← / →       five seconds
  ↑ / ↓       volume
  0–9         jump to that tenth of the video
  Home / End  the ends
  m           mute
  f           fullscreen
  i           Picture in Picture, YouTube's miniplayer
  c           subtitles on or off
  , / .       one frame back or forward, while paused
  shift , / . slower, faster
  shift N / P next, previous video
  Escape      leave fullscreen, then close the player

Speed gets a chip in the transport bar when it is not 1×, since nothing
else there would say so, and clicking it goes back to normal. The other
buttons now name their key in the tooltip.

Arrow keys are taken from a focused slider, which would otherwise seek
by a tenth of a second rather than five. Typing in a field is left
alone, as are the system's own modifier combinations. A frame is taken
as a thirtieth of a second, since a video element will not say what its
frame rate is.

Verified in the running app: 5 jumped to exactly half, j seeked, m
muted, shift-. reached 1.5×, c turned subtitles off and on again, and
shift+N moved to the next video.
This commit is contained in:
vincent
2026-09-03 23:29:25 +02:00
parent 90f1eddb31
commit ddad42593c
2 changed files with 133 additions and 23 deletions
+4 -3
View File
@@ -303,9 +303,10 @@ export default function Player({
// fullscreen. Closing the player as well would drop you all the way back
// to the feed in one keypress.
if (e.key === "Escape" && !document.fullscreenElement) leave();
// Arrow keys only when the video does not own them for seeking.
if (e.key === "ArrowLeft" && e.shiftKey) onPrev?.();
if (e.key === "ArrowRight" && e.shiftKey) onNext?.();
// Moving between videos. Shift with the arrows because the bare ones
// seek, and shift with N and P because that is what YouTube uses.
if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "P" || e.key === "p")) onPrev?.();
if (e.shiftKey && (e.key === "ArrowRight" || e.key === "N" || e.key === "n")) onNext?.();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
+129 -20
View File
@@ -71,6 +71,15 @@ function StyleRow<T extends string | number>({
const SKIP_S = 10;
/** The speeds shift-comma and shift-full-stop step through, as on YouTube. */
const SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
function nearestSpeed(rate: number): number {
return SPEEDS.reduce((best, s) =>
Math.abs(s - rate) < Math.abs(best - rate) ? s : best,
);
}
function clock(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const h = Math.floor(seconds / 3600);
@@ -100,6 +109,8 @@ export default function PlayerControls({
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
// Shown only when it is not 1×, since nothing else in the bar would say so.
const [rate, setRate] = useState(1);
const [pip, setPip] = useState(false);
const [full, setFull] = useState(false);
const [menu, setMenu] = useState(false);
@@ -212,6 +223,23 @@ export default function PlayerControls({
onActivity?.();
};
/** What `c` does: off if anything is showing, otherwise the best match. */
const toggleSubs = useCallback(() => {
const v = videoRef.current;
if (!v) return;
const available = listSubs(v);
if (available.length === 0) return;
const showing = available.find((t) => t.mode === "showing");
chooseSub(
showing
? null
: (available.find((t) =>
(t.language || "").toLowerCase().startsWith(subLang.toLowerCase()),
) ?? available[0]),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoRef, subLang]);
// Mirror the element's state rather than assuming ours is authoritative —
// playback can change from the keyboard, the system, or the video ending.
useEffect(() => {
@@ -223,18 +251,19 @@ export default function PlayerControls({
setDuration(Number.isFinite(v.duration) ? v.duration : 0);
setVolume(v.volume);
setMuted(v.muted);
setRate(v.playbackRate);
};
const onPip = () => setPip(!!document.pictureInPictureElement);
const onFull = () => setFull(!!document.fullscreenElement);
sync();
for (const e of ["play", "pause", "timeupdate", "durationchange", "volumechange", "loadedmetadata", "ended"]) {
for (const e of ["play", "pause", "timeupdate", "durationchange", "volumechange", "loadedmetadata", "ended", "ratechange"]) {
v.addEventListener(e, sync);
}
v.addEventListener("enterpictureinpicture", onPip);
v.addEventListener("leavepictureinpicture", onPip);
document.addEventListener("fullscreenchange", onFull);
return () => {
for (const e of ["play", "pause", "timeupdate", "durationchange", "volumechange", "loadedmetadata", "ended"]) {
for (const e of ["play", "pause", "timeupdate", "durationchange", "volumechange", "loadedmetadata", "ended", "ratechange"]) {
v.removeEventListener(e, sync);
}
v.removeEventListener("enterpictureinpicture", onPip);
@@ -280,36 +309,102 @@ export default function PlayerControls({
}, [stageRef, onActivity]);
/**
* The shortcuts the buttons advertise. Their tooltips have promised
* "(space)" and "(f)" all along with nothing listening, and Escape left
* fullscreen nowhere: WebKit does not handle it for an element made
* fullscreen this way, so pressing it did nothing and the only way out was
* to find the button again.
* YouTube's keyboard shortcuts, because this is a video player and those are
* the ones fingers already know.
*
* space/k play, j/l jump ten seconds, arrows five, up and down are volume,
* m mutes, f is fullscreen, i is Picture in Picture (YouTube's miniplayer),
* c toggles subtitles, digits jump to that tenth of the video, Home and End
* go to the ends, and shift with comma or full stop changes speed.
*
* Escape leaves fullscreen: WebKit does not do it for an element made
* fullscreen this way, so without this there was no way out but the button.
*/
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
// Never while typing, and never over a shortcut of the system's own.
const v = videoRef.current;
if (!v) return;
// Typing is typing. A range input is not: leaving arrows to the slider
// would make them seek by a tenth of a second instead of five.
const t = e.target as HTMLElement | null;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
const typing =
t?.isContentEditable ||
t?.tagName === "TEXTAREA" ||
(t?.tagName === "INPUT" &&
!["range", "checkbox", "button", "submit"].includes((t as HTMLInputElement).type));
if (typing) return;
// Leave the system's own combinations alone. Shift is ours: it carries
// the speed controls.
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === " " || e.code === "Space") {
// Or the space would also press whichever button has focus.
const seek = (to: number) => {
v.currentTime = Math.min(Math.max(to, 0), v.duration || 0);
};
const setVol = (to: number) => {
v.volume = Math.min(Math.max(to, 0), 1);
v.muted = v.volume === 0;
};
const speed = (dir: 1 | -1) => {
const i = SPEEDS.indexOf(nearestSpeed(v.playbackRate));
v.playbackRate = SPEEDS[Math.min(Math.max(i + dir, 0), SPEEDS.length - 1)];
setRate(v.playbackRate);
};
// A digit jumps to that tenth of the way through, as on YouTube.
if (/^[0-9]$/.test(e.key) && !e.shiftKey) {
seek(((v.duration || 0) * Number(e.key)) / 10);
} else if (e.key === " " || e.code === "Space" || e.key === "k" || e.key === "K") {
// Or space would also press whichever button has focus.
e.preventDefault();
const v = videoRef.current;
if (!v) return;
if (v.paused) void v.play().catch(() => {});
else v.pause();
onActivity?.();
} else if (e.key === "j" || e.key === "J") {
seek(v.currentTime - 10);
} else if (e.key === "l" || e.key === "L") {
seek(v.currentTime + 10);
} else if (e.key === "ArrowLeft" && !e.shiftKey) {
e.preventDefault();
seek(v.currentTime - 5);
} else if (e.key === "ArrowRight" && !e.shiftKey) {
e.preventDefault();
seek(v.currentTime + 5);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setVol(v.volume + 0.05);
} else if (e.key === "ArrowDown") {
e.preventDefault();
setVol(v.volume - 0.05);
} else if (e.key === "Home") {
seek(0);
} else if (e.key === "End") {
seek(v.duration || 0);
} else if (e.key === "m" || e.key === "M") {
v.muted = !v.muted;
} else if (e.key === "f" || e.key === "F") {
void toggleFull();
} else if (e.key === "i" || e.key === "I") {
void togglePip();
} else if (e.key === "c" || e.key === "C") {
toggleSubs();
} else if (e.key === "<" || (e.key === "," && e.shiftKey)) {
speed(-1);
} else if (e.key === ">" || (e.key === "." && e.shiftKey)) {
speed(1);
} else if ((e.key === "," || e.key === ".") && v.paused) {
// Frame stepping while paused. A video element will not say what its
// frame rate is, so a frame is taken as a thirtieth of a second.
seek(v.currentTime + (e.key === "." ? 1 / 30 : -1 / 30));
} else if (e.key === "Escape" && document.fullscreenElement) {
void document.exitFullscreen();
} else {
return;
}
onActivity?.();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [videoRef, toggleFull, onActivity]);
}, [videoRef, toggleFull, togglePip, toggleSubs, onActivity]);
const pct = duration > 0 ? (time / duration) * 100 : 0;
@@ -336,7 +431,7 @@ export default function PlayerControls({
<button
onClick={act((v) => (v.currentTime = Math.max(0, v.currentTime - SKIP_S)))}
className={btn}
title={`Back ${SKIP_S}s`}
title={`Back ${SKIP_S}s (j)`}
>
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M11 17l-5-5 5-5M18 17l-5-5 5-5" />
@@ -345,13 +440,27 @@ export default function PlayerControls({
<button
onClick={act((v) => (v.currentTime = Math.min(v.duration || 0, v.currentTime + SKIP_S)))}
className={btn}
title={`Forward ${SKIP_S}s`}
title={`Forward ${SKIP_S}s (l)`}
>
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5-5 5M6 7l5 5-5 5" />
</svg>
</button>
{rate !== 1 && (
<button
onClick={act((v) => {
v.playbackRate = 1;
setRate(1);
})}
title="Back to normal speed"
className="shrink-0 cursor-pointer rounded bg-white/15 px-1.5 py-0.5 text-[11px]
font-medium text-white/90 hover:bg-white/25"
>
{rate}×
</button>
)}
<span className="shrink-0 font-mono text-[12px] tabular-nums text-white/85">{clock(time)}</span>
<input
@@ -377,7 +486,7 @@ export default function PlayerControls({
<button
onClick={act((v) => (v.muted = !v.muted))}
className={btn}
title={muted || volume === 0 ? "Unmute" : "Mute"}
title={muted || volume === 0 ? "Unmute (m)" : "Mute (m)"}
>
<svg viewBox="0 0 24 24" className="size-5" fill="currentColor">
<path d="M4 9v6h4l5 4V5L8 9H4z" />
@@ -421,7 +530,7 @@ export default function PlayerControls({
<button
onClick={() => { setMenu((m) => !m); onActivity?.(); }}
className={btn}
title="Subtitles"
title="Subtitles (c)"
>
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="5" width="18" height="14" rx="2" />
@@ -514,7 +623,7 @@ export default function PlayerControls({
</div>
)}
<button onClick={togglePip} className={btn} title={pip ? "Leave Picture in Picture" : "Picture in Picture"}>
<button onClick={togglePip} className={btn} title={pip ? "Leave Picture in Picture (i)" : "Picture in Picture (i)"}>
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="5" width="18" height="14" rx="2" />
<rect x="12" y="11" width="7" height="6" rx="1" fill="currentColor" stroke="none" />