From ddad42593c629380357dd1fe6e11de009f607839 Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 3 Sep 2026 23:29:25 +0200 Subject: [PATCH] feat: YouTube's keyboard shortcuts in the player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/Player.tsx | 7 +- src/components/PlayerControls.tsx | 149 ++++++++++++++++++++++++++---- 2 files changed, 133 insertions(+), 23 deletions(-) diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 98f117b..9ee9e91 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -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); diff --git a/src/components/PlayerControls.tsx b/src/components/PlayerControls.tsx index c818b92..44be26f 100644 --- a/src/components/PlayerControls.tsx +++ b/src/components/PlayerControls.tsx @@ -71,6 +71,15 @@ function StyleRow({ 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({