diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index b5cda7fb..999c2bcc 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -60,6 +60,32 @@ 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 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. +// +// Two phases (see applyHdPhase1 / switchToHdNative / the quality poll): +// 1. 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 latches 1080p — even while it paints small. (Its own controls, incl. +// the gear, also paint scaled-down in the windowed box — the one downside of phase 1 alone.) +// 2. Once 1080p has actually latched, drop to NATIVE responsive sizing (setSize) so YouTube renders +// its controls at full size. +// +// ⚠️ CONDITIONAL / UNDER OBSERVATION (2026-07-31): phase 2 is NOT proven safe everywhere. In dev, on +// ONE `was_live` stream, quality dropped hd1080 → large (480p) ~2s after going native (YouTube +// re-caps to the now-small internal viewport); on the author's own machine/connection it HELD 1080p +// on a normal VOD. So it's environment-/video-dependent and is being watched in prod. Two levers: +// • HD_DROP_TO_NATIVE = false → skip phase 2, keep phase 1 forever = GUARANTEED 1080p (the viewport +// stays 1920), at the cost of the scaled-down controls. The safe fallback if phase 2 downgrades. +// • FORCE_HD_LOGICAL_SIZE = false → disable the whole trick, back to responsive 100%/100% sizing. +// 16:9 to match the `aspect-video` stage. +const FORCE_HD_LOGICAL_SIZE = true; +const HD_DROP_TO_NATIVE = 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 +289,13 @@ 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); + // HD phase: false = phase 1 (fixed 1920×1080 iframe scaled down, to latch 1080p); true = phase 2 + // (native responsive sizing, so YouTube's own controls render full-size once 1080p has latched). + const hdNativeRef = useRef(false); 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 @@ -423,6 +456,42 @@ export default function PlayerModal({ // 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error. const [playerError, setPlayerError] = useState(null); + // Put the HD iframe into phase 1 (fixed 1920×1080, scaled to fit) so YouTube (re)latches 1080p from + // a full-HD internal viewport. Called on player-ready and when a NEW video is loaded into the same + // player (a description link) — otherwise the new clip would start at the native box's low quality. + const applyHdPhase1 = () => { + if (!FORCE_HD_LOGICAL_SIZE) return; + hdNativeRef.current = false; + const f = playerRef.current?.getIframe?.(); + if (f) { + // Anchor the fixed-HD iframe top-left of its box so the scale-transform (fitPlayerRef) fits it + // in; `maxWidth:none` guards against a global iframe { max-width:100% } reset. + 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?.(); + }; + // Once 1080p has latched (see the quality poll), drop to native responsive sizing so YouTube renders + // its own controls — the gear especially — at full size. Gated by HD_DROP_TO_NATIVE (see the note at + // the top): if phase 2 proves to downgrade in the wild, flip that off to keep phase 1 forever. + const switchToHdNative = () => { + const f = playerRef.current?.getIframe?.(); + if (!f) return; + hdNativeRef.current = true; + f.style.position = ""; + f.style.top = ""; + f.style.left = ""; + f.style.transform = "none"; + f.style.transformOrigin = ""; + f.style.maxWidth = ""; + fitPlayerRef.current?.(); // now runs the native setSize branch + }; + const loadVideo = (id: string, start: number | null) => { const p = playerRef.current; if (!p || typeof p.loadVideoById !== "function") return; @@ -430,6 +499,9 @@ export default function PlayerModal({ const startSeconds = start != null ? start : id === active.id ? resumeAt : 0; setPlayerError(null); p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 }); + // Re-latch 1080p for the newly-loaded clip: back to phase 1 (the quality poll flips it to native + // once 1080p lands). Without this a description-link video would inherit the native box's low pick. + applyHdPhase1(); currentIdRef.current = id; setCurrentVideoId(id); setLiveData(null); @@ -732,12 +804,53 @@ 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; + if (hdNativeRef.current) { + // Phase 2: native responsive sizing — the iframe IS the box, so YouTube's controls render + // at full size. Set the inline size explicitly too: phase 1 left an inline 1920×1080px on the + // element, which would otherwise override setSize's attributes and keep it overflowing. + f.style.width = `${bw}px`; + f.style.height = `${bh}px`; + if (p && typeof p.setSize === "function") p.setSize(bw, bh); + return; + } + // Phase 1: latch 1080p — scale the fixed 1920×1080 iframe DOWN to fit, letterbox-`contain`. + 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(() => { let cancelled = false; const id = active.id; autoMarkedRef.current = false; + // A fresh player starts in HD phase 1 (scaled), regardless of what the previous video ended on. + hdNativeRef.current = false; setPlayerError(null); // Auto-watch only applies to the active item — not to other videos the player @@ -777,8 +890,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 +904,21 @@ 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: () => { + // Start in phase 1 (fixed-HD iframe scaled to fit) so YouTube latches 1080p; the quality + // poll below flips to native sizing once it lands. + applyHdPhase1(); focusModal(); applyVolume(volumeRef.current, false); }, @@ -841,6 +961,18 @@ export default function PlayerModal({ // Periodic checkpoint so progress (and auto-watch) survive a crash/refresh. const timer = window.setInterval(persist, 5000); + // HD phase 2 trigger: once the (phase-1, scaled) player has actually latched 1080p, drop to native + // sizing so YouTube's controls render full-size. Only at hd1080+. Gated by HD_DROP_TO_NATIVE — the + // conditional/under-observation switch (see the note up top): a video that never reaches 1080p, a + // constrained network, or HD_DROP_TO_NATIVE=false all keep phase 1 (the guaranteed-1080p fallback). + // Re-arms automatically after a description-link load (applyHdPhase1 resets the flag). + const HD_NATIVE_QUALITIES = new Set(["hd1080", "hd1440", "hd2160"]); + const hdPoll = window.setInterval(() => { + if (!FORCE_HD_LOGICAL_SIZE || !HD_DROP_TO_NATIVE || hdNativeRef.current) return; + const p = playerRef.current; + const q = p && typeof p.getPlaybackQuality === "function" ? p.getPlaybackQuality() : ""; + if (HD_NATIVE_QUALITIES.has(q)) switchToHdNative(); + }, 700); // Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload, so // without this the resume point lags up to 5s (a seek right before F5 would be lost). A keepalive // beacon so the POST survives the unload. Only for the library item we opened with — a navigated @@ -861,6 +993,7 @@ export default function PlayerModal({ return () => { cancelled = true; window.clearInterval(timer); + window.clearInterval(hdPoll); window.removeEventListener("pagehide", onHide); // Final flush, then refresh the feed + playlists so resume bars reflect this session. Promise.resolve(persist()).finally(() => { @@ -932,8 +1065,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 && ( diff --git a/frontend/src/lib/youtube.ts b/frontend/src/lib/youtube.ts index adc9ce75..2e696f62 100644 --- a/frontend/src/lib/youtube.ts +++ b/frontend/src/lib/youtube.ts @@ -15,6 +15,11 @@ export interface YTPlayer { getDuration(): number; getVideoData(): YTVideoData; getIframe(): HTMLIFrameElement; + // Current latched playback quality (e.g. "hd1080") — used to detect when to drop the HD-latch + // iframe from its scaled-up size to native sizing. `setPlaybackQuality` is deliberately NOT typed: + // YouTube retired it (a hard no-op) — see [[reference-yt-iframe-api-remote-control-map]]. + getPlaybackQuality(): string; + setSize(width: number, height: number): void; getVolume(): number; isMuted(): boolean; setVolume(volume: number): void;