From c4d82580655fc94e37f1b1df20f54788d208d0ad Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 10 Jul 2026 16:27:42 +0200 Subject: [PATCH] feat(player): unlock 1080p in the windowed player + scroll-anywhere volume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/components/PlayerModal.tsx | 71 +++++++++++++++++++------ 1 file changed, 55 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 44ca820..75dd8dd 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { createPortal } from "react-dom"; 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. 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 // 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). @@ -120,8 +129,12 @@ export default function PlayerModal({ // click. `stageRef` is the element we fullscreen (so the volume overlay stays visible). const cardRef = useRef(null); const stageRef = useRef(null); - const overlayRef = useRef(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(null); const volTimerRef = useRef(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. const [volumeUi, setVolumeUi] = useState(null); // 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]); - // Mouse-wheel volume. A cross-origin iframe swallows wheel events, so we catch them on the - // interaction overlay above it (non-passive so preventDefault stops the modal scrolling). + // Mouse-wheel volume over the WHOLE modal (backdrop + card), not just the small player area. + // 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(() => { - const el = overlayRef.current; + const el = dialogRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); @@ -399,8 +414,26 @@ export default function PlayerModal({ }; el.addEventListener("wheel", onWheel, { passive: false }); 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 // 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 (
- {/* Hide the iframe entirely on error so YouTube's own error screen can't bleed - through our overlay. */} -
- {/* Interaction layer over the CENTRE of the video: catches the scroll wheel (volume) - and 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. */} + {/* The player rendered at a fixed 1920×1080 logical size, then CSS-scaled down to fit the + stage — this is what unlocks 1080p in the small windowed player (see PLAYER_BASE_*). + 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 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 && (
{ focusModal(); togglePlay();