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 { 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();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue