diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index b5cda7fb..84abe7e0 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -60,6 +60,20 @@ import { useScrollFade } from "../lib/useScrollFade"; // How close to the end (seconds) counts as "finished" → auto-mark watched. const FINISH_MARGIN = 10; +// Force a high starting resolution. YouTube decides quality at player-CREATION time from the +// iframe's INTERNAL viewport size, then latches it (a later resize neither upgrades nor downgrades — +// measured 2026-07-31). A windowed-size embed (~900×500) is stuck at ~360–480p, which is why the +// video used to open low and needed a manual fullscreen + gear trip to 1080p. So we create the +// iframe at this large FIXED logical size and CSS `transform: scale()` it DOWN to fit the visible +// box: a CSS transform does NOT change the iframe's internal viewport, so YouTube still sees +// 1920×1080 and starts at 1080p — latched for the whole session, even while it paints small. +// 16:9 to match the `aspect-video` stage. Revert lever: set FORCE_HD_LOGICAL_SIZE = false to fall +// back to the old responsive 100%/100% sizing (kept easy to pull in case a restrictive network — +// e.g. behind a VPN — overrides the size hint; the trick can only help or be neutral, never worse). +const FORCE_HD_LOGICAL_SIZE = true; +const HD_W = 1920; +const HD_H = 1080; + // 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). @@ -263,6 +277,10 @@ export default function PlayerModal({ const resumeAt = startAt != null && active.id === video.id ? startAt : active.position_seconds || 0; const mountRef = useRef(null); + // The box the (fixed HD-sized) player iframe is scaled to fit — see FORCE_HD_LOGICAL_SIZE. + const videoBoxRef = useRef(null); + // Rescales the HD iframe into videoBoxRef; set up once (ResizeObserver) and also called on ready. + const fitPlayerRef = useRef<(() => void) | null>(null); const playerRef = useRef(null); const autoMarkedRef = useRef(false); // Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a @@ -732,6 +750,35 @@ export default function PlayerModal({ }; }, []); + // Keep the fixed-HD player iframe scaled to fit its visible box (see FORCE_HD_LOGICAL_SIZE). The + // iframe stays 1920×1080 internally (so YouTube serves 1080p) while we CSS-scale it DOWN to the + // box with a letterbox-`contain` fit — one factor + centring handles both the 16:9 windowed box + // and the non-16:9 big-view box (viewport minus the 56px control bar). Bound once; reads live refs. + useEffect(() => { + if (!FORCE_HD_LOGICAL_SIZE) return; + const box = videoBoxRef.current; + if (!box) return; + const fit = () => { + const p = playerRef.current; + const f = p && typeof p.getIframe === "function" ? p.getIframe() : null; + if (!f) return; + const bw = box.clientWidth; + const bh = box.clientHeight; + if (!bw || !bh) return; + const k = Math.min(bw / HD_W, bh / HD_H); + const cx = (bw - HD_W * k) / 2; + const cy = (bh - HD_H * k) / 2; + f.style.transform = `translate(${cx}px, ${cy}px) scale(${k})`; + }; + fitPlayerRef.current = fit; + const ro = new ResizeObserver(fit); + ro.observe(box); + return () => { + ro.disconnect(); + fitPlayerRef.current = null; + }; + }, []); + // Create the player, resume from the saved position, persist progress, and // auto-mark watched once playback reaches the end. useEffect(() => { @@ -777,8 +824,10 @@ export default function PlayerModal({ loadYouTubeApi().then((YT) => { if (cancelled || !mountRef.current) return; playerRef.current = new YT.Player(mountRef.current, { - width: "100%", - height: "100%", + // A FIXED large logical size (scaled down to fit via fitPlayerRef) so YouTube starts at + // 1080p; falls back to the old responsive sizing if the trick is disabled. See HD_W/HD_H. + width: FORCE_HD_LOGICAL_SIZE ? String(HD_W) : "100%", + height: FORCE_HD_LOGICAL_SIZE ? String(HD_H) : "100%", videoId: id, playerVars: { autoplay: 1, @@ -789,16 +838,33 @@ 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. The real HD lever is the fixed HD_W×HD_H iframe + // size above (see FORCE_HD_LOGICAL_SIZE): YouTube reads quality from the iframe's INTERNAL + // viewport at creation, which a CSS transform-scale does not shrink — so scaling a LARGE + // player DOWN keeps the 1080p pick (an earlier note here that "the scale trick fails" was + // scaling a SMALL player UP, which genuinely doesn't fool the cap; the opposite direction + // does — measured 2026-07-31). vq: "hd1080", }, events: { // Keep keyboard focus on the modal, not the iframe, so shortcuts work right away, and // restore the remembered volume — a fresh player always starts at 100. onReady: () => { + // Anchor the fixed-HD iframe top-left of its box so the transform-scale (fitPlayerRef) + // fits it in; `maxWidth:none` guards against a global iframe { max-width:100% } reset. + if (FORCE_HD_LOGICAL_SIZE) { + const f = playerRef.current?.getIframe?.(); + if (f) { + f.style.position = "absolute"; + f.style.top = "0"; + f.style.left = "0"; + f.style.width = `${HD_W}px`; + f.style.height = `${HD_H}px`; + f.style.maxWidth = "none"; + f.style.transformOrigin = "0 0"; + } + fitPlayerRef.current?.(); + } focusModal(); applyVolume(volumeRef.current, false); }, @@ -932,8 +998,11 @@ export default function PlayerModal({ YT.Player REPLACES the mount element with its iframe (width/height 100% of the parent), which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
)} + {/* Reliable, discoverable path into our big view straight from the windowed player — + the docked bar's controls (incl. our own true-fullscreen ⛶) are off-screen here, and + the only fullscreen button previously reachable in the windowed video was YouTube's + own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there + the docked bar offers OUR true fullscreen. */} + {!navigated && ( + + )} {!navigated && ( )} {!navigated && (