invidious/assets/js/sabr_player.js
2026-08-03 02:50:51 +02:00

787 lines
33 KiB
JavaScript

/**
* SABR Player - Main player initialization for SABR streaming
*
* Re-engineered (from the kira-based POC) to use FreeTube's SABR engine:
* - Builds a data:application/sabr+json manifest from the youtube.js VideoInfo
* - Registers FreeTube's sabr: networking scheme (sabr_scheme_plugin.js)
* - Drives Shaka 5 via the application/sabr+json manifest parser
*
* PoToken is minted browser-side via bgutils-js BotGuard and proxied through
* the Invidious /proxy route for CSP compliance.
*/
'use strict';
var SABRPlayer = (function () {
var VOLUME_KEY = 'youtube_player_volume';
var PLAYBACK_POSITION_KEY = 'youtube_playback_positions';
var SAVE_POSITION_INTERVAL_MS = 5000;
var DEFAULT_ABR_CONFIG = {
enabled: true,
// Adapt quality to the player element size (like FreeTube), NOT a hard 480p cap.
// A maxHeight:480 cap made shaka request the 480p format, which then set
// stickyResolution/lastManualSelectedResolution=480 in the SABR request, so the
// server LOCKED playback to a blurry 480p even after the cap was lifted.
restrictToElementSize: true,
switchInterval: 4,
useNetworkInformation: false
};
// TEMP: bypass the Onesie player-response fetch and use a plain WEB getInfo()
// instead. The Onesie path yields a ustreamerConfig that does NOT opt us into
// SABR contexts (server never sends SABR_CONTEXT_UPDATE / context 5), which
// breaks seeking. A normal WEB getInfo() (like FreeTube's createInnertube)
// returns the full ustreamerConfig. Onesie to be revisited separately.
var BYPASS_ONESIE = false;
// bg-helper-server /generate endpoint. Mints an ATTESTED (StreamProtectionStatus=1)
// PO token server-side, where jsdom presents a youtube.com origin. In-browser BotGuard
// (Invidious origin) can only get status 2 (pending), so it's used only as a fallback.
var BG_HELPER_URL = 'http://127.0.0.1:4416/generate';
// State
var player = null;
var ui = null;
var videoElement = null;
var shakaContainer = null;
var currentVideoId = '';
var isLive = false;
var innertube = null;
var clientConfig = null;
var savePositionInterval = null;
var playbackWebPoToken = null;
var coldStartToken = null;
var playbackWebPoTokenContentBinding = null;
var playbackWebPoTokenCreationLock = false;
var sabrStream = null; // handle returned by setupSabrScheme
// Guards so per-player-instance listeners/filters are installed once, not once
// per reload (loadVideo re-runs on every SABR reload).
var requestFiltersInstalled = false;
var loadedListenerInstalled = false;
var sabrManifest = null; // captured from player.getManifest() on 'loaded'
var currentLoadOptions = null; // for onReloadOnce -> re-run loadVideo
var reloadCount = 0; // cap reloads to prevent infinite loop on blocked/throttled videos
// YouTube walls SABR playback at ~60s of content on some videos and only lets a
// fresh player response (new server_abr_streaming_url) through. FreeTube recovers
// by reloading the SABR player several times (observed: ~4 reloads before it sticks),
// so cap high enough to actually recover instead of giving up early — matching
// FreeTube's behaviour — while still bounding a truly blocked/throttled video.
var MAX_RELOADS = 10;
function getSavedVolume() {
try {
var v = localStorage.getItem(VOLUME_KEY);
return v ? parseFloat(v) : 1;
} catch (e) { return 1; }
}
function saveVolume(volume) {
try { localStorage.setItem(VOLUME_KEY, volume.toString()); } catch (e) {}
}
function getPlaybackPositions() {
try {
var p = localStorage.getItem(PLAYBACK_POSITION_KEY);
return p ? JSON.parse(p) : {};
} catch (e) { return {}; }
}
function savePlaybackPosition(videoId, time) {
if (!videoId || time < 1) return;
try {
var positions = getPlaybackPositions();
positions[videoId] = time;
localStorage.setItem(PLAYBACK_POSITION_KEY, JSON.stringify(positions));
} catch (e) {}
}
function getPlaybackPosition(videoId) {
var positions = getPlaybackPositions();
return positions[videoId] || 0;
}
function base64ToU8(base64) {
// base64ToU8 from googlevideo handles websafe, but for client config we need raw.
var binary = atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
async function initInnertube() {
if (innertube) return innertube;
try {
console.info('[SABRPlayer]', 'Initializing InnerTube API');
if (typeof Innertube === 'undefined') throw new Error('youtubei.js not loaded');
// Set up Platform.shim.eval for URL deciphering (browser bundle lacks Jinter).
if (typeof Platform !== 'undefined' && Platform.shim) {
Platform.shim.eval = async function (data, env) {
var properties = [];
if (env.n) properties.push('n: exportedVars.nFunction("' + env.n + '")');
if (env.sig) properties.push('sig: exportedVars.sigFunction("' + env.sig + '")');
var code = data.output + '\nreturn { ' + properties.join(', ') + ' }';
return new Function(code)();
};
console.info('[SABRPlayer]', 'Platform.shim.eval configured for URL deciphering');
} else {
console.warn('[SABRPlayer]', 'Platform.shim not available, URL deciphering may fail');
}
innertube = await Innertube.create({
fetch: SABRHelpers.fetchWithProxy,
retrieve_player: true,
generate_session_locally: true
});
// NOTE: the in-browser BotguardService is NOT eagerly initialized anymore. The PO
// token is minted by the bg-helper-server (/generate), where jsdom presents a
// youtube.com origin and the token actually attests. In-browser BotGuard could only
// ever produce a StreamProtectionStatus=2 (pending) token AND fired a redundant
// GenerateIT request on every video. It remains only as an on-demand fallback
// (mintContentWebPO -> BotguardService.reinit) if the helper is unreachable.
// Preload the redirector URL.
try {
var redirectorResponse = await SABRHelpers.fetchWithProxy(
'https://redirector.googlevideo.com/initplayback?source=youtube&itag=0&pvi=0&pai=0&owc=yes&cmo:sensitive_content=yes&alr=yes&id=' + Math.round(Math.random() * 1e5),
{ method: 'GET' }
);
var redirectorResponseUrl = await redirectorResponse.text();
if (redirectorResponseUrl.indexOf('https://') === 0) {
localStorage.setItem(SABRHelpers.REDIRECTOR_STORAGE_KEY, redirectorResponseUrl);
}
} catch (e) {
console.warn('[SABRPlayer]', 'Failed to preload redirector URL', e);
}
return innertube;
} catch (error) {
console.error('[SABRPlayer]', 'Failed to initialize Innertube', error);
return null;
}
}
async function fetchOnesieConfig() {
if (clientConfig && SABRHelpers.isConfigValid(clientConfig)) return clientConfig;
var cached = SABRHelpers.loadCachedClientConfig();
if (cached) { clientConfig = cached; return clientConfig; }
try {
var tvConfigResponse = await SABRHelpers.fetchWithProxy(
'https://www.youtube.com/tv_config?action_get_config=true&client=lb4&theme=cl',
{
method: 'GET',
headers: { 'User-Agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version' }
}
);
var tvConfigText = await tvConfigResponse.text();
var tvConfigJson = JSON.parse(tvConfigText.slice(4));
var webPlayerContextConfig = tvConfigJson.webPlayerContextConfig.WEB_PLAYER_CONTEXT_CONFIG_ID_LIVING_ROOM_WATCH;
var onesieHotConfig = webPlayerContextConfig.onesieHotConfig;
clientConfig = {
clientKeyData: base64ToU8(onesieHotConfig.clientKey),
keyExpiresInSeconds: onesieHotConfig.keyExpiresInSeconds,
encryptedClientKey: base64ToU8(onesieHotConfig.encryptedClientKey),
onesieUstreamerConfig: base64ToU8(onesieHotConfig.onesieUstreamerConfig),
baseUrl: onesieHotConfig.baseUrl,
timestamp: Date.now()
};
SABRHelpers.saveCachedClientConfig(clientConfig);
return clientConfig;
} catch (error) {
console.error('[SABRPlayer]', 'Failed to fetch Onesie client config', error);
return null;
}
}
async function mintContentWebPO() {
if (!playbackWebPoTokenContentBinding || playbackWebPoTokenCreationLock) return;
playbackWebPoTokenCreationLock = true;
try {
coldStartToken = BotguardService.mintColdStartToken(playbackWebPoTokenContentBinding);
// Prefer the bg-helper-server: it mints an ATTESTED token (youtube.com-origin jsdom).
// The token is bound to our session's visitorData, so we send that.
var sessionCtx = innertube && innertube.session && innertube.session.context;
var visitorData = sessionCtx && sessionCtx.client && sessionCtx.client.visitorData;
if (visitorData) {
var helperToken = await fetchHelperPoToken(visitorData, currentVideoId, sessionCtx);
if (helperToken) {
playbackWebPoToken = helperToken;
console.info('[SABRPlayer]', 'WebPO token from bg-helper (attested):', helperToken.substring(0, 30) + '...');
return;
}
}
// Fallback: in-browser BotGuard (only StreamProtectionStatus=2/pending, no youtube.com origin).
console.warn('[SABRPlayer]', 'bg-helper unavailable, falling back to in-browser BotGuard (unattested)');
if (!BotguardService.isInitialized()) {
await BotguardService.reinit(innertube && innertube.session && innertube.session.context);
}
if (BotguardService.isInitialized()) {
playbackWebPoToken = await BotguardService.mintWebPoToken(decodeURIComponent(playbackWebPoTokenContentBinding));
console.info('[SABRPlayer]', 'WebPO token created (in-browser):', playbackWebPoToken ? playbackWebPoToken.substring(0, 30) + '...' : 'null');
} else {
console.warn('[SABRPlayer]', 'BotGuard still not initialized after reinit');
}
} catch (err) {
console.error('[SABRPlayer]', 'Error minting WebPO token', err);
} finally {
playbackWebPoTokenCreationLock = false;
}
}
// Fetch an attested PO token from the bg-helper-server, bound to the video id
// (content binding, like FreeTube). The full InnerTube session context is sent so the
// helper's att/get challenge is session-bound to our visitorData.
async function fetchHelperPoToken(visitorData, videoId, context) {
try {
var resp = await fetch(BG_HELPER_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ visitorData: visitorData, videoId: videoId, context: context })
});
if (!resp.ok) {
console.warn('[SABRPlayer]', 'bg-helper /generate returned', resp.status);
return null;
}
var data = await resp.json();
return data && data.poToken ? data.poToken : null;
} catch (err) {
console.warn('[SABRPlayer]', 'bg-helper /generate fetch failed', err && err.message);
return null;
}
}
// Mint a brand new content-bound WebPO token for the current video. Used by the
// sabr: scheme when the server answers with StreamProtectionStatus 3
// (ATTESTATION_REQUIRED) and refuses to send any more media until we re-attest.
async function mintFreshPoToken() {
if (!currentVideoId) return null;
try {
// Re-mint via the bg-helper (attested), same as the initial mint. The in-browser
// BotguardService can't attest (no youtube.com origin) so it must NOT be used here —
// doing so threw BGError: PMD:Undefined and never satisfied the attestation.
var sessionCtx = innertube && innertube.session && innertube.session.context;
var visitorData = sessionCtx && sessionCtx.client && sessionCtx.client.visitorData;
var token = null;
if (visitorData) {
token = await fetchHelperPoToken(visitorData, currentVideoId, sessionCtx);
}
if (!token) {
// Fallback: in-browser (unattested) only if the helper is unreachable.
if (!BotguardService.isInitialized()) {
await BotguardService.reinit(sessionCtx);
}
if (BotguardService.isInitialized()) {
token = await BotguardService.mintWebPoToken(currentVideoId);
}
}
if (token) {
playbackWebPoToken = token;
console.info('[SABRPlayer]', 'Re-minted WebPO token after attestation request');
}
return token;
} catch (err) {
console.warn('[SABRPlayer]', 'Failed to re-mint WebPO token', err);
return null;
}
}
async function initializeShakaPlayer(containerElement, listenMode) {
if (!shaka.Player.isBrowserSupported()) {
throw new Error('Shaka Player is not supported in this browser');
}
shaka.polyfill.installAll();
shakaContainer = document.createElement('div');
shakaContainer.className = 'sabr-player-container';
shakaContainer.style.width = '100%';
shakaContainer.style.height = '100%';
videoElement = document.createElement('video');
videoElement.autoplay = true;
videoElement.style.width = '100%';
videoElement.style.height = '100%';
videoElement.style.backgroundColor = '#000';
shakaContainer.appendChild(videoElement);
containerElement.appendChild(shakaContainer);
player = new shaka.Player();
player.configure({
abr: DEFAULT_ABR_CONFIG,
streaming: {
// Match FreeTube's SABR streaming config exactly (values YouTube itself uses):
// large buffering goal, tiny rebuffering goal, big bufferBehind, and a doubled
// (60s) retry timeout to tolerate the larger SABR UMP responses.
bufferingGoal: 180,
rebufferingGoal: 0.02,
bufferBehind: 300,
retryParameters: { timeout: 60 * 1000 }
},
manifest: {
// disableVideo is read by our SabrManifestParser to skip video streams for audio-only/listen mode.
disableVideo: !!listenMode
}
});
videoElement.volume = getSavedVolume();
videoElement.addEventListener('volumechange', function () { saveVolume(videoElement.volume); });
videoElement.addEventListener('playing', function () {
player.configure('abr.restrictions.maxHeight', Infinity);
});
videoElement.addEventListener('pause', function () {
if (currentVideoId) savePlaybackPosition(currentVideoId, videoElement.currentTime);
});
await player.attach(videoElement);
if (shaka.ui && shaka.ui.Overlay) {
ui = new shaka.ui.Overlay(player, shakaContainer, videoElement);
ui.configure({
overflowMenuButtons: ['captions', 'quality', 'language', 'playback_rate', 'loop', 'picture_in_picture']
});
}
return player;
}
// Route non-sabr: requests (captions, storyboards, image tiles) through the
// Invidious /proxy so they satisfy CSP. The sabr: scheme is handled
// separately by setupSabrScheme and also routes through /proxy internally.
function setupRequestFilters() {
var networkingEngine = player && player.getNetworkingEngine ? player.getNetworkingEngine() : null;
if (!networkingEngine) return;
networkingEngine.registerRequestFilter(function (type, request) {
var uri = request.uris[0];
if (!uri) return;
// sabr: is owned by the SABR scheme plugin; never rewrite it here.
if (uri.indexOf('sabr:') === 0) return;
try {
var url = new URL(uri);
} catch (e) { return; }
var isGoogleVideo = url.hostname.endsWith('.googlevideo.com') || url.hostname.indexOf('googlevideo') !== -1;
var isYouTube = url.hostname.endsWith('.youtube.com');
if (!isGoogleVideo && !isYouTube) return;
// Reuse the shared proxy helper so __host/__headers are set consistently.
var proxied = SABRHelpers.proxyUrl(url, request.headers || {});
proxied.searchParams.set('alr', 'yes');
request.uris[0] = proxied.toString();
});
}
// Map a youtube.js Format to the SabrManifest "formats" entry shape expected
// by sabr_manifest_parser.js (port of FreeTube's SabrManifestParser).
function mapFormatToManifestEntry(fmt) {
var audioTrack = fmt.audio_track || null;
var colorInfo = fmt.color_info || null;
return {
itag: fmt.itag,
lastModified: fmt.last_modified_ms,
mimeType: fmt.mime_type,
xtags: fmt.xtags,
bitrate: fmt.bitrate,
initRange: fmt.init_range,
indexRange: fmt.index_range,
width: fmt.width,
height: fmt.height,
frameRate: fmt.fps,
quality: fmt.quality,
language: fmt.language,
audioSampleRate: fmt.audio_sample_rate,
audioChannels: fmt.audio_channels,
isDrc: fmt.is_drc,
isVoiceBoost: fmt.is_vb,
isOriginal: fmt.is_original,
isDubbed: fmt.is_dubbed,
isAutoDubbed: fmt.is_auto_dubbed,
isDescriptive: fmt.is_descriptive,
isSecondary: fmt.is_secondary,
spatialAudio: !!(fmt.spatial_audio_type),
label: audioTrack ? audioTrack.display_name : undefined,
colorTransferCharacteristics: colorInfo ? colorInfo.transfer_characteristics : undefined,
colorPrimaries: colorInfo ? colorInfo.primaries : undefined
};
}
function buildCaptions(videoInfo) {
var out = [];
var tracks = videoInfo.captions && videoInfo.captions.caption_tracks;
if (!tracks) return out;
for (var i = 0; i < tracks.length; i++) {
var c = tracks[i];
var url;
try {
url = new URL(c.base_url);
url.searchParams.set('fmt', 'vtt');
url = url.toString();
} catch (e) {
url = c.base_url;
}
out.push({
id: c.vss_id,
url: url,
label: c.name ? c.name.text : (c.language_code || ''),
language: c.language_code || 'und',
mimeType: 'text/vtt'
});
}
return out;
}
function buildStoryboards(videoInfo) {
var out = [];
var sb = videoInfo.storyboards;
if (!sb || sb.type !== 'PlayerStoryboardSpec') return out;
var boards = sb.boards || [];
// Pick the highest-res storyboard (matches FreeTube behaviour).
var board = boards.length ? boards[boards.length - 1] : null;
if (!board) return out;
out.push({
templateUrl: board.template_url,
mimeType: 'image/webp',
columns: board.columns,
rows: board.rows,
thumbnailCount: board.thumbnail_count,
thumbnailWidth: board.thumbnail_width,
thumbnailHeight: board.thumbnail_height,
storyboardCount: board.storyboard_count,
interval: board.interval > 0 ? board.interval / 1000 : 0
});
return out;
}
function buildChapters(videoInfo) {
var out = [];
var ch = videoInfo.chapters;
if (!ch || !ch.get) return out;
try {
var list = ch.get();
if (!list || !list.length) return out;
for (var i = 0; i < list.length; i++) {
var c = list[i];
out.push({
title: c.title || '',
startSeconds: c.start || 0,
endSeconds: c.end || (i + 1 < list.length ? (list[i + 1].start || 0) : 0)
});
}
} catch (e) {
// chapters not available for this video - harmless
}
return out;
}
// Build a data:application/sabr+json manifest URI from a youtube.js VideoInfo.
// Port of FreeTube's Watch.js#createLocalSabrManifest.
function buildSabrManifest(videoInfo, poToken, clientInfo) {
var streamingData = videoInfo.streaming_data;
if (!streamingData || !streamingData.server_abr_streaming_url || !streamingData.adaptive_formats) {
return null;
}
var url = new URL(streamingData.server_abr_streaming_url);
url.searchParams.set('alr', 'yes');
if (videoInfo.cpn) url.searchParams.set('cpn', videoInfo.cpn);
var sabrData = {
url: url.toString(),
poToken: poToken || '',
ustreamerConfig: videoInfo.player_config &&
videoInfo.player_config.media_common_config &&
videoInfo.player_config.media_common_config.media_ustreamer_request_config &&
videoInfo.player_config.media_common_config.media_ustreamer_request_config.video_playback_ustreamer_config || '',
clientInfo: clientInfo
};
SABRPlayer._lastSabrData = sabrData;
var adaptiveFormats = streamingData.adaptive_formats;
var duration = Infinity;
for (var i = 0; i < adaptiveFormats.length; i++) {
var d = adaptiveFormats[i].approx_duration_ms;
if (typeof d === 'number' && d < duration) duration = d;
}
if (!isFinite(duration)) duration = (videoInfo.basic_info && videoInfo.basic_info.duration) ? videoInfo.basic_info.duration * 1000 : 0;
duration = duration / 1000;
var formats = [];
for (var j = 0; j < adaptiveFormats.length; j++) {
formats.push(mapFormatToManifestEntry(adaptiveFormats[j]));
}
var sabrManifest = {
duration: duration,
formats: formats,
captions: buildCaptions(videoInfo),
storyboards: buildStoryboards(videoInfo),
chapters: buildChapters(videoInfo)
};
return 'data:' + window.MANIFEST_TYPE_SABR + ',' + encodeURIComponent(JSON.stringify(sabrManifest));
}
function getPlayerWidth() { return videoElement ? videoElement.clientWidth : 1280; }
function getPlayerHeight() { return videoElement ? videoElement.clientHeight : 720; }
function getPlayer() { return player; }
function getManifest() { return sabrManifest; }
function wireSabrStream(loadFn) {
if (!sabrStream) return;
sabrStream.onBackoffRequested(function (info) {
var ms = info && info.backoffMs ? info.backoffMs : 0;
console.warn('[SABRPlayer] SABR backoff requested:', ms, 'ms');
var toast = document.querySelector('.sabr-backoff-toast');
if (!toast && shakaContainer) {
toast = document.createElement('div');
toast.className = 'sabr-backoff-toast';
toast.style.cssText = 'position:absolute;bottom:48px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.75);color:#fff;padding:6px 12px;border-radius:4px;font-size:13px;pointer-events:none;z-index:10';
shakaContainer.appendChild(toast);
}
if (toast) {
toast.textContent = 'SABR throttled by YouTube, retrying in ' + (ms / 1000).toFixed(1) + 's…';
clearTimeout(toast._t);
toast._t = setTimeout(function () { if (toast.parentNode) toast.parentNode.removeChild(toast); }, Math.max(ms + 500, 2000));
}
});
sabrStream.onReloadOnce(function () {
reloadCount++;
if (reloadCount >= MAX_RELOADS) {
console.error('[SABRPlayer] SABR reload limit reached; giving up on video', currentVideoId);
if (shakaContainer) {
var errDiv = document.createElement('div');
errDiv.className = 'sabr-error-display';
errDiv.innerHTML = '<p>This video is currently unavailable via SABR.</p>' +
'<p>YouTube is throttling this request.</p>' +
'<p><a href="?quality=dash">Try DASH player instead</a></p>';
shakaContainer.appendChild(errDiv);
}
return;
}
console.warn('[SABRPlayer] SABR reload requested by server; re-loading video (attempt ' + reloadCount + '/' + MAX_RELOADS + ')');
// Match FreeTube's reloadView(): re-fetch with a FRESH innertube session
// (new visitorData) and a re-attested BotGuard, instead of reusing the same
// session identity. The 60s SABR wall is tied to the session/streaming
// context, so a fresh session is what actually lets the reload recover.
var resumeAt = (videoElement && isFinite(videoElement.currentTime)) ? videoElement.currentTime : undefined;
innertube = null;
clientConfig = null;
playbackWebPoToken = null;
coldStartToken = null;
try { BotguardService.dispose(); } catch (e) {}
var reloadOptions = Object.assign({}, currentLoadOptions || {});
if (resumeAt !== undefined && resumeAt > 1) reloadOptions.startTime = resumeAt;
if (currentVideoId && loadFn) loadFn(currentVideoId, shakaContainer, reloadOptions);
});
}
async function loadVideo(videoId, containerElement, options) {
options = options || {};
if (videoId !== currentVideoId) reloadCount = 0;
currentVideoId = videoId;
currentLoadOptions = options;
playbackWebPoToken = null;
playbackWebPoTokenContentBinding = videoId;
try {
// Start Shaka player init (DOM + polyfills) in parallel with network init.
var shakaPromise;
if (!player) {
requestFiltersInstalled = false;
loadedListenerInstalled = false;
shakaPromise = initializeShakaPlayer(containerElement, options.listen);
} else {
player.configure('abr', DEFAULT_ABR_CONFIG);
player.configure('manifest.disableVideo', !!options.listen);
shakaPromise = Promise.resolve();
}
// Network init chain: Innertube → Onesie config → PoToken.
var netPromise = (async function () {
if (!innertube) {
innertube = await initInnertube();
if (!innertube) throw new Error('Failed to initialize Innertube');
}
if (!clientConfig) {
await fetchOnesieConfig();
}
// Mint a content-bound PoToken before getInfo so streaming_data includes
// the server_abr_streaming_url and so the SABR requests authenticate.
try { await mintContentWebPO(); } catch (e) { console.warn('[SABRPlayer] poToken mint failed, continuing', e); }
})();
await Promise.all([shakaPromise, netPromise]);
var poToken = playbackWebPoToken || coldStartToken || '';
if (!requestFiltersInstalled) {
setupRequestFilters();
requestFiltersInstalled = true;
}
// Fetch the player response through Onesie (WEB client) so the SABR
// streaming URL isn't bound to the proxy's egress IP. Falls back to a
// plain getInfo() if the onesie path is unavailable. Media still flows
// over SABR (sabr_scheme_plugin.js) either way.
var videoInfo;
try {
if (BYPASS_ONESIE) {
throw new Error('Onesie bypassed (BYPASS_ONESIE) - using plain WEB getInfo');
}
if (typeof window.fetchOnesiePlayerResponse !== 'function' || !window.YT || !window.YT.VideoInfo) {
throw new Error('Onesie support not loaded');
}
var playerResponse = await window.fetchOnesiePlayerResponse(innertube, videoId, poToken || undefined, clientConfig);
videoInfo = new window.YT.VideoInfo(
[{ success: true, status_code: 200, data: playerResponse }],
innertube.actions,
SABRHelpers.generateRandomString(16)
);
console.info('[SABRPlayer]', 'Player response fetched via Onesie (WEB)');
} catch (onesieErr) {
console.warn('[SABRPlayer]', 'Onesie player request failed, falling back to getInfo:', onesieErr);
videoInfo = await innertube.getInfo(videoId, { po_token: poToken || undefined });
}
if (!videoInfo || !videoInfo.playability_status || videoInfo.playability_status.status !== 'OK') {
var reason = (videoInfo && videoInfo.playability_status && videoInfo.playability_status.reason) || 'Unknown error';
throw new Error('Video unavailable: ' + reason);
}
isLive = !!(videoInfo.basic_info && videoInfo.basic_info.is_live);
var streamingData = videoInfo.streaming_data;
if (!streamingData) throw new Error('No streaming data available');
// Derive clientInfo from the innertube session (matches FreeTube's local.js).
var ctxClient = innertube.session && innertube.session.context && innertube.session.context.client;
var clientName = ctxClient ? ctxClient.clientName : 'ANDROID';
var clientInfo = {
clientName: (typeof Constants !== 'undefined' && Constants.CLIENT_NAME_IDS && Constants.CLIENT_NAME_IDS[clientName]) || 3,
clientVersion: ctxClient ? ctxClient.clientVersion : (Constants.CLIENTS && Constants.CLIENTS.ANDROID ? Constants.CLIENTS.ANDROID.VERSION : '19.09.37'),
osName: ctxClient ? ctxClient.osName : 'Android',
osVersion: ctxClient ? ctxClient.osVersion : '14'
};
var manifestUri;
if (isLive) {
// Live: don't route through the SABR parser yet; use HLS/DASH.
manifestUri = streamingData.hls_manifest_url || streamingData.dash_manifest_url;
if (streamingData.hls_manifest_url) {
if (innertube.session && innertube.session.player && innertube.session.player.decipher) {
try { streamingData.hls_manifest_url = await innertube.session.player.decipher(streamingData.hls_manifest_url); } catch (e) {}
}
manifestUri = streamingData.hls_manifest_url;
}
} else {
if (streamingData.server_abr_streaming_url && innertube.session && innertube.session.player && innertube.session.player.decipher) {
try {
streamingData.server_abr_streaming_url = await innertube.session.player.decipher(streamingData.server_abr_streaming_url);
} catch (e) {
console.warn('[SABRPlayer] Failed to decipher server_abr_streaming_url', e);
}
}
manifestUri = buildSabrManifest(videoInfo, poToken, clientInfo);
if (!manifestUri) {
// No SABR URL available - fall back to DASH.
var dashManifest = await videoInfo.toDash({ manifest_options: { captions_format: 'vtt', include_thumbnails: false } });
manifestUri = 'data:application/dash+xml;base64,' + btoa(dashManifest);
}
}
if (!manifestUri) throw new Error('Could not find a valid manifest URI');
// Register the sabr: scheme before loading (idempotent re-registration is fine).
if (!isLive && window.setupSabrScheme && SABRPlayer._lastSabrData) {
if (sabrStream) {
try { sabrStream.cleanup(); } catch (e) {}
}
sabrStream = window.setupSabrScheme(
SABRPlayer._lastSabrData, getPlayer, getManifest, getPlayerWidth, getPlayerHeight, mintFreshPoToken
);
wireSabrStream(loadVideo);
}
// Capture the parsed manifest once Shaka has loaded it, so the sabr:
// scheme plugin can read variant/segment indices from it. Install once per
// player instance (loadVideo re-runs on every reload).
if (!loadedListenerInstalled) {
player.addEventListener('loaded', function () {
if (typeof player.getManifest === 'function') {
sabrManifest = player.getManifest();
}
});
loadedListenerInstalled = true;
}
// Default to the ORIGINAL audio language (e.g. English), not a dubbed track.
// Without a preferredAudioLanguage, shaka picks the first variant (often a dub);
// it doesn't honor our `primary`/original flag on its own. Derive the original
// language from the adaptive format flagged is_original and set it as preferred.
if (!isLive && streamingData.adaptive_formats) {
var originalAudioLang = null;
for (var afi = 0; afi < streamingData.adaptive_formats.length; afi++) {
var af = streamingData.adaptive_formats[afi];
if (af.is_original && af.language) { originalAudioLang = af.language; break; }
}
if (originalAudioLang) {
try { player.configure('preferredAudioLanguage', originalAudioLang); } catch (e) {}
}
}
var startTime = options.startTime;
if (startTime === undefined && options.savePlayerPos !== false) {
startTime = getPlaybackPosition(videoId);
}
var mimeType = (!isLive && manifestUri.indexOf('data:' + window.MANIFEST_TYPE_SABR) === 0)
? window.MANIFEST_TYPE_SABR
: undefined;
await player.load(manifestUri, isLive ? undefined : startTime, mimeType);
videoElement.play().catch(function (err) {
if (err.name === 'NotAllowedError') console.warn('[SABRPlayer]', 'Autoplay was prevented by the browser');
});
if (savePositionInterval) clearInterval(savePositionInterval);
savePositionInterval = setInterval(function () {
if (videoElement && currentVideoId && !videoElement.paused) {
savePlaybackPosition(currentVideoId, videoElement.currentTime);
}
}, SAVE_POSITION_INTERVAL_MS);
return { player: player, videoElement: videoElement, videoInfo: videoInfo };
} catch (error) {
console.error('[SABRPlayer]', 'Error loading video:', error);
throw error;
}
}
async function dispose() {
if (savePositionInterval) { clearInterval(savePositionInterval); savePositionInterval = null; }
if (videoElement && currentVideoId) savePlaybackPosition(currentVideoId, videoElement.currentTime);
if (sabrStream) { try { sabrStream.cleanup(); } catch (e) {} sabrStream = null; }
if (player) { await player.destroy(); player = null; }
if (ui) { ui.destroy(); ui = null; }
if (shakaContainer && shakaContainer.parentNode) shakaContainer.parentNode.removeChild(shakaContainer);
videoElement = null;
shakaContainer = null;
currentVideoId = '';
sabrManifest = null;
}
function getPlayerInstance() { return player; }
function getVideoElement() { return videoElement; }
return {
loadVideo: loadVideo,
dispose: dispose,
getPlayer: getPlayerInstance,
getVideoElement: getVideoElement,
initInnertube: initInnertube,
fetchOnesieConfig: fetchOnesieConfig
};
})();
window.SABRPlayer = SABRPlayer;