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:
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user