diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 88a1483..fc324db 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { createPortal } from "react-dom"; 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 AddToPlaylist from "./AddToPlaylist"; import { api, type Video } from "../lib/api"; @@ -88,6 +88,45 @@ export default function PlayerModal({ const mountRef = useRef(null); const playerRef = useRef(null); 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(null); + const stageRef = useRef(null); + const overlayRef = useRef(null); + const volTimerRef = useRef(undefined); + // Volume level to flash in the on-player overlay (null = hidden). Auto-fades after a moment. + const [volumeUi, setVolumeUi] = useState(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 // currently-playing id may differ from the active item. @@ -173,21 +212,59 @@ export default function PlayerModal({ 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(() => { 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); + focusModal(); const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prevOverflow; window.clearTimeout(closeTimer.current); + window.clearTimeout(volTimerRef.current); }; }, [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 // auto-mark watched once playback reaches the end. useEffect(() => { @@ -242,6 +319,8 @@ export default function PlayerModal({ playsinline: 1, }, events: { + // Keep keyboard focus on the modal, not the iframe, so shortcuts work right away. + onReady: () => focusModal(), onStateChange: (e: any) => { // 1 === playing → sync the navigated-to video's title/author for display. if (e?.data === 1) { @@ -297,13 +376,44 @@ export default function PlayerModal({ aria-modal="true" >
e.stopPropagation()} > -
+
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed through our overlay. */}
+ {/* 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 && ( +
{ + 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 && ( +
+ {volumeUi === 0 ? : } +
+
+
+ {volumeUi} +
+ )} {playerError != null && (
diff --git a/frontend/src/i18n/locales/de/player.json b/frontend/src/i18n/locales/de/player.json index ebe909e..f1d5758 100644 --- a/frontend/src/i18n/locales/de/player.json +++ b/frontend/src/i18n/locales/de/player.json @@ -19,5 +19,6 @@ "unavailableTitle": "Hier nicht abspielbar", "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.", - "openOnYoutube": "Auf YouTube öffnen" + "openOnYoutube": "Auf YouTube öffnen", + "shortcutsHint": "Klick: Wiedergabe/Pause · Scrollen: Lautstärke · F: Vollbild" } diff --git a/frontend/src/i18n/locales/en/player.json b/frontend/src/i18n/locales/en/player.json index e71f155..aaa7f3f 100644 --- a/frontend/src/i18n/locales/en/player.json +++ b/frontend/src/i18n/locales/en/player.json @@ -19,5 +19,6 @@ "unavailableTitle": "Can't play this here", "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.", - "openOnYoutube": "Open on YouTube" + "openOnYoutube": "Open on YouTube", + "shortcutsHint": "Click: play/pause · Scroll: volume · F: fullscreen" } diff --git a/frontend/src/i18n/locales/hu/player.json b/frontend/src/i18n/locales/hu/player.json index acce7d8..005f653 100644 --- a/frontend/src/i18n/locales/hu/player.json +++ b/frontend/src/i18n/locales/hu/player.json @@ -19,5 +19,6 @@ "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.", "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ő" } diff --git a/frontend/src/index.css b/frontend/src/index.css index 6a03dfe..d66cc3e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 */ * { scrollbar-color: var(--border) transparent;