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:
parent
a1dfa033a9
commit
c4d8258065
1 changed files with 55 additions and 16 deletions
|
|
@ -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<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);
|
||||
// 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<number | null>(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 (
|
||||
<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"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
|
|
@ -575,17 +609,22 @@ export default function PlayerModal({
|
|||
ref={stageRef}
|
||||
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
|
||||
through our overlay. */}
|
||||
<div ref={mountRef} className={`w-full h-full ${playerError != null ? "invisible" : ""}`} />
|
||||
{/* 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. */}
|
||||
<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
|
||||
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 && (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={() => {
|
||||
focusModal();
|
||||
togglePlay();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue