From ca006446981c1f6f12da48ef3b0ab4037aa7e3ce Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 10 Jul 2026 17:30:49 +0200 Subject: [PATCH 1/2] feat(player): experimental one-time fullscreen flash to unlock windowed HD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YouTube hard-caps a windowed embed to ~360p and only a real fullscreen lifts the cap — after which the higher quality persists for the session. Since we can't set quality via the (dead) API, coax it: on the first video opened per page session, briefly enter fullscreen using the modal-open click's live user activation, then exit back to the small player. Exits early once onPlaybackQualityChange reports the quality actually rose (else after a ms cap). Module-scoped hdUnlockDone flag makes it fire once per session (the unlock persists), with a retry on the first overlay click if the open-click activation had expired. Cleaned up on teardown so a mid-flash close can't leave the player stuck in fullscreen. Best-effort — bandwidth still gates the actual bitrate. --- frontend/src/components/PlayerModal.tsx | 73 ++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 88568a5..9d592f5 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -20,6 +20,14 @@ import { useBackToClose } from "../lib/history"; // How close to the end (seconds) counts as "finished" → auto-mark watched. 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 // 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). @@ -124,6 +132,9 @@ export default function PlayerModal({ // modal adjusts volume (not just the small player area). const dialogRef = useRef(null); const volTimerRef = useRef(undefined); + // HD-unlock flash bookkeeping (see attemptHdUnlock / finishHdUnlock). + const unlockTimerRef = useRef(undefined); + const unlockingRef = useRef(false); // 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 @@ -146,6 +157,32 @@ export default function PlayerModal({ if (document.fullscreenElement) document.exitFullscreen?.(); 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) => { setVolumeUi(level); window.clearTimeout(volTimerRef.current); @@ -488,18 +525,24 @@ export default function PlayerModal({ playsinline: 1, // 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 - // honoured — kept because it's harmless. NOTE: YouTube hard-caps an embed's max quality by - // the player's on-screen size; a windowed embed is stuck ~360p and only true fullscreen - // lifts the cap (after which the higher pick persists for the session). We can't beat that - // programmatically — a CSS transform-scale trick was tried and does NOT fool the cap. + // honoured — kept because it's harmless. YouTube hard-caps an embed's max quality by the + // player's on-screen size; a windowed embed is stuck ~360p and only true fullscreen lifts + // the cap (then it persists for the session). A CSS transform-scale trick does NOT fool it; + // we instead do a one-time fullscreen flash on open (see attemptHdUnlock) to lift the cap. vq: "hd1080", }, events: { - // Keep keyboard focus on the modal, not the iframe, so shortcuts work right away. - onReady: () => focusModal(), + // Keep keyboard focus on the modal, not the iframe, so shortcuts work right away, and try + // the one-time HD-unlock flash while the modal-open click is still a live user activation. + onReady: () => { + focusModal(); + attemptHdUnlock(); + }, 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) { + attemptHdUnlock(); const p = playerRef.current; const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null; if (d) setLiveData({ title: d.title, author: d.author }); @@ -513,6 +556,15 @@ export default function PlayerModal({ 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…). // Surface our own message + an "Open on YouTube" escape hatch. onError: (e: any) => setPlayerError(typeof e?.data === "number" ? e.data : -1), @@ -526,6 +578,12 @@ export default function PlayerModal({ return () => { cancelled = true; 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. Promise.resolve(persist()).finally(() => { qc.invalidateQueries({ queryKey: ["feed"] }); @@ -593,6 +651,7 @@ export default function PlayerModal({
{ focusModal(); + attemptHdUnlock(); // fallback: a real click is a fresh user activation for the flash togglePlay(); }} title={t("player.shortcutsHint")} From 50cef1a55230dbcae1e752140554ab004a293a64 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 10 Jul 2026 17:30:50 +0200 Subject: [PATCH 2/2] chore(release): 0.36.3 --- VERSION | 2 +- frontend/src/lib/releaseNotes.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 6d59e65..f412377 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.36.2 \ No newline at end of file +0.36.3 \ No newline at end of file diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index c843490..b2755e0 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,14 @@ export interface 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", date: "2026-07-10",