Merge: video-player keyboard + scroll-wheel controls

feature/player-keyboard-controls: F=fullscreen, Space=play/pause, scroll wheel=
volume (with a volume-bar overlay), keeping keyboard focus on the modal.
This commit is contained in:
npeter83 2026-06-29 23:41:00 +02:00
commit 12eded5dc9
5 changed files with 130 additions and 8 deletions

View file

@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, X } from "lucide-react"; import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist"; import AddToPlaylist from "./AddToPlaylist";
import { api, type Video } from "../lib/api"; import { api, type Video } from "../lib/api";
@ -88,6 +88,45 @@ export default function PlayerModal({
const mountRef = useRef<HTMLDivElement | null>(null); const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any>(null); const playerRef = useRef<any>(null);
const autoMarkedRef = useRef(false); const autoMarkedRef = useRef(false);
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
// iframe) so F / Space / Esc keep working until the user clicks into YouTube's native
// controls; the interaction overlay below also stops the iframe stealing focus on a video
// click. `stageRef` is the element we fullscreen (so the volume overlay stays visible).
const cardRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const volTimerRef = useRef<number | undefined>(undefined);
// Volume level to flash in the on-player overlay (null = hidden). Auto-fades after a moment.
const [volumeUi, setVolumeUi] = useState<number | null>(null);
const focusModal = () => cardRef.current?.focus();
const togglePlay = () => {
const p = playerRef.current;
if (!p || typeof p.getPlayerState !== "function") return;
if (p.getPlayerState() === 1) p.pauseVideo?.(); // 1 === playing
else p.playVideo?.();
};
const toggleFullscreen = () => {
const el = stageRef.current;
if (!el) return;
if (document.fullscreenElement) document.exitFullscreen?.();
else el.requestFullscreen?.();
};
const flashVolume = (level: number) => {
setVolumeUi(level);
window.clearTimeout(volTimerRef.current);
volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200);
};
const nudgeVolume = (delta: number) => {
const p = playerRef.current;
if (!p || typeof p.getVolume !== "function") return;
const cur = p.isMuted?.() ? 0 : p.getVolume?.() ?? 50;
const next = Math.max(0, Math.min(100, Math.round(cur + delta)));
if (next > 0) p.unMute?.();
p.setVolume?.(next);
if (next === 0) p.mute?.();
flashVolume(next);
};
// The player can navigate to other videos (YouTube links in a description). The // The player can navigate to other videos (YouTube links in a description). The
// currently-playing id may differ from the active item. // currently-playing id may differ from the active item.
@ -173,21 +212,59 @@ export default function PlayerModal({
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
// ESC + background scroll lock. // Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock.
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") {
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (document.fullscreenElement) return;
onClose();
return;
}
const el = document.activeElement as HTMLElement | null;
const tag = (el?.tagName || "").toLowerCase();
const typing = tag === "input" || tag === "textarea" || tag === "select" || !!el?.isContentEditable;
if (e.key === "f" || e.key === "F") {
if (typing) return;
e.preventDefault();
toggleFullscreen();
} else if (e.key === " " || e.code === "Space") {
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
// page from scrolling).
if (typing || tag === "button" || tag === "a") return;
e.preventDefault();
togglePlay();
}
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
focusModal();
const prevOverflow = document.body.style.overflow; const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
return () => { return () => {
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow; document.body.style.overflow = prevOverflow;
window.clearTimeout(closeTimer.current); window.clearTimeout(closeTimer.current);
window.clearTimeout(volTimerRef.current);
}; };
}, [onClose]); }, [onClose]);
// Mouse-wheel volume. A cross-origin iframe swallows wheel events, so we catch them on the
// interaction overlay above it (non-passive so preventDefault stops the modal scrolling).
useEffect(() => {
const el = overlayRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
focusModal(); // re-grab focus if it had slipped into the native controls
nudgeVolume(e.deltaY < 0 ? 5 : -5);
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
// Re-attach if the overlay remounts (it's hidden while the embed shows an error).
}, [playerError]);
// Create the player, resume from the saved position, persist progress, and // Create the player, resume from the saved position, persist progress, and
// auto-mark watched once playback reaches the end. // auto-mark watched once playback reaches the end.
useEffect(() => { useEffect(() => {
@ -242,6 +319,8 @@ export default function PlayerModal({
playsinline: 1, playsinline: 1,
}, },
events: { events: {
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away.
onReady: () => focusModal(),
onStateChange: (e: any) => { onStateChange: (e: any) => {
// 1 === playing → sync the navigated-to video's title/author for display. // 1 === playing → sync the navigated-to video's title/author for display.
if (e?.data === 1) { if (e?.data === 1) {
@ -297,13 +376,44 @@ export default function PlayerModal({
aria-modal="true" aria-modal="true"
> >
<div <div
className="glass-card relative w-full max-w-4xl max-h-full overflow-y-auto rounded-2xl shadow-2xl" ref={cardRef}
tabIndex={-1}
className="glass-card relative w-full max-w-4xl max-h-full overflow-y-auto rounded-2xl shadow-2xl outline-none"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden"> <div
ref={stageRef}
className="player-stage relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden"
>
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed {/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
through our overlay. */} through our overlay. */}
<div ref={mountRef} className={`w-full h-full ${playerError != null ? "invisible" : ""}`} /> <div ref={mountRef} className={`w-full h-full ${playerError != null ? "invisible" : ""}`} />
{/* Interaction layer over the video: catches the scroll wheel (volume) and click
(play/pause), and stops the iframe stealing keyboard focus. The bottom strip is
left uncovered so YouTube's native control bar (seek / settings / CC / fullscreen)
stays usable. Hidden on error so the "Open on YouTube" CTA is clickable. */}
{playerError == null && (
<div
ref={overlayRef}
onClick={() => {
focusModal();
togglePlay();
}}
title={t("player.shortcutsHint")}
aria-label={t("player.shortcutsHint")}
className="absolute inset-x-0 top-0 bottom-14 z-10 cursor-pointer"
/>
)}
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
{volumeUi != null && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
{volumeUi === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
<div className="h-full rounded-full bg-white transition-all" style={{ width: `${volumeUi}%` }} />
</div>
<span className="w-7 text-right tabular-nums">{volumeUi}</span>
</div>
)}
{playerError != null && ( {playerError != null && (
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center"> <div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
<div className="max-w-sm"> <div className="max-w-sm">

View file

@ -19,5 +19,6 @@
"unavailableTitle": "Hier nicht abspielbar", "unavailableTitle": "Hier nicht abspielbar",
"unavailableBody": "Dieses Video ist nicht verfügbar — möglicherweise privat, entfernt oder regional gesperrt.", "unavailableBody": "Dieses Video ist nicht verfügbar — möglicherweise privat, entfernt oder regional gesperrt.",
"embedDisabledBody": "Der Eigentümer hat die Wiedergabe auf anderen Seiten deaktiviert (häufig bei automatisch generierten „Topic“-Musiktiteln). Auf YouTube ist es weiterhin abspielbar.", "embedDisabledBody": "Der Eigentümer hat die Wiedergabe auf anderen Seiten deaktiviert (häufig bei automatisch generierten „Topic“-Musiktiteln). Auf YouTube ist es weiterhin abspielbar.",
"openOnYoutube": "Auf YouTube öffnen" "openOnYoutube": "Auf YouTube öffnen",
"shortcutsHint": "Klick: Wiedergabe/Pause · Scrollen: Lautstärke · F: Vollbild"
} }

View file

@ -19,5 +19,6 @@
"unavailableTitle": "Can't play this here", "unavailableTitle": "Can't play this here",
"unavailableBody": "This video isn't available — it may be private, removed, or region-restricted.", "unavailableBody": "This video isn't available — it may be private, removed, or region-restricted.",
"embedDisabledBody": "The owner disabled playback on other sites (common for auto-generated “Topic” music tracks). It still plays on YouTube.", "embedDisabledBody": "The owner disabled playback on other sites (common for auto-generated “Topic” music tracks). It still plays on YouTube.",
"openOnYoutube": "Open on YouTube" "openOnYoutube": "Open on YouTube",
"shortcutsHint": "Click: play/pause · Scroll: volume · F: fullscreen"
} }

View file

@ -19,5 +19,6 @@
"unavailableTitle": "Ez itt nem játszható le", "unavailableTitle": "Ez itt nem játszható le",
"unavailableBody": "Ez a videó nem elérhető — lehet privát, eltávolított vagy régiókorlátozott.", "unavailableBody": "Ez a videó nem elérhető — lehet privát, eltávolított vagy régiókorlátozott.",
"embedDisabledBody": "A tulajdonos letiltotta a lejátszást más oldalakon (gyakori az auto-generált „Topic” zenei sávoknál). A YouTube-on viszont lejátszható.", "embedDisabledBody": "A tulajdonos letiltotta a lejátszást más oldalakon (gyakori az auto-generált „Topic” zenei sávoknál). A YouTube-on viszont lejátszható.",
"openOnYoutube": "Megnyitás YouTube-on" "openOnYoutube": "Megnyitás YouTube-on",
"shortcutsHint": "Kattintás: lejátszás/szünet · Görgő: hangerő · F: teljes képernyő"
} }

View file

@ -262,6 +262,15 @@ html[data-scheme="youtube"][data-theme="light"] {
} }
} }
/* The in-app player stage, when fullscreened via the F key / native button, fills the screen
instead of keeping its 16:9 letterbox (and drops the rounded top corners). */
.player-stage:fullscreen {
width: 100vw;
height: 100vh;
aspect-ratio: auto;
border-radius: 0;
}
/* Thin, theme-aware scrollbars */ /* Thin, theme-aware scrollbars */
* { * {
scrollbar-color: var(--border) transparent; scrollbar-color: var(--border) transparent;