revert(player): drop the transform-scale HD hack (didn't beat YouTube's cap)

Confirmed on prod: rendering the iframe at 1920x1080 logical and CSS transform:
scale()-ing it down does NOT lift YouTube's quality cap — the embed stays ~360p in
the windowed player and manual HD still snaps back; only true fullscreen unlocks
1080p (which then persists for the session). YouTube caps by the on-screen size, not
the iframe's window.innerWidth, so the transform only shrank YouTube's native
controls for no benefit. Restore the plain 100%/100% mount.

Kept: scroll-anywhere volume (works), the native-menu-yield fix, max-w-6xl, and the
harmless vq=hd1080 hint.
This commit is contained in:
npeter83 2026-07-10 16:56:24 +02:00
parent 42f112d4df
commit 71e0a40481

View file

@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
@ -20,15 +20,6 @@ import { useBackToClose } from "../lib/history";
// How close to the end (seconds) counts as "finished" → auto-mark watched. // How close to the end (seconds) counts as "finished" → auto-mark watched.
const FINISH_MARGIN = 10; const FINISH_MARGIN = 10;
// Fixed logical size the player iframe is rendered at, then CSS-scaled down to fit the (smaller)
// on-screen stage. YouTube caps an embed's max quality to the player's OWN inner viewport size, so
// a physically small player is stuck at ~360p even if you pick 1080p manually — it snaps back. A
// CSS `transform: scale()` shrinks the visual box WITHOUT changing the iframe's window.innerWidth,
// so YouTube keeps seeing a 1920×1080 viewport and allows 1080p while we display it small. (Actual
// quality is still bandwidth-gated by YouTube, but manual HD selection now sticks.)
const PLAYER_BASE_W = 1920;
const PLAYER_BASE_H = 1080;
// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a // 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 // 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). // ("all"), or neither ("off"). Both apply to any queued player (feed or playlist).
@ -133,8 +124,6 @@ export default function PlayerModal({
// modal adjusts volume (not just the small player area). // modal adjusts volume (not just the small player area).
const dialogRef = useRef<HTMLDivElement | null>(null); const dialogRef = useRef<HTMLDivElement | null>(null);
const volTimerRef = useRef<number | undefined>(undefined); const volTimerRef = useRef<number | undefined>(undefined);
// CSS scale that fits the 1920×1080 logical player onto the actual stage (see PLAYER_BASE_* above).
const [playerScale, setPlayerScale] = useState(0);
// Volume level to flash in the on-player overlay (null = hidden). Auto-fades after a moment. // Volume level to flash in the on-player overlay (null = hidden). Auto-fades after a moment.
const [volumeUi, setVolumeUi] = useState<number | null>(null); const [volumeUi, setVolumeUi] = useState<number | null>(null);
// When the user interacts with YouTube's own controls (gear/seek/CC), focus moves into the // When the user interacts with YouTube's own controls (gear/seek/CC), focus moves into the
@ -416,25 +405,6 @@ export default function PlayerModal({
return () => el.removeEventListener("wheel", onWheel); return () => el.removeEventListener("wheel", onWheel);
}, []); }, []);
// Keep the 1920×1080 logical player scaled to exactly fit the on-screen stage (windowed AND
// fullscreen). Measured before paint so the player never flashes at the wrong size.
useLayoutEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const update = () => {
const w = stage.clientWidth;
if (w > 0) setPlayerScale(w / PLAYER_BASE_W);
};
update();
const ro = new ResizeObserver(update);
ro.observe(stage);
document.addEventListener("fullscreenchange", update);
return () => {
ro.disconnect();
document.removeEventListener("fullscreenchange", update);
};
}, []);
// Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek // Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek
// bar / CC) moves focus into the player iframe — the only cross-origin signal we get. While the // bar / CC) moves focus into the player iframe — the only cross-origin signal we get. While the
// iframe holds focus we drop the overlay's pointer-events so the (arbitrarily tall) settings // iframe holds focus we drop the overlay's pointer-events so the (arbitrarily tall) settings
@ -517,9 +487,11 @@ export default function PlayerModal({
origin: window.location.origin, origin: window.location.origin,
playsinline: 1, playsinline: 1,
// Best-effort HD hint. The IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops // Best-effort HD hint. The IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops
// now (YouTube removed them), but the undocumented `vq` URL param is still honoured for many // now (YouTube removed them), and the undocumented `vq` URL param is only occasionally
// videos and is harmless otherwise. The real quality lever is the player's rendered size // honoured — kept because it's harmless. NOTE: YouTube hard-caps an embed's max quality by
// (see max-w-6xl below) — YouTube's ABR targets a resolution to match the pixel box. // 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.
vq: "hd1080", vq: "hd1080",
}, },
events: { events: {
@ -609,15 +581,9 @@ export default function PlayerModal({
ref={stageRef} ref={stageRef}
className="player-stage relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden" className="player-stage relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden"
> >
{/* The player rendered at a fixed 1920×1080 logical size, then CSS-scaled down to fit the {/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
stage this is what unlocks 1080p in the small windowed player (see PLAYER_BASE_*). through our overlay. */}
Hide the iframe entirely on error so YouTube's own error screen can't bleed through. */} <div ref={mountRef} className={`w-full h-full ${playerError != null ? "invisible" : ""}`} />
<div
className="absolute top-0 left-0 origin-top-left"
style={{ width: PLAYER_BASE_W, height: PLAYER_BASE_H, transform: `scale(${playerScale})` }}
>
<div ref={mountRef} className={`w-full h-full ${playerError != null ? "invisible" : ""}`} />
</div>
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) and stops {/* Interaction layer over the CENTRE of the video: catches click (play/pause) and stops
the iframe stealing keyboard focus. It deliberately leaves the top AND bottom edges the iframe stealing keyboard focus. It deliberately leaves the top AND bottom edges
uncovered so YouTube's native controls the top-right cluster (volume / CC / settings) uncovered so YouTube's native controls the top-right cluster (volume / CC / settings)