Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)'
}
]
]);

Expand Down
143 changes: 143 additions & 0 deletions src/live-catchup.ts
Original file line number Diff line number Diff line change
@@ -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);
5 changes: 5 additions & 0 deletions src/player_api/yt-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type VideoID = string;

export interface VideoData {
video_id: VideoID | undefined;
isLive?: boolean;
}

export type PlayerStateKeys =
Expand Down Expand Up @@ -78,4 +79,8 @@ export interface YTPlayer extends HTMLElement {
isInline(): boolean;

getVideoStats(): VideoStats;

getPlaybackRate(): number;

setPlaybackRate(rate: number): void;
}
1 change: 1 addition & 0 deletions src/ui.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
padding: 1em;
font-size: 1.4rem;
z-index: 1000;
overflow-y: auto;
}

.ytaf-ui-container :focus {
Expand Down
1 change: 1 addition & 0 deletions src/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
1 change: 1 addition & 0 deletions src/userScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ import './remove-endscreen';
import './hooks';
import './block-webos-cast';
import './auto-account-select';
import './live-catchup';