From c4d82580655fc94e37f1b1df20f54788d208d0ad Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 10 Jul 2026 16:27:42 +0200 Subject: [PATCH 1/2] 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(); From efd7a3d09dfa91dafd66cbd9a93f9f4b2a3dad7e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 10 Jul 2026 16:27:43 +0200 Subject: [PATCH 2/2] chore(release): 0.36.1 --- VERSION | 2 +- frontend/src/lib/releaseNotes.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 28f0245..9885af0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.36.0 \ No newline at end of file +0.36.1 \ No newline at end of file diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 1899328..e3134f3 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,15 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.36.1", + date: "2026-07-10", + summary: "Full-HD in the normal (windowed) player too, and scroll-anywhere volume.", + features: [ + "Higher quality without going fullscreen: YouTube used to cap the small in-app player to a low resolution (only fullscreen unlocked 1080p). The player now renders at full HD internally and scales to fit, so you can pick 1080p in the normal windowed size too (still subject to your connection speed).", + "Scroll to change the volume now works anywhere over the player window — not only directly on the video.", + ], + }, { version: "0.36.0", date: "2026-07-10",