From e03cfbcef3c9ca1fa5aa89285fff7767df3106f3 Mon Sep 17 00:00:00 2001 From: Angelo Elias Dal Zotto Date: Sun, 5 Jul 2026 10:02:21 -0300 Subject: [PATCH 1/2] Add live catch-up --- README.md | 1 + src/config.js | 7 ++ src/live-catchup.ts | 143 +++++++++++++++++++++++++++++++++++++++ src/player_api/yt-api.ts | 5 ++ src/ui.js | 1 + src/userScript.ts | 1 + 6 files changed, 158 insertions(+) create mode 100644 src/live-catchup.ts diff --git a/README.md b/README.md index 3c400c32..f9cc80bd 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ - YouTube Logo Removal - Remove end screens - Bypass account selector screen +- Automatic live catch-up > [!NOTE] > Press the 🟩 **Green** button on your remote to access the configuration screen. diff --git a/src/config.js b/src/config.js index 43ea8551..2097f6bf 100644 --- a/src/config.js +++ b/src/config.js @@ -76,6 +76,13 @@ const configOptions = new Map([ default: false, desc: 'Bypass initial account selection on startup' } + ], + [ + 'enableLiveCatchup', + { + default: false, + desc: 'Reduce live stream delay (skip to live edge)' + } ] ]); diff --git a/src/live-catchup.ts b/src/live-catchup.ts new file mode 100644 index 00000000..8c3542ac --- /dev/null +++ b/src/live-catchup.ts @@ -0,0 +1,143 @@ +import { configRead } from './config'; +import { getPlayerManager } from './player_api'; +import { showNotification } from './ui'; + +const MARGIN_MIN = 1.5; +const MARGIN_MAX = 8; +const MARGIN_GROW = 1.5; +const MARGIN_DECAY = 0.5; +const STABLE_WINDOW_MS = 30000; + +const TRIGGER_OVER_MARGIN = 8; +const OPEN_SETTLE_MS = 3000; +const OPEN_TRIGGER_OVER_MARGIN = 2; + +const playerManager = await getPlayerManager(); +const player = playerManager.player; + +let uiSeekSeen = false; +let suspended = false; +let openedAt: number | null = null; +let openSkipDone = false; + +let margin = 2; +let wasBuffering = false; +let lastMarginChange = Date.now(); + +let selfSeek = false; +let lastKnownTime = 0; + +playerManager.addEventListener('newVideo', () => { + uiSeekSeen = false; + suspended = false; + openedAt = null; + openSkipDone = false; +}); + +document.addEventListener( + 'seeking', + (evt) => { + if (!(evt.target instanceof HTMLVideoElement)) return; + if (selfSeek) { + selfSeek = false; + return; + } + if (!configRead('enableLiveCatchup') || !player.getVideoData().isLive) { + return; + } + + const backward = lastKnownTime - evt.target.currentTime; + if ( + player.getPlayerStateObject().isUiSeeking || + (openSkipDone && backward > 3) + ) { + uiSeekSeen = true; + } + }, + true +); + +function liveEdge(video: HTMLVideoElement): number { + const buffered = video.buffered; + const seekable = video.seekable; + const bufEnd = buffered.length ? buffered.end(buffered.length - 1) : 0; + const seekEnd = seekable.length ? seekable.end(seekable.length - 1) : 0; + return seekEnd > bufEnd && seekEnd - bufEnd < 120 ? seekEnd : bufEnd; +} + +function skipToEdge(video: HTMLVideoElement, edge: number, ahead: number) { + console.info('[live-catchup] behind', ahead.toFixed(1), 's, seeking'); + selfSeek = true; + video.currentTime = edge - margin; + showNotification( + `Live catch-up: skipped ${(ahead - margin).toFixed(0)}s to live edge` + ); +} + +function adaptMargin(state: { isBuffering: boolean; isSeeking: boolean }) { + const stalled = state.isBuffering && !state.isSeeking; + + if (stalled && !wasBuffering && openSkipDone && !suspended) { + margin = Math.min(margin + MARGIN_GROW, MARGIN_MAX); + lastMarginChange = Date.now(); + console.info('[live-catchup] stall, margin now', margin); + } else if ( + margin > MARGIN_MIN && + Date.now() - lastMarginChange > STABLE_WINDOW_MS + ) { + margin = Math.max(MARGIN_MIN, margin - MARGIN_DECAY); + lastMarginChange = Date.now(); + console.info('[live-catchup] stable, margin now', margin); + } + + wasBuffering = stalled; +} + +function tick() { + if (!configRead('enableLiveCatchup') || !player.getVideoData().isLive) { + return; + } + + const state = player.getPlayerStateObject(); + if (state.isUiSeeking) uiSeekSeen = true; + adaptMargin(state); + if (!state.isPlaying || state.isSeeking) return; + + const video = player.querySelector('video'); + if (!video || video.buffered.length === 0) return; + + const edge = liveEdge(video); + const ahead = edge - video.currentTime; + lastKnownTime = video.currentTime; + + if (uiSeekSeen) { + uiSeekSeen = false; + const wantsDvr = ahead > margin + TRIGGER_OVER_MARGIN; + if (wantsDvr !== suspended) { + suspended = wantsDvr; + console.info('[live-catchup]', suspended ? 'suspended' : 'resumed'); + showNotification( + suspended + ? 'Live catch-up paused (manual seek)' + : 'Live catch-up resumed' + ); + } + return; + } + + if (suspended) return; + + if (!openSkipDone) { + openedAt ??= Date.now(); + if (Date.now() - openedAt < OPEN_SETTLE_MS) return; + openSkipDone = true; + if (ahead > margin + OPEN_TRIGGER_OVER_MARGIN) { + skipToEdge(video, edge, ahead); + } + return; + } + + if (ahead > margin + TRIGGER_OVER_MARGIN) skipToEdge(video, edge, ahead); +} + +window.setInterval(tick, 500); diff --git a/src/player_api/yt-api.ts b/src/player_api/yt-api.ts index 704405aa..c86c6c67 100644 --- a/src/player_api/yt-api.ts +++ b/src/player_api/yt-api.ts @@ -23,6 +23,7 @@ export type VideoID = string; export interface VideoData { video_id: VideoID | undefined; + isLive?: boolean; } export type PlayerStateKeys = @@ -78,4 +79,8 @@ export interface YTPlayer extends HTMLElement { isInline(): boolean; getVideoStats(): VideoStats; + + getPlaybackRate(): number; + + setPlaybackRate(rate: number): void; } diff --git a/src/ui.js b/src/ui.js index c7c34b1a..16d8034b 100644 --- a/src/ui.js +++ b/src/ui.js @@ -130,6 +130,7 @@ function createOptionsPanel() { elmContainer.appendChild(createConfigCheckbox('forceHighResVideo')); elmContainer.appendChild(createConfigCheckbox('removeEndscreen')); elmContainer.appendChild(createConfigCheckbox('autoAccountSelect')); + elmContainer.appendChild(createConfigCheckbox('enableLiveCatchup')); elmContainer.appendChild(createConfigCheckbox('enableSponsorBlock')); const elmBlock = document.createElement('blockquote'); diff --git a/src/userScript.ts b/src/userScript.ts index 1edea6b7..c3c1eb0f 100644 --- a/src/userScript.ts +++ b/src/userScript.ts @@ -29,3 +29,4 @@ import './remove-endscreen'; import './hooks'; import './block-webos-cast'; import './auto-account-select'; +import './live-catchup'; From 4264c7e0a9b63961c3911a2f4a856a513a392a98 Mon Sep 17 00:00:00 2001 From: Angelo Elias Dal Zotto Date: Sun, 5 Jul 2026 10:02:35 -0300 Subject: [PATCH 2/2] Allow scrolling on options --- src/ui.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui.css b/src/ui.css index b90ea08c..8ce06a18 100644 --- a/src/ui.css +++ b/src/ui.css @@ -13,6 +13,7 @@ padding: 1em; font-size: 1.4rem; z-index: 1000; + overflow-y: auto; } .ytaf-ui-container :focus {