diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 88568a5..9d592f5 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -20,6 +20,14 @@ import { useBackToClose } from "../lib/history"; // How close to the end (seconds) counts as "finished" → auto-mark watched. const FINISH_MARGIN = 10; +// One-time HD-unlock (see attemptHdUnlock). YouTube hard-caps a windowed embed to ~360p and only a +// real fullscreen lifts the cap; the lift then persists for the whole page session. So on the first +// video opened we briefly flash the player to fullscreen and back to coax YouTube past the cap once. +// Cap the flash at this many ms, but exit as soon as we see the quality actually rise. +const HD_UNLOCK_MAX_MS = 2500; +// Module-scoped so the flash happens only ONCE per page session (the unlock persists afterwards). +let hdUnlockDone = false; + // Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a // video ends; loop = whether it repeats the current video ("one"), wraps the list at its ends // ("all"), or neither ("off"). Both apply to any queued player (feed or playlist). @@ -124,6 +132,9 @@ export default function PlayerModal({ // modal adjusts volume (not just the small player area). const dialogRef = useRef(null); const volTimerRef = useRef(undefined); + // HD-unlock flash bookkeeping (see attemptHdUnlock / finishHdUnlock). + const unlockTimerRef = useRef(undefined); + const unlockingRef = useRef(false); // Volume level to flash in the on-player overlay (null = hidden). Auto-fades after a moment. const [volumeUi, setVolumeUi] = useState(null); // When the user interacts with YouTube's own controls (gear/seek/CC), focus moves into the @@ -146,6 +157,32 @@ export default function PlayerModal({ if (document.fullscreenElement) document.exitFullscreen?.(); else el.requestFullscreen?.(); }; + // End the HD-unlock flash: exit fullscreen (if we're still in it) and mark the session unlocked so + // it never flashes again. Called either when YouTube's quality is seen to rise or on the max timeout. + const finishHdUnlock = () => { + if (!unlockingRef.current) return; + unlockingRef.current = false; + window.clearTimeout(unlockTimerRef.current); + hdUnlockDone = true; + if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {}); + }; + // Coax YouTube past its windowed 360p cap: on the first opened video, briefly enter fullscreen so + // YouTube lifts the cap (which then persists for the session), then exit back to the small player. + // Needs live user activation (the modal-open click) — silently retried on the next click if it's + // already expired, and a no-op once the session is unlocked. Best-effort; can't be forced further. + const attemptHdUnlock = () => { + if (hdUnlockDone || unlockingRef.current) return; + const el = stageRef.current; + if (!el || !el.requestFullscreen || document.fullscreenElement) return; + el.requestFullscreen() + .then(() => { + unlockingRef.current = true; + unlockTimerRef.current = window.setTimeout(finishHdUnlock, HD_UNLOCK_MAX_MS); + }) + .catch(() => { + /* no user activation yet / fullscreen blocked — leave hdUnlockDone false to retry on a click */ + }); + }; const flashVolume = (level: number) => { setVolumeUi(level); window.clearTimeout(volTimerRef.current); @@ -488,18 +525,24 @@ export default function PlayerModal({ playsinline: 1, // Best-effort HD hint. The IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops // now (YouTube removed them), and the undocumented `vq` URL param is only occasionally - // honoured — kept because it's harmless. NOTE: YouTube hard-caps an embed's max quality by - // the player's on-screen size; a windowed embed is stuck ~360p and only true fullscreen - // lifts the cap (after which the higher pick persists for the session). We can't beat that - // programmatically — a CSS transform-scale trick was tried and does NOT fool the cap. + // honoured — kept because it's harmless. YouTube hard-caps an embed's max quality by the + // player's on-screen size; a windowed embed is stuck ~360p and only true fullscreen lifts + // the cap (then it persists for the session). A CSS transform-scale trick does NOT fool it; + // we instead do a one-time fullscreen flash on open (see attemptHdUnlock) to lift the cap. vq: "hd1080", }, events: { - // Keep keyboard focus on the modal, not the iframe, so shortcuts work right away. - onReady: () => focusModal(), + // Keep keyboard focus on the modal, not the iframe, so shortcuts work right away, and try + // the one-time HD-unlock flash while the modal-open click is still a live user activation. + onReady: () => { + focusModal(); + attemptHdUnlock(); + }, 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, and retry the + // HD-unlock (a second chance at the flash right as playback begins). if (e?.data === 1) { + attemptHdUnlock(); const p = playerRef.current; const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null; if (d) setLiveData({ title: d.title, author: d.author }); @@ -513,6 +556,15 @@ export default function PlayerModal({ advanceOnEnd(); } }, + // While the HD-unlock flash is running, exit fullscreen as soon as YouTube's quality is + // seen to rise (no need to hold the flash the full timeout). onPlaybackQualityChange is one + // of the few still-live quality signals; e.data is a label ("hd1080"/"hd720"/"large"/…). + onPlaybackQualityChange: (e: any) => { + const q = String(e?.data || ""); + if (unlockingRef.current && (q === "hd1080" || q === "hd720" || q === "large" || q === "highres")) { + finishHdUnlock(); + } + }, // The embed couldn't play this video (embedding disabled, removed, private…). // Surface our own message + an "Open on YouTube" escape hatch. onError: (e: any) => setPlayerError(typeof e?.data === "number" ? e.data : -1), @@ -526,6 +578,12 @@ export default function PlayerModal({ return () => { cancelled = true; window.clearInterval(timer); + // If the modal closes / advances mid-flash, don't leave the player stuck in fullscreen. + window.clearTimeout(unlockTimerRef.current); + if (unlockingRef.current) { + unlockingRef.current = false; + if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {}); + } // Final flush, then refresh the feed + playlists so resume bars reflect this session. Promise.resolve(persist()).finally(() => { qc.invalidateQueries({ queryKey: ["feed"] }); @@ -593,6 +651,7 @@ export default function PlayerModal({
{ focusModal(); + attemptHdUnlock(); // fallback: a real click is a fresh user activation for the flash togglePlay(); }} title={t("player.shortcutsHint")}