feat(player): unlock 1080p in the windowed player + scroll-anywhere volume

YouTube caps an embedded player's max quality to the iframe's OWN inner viewport
size, so the small windowed player was stuck at ~360p (manual HD selection snapped
back); only fullscreen unlocked 1080p. Render the player at a fixed 1920x1080
logical size and CSS transform: scale() it down to fit the stage — the transform
doesn't change the iframe's window.innerWidth, so YouTube keeps seeing a 1080p
viewport and lets you pick 1080p while we display it small. A ResizeObserver keeps
the scale fitting the stage in both windowed and fullscreen.

Also move wheel-to-volume from the small centre overlay to the whole modal, so
scrolling anywhere over the player window adjusts volume.
This commit is contained in:
npeter83 2026-07-10 16:27:42 +02:00
parent a1dfa033a9
commit c4d8258065

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useLayoutEffect, 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,6 +20,15 @@ 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).
@ -120,8 +129,12 @@ export default function PlayerModal({
// click. `stageRef` is the element we fullscreen (so the volume overlay stays visible). // click. `stageRef` is the element we fullscreen (so the volume overlay stays visible).
const cardRef = useRef<HTMLDivElement | null>(null); const cardRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null); const stageRef = useRef<HTMLDivElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null); // The whole modal (backdrop + card) — the wheel-to-volume target, so scrolling anywhere over the
// modal adjusts volume (not just the small player area).
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
@ -387,10 +400,12 @@ export default function PlayerModal({
}; };
}, [onClose]); }, [onClose]);
// Mouse-wheel volume. A cross-origin iframe swallows wheel events, so we catch them on the // Mouse-wheel volume over the WHOLE modal (backdrop + card), not just the small player area.
// interaction overlay above it (non-passive so preventDefault stops the modal scrolling). // Non-passive so preventDefault stops the modal/page scrolling. Note: the cross-origin iframe
// swallows wheel events over its own native-control strips, but the central interaction overlay
// is our element (wheel there bubbles here), and everything outside the video works directly.
useEffect(() => { useEffect(() => {
const el = overlayRef.current; const el = dialogRef.current;
if (!el) return; if (!el) return;
const onWheel = (e: WheelEvent) => { const onWheel = (e: WheelEvent) => {
e.preventDefault(); e.preventDefault();
@ -399,8 +414,26 @@ export default function PlayerModal({
}; };
el.addEventListener("wheel", onWheel, { passive: false }); el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel); return () => el.removeEventListener("wheel", onWheel);
// Re-attach if the overlay remounts (it's hidden while the embed shows an error). }, []);
}, [playerError]);
// 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
@ -541,6 +574,7 @@ export default function PlayerModal({
return ( return (
<div <div
ref={dialogRef}
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm" className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm"
onClick={onClose} onClick={onClose}
role="dialog" role="dialog"
@ -575,17 +609,22 @@ 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"
> >
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed {/* The player rendered at a fixed 1920×1080 logical size, then CSS-scaled down to fit the
through our overlay. */} stage this is what unlocks 1080p in the small windowed player (see PLAYER_BASE_*).
<div ref={mountRef} className={`w-full h-full ${playerError != null ? "invisible" : ""}`} /> Hide the iframe entirely on error so YouTube's own error screen can't bleed through. */}
{/* Interaction layer over the CENTRE of the video: catches the scroll wheel (volume) <div
and click (play/pause), and stops the iframe stealing keyboard focus. It deliberately className="absolute top-0 left-0 origin-top-left"
leaves the top AND bottom edges uncovered so YouTube's native controls the top-right style={{ width: PLAYER_BASE_W, height: PLAYER_BASE_H, transform: `scale(${playerScale})` }}
cluster (volume / CC / settings) and the bottom bar (seek / More videos / fullscreen) >
stay clickable. Hidden on error so the "Open on YouTube" CTA is clickable. */} <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
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)
and the bottom bar (seek / More videos / fullscreen) stay clickable. Hidden on error
so the "Open on YouTube" CTA is clickable. */}
{playerError == null && ( {playerError == null && (
<div <div
ref={overlayRef}
onClick={() => { onClick={() => {
focusModal(); focusModal();
togglePlay(); togglePlay();