Merge: promote dev to prod
This commit is contained in:
commit
a11d3aa158
3 changed files with 75 additions and 8 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.36.2
|
0.36.3
|
||||||
|
|
@ -20,6 +20,14 @@ 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;
|
||||||
|
|
||||||
|
// One-time HD-unlock (see attemptHdUnlock). YouTube hard-caps a windowed embed to ~360p and only a
|
||||||
|
// real fullscreen lifts the cap; the lift then persists for the whole page session. So on the first
|
||||||
|
// video opened we briefly flash the player to fullscreen and back to coax YouTube past the cap once.
|
||||||
|
// Cap the flash at this many ms, but exit as soon as we see the quality actually rise.
|
||||||
|
const HD_UNLOCK_MAX_MS = 2500;
|
||||||
|
// Module-scoped so the flash happens only ONCE per page session (the unlock persists afterwards).
|
||||||
|
let hdUnlockDone = false;
|
||||||
|
|
||||||
// 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).
|
||||||
|
|
@ -124,6 +132,9 @@ export default function PlayerModal({
|
||||||
// modal adjusts volume (not just the small player area).
|
// modal adjusts volume (not just the small player area).
|
||||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||||
const volTimerRef = useRef<number | undefined>(undefined);
|
const volTimerRef = useRef<number | undefined>(undefined);
|
||||||
|
// HD-unlock flash bookkeeping (see attemptHdUnlock / finishHdUnlock).
|
||||||
|
const unlockTimerRef = useRef<number | undefined>(undefined);
|
||||||
|
const unlockingRef = useRef(false);
|
||||||
// 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
|
||||||
|
|
@ -146,6 +157,32 @@ export default function PlayerModal({
|
||||||
if (document.fullscreenElement) document.exitFullscreen?.();
|
if (document.fullscreenElement) document.exitFullscreen?.();
|
||||||
else el.requestFullscreen?.();
|
else el.requestFullscreen?.();
|
||||||
};
|
};
|
||||||
|
// End the HD-unlock flash: exit fullscreen (if we're still in it) and mark the session unlocked so
|
||||||
|
// it never flashes again. Called either when YouTube's quality is seen to rise or on the max timeout.
|
||||||
|
const finishHdUnlock = () => {
|
||||||
|
if (!unlockingRef.current) return;
|
||||||
|
unlockingRef.current = false;
|
||||||
|
window.clearTimeout(unlockTimerRef.current);
|
||||||
|
hdUnlockDone = true;
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
|
||||||
|
};
|
||||||
|
// Coax YouTube past its windowed 360p cap: on the first opened video, briefly enter fullscreen so
|
||||||
|
// YouTube lifts the cap (which then persists for the session), then exit back to the small player.
|
||||||
|
// Needs live user activation (the modal-open click) — silently retried on the next click if it's
|
||||||
|
// already expired, and a no-op once the session is unlocked. Best-effort; can't be forced further.
|
||||||
|
const attemptHdUnlock = () => {
|
||||||
|
if (hdUnlockDone || unlockingRef.current) return;
|
||||||
|
const el = stageRef.current;
|
||||||
|
if (!el || !el.requestFullscreen || document.fullscreenElement) return;
|
||||||
|
el.requestFullscreen()
|
||||||
|
.then(() => {
|
||||||
|
unlockingRef.current = true;
|
||||||
|
unlockTimerRef.current = window.setTimeout(finishHdUnlock, HD_UNLOCK_MAX_MS);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* no user activation yet / fullscreen blocked — leave hdUnlockDone false to retry on a click */
|
||||||
|
});
|
||||||
|
};
|
||||||
const flashVolume = (level: number) => {
|
const flashVolume = (level: number) => {
|
||||||
setVolumeUi(level);
|
setVolumeUi(level);
|
||||||
window.clearTimeout(volTimerRef.current);
|
window.clearTimeout(volTimerRef.current);
|
||||||
|
|
@ -488,18 +525,24 @@ 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. NOTE: YouTube hard-caps an embed's max quality by
|
// honoured — kept because it's harmless. YouTube hard-caps an embed's max quality by the
|
||||||
// the player's on-screen size; a windowed embed is stuck ~360p and only true fullscreen
|
// player's on-screen size; a windowed embed is stuck ~360p and only true fullscreen lifts
|
||||||
// lifts the cap (after which the higher pick persists for the session). We can't beat that
|
// the cap (then it persists for the session). A CSS transform-scale trick does NOT fool it;
|
||||||
// programmatically — a CSS transform-scale trick was tried and does NOT fool the cap.
|
// we instead do a one-time fullscreen flash on open (see attemptHdUnlock) to lift the cap.
|
||||||
vq: "hd1080",
|
vq: "hd1080",
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away.
|
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away, and try
|
||||||
onReady: () => focusModal(),
|
// the one-time HD-unlock flash while the modal-open click is still a live user activation.
|
||||||
|
onReady: () => {
|
||||||
|
focusModal();
|
||||||
|
attemptHdUnlock();
|
||||||
|
},
|
||||||
onStateChange: (e: any) => {
|
onStateChange: (e: any) => {
|
||||||
// 1 === playing → sync the navigated-to video's title/author for display.
|
// 1 === playing → sync the navigated-to video's title/author for display, and retry the
|
||||||
|
// HD-unlock (a second chance at the flash right as playback begins).
|
||||||
if (e?.data === 1) {
|
if (e?.data === 1) {
|
||||||
|
attemptHdUnlock();
|
||||||
const p = playerRef.current;
|
const p = playerRef.current;
|
||||||
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
|
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
|
||||||
if (d) setLiveData({ title: d.title, author: d.author });
|
if (d) setLiveData({ title: d.title, author: d.author });
|
||||||
|
|
@ -513,6 +556,15 @@ export default function PlayerModal({
|
||||||
advanceOnEnd();
|
advanceOnEnd();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// While the HD-unlock flash is running, exit fullscreen as soon as YouTube's quality is
|
||||||
|
// seen to rise (no need to hold the flash the full timeout). onPlaybackQualityChange is one
|
||||||
|
// of the few still-live quality signals; e.data is a label ("hd1080"/"hd720"/"large"/…).
|
||||||
|
onPlaybackQualityChange: (e: any) => {
|
||||||
|
const q = String(e?.data || "");
|
||||||
|
if (unlockingRef.current && (q === "hd1080" || q === "hd720" || q === "large" || q === "highres")) {
|
||||||
|
finishHdUnlock();
|
||||||
|
}
|
||||||
|
},
|
||||||
// The embed couldn't play this video (embedding disabled, removed, private…).
|
// The embed couldn't play this video (embedding disabled, removed, private…).
|
||||||
// Surface our own message + an "Open on YouTube" escape hatch.
|
// Surface our own message + an "Open on YouTube" escape hatch.
|
||||||
onError: (e: any) => setPlayerError(typeof e?.data === "number" ? e.data : -1),
|
onError: (e: any) => setPlayerError(typeof e?.data === "number" ? e.data : -1),
|
||||||
|
|
@ -526,6 +578,12 @@ export default function PlayerModal({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
|
// If the modal closes / advances mid-flash, don't leave the player stuck in fullscreen.
|
||||||
|
window.clearTimeout(unlockTimerRef.current);
|
||||||
|
if (unlockingRef.current) {
|
||||||
|
unlockingRef.current = false;
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
|
||||||
|
}
|
||||||
// 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(() => {
|
||||||
qc.invalidateQueries({ queryKey: ["feed"] });
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
|
|
@ -593,6 +651,7 @@ export default function PlayerModal({
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
focusModal();
|
focusModal();
|
||||||
|
attemptHdUnlock(); // fallback: a real click is a fresh user activation for the flash
|
||||||
togglePlay();
|
togglePlay();
|
||||||
}}
|
}}
|
||||||
title={t("player.shortcutsHint")}
|
title={t("player.shortcutsHint")}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,14 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.36.3",
|
||||||
|
date: "2026-07-10",
|
||||||
|
summary: "Experimental: the player tries to unlock higher video quality automatically.",
|
||||||
|
features: [
|
||||||
|
"The in-app player now tries to lift YouTube's windowed quality limit for you. YouTube caps a small embedded player to a low resolution and only real fullscreen unlocks HD — so on the first video you open in a session, the player briefly flashes to fullscreen and back to trigger that unlock, which then sticks for the rest of the session. You may notice a short flash on that first video; later videos won't flash. (It's still bandwidth-dependent, and can be reverted if it doesn't help on your setup.)",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.36.2",
|
version: "0.36.2",
|
||||||
date: "2026-07-10",
|
date: "2026-07-10",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue