feat(player): drop to native sizing after 1080p latches (full-size YouTube controls)

Two-phase HD: phase 1 keeps the fixed 1920x1080 iframe scaled down to latch
1080p; once the quality poll sees hd1080, switch to native responsive sizing so
YouTube renders its own controls (the gear especially) at full size instead of
scaled-down. A latched pick survives the shrink on a normal VOD.

CONDITIONAL / under observation: on one was_live stream in dev, quality dropped
hd1080 -> 480p ~2s after going native (YouTube re-caps to the small viewport);
held 1080p elsewhere. Gated behind HD_DROP_TO_NATIVE so phase 2 can be turned
off in one line to fall back to the guaranteed-1080p permanent-scale phase 1.
This commit is contained in:
2026-07-31 05:47:20 +02:00
parent 8349f9338c
commit 8f1d576341
2 changed files with 96 additions and 24 deletions
+91 -24
View File
@@ -61,16 +61,28 @@ import { useScrollFade } from "../lib/useScrollFade";
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).
// 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;
@@ -281,6 +293,9 @@ export default function PlayerModal({
const videoBoxRef = useRef<HTMLDivElement | null>(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<YTPlayer | null>(null);
const autoMarkedRef = useRef(false);
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a
@@ -441,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<number | null>(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;
@@ -448,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);
@@ -765,6 +819,16 @@ export default function PlayerModal({
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;
@@ -785,6 +849,8 @@ export default function PlayerModal({
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
@@ -850,21 +916,9 @@ export default function PlayerModal({
// 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?.();
}
// 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);
},
@@ -907,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
@@ -927,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(() => {
+5
View File
@@ -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;