feat(player): start videos at 1080p + add a windowed fullscreen button (R8 S5)

YouTube picks an embed's quality at player-creation from the iframe's internal
viewport and latches it; a windowed-size embed was stuck ~360-480p, forcing a
manual fullscreen + gear trip to 1080p. Create the iframe at a fixed 1920x1080
logical size and CSS transform-scale it DOWN to fit (a CSS transform doesn't
shrink the internal viewport, so YouTube still serves 1080p, latched for the
session even while it paints small). A letterbox-contain fit via a
ResizeObserver handles both the windowed and big-view boxes. Gated behind
FORCE_HD_LOGICAL_SIZE for an easy revert if a restrictive network overrides it.

Also add a maximise (fullscreen) button to the windowed player's meta row: the
only fullscreen control reachable in the windowed video was YouTube's own, which
hides our chrome. It mirrors the F key and leads to the docked bar + our true
fullscreen. Corrects the stale code note that claimed the scale trick fails
(that was scaling a SMALL player up; scaling a LARGE player down works).
This commit is contained in:
2026-07-31 05:03:34 +02:00
parent ee82ddcdae
commit 8349f9338c
+92 -8
View File
@@ -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<HTMLDivElement | null>(null);
// The box the (fixed HD-sized) player iframe is scaled to fit — see FORCE_HD_LOGICAL_SIZE.
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);
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
@@ -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. */}
<div
ref={videoBoxRef}
className={
maximized || isFullscreen ? "absolute inset-x-0 top-0 bottom-14" : "w-full h-full"
maximized || isFullscreen
? "absolute inset-x-0 top-0 bottom-14 overflow-hidden"
: "relative w-full h-full overflow-hidden"
}
>
<div
@@ -1279,10 +1348,25 @@ export default function PlayerModal({
</div>
)}
{/* 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 && (
<button
onClick={toggleFullscreen}
title={`${t("player.fullscreen")} · F`}
aria-label={t("player.fullscreen")}
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
>
<Maximize className="w-4 h-4" />
</button>
)}
{!navigated && (
<AddToPlaylist
videoId={active.id}
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
{!navigated && (