feat(player): keyboard + scroll-wheel controls in the video modal
Adds player-modal shortcuts and keeps keyboard focus on the modal (not the cross-origin player iframe) on open and on player-ready, so they work until you click into YouTube's native controls: - F: toggle fullscreen (the player stage element, so the volume flash stays visible; a :fullscreen CSS rule drops the 16:9 letterbox). Esc in fullscreen exits fullscreen only, without also closing the modal. - Space: play/pause (ignored while a button/input is focused). - Scroll wheel over the video: volume up/down with an auto-fading volume-bar overlay. A transparent interaction layer over the iframe catches the wheel (the cross-origin iframe otherwise swallows it) and maps click to play/pause; it leaves the bottom strip uncovered so YouTube's native control bar (seek / settings / captions / fullscreen) stays usable, and is hidden on an embed error so the 'Open on YouTube' CTA stays clickable. i18n shortcutsHint EN/HU/DE.
This commit is contained in:
parent
ff9b0601c3
commit
d84d5a1819
5 changed files with 130 additions and 8 deletions
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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ő"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue