Merge: promote dev to prod
This commit is contained in:
@@ -60,32 +60,6 @@ import { useScrollFade } from "../lib/useScrollFade";
|
|||||||
// 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;
|
||||||
|
|
||||||
// Force a high starting resolution. YouTube decides quality at player-CREATION time from the
|
|
||||||
// iframe's INTERNAL viewport size, then latches it. A windowed-size embed (~900×500) is stuck at
|
|
||||||
// ~360–480p, which is why the video used to open low and needed a manual fullscreen + gear trip to
|
|
||||||
// 1080p.
|
|
||||||
//
|
|
||||||
// Two phases (see applyHdPhase1 / switchToHdNative / the quality poll):
|
|
||||||
// 1. Create the iframe at this large FIXED logical size and CSS `transform: scale()` it DOWN to fit
|
|
||||||
// the visible box. A CSS transform does NOT change the iframe's internal viewport, so YouTube
|
|
||||||
// still sees 1920×1080 and latches 1080p — even while it paints small. (Its own controls, incl.
|
|
||||||
// the gear, also paint scaled-down in the windowed box — the one downside of phase 1 alone.)
|
|
||||||
// 2. Once 1080p has actually latched, drop to NATIVE responsive sizing (setSize) so YouTube renders
|
|
||||||
// its controls at full size.
|
|
||||||
//
|
|
||||||
// ⚠️ CONDITIONAL / UNDER OBSERVATION (2026-07-31): phase 2 is NOT proven safe everywhere. In dev, on
|
|
||||||
// ONE `was_live` stream, quality dropped hd1080 → large (480p) ~2s after going native (YouTube
|
|
||||||
// re-caps to the now-small internal viewport); on the author's own machine/connection it HELD 1080p
|
|
||||||
// on a normal VOD. So it's environment-/video-dependent and is being watched in prod. Two levers:
|
|
||||||
// • HD_DROP_TO_NATIVE = false → skip phase 2, keep phase 1 forever = GUARANTEED 1080p (the viewport
|
|
||||||
// stays 1920), at the cost of the scaled-down controls. The safe fallback if phase 2 downgrades.
|
|
||||||
// • FORCE_HD_LOGICAL_SIZE = false → disable the whole trick, back to responsive 100%/100% sizing.
|
|
||||||
// 16:9 to match the `aspect-video` stage.
|
|
||||||
const FORCE_HD_LOGICAL_SIZE = true;
|
|
||||||
const HD_DROP_TO_NATIVE = true;
|
|
||||||
const HD_W = 1920;
|
|
||||||
const HD_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).
|
||||||
@@ -289,13 +263,6 @@ export default function PlayerModal({
|
|||||||
const resumeAt =
|
const resumeAt =
|
||||||
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
|
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
|
||||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||||
// The box the (fixed HD-sized) player iframe is scaled to fit — see FORCE_HD_LOGICAL_SIZE.
|
|
||||||
const videoBoxRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
// Rescales the HD iframe into videoBoxRef; set up once (ResizeObserver) and also called on ready.
|
|
||||||
const fitPlayerRef = useRef<(() => void) | null>(null);
|
|
||||||
// HD phase: false = phase 1 (fixed 1920×1080 iframe scaled down, to latch 1080p); true = phase 2
|
|
||||||
// (native responsive sizing, so YouTube's own controls render full-size once 1080p has latched).
|
|
||||||
const hdNativeRef = useRef(false);
|
|
||||||
const playerRef = useRef<YTPlayer | null>(null);
|
const playerRef = useRef<YTPlayer | null>(null);
|
||||||
const autoMarkedRef = useRef(false);
|
const autoMarkedRef = useRef(false);
|
||||||
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a
|
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a
|
||||||
@@ -456,42 +423,6 @@ export default function PlayerModal({
|
|||||||
// 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error.
|
// 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error.
|
||||||
const [playerError, setPlayerError] = useState<number | null>(null);
|
const [playerError, setPlayerError] = useState<number | null>(null);
|
||||||
|
|
||||||
// Put the HD iframe into phase 1 (fixed 1920×1080, scaled to fit) so YouTube (re)latches 1080p from
|
|
||||||
// a full-HD internal viewport. Called on player-ready and when a NEW video is loaded into the same
|
|
||||||
// player (a description link) — otherwise the new clip would start at the native box's low quality.
|
|
||||||
const applyHdPhase1 = () => {
|
|
||||||
if (!FORCE_HD_LOGICAL_SIZE) return;
|
|
||||||
hdNativeRef.current = false;
|
|
||||||
const f = playerRef.current?.getIframe?.();
|
|
||||||
if (f) {
|
|
||||||
// Anchor the fixed-HD iframe top-left of its box so the scale-transform (fitPlayerRef) fits it
|
|
||||||
// in; `maxWidth:none` guards against a global iframe { max-width:100% } reset.
|
|
||||||
f.style.position = "absolute";
|
|
||||||
f.style.top = "0";
|
|
||||||
f.style.left = "0";
|
|
||||||
f.style.width = `${HD_W}px`;
|
|
||||||
f.style.height = `${HD_H}px`;
|
|
||||||
f.style.maxWidth = "none";
|
|
||||||
f.style.transformOrigin = "0 0";
|
|
||||||
}
|
|
||||||
fitPlayerRef.current?.();
|
|
||||||
};
|
|
||||||
// Once 1080p has latched (see the quality poll), drop to native responsive sizing so YouTube renders
|
|
||||||
// its own controls — the gear especially — at full size. Gated by HD_DROP_TO_NATIVE (see the note at
|
|
||||||
// the top): if phase 2 proves to downgrade in the wild, flip that off to keep phase 1 forever.
|
|
||||||
const switchToHdNative = () => {
|
|
||||||
const f = playerRef.current?.getIframe?.();
|
|
||||||
if (!f) return;
|
|
||||||
hdNativeRef.current = true;
|
|
||||||
f.style.position = "";
|
|
||||||
f.style.top = "";
|
|
||||||
f.style.left = "";
|
|
||||||
f.style.transform = "none";
|
|
||||||
f.style.transformOrigin = "";
|
|
||||||
f.style.maxWidth = "";
|
|
||||||
fitPlayerRef.current?.(); // now runs the native setSize branch
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadVideo = (id: string, start: number | null) => {
|
const loadVideo = (id: string, start: number | null) => {
|
||||||
const p = playerRef.current;
|
const p = playerRef.current;
|
||||||
if (!p || typeof p.loadVideoById !== "function") return;
|
if (!p || typeof p.loadVideoById !== "function") return;
|
||||||
@@ -499,9 +430,6 @@ export default function PlayerModal({
|
|||||||
const startSeconds = start != null ? start : id === active.id ? resumeAt : 0;
|
const startSeconds = start != null ? start : id === active.id ? resumeAt : 0;
|
||||||
setPlayerError(null);
|
setPlayerError(null);
|
||||||
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
|
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
|
||||||
// Re-latch 1080p for the newly-loaded clip: back to phase 1 (the quality poll flips it to native
|
|
||||||
// once 1080p lands). Without this a description-link video would inherit the native box's low pick.
|
|
||||||
applyHdPhase1();
|
|
||||||
currentIdRef.current = id;
|
currentIdRef.current = id;
|
||||||
setCurrentVideoId(id);
|
setCurrentVideoId(id);
|
||||||
setLiveData(null);
|
setLiveData(null);
|
||||||
@@ -804,53 +732,12 @@ export default function PlayerModal({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Keep the fixed-HD player iframe scaled to fit its visible box (see FORCE_HD_LOGICAL_SIZE). The
|
|
||||||
// iframe stays 1920×1080 internally (so YouTube serves 1080p) while we CSS-scale it DOWN to the
|
|
||||||
// box with a letterbox-`contain` fit — one factor + centring handles both the 16:9 windowed box
|
|
||||||
// and the non-16:9 big-view box (viewport minus the 56px control bar). Bound once; reads live refs.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!FORCE_HD_LOGICAL_SIZE) return;
|
|
||||||
const box = videoBoxRef.current;
|
|
||||||
if (!box) return;
|
|
||||||
const fit = () => {
|
|
||||||
const p = playerRef.current;
|
|
||||||
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
|
|
||||||
if (!f) return;
|
|
||||||
const bw = box.clientWidth;
|
|
||||||
const bh = box.clientHeight;
|
|
||||||
if (!bw || !bh) return;
|
|
||||||
if (hdNativeRef.current) {
|
|
||||||
// Phase 2: native responsive sizing — the iframe IS the box, so YouTube's controls render
|
|
||||||
// at full size. Set the inline size explicitly too: phase 1 left an inline 1920×1080px on the
|
|
||||||
// element, which would otherwise override setSize's attributes and keep it overflowing.
|
|
||||||
f.style.width = `${bw}px`;
|
|
||||||
f.style.height = `${bh}px`;
|
|
||||||
if (p && typeof p.setSize === "function") p.setSize(bw, bh);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Phase 1: latch 1080p — scale the fixed 1920×1080 iframe DOWN to fit, letterbox-`contain`.
|
|
||||||
const k = Math.min(bw / HD_W, bh / HD_H);
|
|
||||||
const cx = (bw - HD_W * k) / 2;
|
|
||||||
const cy = (bh - HD_H * k) / 2;
|
|
||||||
f.style.transform = `translate(${cx}px, ${cy}px) scale(${k})`;
|
|
||||||
};
|
|
||||||
fitPlayerRef.current = fit;
|
|
||||||
const ro = new ResizeObserver(fit);
|
|
||||||
ro.observe(box);
|
|
||||||
return () => {
|
|
||||||
ro.disconnect();
|
|
||||||
fitPlayerRef.current = null;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Create the player, resume from the saved position, persist progress, and
|
// Create the player, resume from the saved position, persist progress, and
|
||||||
// auto-mark watched once playback reaches the end.
|
// auto-mark watched once playback reaches the end.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const id = active.id;
|
const id = active.id;
|
||||||
autoMarkedRef.current = false;
|
autoMarkedRef.current = false;
|
||||||
// A fresh player starts in HD phase 1 (scaled), regardless of what the previous video ended on.
|
|
||||||
hdNativeRef.current = false;
|
|
||||||
setPlayerError(null);
|
setPlayerError(null);
|
||||||
|
|
||||||
// Auto-watch only applies to the active item — not to other videos the player
|
// Auto-watch only applies to the active item — not to other videos the player
|
||||||
@@ -890,10 +777,8 @@ export default function PlayerModal({
|
|||||||
loadYouTubeApi().then((YT) => {
|
loadYouTubeApi().then((YT) => {
|
||||||
if (cancelled || !mountRef.current) return;
|
if (cancelled || !mountRef.current) return;
|
||||||
playerRef.current = new YT.Player(mountRef.current, {
|
playerRef.current = new YT.Player(mountRef.current, {
|
||||||
// A FIXED large logical size (scaled down to fit via fitPlayerRef) so YouTube starts at
|
width: "100%",
|
||||||
// 1080p; falls back to the old responsive sizing if the trick is disabled. See HD_W/HD_H.
|
height: "100%",
|
||||||
width: FORCE_HD_LOGICAL_SIZE ? String(HD_W) : "100%",
|
|
||||||
height: FORCE_HD_LOGICAL_SIZE ? String(HD_H) : "100%",
|
|
||||||
videoId: id,
|
videoId: id,
|
||||||
playerVars: {
|
playerVars: {
|
||||||
autoplay: 1,
|
autoplay: 1,
|
||||||
@@ -904,21 +789,16 @@ export default function PlayerModal({
|
|||||||
playsinline: 1,
|
playsinline: 1,
|
||||||
// Best-effort HD hint. The IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops
|
// Best-effort HD hint. The IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops
|
||||||
// now (YouTube removed them), and the undocumented `vq` URL param is only occasionally
|
// now (YouTube removed them), and the undocumented `vq` URL param is only occasionally
|
||||||
// honoured — kept because it's harmless. The real HD lever is the fixed HD_W×HD_H iframe
|
// honoured — kept because it's harmless. NOTE: YouTube hard-caps an embed's max quality by
|
||||||
// size above (see FORCE_HD_LOGICAL_SIZE): YouTube reads quality from the iframe's INTERNAL
|
// the player's on-screen size; a windowed embed is stuck ~360p and only true fullscreen
|
||||||
// viewport at creation, which a CSS transform-scale does not shrink — so scaling a LARGE
|
// lifts the cap (after which the higher pick persists for the session). We can't beat that
|
||||||
// player DOWN keeps the 1080p pick (an earlier note here that "the scale trick fails" was
|
// programmatically — a CSS transform-scale trick was tried and does NOT fool the cap.
|
||||||
// scaling a SMALL player UP, which genuinely doesn't fool the cap; the opposite direction
|
|
||||||
// does — measured 2026-07-31).
|
|
||||||
vq: "hd1080",
|
vq: "hd1080",
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away, and
|
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away, and
|
||||||
// restore the remembered volume — a fresh player always starts at 100.
|
// restore the remembered volume — a fresh player always starts at 100.
|
||||||
onReady: () => {
|
onReady: () => {
|
||||||
// Start in phase 1 (fixed-HD iframe scaled to fit) so YouTube latches 1080p; the quality
|
|
||||||
// poll below flips to native sizing once it lands.
|
|
||||||
applyHdPhase1();
|
|
||||||
focusModal();
|
focusModal();
|
||||||
applyVolume(volumeRef.current, false);
|
applyVolume(volumeRef.current, false);
|
||||||
},
|
},
|
||||||
@@ -961,18 +841,6 @@ export default function PlayerModal({
|
|||||||
|
|
||||||
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
|
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
|
||||||
const timer = window.setInterval(persist, 5000);
|
const timer = window.setInterval(persist, 5000);
|
||||||
// HD phase 2 trigger: once the (phase-1, scaled) player has actually latched 1080p, drop to native
|
|
||||||
// sizing so YouTube's controls render full-size. Only at hd1080+. Gated by HD_DROP_TO_NATIVE — the
|
|
||||||
// conditional/under-observation switch (see the note up top): a video that never reaches 1080p, a
|
|
||||||
// constrained network, or HD_DROP_TO_NATIVE=false all keep phase 1 (the guaranteed-1080p fallback).
|
|
||||||
// Re-arms automatically after a description-link load (applyHdPhase1 resets the flag).
|
|
||||||
const HD_NATIVE_QUALITIES = new Set(["hd1080", "hd1440", "hd2160"]);
|
|
||||||
const hdPoll = window.setInterval(() => {
|
|
||||||
if (!FORCE_HD_LOGICAL_SIZE || !HD_DROP_TO_NATIVE || hdNativeRef.current) return;
|
|
||||||
const p = playerRef.current;
|
|
||||||
const q = p && typeof p.getPlaybackQuality === "function" ? p.getPlaybackQuality() : "";
|
|
||||||
if (HD_NATIVE_QUALITIES.has(q)) switchToHdNative();
|
|
||||||
}, 700);
|
|
||||||
// Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload, so
|
// Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload, so
|
||||||
// without this the resume point lags up to 5s (a seek right before F5 would be lost). A keepalive
|
// without this the resume point lags up to 5s (a seek right before F5 would be lost). A keepalive
|
||||||
// beacon so the POST survives the unload. Only for the library item we opened with — a navigated
|
// beacon so the POST survives the unload. Only for the library item we opened with — a navigated
|
||||||
@@ -993,7 +861,6 @@ export default function PlayerModal({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
window.clearInterval(hdPoll);
|
|
||||||
window.removeEventListener("pagehide", onHide);
|
window.removeEventListener("pagehide", onHide);
|
||||||
// Final flush, then refresh the feed + playlists so resume bars reflect this session.
|
// Final flush, then refresh the feed + playlists so resume bars reflect this session.
|
||||||
Promise.resolve(persist()).finally(() => {
|
Promise.resolve(persist()).finally(() => {
|
||||||
@@ -1065,11 +932,8 @@ export default function PlayerModal({
|
|||||||
YT.Player REPLACES the mount element with its iframe (width/height 100% of the parent),
|
YT.Player REPLACES the mount element with its iframe (width/height 100% of the parent),
|
||||||
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
|
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
|
||||||
<div
|
<div
|
||||||
ref={videoBoxRef}
|
|
||||||
className={
|
className={
|
||||||
maximized || isFullscreen
|
maximized || isFullscreen ? "absolute inset-x-0 top-0 bottom-14" : "w-full h-full"
|
||||||
? "absolute inset-x-0 top-0 bottom-14 overflow-hidden"
|
|
||||||
: "relative w-full h-full overflow-hidden"
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -15,11 +15,6 @@ export interface YTPlayer {
|
|||||||
getDuration(): number;
|
getDuration(): number;
|
||||||
getVideoData(): YTVideoData;
|
getVideoData(): YTVideoData;
|
||||||
getIframe(): HTMLIFrameElement;
|
getIframe(): HTMLIFrameElement;
|
||||||
// Current latched playback quality (e.g. "hd1080") — used to detect when to drop the HD-latch
|
|
||||||
// iframe from its scaled-up size to native sizing. `setPlaybackQuality` is deliberately NOT typed:
|
|
||||||
// YouTube retired it (a hard no-op) — see [[reference-yt-iframe-api-remote-control-map]].
|
|
||||||
getPlaybackQuality(): string;
|
|
||||||
setSize(width: number, height: number): void;
|
|
||||||
getVolume(): number;
|
getVolume(): number;
|
||||||
isMuted(): boolean;
|
isMuted(): boolean;
|
||||||
setVolume(volume: number): void;
|
setVolume(volume: number): void;
|
||||||
|
|||||||
Reference in New Issue
Block a user