Skip to content
Merged
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
28 changes: 27 additions & 1 deletion mods/features/adblock.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { timelyAction, longPressData, MenuServiceItemRenderer, ShelfRenderer, Ti
import { PatchSettings } from '../ui/customYTSettings.js';
import { t } from 'i18next';
import './logServer.js';
import { autoStartCollect } from './playlistBatchCollect.js';
import { autoStartCollect, getCachedFullPlaylist, noteInitialPlaylistContents } from './playlistBatchCollect.js';
import {
appendFileOnlyLog,
detectAndStorePage,
Expand Down Expand Up @@ -158,6 +158,7 @@ function attemptPlaylistAutoLoad(reason = 'playlist.auto_load', attempt = 0) {
appendFileOnlyLog('playlist.auto_load.trigger', { reason, attempt, method: 'no_trigger' });
}
window.__ttAttemptPlaylistAutoLoad = attemptPlaylistAutoLoad;
window.__ttResolveCommand = resolveCommand;

function schedulePlaylistAutoLoad(reason = 'playlist.auto_load') {
if ((window.__ttLastDetectedPage || detectCurrentPage()) !== 'playlist') return;
Expand Down Expand Up @@ -917,10 +918,35 @@ JSON.parse = function () {

const topPlaylistRenderer = r?.contents?.tvBrowseRenderer?.content?.tvSurfaceContentRenderer?.content?.twoColumnRenderer?.rightColumn?.playlistVideoListRenderer;
if (topPlaylistRenderer?.contents) {
// If a previous visit (or the pre-reload pass) already collected this
// playlist in full, serve it here instead. Nulling continuations is the
// whole point: with no continuation token there is no keep-one branch,
// so no helper tile is created and nothing gets stranded in the virtual
// list's data model. Starving YouTube's refill loop is safe here
// precisely because the list is already complete — nothing further
// needs loading.
const playlistKey = String(window.location?.hash || '');
const cachedFull = getCachedFullPlaylist(playlistKey);
if (cachedFull) {
appendFileOnlyLog('playlist.full_cache.injected', {
key: playlistKey,
items: cachedFull.length,
replaced: topPlaylistRenderer.contents.length,
});
topPlaylistRenderer.contents = cachedFull.slice();
topPlaylistRenderer.continuations = null;
} else {
// Hand the raw first batch to the collector: _collectAll only returns
// the CONTINUATION batches, so the cached full list needs this to be
// complete from item 1.
noteInitialPlaylistContents(playlistKey, topPlaylistRenderer.contents);
}
storePlaylistContinuationToken(topPlaylistRenderer.continuations, 'topPlaylist');
// Start collecting the rest of the playlist in the background right
// now, instead of waiting for the user to scroll down once to trigger
// it — see playlistBatchCollect.js's autoStartCollect() doc comment.
// No-ops when serving from cache: continuations is null, so there is no
// token to collect with.
autoStartCollect(topPlaylistRenderer.continuations);
filterPlaylistRendererContents(topPlaylistRenderer, detectedPage, 'playlist.renderer');
}
Expand Down
112 changes: 112 additions & 0 deletions mods/features/playlistBatchCollect.js
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,103 @@ async function _collectAll(url, plc, context, headers) {
return { allContents, continuations, aborted: abort.signal.aborted };
}

// ── Full-playlist cache and one-shot reload ──────────────────────────────────
// Goal: get the ENTIRE playlist into the INITIAL page response, because that
// response is the only one where continuations can be nulled without starving
// YouTube's refill loop — and with no continuation there is no keep-one branch,
// so no helper tile is ever created. Helpers are what leave the permanently
// stranded blank slots (the virtual list's data model is unreachable on Tizen
// 5.0, 654577e), so removing the need for them is the only way to be rid of
// them.
//
// The catch is ordering: on a first visit the full list only exists AFTER the
// background collector finishes, which is well after the initial response has
// already been rendered. Injecting into a later continuation cannot fix it
// either — the initial batch has already kept its helper by then. So once
// collection completes we cache the whole playlist and reload the page once;
// the reloaded initial response is served from cache, complete, with
// continuations nulled.
//
// Reloading per batch (rather than after collection) would not work: a reload
// re-fetches the FIRST page, so it would loop on batch 1 forever.
const FULL_CACHE_MAX_PLAYLISTS = 3;

function playlistKeyFromHash() {
try { return String(window.location?.hash || ''); } catch (_) { return ''; }
}

function getFullCache() {
if (!window.__ttPlaylistFullCache) window.__ttPlaylistFullCache = {};
return window.__ttPlaylistFullCache;
}

export function getCachedFullPlaylist(key) {
const entry = getFullCache()[key];
return entry && Array.isArray(entry.contents) ? entry.contents : null;
}

// adblock.js hands us the raw, unfiltered contents of the initial response so
// the cached list can start with batch 1 — _collectAll only ever returns the
// CONTINUATION batches (53 of 68 on the measured playlist), never the first.
export function noteInitialPlaylistContents(key, contents) {
if (!Array.isArray(contents) || !contents.length) return;
window.__ttInitialPlaylistContents = { key, contents: contents.slice() };
}

function storeFullPlaylist(key, collectedContents) {
const initial = window.__ttInitialPlaylistContents;
if (!initial || initial.key !== key || !Array.isArray(initial.contents)) {
_log('playlist.full_cache.skip_no_initial', { key });
return false;
}
const cache = getFullCache();
// Bound the cache: these are full renderer objects and this runs on a TV.
const keys = Object.keys(cache);
if (keys.length >= FULL_CACHE_MAX_PLAYLISTS && !cache[key]) {
const oldest = keys.reduce((a, b) => (cache[a].ts <= cache[b].ts ? a : b));
delete cache[oldest];
}
cache[key] = { contents: initial.contents.concat(collectedContents), ts: Date.now() };
_log('playlist.full_cache.stored', {
key,
initial: initial.contents.length,
collected: collectedContents.length,
total: cache[key].contents.length,
});
return true;
}

// Reload once per playlist, and only after a COMPLETE collection — a partial
// list would render as a truncated playlist with no way to load the rest,
// since the injected response carries no continuation token.
function maybeReloadForFullPlaylist(key) {
if (!window.__ttFullReloadDone) window.__ttFullReloadDone = {};
if (window.__ttFullReloadDone[key]) {
_log('playlist.full_cache.reload_skipped', { key, reason: 'already_reloaded' });
return;
}
if (playlistKeyFromHash() !== key) {
_log('playlist.full_cache.reload_skipped', { key, reason: 'navigated_away' });
return;
}
window.__ttFullReloadDone[key] = true;
_log('playlist.full_cache.reloading', { key });
try {
// Reached through a window global rather than an ES import. This module
// must evaluate BEFORE adblock.js so it can capture JSON.parse ahead of
// adblock's patch; importing resolveCommand (which pulls in settings/UI)
// would risk reordering that. adblock.js publishes the global.
const rc = window.__ttResolveCommand;
if (typeof rc !== 'function') {
_log('playlist.full_cache.reload_error', { key, err: 'resolveCommand unavailable' });
return;
}
rc({ signalAction: { signal: 'SOFT_RELOAD_PAGE' } });
} catch (err) {
_log('playlist.full_cache.reload_error', { key, err: String(err?.message || err) });
}
}

// ── Auto-trigger on playlist page load ────────────────────────────────────────
// Called by adblock.js right after the initial playlist page's own
// continuation token is parsed (topPlaylistRenderer.continuations) — starts
Expand Down Expand Up @@ -401,6 +498,7 @@ export function autoStartCollect(continuations) {
const context = _lastBrowseContext;
const url = _lastBrowseUrl;
const headers = _lastBrowseHeaders;
const startKey = playlistKeyFromHash();

;(async () => {
let collected = null;
Expand Down Expand Up @@ -428,6 +526,20 @@ export function autoStartCollect(continuations) {
hasMore: !!collected.continuations,
auto: true,
});

// Complete collection (no continuation token left) means we now hold the
// whole playlist. Cache it and reload once, so the initial response can be
// served complete with continuations nulled — and with no continuation
// there is no keep-one branch, hence no helper tile at all.
// A partial collection is deliberately NOT cached: the injected response
// carries no continuation token, so a truncated list would have no way to
// ever load its remainder.
if (!collected.continuations && !collected.aborted) {
if (storeFullPlaylist(startKey, collected.allContents)) {
maybeReloadForFullPlaylist(startKey);
return;
}
}
_triggerReveal('playlist.batch_collect.auto_reveal');
})();
}
Expand Down