|
|
|
@@ -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, ChevronLeft, ChevronRight, ExternalLink, Repeat, Repeat1, Shuffle, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
|
|
|
|
|
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ChevronLeft, ChevronRight, ExternalLink, Repeat, Repeat1, Settings, Shuffle, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
|
|
|
|
|
import Avatar from "./Avatar";
|
|
|
|
|
import AddToPlaylist from "./AddToPlaylist";
|
|
|
|
|
import DownloadButton from "./DownloadButton";
|
|
|
|
@@ -124,6 +124,12 @@ export default function PlayerModal({
|
|
|
|
|
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);
|
|
|
|
|
// When the user interacts with YouTube's own controls (gear/seek/CC), focus moves into the
|
|
|
|
|
// cross-origin player iframe. We detect that and drop the interaction overlay's pointer-events
|
|
|
|
|
// so YouTube's settings menu — which expands DOWN over the video, past our overlay — is fully
|
|
|
|
|
// navigable (previously the overlay ate clicks below the top items). Moving the pointer off the
|
|
|
|
|
// video, or the window regaining focus, re-arms our click/scroll/keyboard shortcuts.
|
|
|
|
|
const [nativeControls, setNativeControls] = useState(false);
|
|
|
|
|
|
|
|
|
|
const focusModal = () => cardRef.current?.focus();
|
|
|
|
|
const togglePlay = () => {
|
|
|
|
@@ -396,6 +402,35 @@ export default function PlayerModal({
|
|
|
|
|
// Re-attach if the overlay remounts (it's hidden while the embed shows an error).
|
|
|
|
|
}, [playerError]);
|
|
|
|
|
|
|
|
|
|
// Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek
|
|
|
|
|
// bar / CC) moves focus into the player iframe — the only cross-origin signal we get. While the
|
|
|
|
|
// iframe holds focus we drop the overlay's pointer-events so the (arbitrarily tall) settings
|
|
|
|
|
// menu is clickable end to end. Re-arm — restoring click-to-play, wheel volume and our keyboard
|
|
|
|
|
// shortcuts — when the pointer leaves the video or the window regains focus (a click back on our
|
|
|
|
|
// card blurs the iframe → the parent window fires 'focus').
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const iframeFocused = () => {
|
|
|
|
|
const p = playerRef.current;
|
|
|
|
|
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
|
|
|
|
|
return !!f && document.activeElement === f;
|
|
|
|
|
};
|
|
|
|
|
// activeElement settles on the tick after blur, so defer the check.
|
|
|
|
|
const onBlur = () => window.setTimeout(() => iframeFocused() && setNativeControls(true), 0);
|
|
|
|
|
const rearm = () => {
|
|
|
|
|
setNativeControls(false);
|
|
|
|
|
focusModal();
|
|
|
|
|
};
|
|
|
|
|
const stage = stageRef.current;
|
|
|
|
|
window.addEventListener("blur", onBlur);
|
|
|
|
|
window.addEventListener("focus", rearm);
|
|
|
|
|
stage?.addEventListener("mouseleave", rearm);
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener("blur", onBlur);
|
|
|
|
|
window.removeEventListener("focus", rearm);
|
|
|
|
|
stage?.removeEventListener("mouseleave", rearm);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Create the player, resume from the saved position, persist progress, and
|
|
|
|
|
// auto-mark watched once playback reaches the end.
|
|
|
|
|
useEffect(() => {
|
|
|
|
@@ -448,6 +483,11 @@ export default function PlayerModal({
|
|
|
|
|
enablejsapi: 1,
|
|
|
|
|
origin: window.location.origin,
|
|
|
|
|
playsinline: 1,
|
|
|
|
|
// Best-effort HD hint. The IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops
|
|
|
|
|
// now (YouTube removed them), but the undocumented `vq` URL param is still honoured for many
|
|
|
|
|
// videos and is harmless otherwise. The real quality lever is the player's rendered size
|
|
|
|
|
// (see max-w-6xl below) — YouTube's ABR targets a resolution to match the pixel box.
|
|
|
|
|
vq: "hd1080",
|
|
|
|
|
},
|
|
|
|
|
events: {
|
|
|
|
|
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away.
|
|
|
|
@@ -510,7 +550,7 @@ export default function PlayerModal({
|
|
|
|
|
playlist). Each strip sits just outside the card, spans its full height, and fades out at
|
|
|
|
|
the ends. stopPropagation so a click steps instead of closing. Hidden on narrow screens
|
|
|
|
|
where there's no room beside the card. */}
|
|
|
|
|
<div className="relative flex w-full max-w-4xl max-h-full">
|
|
|
|
|
<div className="relative flex w-full max-w-6xl max-h-full">
|
|
|
|
|
{hasQueue && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
@@ -552,9 +592,18 @@ export default function PlayerModal({
|
|
|
|
|
}}
|
|
|
|
|
title={t("player.shortcutsHint")}
|
|
|
|
|
aria-label={t("player.shortcutsHint")}
|
|
|
|
|
className="absolute inset-x-0 top-[12%] bottom-[22%] z-10 cursor-pointer"
|
|
|
|
|
className={`absolute inset-x-0 top-[12%] bottom-[22%] z-10 cursor-pointer ${
|
|
|
|
|
nativeControls ? "pointer-events-none" : ""
|
|
|
|
|
}`}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
|
|
|
|
|
{playerError == null && nativeControls && (
|
|
|
|
|
<div className="pointer-events-none absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-sm">
|
|
|
|
|
<Settings className="h-3.5 w-3.5" />
|
|
|
|
|
{t("player.nativeControls")}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{/* 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">
|
|
|
|
|