|
|
|
@@ -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<HTMLDivElement | null>(null);
|
|
|
|
|
const playerRef = useRef<any>(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<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
|
|
|
|
|
// 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"
|
|
|
|
|
>
|
|
|
|
|
<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()}
|
|
|
|
|
>
|
|
|
|
|
<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
|
|
|
|
|
through our overlay. */}
|
|
|
|
|
<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 && (
|
|
|
|
|
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
|
|
|
|
|
<div className="max-w-sm">
|
|
|
|
|