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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user