From 3b053e4bd4e38760f47a411b86409abfd80744a5 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 12:38:26 +0200 Subject: [PATCH 01/35] feat(player): SABR PO token via headless WebView --- app/src/main/assets/sabr_potoken_poc.js | 301 ++++++++++++++++++ .../datasource/WebViewPoTokenProvider.java | 281 ++++++++++++++++ 2 files changed, 582 insertions(+) create mode 100644 app/src/main/assets/sabr_potoken_poc.js create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java diff --git a/app/src/main/assets/sabr_potoken_poc.js b/app/src/main/assets/sabr_potoken_poc.js new file mode 100644 index 0000000000..88b5ffa82d --- /dev/null +++ b/app/src/main/assets/sabr_potoken_poc.js @@ -0,0 +1,301 @@ +/* + * SABR PO token POC — WebView pipeline (att mode). + * + * Ported from the local research mint (mint-po-token-browser.mjs), adapted to run inside an Android + * WebView instead of puppeteer. It must be injected AFTER https://www.youtube.com/ has finished + * loading (same-origin is required: att/get and GenerateIT are youtube.com endpoints, and the + * BotGuard interpreter is embedded in the challenge). + * + * Flow: read browser context -> att/get challenge -> run BotGuard VM -> snapshot -> GenerateIT + * integrity token -> mint a videoId-bound PO token -> hand the result back through the + * `SabrPocBridge` JavascriptInterface. + * + * INTERNAL / LOCAL POC ONLY. The minted PO token is session-bound; keep it out of any public log. + * API_KEY / REQUEST_KEY are the well-known public ecosystem constants, not secrets. + */ +(function () { + 'use strict'; + + // YouTube ships a Trusted Types CSP (require-trusted-types-for 'script') that blocks + // new Function()/eval of the BotGuard interpreter. Installing an identity "default" policy makes + // Chromium route those sinks through it, restoring dynamic evaluation. If the CSP forbids + // creating the policy, loadBotGuard() will surface the eval error instead. + try { + if (window.trustedTypes && window.trustedTypes.createPolicy + && !window.trustedTypes.defaultPolicy) { + window.trustedTypes.createPolicy('default', { + createHTML: function (value) { return value; }, + createScript: function (value) { return value; }, + createScriptURL: function (value) { return value; } + }); + } + } catch (ttError) { + // ignore; surfaced later as an eval failure + } + + var API_KEY = 'AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw'; + var REQUEST_KEY = 'O43z0dpjhgX20SCx4KAo'; + + function report(result) { + try { + // eslint-disable-next-line no-undef + SabrPocBridge.onResult(JSON.stringify(result)); + } catch (e) { + // Bridge not present (e.g. plain browser run): fall back to console. + try { + console.log('[sabr-poc] ' + JSON.stringify(result)); + } catch (ignored) { + // nothing else we can do + } + } + } + + function step(message) { + try { console.log('[sabr-poc] ' + message); } catch (e) { /* ignore */ } + } + + function readVisitorData() { + var cfg = window.ytcfg; + var fromCfg = cfg && typeof cfg.get === 'function' ? cfg.get('VISITOR_DATA') : null; + if (fromCfg) { + return fromCfg; + } + var html = document.documentElement.innerHTML; + var marker = '"VISITOR_DATA":"'; + var start = html.indexOf(marker); + if (start < 0) { + throw new Error('Could not find visitor data'); + } + var from = start + marker.length; + var end = html.indexOf('"', from); + if (end < 0) { + throw new Error('Could not find visitor data end'); + } + return html.slice(from, end); + } + + function readClientVersion() { + var cfg = window.ytcfg; + var fromCfg = cfg && typeof cfg.get === 'function' + ? cfg.get('INNERTUBE_CLIENT_VERSION') : null; + return fromCfg || '2.20260114.01.00'; + } + + function normalizeTrustedUrl(value) { + if (!value) { + throw new Error('Missing interpreter url'); + } + return value.indexOf('//') === 0 ? 'https:' + value : value; + } + + function fetchChallenge(ctx) { + var context = { + client: { + clientName: 'WEB', + clientVersion: ctx.clientVersion, + hl: 'en', + gl: 'US', + utcOffsetMinutes: 0, + visitorData: ctx.visitorData + } + }; + return fetch('https://www.youtube.com/youtubei/v1/att/get?prettyPrint=false&alt=json', { + method: 'POST', + headers: { + 'Accept': '*/*', + 'Content-Type': 'application/json', + 'X-Goog-Visitor-Id': ctx.visitorData, + 'X-Youtube-Client-Version': ctx.clientVersion, + 'X-Youtube-Client-Name': '1' + }, + body: JSON.stringify({ + engagementType: 'ENGAGEMENT_TYPE_UNBOUND', + context: context + }) + }).then(function (response) { + return response.json().then(function (data) { + if (!response.ok || !data.bgChallenge) { + throw new Error('att/get failed status=' + response.status); + } + return data.bgChallenge; + }); + }); + } + + function resolveInterpreter(challenge, userAgent) { + var embedded = challenge.interpreterJavascript + && challenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue; + if (embedded) { + return Promise.resolve(embedded); + } + var url = normalizeTrustedUrl( + (challenge.interpreterJavascript + && challenge.interpreterJavascript + .privateDoNotAccessOrElseTrustedResourceUrlWrappedValue) + || (challenge.interpreterUrl + && challenge.interpreterUrl + .privateDoNotAccessOrElseTrustedResourceUrlWrappedValue)); + return fetch(url, { headers: { 'User-Agent': userAgent } }).then(function (response) { + return response.text().then(function (js) { + if (!response.ok || !js) { + throw new Error('interpreter fetch failed status=' + response.status); + } + return js; + }); + }); + } + + function loadBotGuard(interpreterJavascript, program, globalName) { + return new Promise(function (resolve, reject) { + try { + new Function(interpreterJavascript)(); + } catch (e) { + reject(new Error('interpreter eval failed: ' + e.message)); + return; + } + var vm = window[globalName]; + if (!vm || typeof vm.a !== 'function') { + reject(new Error('BotGuard VM missing init function')); + return; + } + var timeout = setTimeout(function () { + reject(new Error('BotGuard init timeout')); + }, 10000); + try { + vm.a(program, function (asyncSnapshotFunction) { + clearTimeout(timeout); + resolve({ asyncSnapshotFunction: asyncSnapshotFunction }); + }, true, undefined, function () { }, [[], []]); + } catch (e) { + clearTimeout(timeout); + reject(new Error('BotGuard init threw: ' + e.message)); + } + }); + } + + function snapshot(functions, webPoSignalOutput) { + return new Promise(function (resolve, reject) { + var timeout = setTimeout(function () { + reject(new Error('BotGuard snapshot timeout')); + }, 10000); + functions.asyncSnapshotFunction(function (response) { + clearTimeout(timeout); + resolve(response); + }, [undefined, undefined, webPoSignalOutput, undefined]); + }); + } + + function fetchIntegrityToken(botGuardResponse, userAgent) { + return fetch('https://www.youtube.com/api/jnn/v1/GenerateIT', { + method: 'POST', + headers: { + 'content-type': 'application/json+protobuf', + 'x-goog-api-key': API_KEY, + 'x-user-agent': 'grpc-web-javascript/0.1', + 'User-Agent': userAgent + }, + body: JSON.stringify([REQUEST_KEY, botGuardResponse]) + }).then(function (response) { + return response.json().then(function (data) { + var integrityToken = data[0]; + if (typeof integrityToken !== 'string') { + throw new Error('GenerateIT failed status=' + response.status); + } + return integrityToken; + }); + }); + } + + function base64ToU8(value) { + var normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + var padded = normalized + '==='.slice((normalized.length + 3) % 4); + var binary = atob(padded); + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } + + function u8ToBase64Url(value) { + var binary = ''; + for (var i = 0; i < value.length; i++) { + binary += String.fromCharCode(value[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + } + + function mint(webPoSignalOutput, integrityToken, identifier) { + var getMinter = webPoSignalOutput[0]; + if (typeof getMinter !== 'function') { + return Promise.reject(new Error('Missing PO minter factory')); + } + return Promise.resolve(getMinter(base64ToU8(integrityToken))).then(function (mintCallback) { + if (typeof mintCallback !== 'function') { + throw new Error('Missing PO mint callback'); + } + return Promise.resolve(mintCallback(new TextEncoder().encode(identifier))) + .then(u8ToBase64Url); + }); + } + + function run() { + step('run start, readyState=' + document.readyState + ' origin=' + location.origin); + var videoId = window.__SABR_POC_VIDEO_ID || 'aqz-KE-bpKQ'; + var ctx = { + visitorData: readVisitorData(), + clientVersion: readClientVersion(), + userAgent: navigator.userAgent + }; + step('context ok visitorLen=' + ctx.visitorData.length + ' clientVersion=' + ctx.clientVersion); + var webPoSignalOutput = []; + var integrityTokenLength = -1; + step('fetching att/get challenge...'); + return fetchChallenge(ctx).then(function (challenge) { + step('challenge ok embedded=' + + !!(challenge.interpreterJavascript + && challenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue)); + return resolveInterpreter(challenge, ctx.userAgent).then(function (interpreterJs) { + step('interpreter resolved len=' + (interpreterJs ? interpreterJs.length : -1)); + return loadBotGuard(interpreterJs, challenge.program, challenge.globalName); + }); + }).then(function (functions) { + step('botguard loaded, taking snapshot...'); + return snapshot(functions, webPoSignalOutput); + }).then(function (botGuardResponse) { + step('snapshot ok, calling GenerateIT...'); + return fetchIntegrityToken(botGuardResponse, ctx.userAgent); + }).then(function (integrityToken) { + step('integrity token len=' + integrityToken.length + ', minting...'); + integrityTokenLength = integrityToken.length; + return mint(webPoSignalOutput, integrityToken, videoId); + }).then(function (poToken) { + report({ + ok: true, + videoId: videoId, + clientVersion: ctx.clientVersion, + visitorDataLength: ctx.visitorData.length, + integrityTokenLength: integrityTokenLength, + poTokenLength: poToken.length, + poToken: poToken, + userAgent: ctx.userAgent + }); + }); + } + + function reportError(e) { + report({ + ok: false, + error: (e && e.message) ? e.message : String(e), + errorName: e && e.name ? e.name : '', + stack: e && e.stack ? String(e.stack).slice(0, 400) : '', + userAgent: navigator.userAgent + }); + } + + try { + run().catch(reportError); + } catch (e) { + reportError(e); + } +})(); diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java b/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java new file mode 100644 index 0000000000..70a62df7cc --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java @@ -0,0 +1,281 @@ +package org.schabi.newpipe.player.datasource; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.webkit.JavascriptInterface; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import androidx.annotation.Nullable; + +import org.json.JSONObject; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrPoTokenProvider; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrStreamState; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Generates YouTube SABR PO tokens by running the official BotGuard challenge inside a headless + * WebView (the legitimate attestation runtime), then handing the minted, videoId-bound token to the + * extractor's SABR session via {@link SabrPoTokenProvider}. + * + *

Validated end to end (emulator + Pixel 8 / GrapheneOS Vanadium): the WebView produces a token + * GenerateIT accepts and that flips SABR protection status 2 -> 1.

+ * + *

The provider blocks the calling (loading) thread on a latch while the WebView, driven on the + * main thread, runs the pipeline. Tokens are cached per videoId (~6h, well under the measured ~7-8h + * lifetime).

+ */ +public final class WebViewPoTokenProvider implements SabrPoTokenProvider { + + private static final String TAG = "WebViewPoToken"; + private static final String ASSET = "sabr_potoken_poc.js"; + private static final String DESKTOP_UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"; + private static final long TOKEN_TTL_MS = 6L * 60L * 60L * 1000L; // 6 hours + private static final long PIPELINE_TIMEOUT_MS = 45_000L; + private static final int READY_RETRIES = 20; + private static final long READY_POLL_MS = 250L; + + private static final class CachedToken { + private final byte[] token; + private final long mintedAtMs; + + CachedToken(final byte[] token, final long mintedAtMs) { + this.token = token; + this.mintedAtMs = mintedAtMs; + } + } + + private final Context appContext; + private final Handler mainHandler; + private final Map cache = new ConcurrentHashMap<>(); + // one lock per videoId so two callers (pre-warm + pump) don't both fire the ~45s WebView mint + // for the same video. second one just waits and takes the cached token. + private final Map mintLocks = new ConcurrentHashMap<>(); + + public WebViewPoTokenProvider(final Context context) { + this.appContext = context.getApplicationContext(); + this.mainHandler = new Handler(Looper.getMainLooper()); + } + + @Nullable + @Override + public byte[] getPoToken(final YoutubeSabrInfo info, final YoutubeSabrStreamState streamState) { + return getPoToken(info, streamState, false); + } + + @Nullable + @Override + public byte[] getPoToken(final YoutubeSabrInfo info, final YoutubeSabrStreamState streamState, + final boolean forceRefresh) { + final String videoId = info.getVideoId(); + if (forceRefresh) { + // Server rejected the cached token (expired): drop it and mint a fresh one. + cache.remove(videoId); + } + synchronized (mintLocks.computeIfAbsent(videoId, k -> new Object())) { + final long now = System.currentTimeMillis(); + final CachedToken cached = cache.get(videoId); + if (cached != null && now - cached.mintedAtMs < TOKEN_TTL_MS) { + return cached.token; + } + final String tokenB64 = mintBlocking(videoId); + if (tokenB64 == null || tokenB64.isEmpty()) { + return null; + } + final byte[] token; + try { + token = Base64.getUrlDecoder().decode(tokenB64); + } catch (final IllegalArgumentException e) { + Log.e(TAG, "could not decode PO token", e); + return null; + } + cache.put(videoId, new CachedToken(token, now)); + return token; + } + } + + @Nullable + private String mintBlocking(final String videoId) { + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference tokenRef = new AtomicReference<>(); + final AtomicReference webViewRef = new AtomicReference<>(); + + mainHandler.post(() -> { + try { + webViewRef.set(createWebView(videoId, tokenRef, latch)); + } catch (final Exception e) { + Log.e(TAG, "failed to start WebView pipeline", e); + latch.countDown(); + } + }); + + try { + if (!latch.await(PIPELINE_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + Log.w(TAG, "PO token pipeline timed out for " + videoId); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + mainHandler.post(() -> destroyWebView(webViewRef.getAndSet(null))); + } + return tokenRef.get(); + } + + @SuppressLint("SetJavaScriptEnabled") + private WebView createWebView(final String videoId, + final AtomicReference tokenRef, + final CountDownLatch latch) { + final WebView webView = new WebView(appContext); + final WebSettings settings = webView.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setUserAgentString(DESKTOP_UA); + webView.addJavascriptInterface(new Bridge(tokenRef, latch), "SabrPocBridge"); + webView.setWebViewClient(new WebViewClient() { + private boolean injected = false; + + @Override + public WebResourceResponse shouldInterceptRequest(final WebView view, + final WebResourceRequest request) { + final String url = request.getUrl().toString(); + if (url.contains("/js/th/")) { + return fetchWithCors(url); + } + return super.shouldInterceptRequest(view, request); + } + + @Override + public void onPageFinished(final WebView view, final String url) { + super.onPageFinished(view, url); + if (injected || url == null || !url.contains("youtube.com")) { + return; + } + injected = true; + waitForReadyThenInject(view, videoId, 0); + } + }); + webView.loadUrl("https://www.youtube.com/"); + return webView; + } + + private void waitForReadyThenInject(final WebView view, final String videoId, final int attempt) { + view.evaluateJavascript("document.readyState", value -> { + final boolean complete = value != null && value.contains("complete"); + if (complete || attempt >= READY_RETRIES) { + view.evaluateJavascript( + "window.__SABR_POC_VIDEO_ID=" + jsString(videoId) + ";", null); + view.evaluateJavascript(loadPipelineScript(), null); + } else { + mainHandler.postDelayed( + () -> waitForReadyThenInject(view, videoId, attempt + 1), READY_POLL_MS); + } + }); + } + + private static void destroyWebView(@Nullable final WebView webView) { + if (webView == null) { + return; + } + try { + webView.stopLoading(); + webView.loadUrl("about:blank"); + webView.removeAllViews(); + webView.destroy(); + } catch (final Exception ignored) { + // best effort + } + } + + private static String jsString(final String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private String loadPipelineScript() { + try (InputStream in = appContext.getAssets().open(ASSET); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + final byte[] chunk = new byte[8192]; + int read; + while ((read = in.read(chunk)) != -1) { + out.write(chunk, 0, read); + } + return out.toString("UTF-8"); + } catch (final Exception e) { + Log.e(TAG, "could not read pipeline asset", e); + return ""; + } + } + + @Nullable + private static WebResourceResponse fetchWithCors(final String url) { + try { + final HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setRequestProperty("User-Agent", DESKTOP_UA); + connection.setConnectTimeout(15000); + connection.setReadTimeout(15000); + final int code = connection.getResponseCode(); + final InputStream body = code >= 400 + ? connection.getErrorStream() : connection.getInputStream(); + final String contentType = connection.getContentType(); + String mime = "application/javascript"; + if (contentType != null) { + final int sep = contentType.indexOf(';'); + mime = sep > 0 ? contentType.substring(0, sep).trim() : contentType.trim(); + } + final Map headers = new HashMap<>(); + headers.put("Access-Control-Allow-Origin", "*"); + final WebResourceResponse response = new WebResourceResponse(mime, "UTF-8", body); + response.setStatusCodeAndReasonPhrase(code, code >= 400 ? "ERROR" : "OK"); + response.setResponseHeaders(headers); + return response; + } catch (final Exception e) { + Log.e(TAG, "interpreter native fetch failed", e); + return null; + } + } + + private static final class Bridge { + private final AtomicReference tokenRef; + private final CountDownLatch latch; + + Bridge(final AtomicReference tokenRef, final CountDownLatch latch) { + this.tokenRef = tokenRef; + this.latch = latch; + } + + @JavascriptInterface + public void onResult(final String json) { + try { + final JSONObject obj = new JSONObject(json); + if (obj.optBoolean("ok", false)) { + tokenRef.set(obj.optString("poToken", null)); + } else { + Log.w(TAG, "PO token pipeline failed: " + obj.optString("error", "unknown")); + } + } catch (final Exception e) { + Log.e(TAG, "could not parse pipeline result", e); + } finally { + latch.countDown(); + } + } + } +} From cc554d2537b0918198c070b72d7ebc499f046b69 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 12:38:26 +0200 Subject: [PATCH 02/35] feat(player): SABR session store and format selection --- .../player/datasource/SabrSessionStore.java | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java new file mode 100644 index 0000000000..e250bcc4d3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -0,0 +1,289 @@ +package org.schabi.newpipe.player.datasource; + +import android.content.Context; +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.localization.ContentCountry; +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrPoTokenProvider; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrClientProfile; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Caches one shared {@link YoutubeSabrSession} per videoId so the audio and video + * {@link SabrDataSource}s drive the same session (a single SABR response carries both formats, so + * the session's segment cache serves both without doubling bandwidth). + * + *

v1: uses the best audio/video formats from the player response and a fixed en/US locale.

+ */ +public final class SabrSessionStore { + + private static final Map SESSIONS = new ConcurrentHashMap<>(); + // Keep only the few most-recent sessions so the map + per-video segment caches + pump threads + // don't accumulate forever as the user browses videos. Mutated only under the class lock. + private static final int MAX_SESSIONS = 3; + private static final java.util.Deque ORDER = new java.util.ArrayDeque<>(); + // Shared across videos so the PO-token cache (videoId-keyed, ~6h) is reused and a single + // WebView is held instead of one per video. + private static volatile WebViewPoTokenProvider sharedProvider; + + private SabrSessionStore() { + } + + @NonNull + private static WebViewPoTokenProvider provider(@NonNull final Context context) { + WebViewPoTokenProvider p = sharedProvider; + if (p == null) { + synchronized (SabrSessionStore.class) { + p = sharedProvider; + if (p == null) { + p = new WebViewPoTokenProvider(context.getApplicationContext()); + sharedProvider = p; + } + } + } + return p; + } + + /** Bundle of the session and its selected formats for a given video. */ + public static final class Holder { + @NonNull public final String videoId; + @NonNull public final YoutubeSabrInfo info; + @NonNull public final YoutubeSabrSession session; + @NonNull public final YoutubeSabrFormat audioFormat; + @NonNull public final YoutubeSabrFormat videoFormat; + + // Real playback position (ms); written by the player loop. Kept for reference but NOT used to + // drive the pump/eviction: it freezes when the player buffers, which deadlocked everything. + private volatile long playerTimeMs; + // What each track's data source has actually read (segment end ms). This is the truth the pump + // and eviction run on: it never goes stale (a stalled reader sits on its last segment, so the + // pump sees edge ~= readerHead and keeps feeding instead of pacing off a frozen play head). + private final Map readerPositions = new ConcurrentHashMap<>(); + private volatile SabrStreamPump pump; + + Holder(@NonNull final String videoId, + @NonNull final YoutubeSabrInfo info, + @NonNull final YoutubeSabrSession session, + @NonNull final YoutubeSabrFormat audioFormat, + @NonNull final YoutubeSabrFormat videoFormat) { + this.videoId = videoId; + this.info = info; + this.session = session; + this.audioFormat = audioFormat; + this.videoFormat = videoFormat; + } + + public long getPlayerTimeMs() { + return playerTimeMs; + } + + void setPlayerTimeMs(final long playerTimeMs) { + this.playerTimeMs = playerTimeMs; + } + + /** A data source reports how far it has read (last served segment end, ms). */ + public void setReaderPositionMs(final int itag, final long ms) { + readerPositions.put(itag, ms); + } + + /** Furthest-read track: the pump keeps the buffered edge a cushion ahead of THIS. */ + public long getReaderHeadMs() { + long head = 0; + final Long a = readerPositions.get(audioFormat.getItag()); + final Long v = readerPositions.get(videoFormat.getItag()); + if (a != null) { + head = Math.max(head, a); + } + if (v != null) { + head = Math.max(head, v); + } + return head; + } + + /** Slowest-read track: nothing before this is needed any more, so eviction starts here. Zero + * until BOTH tracks have read something (else we'd evict the other track's unread segments). */ + public long getReaderTailMs() { + final Long a = readerPositions.get(audioFormat.getItag()); + final Long v = readerPositions.get(videoFormat.getItag()); + if (a == null || v == null) { + return 0; + } + return Math.min(a, v); + } + + /** Lazily create the single background pump that feeds both data sources for this video. */ + synchronized SabrStreamPump getPump(@NonNull final Localization localization) { + if (pump == null) { + pump = new SabrStreamPump(session, this, localization); + } + return pump; + } + + boolean isBeyondEnd(@NonNull final SabrSegmentRequest request) { + return session.isBeyondEnd(request); + } + } + + // Report the real playback position; no-op when the video has no live SABR session. + public static void updatePlayerTime(@NonNull final String videoId, final long playerTimeMs) { + final Holder holder = SESSIONS.get(videoId); + if (holder != null && playerTimeMs >= 0) { + holder.setPlayerTimeMs(playerTimeMs); + } + } + + @NonNull + public static Holder getOrCreate(@NonNull final Context context, + @NonNull final String videoId) + throws IOException, ExtractionException { + final Holder existing = SESSIONS.get(videoId); + if (existing != null) { + return existing; + } + synchronized (SabrSessionStore.class) { + final Holder racing = SESSIONS.get(videoId); + if (racing != null) { + return racing; + } + final Localization localization = new Localization("en", "US"); + final ContentCountry contentCountry = new ContentCountry("US"); + final YoutubeSabrInfo info = YoutubeSabrProbeFetch(videoId, localization, contentCountry); + final YoutubeSabrFormat audioFormat = info.findBestAudioFormat(); + final YoutubeSabrFormat videoFormat = pickHardwareFriendlyVideo(info); + if (audioFormat == null || videoFormat == null) { + throw new IOException("SABR: could not select audio/video formats for " + videoId); + } + final SabrPoTokenProvider provider = provider(context); + final YoutubeSabrSession session = + new YoutubeSabrSession(info, audioFormat, videoFormat, provider); + final Holder holder = new Holder(videoId, info, session, audioFormat, videoFormat); + SESSIONS.put(videoId, holder); + // LRU bound: evict the oldest sessions (their pumps are stopped, caches freed). + ORDER.remove(videoId); + ORDER.addLast(videoId); + while (ORDER.size() > MAX_SESSIONS) { + final String old = ORDER.pollFirst(); + if (old != null && !old.equals(videoId)) { + evict(old); + } + } + // Pre-warm the PO token off-thread so the ~45s WebView mint overlaps the initial probe + // and buffering instead of stalling the pump on its first protected response. + final Thread warm = new Thread(() -> { + try { + provider.getPoToken(info, session.getStreamState()); + } catch (final Exception ignored) { + // Best-effort; the pump mints on demand if this fails. + } + }, "SabrTokenPrewarm"); + warm.setDaemon(true); + warm.start(); + return holder; + } + } + + @NonNull + private static YoutubeSabrInfo YoutubeSabrProbeFetch(@NonNull final String videoId, + @NonNull final Localization localization, + @NonNull final ContentCountry contentCountry) + throws IOException, ExtractionException { + return org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrProbe.fetchSabrInfo( + videoId, YoutubeSabrClientProfile.WEB, localization, contentCountry); + } + + /** + * Pick the highest-resolution video format the device can decode in HARDWARE. The decoder is + * chosen by ExoPlayer from the container bytes, so a codec the device only decodes in software + * (e.g. AV1 on most phones, or VP9 where there's no HW VP9) melts the CPU and overheats. So we + * allow AVC always (universally HW), VP9 only when a HW VP9 decoder exists, AV1 only when a HW + * AV1 decoder exists; otherwise fall back to the overall best (better some playback than none). + */ + @NonNull + private static YoutubeSabrFormat pickHardwareFriendlyVideo(@NonNull final YoutubeSabrInfo info) { + final boolean hwVp9 = hasHardwareDecoder("video/x-vnd.on2.vp9"); + final boolean hwAv1 = hasHardwareDecoder("video/av01"); + YoutubeSabrFormat best = null; + for (final YoutubeSabrFormat f : info.getFormats()) { + if (!f.isVideo()) { + continue; + } + final String codec = codecFamily(f.getMimeType()); + final boolean decodable = "avc".equals(codec) + || ("vp9".equals(codec) && hwVp9) + || ("av1".equals(codec) && hwAv1); + if (!decodable) { + continue; + } + if (best == null || f.getHeight() > best.getHeight() + || (f.getHeight() == best.getHeight() && f.getBitrate() > best.getBitrate())) { + best = f; + } + } + return best != null ? best : info.findBestVideoFormat(); + } + + /** Normalise a SABR format mimeType ({@code codecs="..."}) to a codec family, or null. */ + @NonNull + private static String codecFamily(final String mimeType) { + if (mimeType == null) { + return ""; + } + if (mimeType.contains("avc1") || mimeType.contains("avc3")) { + return "avc"; + } + if (mimeType.contains("vp9") || mimeType.contains("vp09")) { + return "vp9"; + } + if (mimeType.contains("av01")) { + return "av1"; + } + return ""; + } + + /** True if the device exposes a non-software (hardware) decoder for the given mime type. */ + private static boolean hasHardwareDecoder(@NonNull final String mimeType) { + try { + for (final MediaCodecInfo codec + : new MediaCodecList(MediaCodecList.ALL_CODECS).getCodecInfos()) { + if (codec.isEncoder()) { + continue; + } + final String name = codec.getName().toLowerCase(); + // Software decoders on Android are named c2.android.* / c2.google.* / omx.google.*. + if (name.startsWith("c2.android.") || name.startsWith("c2.google.") + || name.startsWith("omx.google.")) { + continue; + } + for (final String type : codec.getSupportedTypes()) { + if (type.equalsIgnoreCase(mimeType)) { + return true; + } + } + } + } catch (final Exception e) { + // If capability probing fails, be conservative (treat as no HW decoder). + return false; + } + return false; + } + + /** Evict a cached session, stopping its pump so the thread + buffers are released. */ + public static void evict(@NonNull final String videoId) { + final Holder holder = SESSIONS.remove(videoId); + if (holder != null && holder.pump != null) { + holder.pump.stop(); + } + } +} From 2fe33cb539c8d0e70cca0844a1bb853547f80e8c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 12:38:26 +0200 Subject: [PATCH 03/35] feat(player): SABR pump and datasource (reader-driven) --- .../player/datasource/SabrDataSource.java | 215 ++++++++++++++++++ .../player/datasource/SabrStreamPump.java | 165 ++++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrDataSource.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDataSource.java new file mode 100644 index 0000000000..0afe457da0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDataSource.java @@ -0,0 +1,215 @@ +package org.schabi.newpipe.player.datasource; + +import android.net.Uri; +import android.util.Log; + +import androidx.annotation.Nullable; + +import androidx.media3.common.C; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.TransferListener; + +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; + +import java.io.IOException; + +/** + * ExoPlayer {@link DataSource} exposing one SABR format (audio or video) as a continuous byte + * stream: init segment then media segments. It only reads the session's concurrent cache, which a + * single {@link SabrStreamPump} fills ahead of the play head; so the two data sources never touch + * the network nor block each other. We end the stream past the last segment, on a pump fatal error, + * or if the play head stays frozen long enough to call it a genuine stall. + * + *

v1: sequential read from the start, seeks skip forward, length unknown until end-of-stream.

+ */ +public final class SabrDataSource implements DataSource { + + private static final String TAG = "SabrDataSource"; + + private static final long WAIT_MS = 250; + // only bail once the pump's been dry a while (bailing makes ExoPlayer re-open us, which unsticks + // the flow). be patient at cold start: the ~45s WebView mint = zero segments for a bit, and most + // underruns just sort themselves out once the pump catches up. + // do NOT EOF early on a stall: ExoPlayer re-opens at a byte offset and our v1 byte-skip seek + // fucks the fragmented container = frozen video. so we just ride the stall out. + private static final long STALL_MS = 120_000; + + private final SabrSessionStore.Holder holder; + private final YoutubeSabrFormat format; + private final Localization localization; + + @Nullable + private Uri uri; + @Nullable + private byte[] current; + private int currentPos; + private boolean initServed; + private int nextSeq; + private boolean ended; + private long skipRemaining; + private volatile boolean canceled; + + public SabrDataSource(final SabrSessionStore.Holder holder, + final YoutubeSabrFormat format, + final Localization localization) { + this.holder = holder; + this.format = format; + this.localization = localization; + } + + @Override + public void addTransferListener(final TransferListener transferListener) { + // Bandwidth metering not wired for the SABR v1 source. + } + + @Override + public long open(final DataSpec dataSpec) { + this.uri = dataSpec.uri; + this.current = null; + this.currentPos = 0; + this.initServed = false; + this.nextSeq = 1; // SABR media sequence numbers are 1-based (0 is rejected) + this.ended = false; + this.skipRemaining = Math.max(0, dataSpec.position); + this.canceled = false; + return C.LENGTH_UNSET; + } + + @Override + public int read(final byte[] target, final int offset, final int length) throws IOException { + if (length == 0) { + return 0; + } + if (ended) { + return C.RESULT_END_OF_INPUT; + } + // Drop bytes for a forward seek (v1 skips from the start). + while (skipRemaining > 0) { + if (!ensureBuffer()) { + return C.RESULT_END_OF_INPUT; + } + final int available = current.length - currentPos; + final int drop = (int) Math.min(available, skipRemaining); + currentPos += drop; + skipRemaining -= drop; + } + if (!ensureBuffer()) { + return C.RESULT_END_OF_INPUT; + } + final int available = current.length - currentPos; + final int toCopy = Math.min(length, available); + System.arraycopy(current, currentPos, target, offset, toCopy); + currentPos += toCopy; + return toCopy; + } + + /** + * Make sure {@link #current} has unread bytes, waiting for the pump to cache the next segment. + * + * @return false if the stream is exhausted + */ + private boolean ensureBuffer() throws IOException { + if (current != null && currentPos < current.length) { + return true; + } + final SabrStreamPump pump = holder.getPump(localization); + while (true) { + if (canceled) { + ended = true; + return false; + } + final SabrSegmentRequest request = initServed + ? SabrSegmentRequest.media(format, nextSeq) + : SabrSegmentRequest.initialization(format); + pump.ensureStarted(); + final SabrMediaSegment segment = pump.getCached(request); + if (segment != null) { + if (initServed) { + nextSeq++; + } else { + initServed = true; + } + if (!segment.getHeader().isInitSegment()) { + // tell the pump/eviction how far this track has actually read (never stale). + holder.setReaderPositionMs(format.getItag(), + segment.getHeader().getStartMs() + segment.getHeader().getDurationMs()); + } + current = segment.getData(); + currentPos = 0; + if (current.length == 0) { + continue; + } + return true; + } + if (holder.isBeyondEnd(request)) { + ended = true; + return false; + } + if (pump.isFatal()) { + // Surface a real error (not a clean EOF) so ExoPlayer reports a playback error + // instead of pretending the video ended. The session was evicted on fatal, so a + // retry rebuilds a fresh one. + throw new IOException("SABR pump fatal for itag=" + format.getItag() + + " at seq=" + nextSeq); + } + // not cached yet: pump's fetching or the server's pacing us. wait, don't signal EOF + // (that triggers a corrupting re-open). only bail if the pump's been fully dry long + // enough to be a real dead stall. + if (pump.millisSinceLastSegment() > STALL_MS) { + Log.i(TAG, "end of SABR stream (stalled) itag=" + format.getItag() + + " at seq=" + nextSeq); + ended = true; + return false; + } + try { + Thread.sleep(WAIT_MS); + } catch (final InterruptedException ie) { + Thread.currentThread().interrupt(); + if (canceled) { + ended = true; + return false; // clean cancellation (close/seek/release), not a playback error + } + throw new IOException("Interrupted during SABR wait", ie); + } + } + } + + @Nullable + @Override + public Uri getUri() { + return uri; + } + + @Override + public void close() { + // Unblock a read() that is waiting for the pump (it polls this flag), so ExoPlayer can + // release this loader thread promptly on stop/seek/track-change. + canceled = true; + current = null; + currentPos = 0; + } + + /** Factory binding a {@link SabrDataSource} to one shared session holder + format. */ + public static final class Factory implements DataSource.Factory { + private final SabrSessionStore.Holder holder; + private final YoutubeSabrFormat format; + private final Localization localization; + + public Factory(final SabrSessionStore.Holder holder, + final YoutubeSabrFormat format, + final Localization localization) { + this.holder = holder; + this.format = format; + this.localization = localization; + } + + @Override + public DataSource createDataSource() { + return new SabrDataSource(holder, format, localization); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java new file mode 100644 index 0000000000..58af654672 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java @@ -0,0 +1,165 @@ +package org.schabi.newpipe.player.datasource; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; + +import java.io.IOException; +import java.util.List; + +/** + * Single consumer of a {@link YoutubeSabrSession}: one daemon thread pumps the server-driven SABR + * stream and fills the session's (concurrent) segment cache ahead of the play head. The server + * paces us with policy-only responses once we are far enough ahead. Both the audio and video + * {@link SabrDataSource}s only read the cache, so they never fight over the session or block each + * other on a network round-trip, which is exactly what starved a track in the old on-demand approach. + */ +final class SabrStreamPump { + + private static final String TAG = "SabrStreamPump"; + private static final long IDLE_POLL_MS = 400; // server paced us / nothing new this round + private static final long ERROR_RETRY_MS = 1000; // transient network error + // no reads for this long -> playback is gone. MUST stay above READAHEAD_CUSHION_MS: once the + // player buffer is full it stops reading us for ~cushion seconds, and killing the pump in that + // window left the cache to drain dry -> periodic rebuffering. + private static final long IDLE_STOP_MS = 90_000; + // Margin the buffered edge stays ahead of the furthest-read track. Driven off the reader (not the + // play head) it only needs to cover a few segments, so it stays small -> bounded memory even at 4K. + private static final long READAHEAD_CUSHION_MS = 30_000; + // Hard byte ceiling on read-ahead so a high-bitrate (4K) stream can't OOM the heap: 50s of 4K is + // ~160MB and crashed. ~100MB still covers the player's ~30s read-ahead, well under the OOM line. + private static final long MAX_AHEAD_BYTES = 100L * 1024 * 1024; + + private final YoutubeSabrSession session; + private final SabrSessionStore.Holder holder; + private final Localization localization; + + private volatile boolean started; + private volatile boolean stopped; + private volatile boolean fatal; + private volatile long lastReadMs; + private volatile long lastSegmentMs; + private Thread thread; + + SabrStreamPump(@NonNull final YoutubeSabrSession session, + @NonNull final SabrSessionStore.Holder holder, + @NonNull final Localization localization) { + this.session = session; + this.holder = holder; + this.localization = localization; + } + + /** Start (or restart, if it idled out) the pump thread, and mark the session as actively read. */ + void ensureStarted() { + lastReadMs = System.currentTimeMillis(); + if (fatal || (started && !stopped)) { + return; + } + synchronized (this) { + if (fatal || (started && !stopped)) { + return; + } + stopped = false; + started = true; + lastSegmentMs = System.currentTimeMillis(); + thread = new Thread(this::loop, "SabrStreamPump"); + thread.setDaemon(true); + thread.start(); + } + } + + /** Stop the pump thread and release it (called on eviction / playback teardown). */ + void stop() { + synchronized (this) { + stopped = true; + // Don't self-interrupt: stop() is also reached from the pump thread itself via + // evict-on-fatal, and setting our own interrupt flag could break a later blocking call. + if (thread != null && thread != Thread.currentThread()) { + thread.interrupt(); + } + } + } + + /** ms since the pump last grabbed a segment. basically "is this thing dead or what". */ + long millisSinceLastSegment() { + return System.currentTimeMillis() - lastSegmentMs; + } + + @Nullable + SabrMediaSegment getCached(@NonNull final SabrSegmentRequest request) { + // revive the pump if it idled out: any read means playback is live again. + ensureStarted(); + return session.getCachedSegment(request); + } + + boolean isFatal() { + return fatal; + } + + private void loop() { + try { + while (!stopped) { + if (System.currentTimeMillis() - lastReadMs > IDLE_STOP_MS || session.isComplete()) { + break; + } + try { + // Drive off what the player has ACTUALLY read, not the play head: the play head + // freezes while buffering and that deadlocked the pump. readerHead = furthest + // track read; readerTail = slowest track read (safe to evict below). + final long readerHeadMs = holder.getReaderHeadMs(); + // evict everything both tracks have read past, EVERY round (even before we throttle + // below) or a full cache never drains and the throttle latches forever -> freeze. + session.setPlayHeadMs(holder.getReaderTailMs()); + session.evictPlayed(); + final long edgeMs = session.getStreamState().getMinBufferedEndMs(); + if (edgeMs - readerHeadMs > READAHEAD_CUSHION_MS + || session.getCachedBytes() > MAX_AHEAD_BYTES) { + Thread.sleep(IDLE_POLL_MS); + continue; + } + // Report the CONTIGUOUS buffered edge (not readerHead): the server fills from the + // reported position, so reporting readerHead (ahead of a laggard track) made it + // skip past the gap and the slow track's edge never advanced. Pace on readerHead, + // report on edge. + session.getStreamState().setPlayerTimeMs(edgeMs); + final List segments = session.pumpOnce(localization); + if (segments.isEmpty()) { + Thread.sleep(IDLE_POLL_MS); + } else { + lastSegmentMs = System.currentTimeMillis(); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (final IOException e) { + sleepQuietly(ERROR_RETRY_MS); + } catch (final ExtractionException e) { + Log.i(TAG, "SABR pump fatal: " + e.getMessage()); + fatal = true; + // Drop the dead session so a re-open rebuilds a fresh one (new token, new state). + SabrSessionStore.evict(holder.videoId); + break; + } + } + } finally { + synchronized (this) { + stopped = true; + } + } + } + + private static void sleepQuietly(final long ms) { + try { + Thread.sleep(ms); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} From e645821daac6c9dff4b5639c1a2f70609deafb70 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 12:38:26 +0200 Subject: [PATCH 04/35] feat(player): wire SABR into the player, resolver and load control --- app/src/main/AndroidManifest.xml | 1 + .../org/schabi/newpipe/player/Player.java | 44 +++++++++++++++++-- .../newpipe/player/helper/LoadController.java | 28 ++++++++++++ .../player/resolver/PlaybackResolver.java | 33 ++++++++++++++ 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 73f63fa818..733bf3b4c6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -50,6 +50,7 @@ android:banner="@mipmap/tv_banner" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:largeHeap="true" android:logo="@mipmap/ic_launcher" android:theme="@style/OpeningTheme" android:resizeableActivity="true" diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 53b8e54bb9..8e6bc73aa5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -133,6 +133,7 @@ import org.schabi.newpipe.player.helper.MediaSessionManager; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.helper.PlayerHelper; +import org.schabi.newpipe.player.datasource.SabrSessionStore; import org.schabi.newpipe.player.listeners.view.PlaybackSpeedClickListener; import org.schabi.newpipe.player.listeners.view.QualityClickListener; import org.schabi.newpipe.player.mediaitem.MediaItemTag; @@ -1797,6 +1798,10 @@ private void onUpdateProgress(final int currentProgress, return; } + // Feed the real play head to any live SABR session (no-op otherwise). + getCurrentStreamInfo().ifPresent(info -> + SabrSessionStore.updatePlayerTime(info.getId(), currentProgress)); + if (duration != binding.playbackSeekBar.getMax()) { setVideoDurationToControls(duration); } @@ -3376,9 +3381,36 @@ public void onPlaybackSynchronize(@NonNull final PlayQueueItem item, final boole } public boolean shouldSeek() { + // our v1 SABR seek is a dumb byte-skip that can't land on a real position, so resuming + // mid-video just freezes the whole thing. so SABR always starts from 0, scrubbing can wait. + // honestly nobody died from rewatching an intro. plays fine from 0. + if (isCurrentStreamSabr()) { + return false; + } return !prefs.getBoolean(context.getString(R.string.always_start_from_beginning_key), false); } + private boolean isCurrentStreamSabr() { + return getCurrentStreamInfo().map(info -> { + for (final VideoStream s : info.getVideoOnlyStreams()) { + if (s.getDeliveryMethod() == DeliveryMethod.SABR) { + return true; + } + } + for (final VideoStream s : info.getVideoStreams()) { + if (s.getDeliveryMethod() == DeliveryMethod.SABR) { + return true; + } + } + for (final AudioStream s : info.getAudioStreams()) { + if (s.getDeliveryMethod() == DeliveryMethod.SABR) { + return true; + } + } + return false; + }).orElse(false); + } + public void seekTo(final long positionMillis) { if (DEBUG) { Log.d(TAG, "seekBy() called with: position = [" + positionMillis + "]"); @@ -4079,15 +4111,21 @@ private void buildQualityMenu() { for (int i = 0; i < availableStreams.size(); i++) { final VideoStream videoStream = availableStreams.get(i); - qualityPopupMenu.getMenu().add(POPUP_MENU_ID_QUALITY, i, Menu.NONE, videoStream.getCodec().toUpperCase().split("\\.")[0] + " " + videoStream.resolution); + qualityPopupMenu.getMenu().add(POPUP_MENU_ID_QUALITY, i, Menu.NONE, videoStream.getCodec().toUpperCase().split("\\.")[0] + " " + videoStream.resolution + sabrTag(videoStream)); } if (getSelectedVideoStream() != null) { - binding.qualityTextView.setText(getSelectedVideoStream().resolution); + binding.qualityTextView.setText(getSelectedVideoStream().resolution + sabrTag(getSelectedVideoStream())); } qualityPopupMenu.setOnMenuItemClickListener(this); qualityPopupMenu.setOnDismissListener(this); } + // PoC marker: flag SABR-delivered streams in the quality UI + private static String sabrTag(final VideoStream stream) { + return stream != null && stream.getDeliveryMethod() == DeliveryMethod.SABR + ? " (SABR)" : ""; + } + private void buildPlaybackSpeedMenu() { if (playbackSpeedPopupMenu == null) { return; @@ -4227,7 +4265,7 @@ public void onDismiss(@Nullable final PopupMenu menu) { } isSomePopupMenuVisible = false; //TODO check if this works if (getSelectedVideoStream() != null) { - binding.qualityTextView.setText(getSelectedVideoStream().resolution); + binding.qualityTextView.setText(getSelectedVideoStream().resolution + sabrTag(getSelectedVideoStream())); } if (isPlaying()) { hideControls(DEFAULT_CONTROLS_DURATION, 0); diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java index bcdcd96437..5ee0dacb2e 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java @@ -1,12 +1,40 @@ package org.schabi.newpipe.player.helper; +import androidx.media3.common.C; import androidx.media3.exoplayer.DefaultLoadControl; +import androidx.media3.exoplayer.upstream.DefaultAllocator; public class LoadController extends DefaultLoadControl { public static final String TAG = "LoadController"; + + // Keep the player's buffer target well BELOW the SABR pump's read-ahead cushion (50s). The player + // reads ahead to fill maxBuffer (plus up to one whole segment, ~10s for audio), and if that runs + // past what the pump has fed it blocks on a segment the pump won't fetch yet (pump paces off the + // play head): cold start deadlocked, and the longer-segment track (audio) dropped out mid-play. + // 20s target -> ~30s real read-ahead, comfortably inside the 50s cushion. Fine for DASH/HLS. + private static final int MIN_BUFFER_MS = 12_000; + private static final int MAX_BUFFER_MS = 20_000; + private static final int BUFFER_FOR_PLAYBACK_MS = 2_000; + private static final int BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = 3_000; + private boolean preloadingEnabled = true; + public LoadController() { + super(new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE), + MIN_BUFFER_MS, + MAX_BUFFER_MS, + BUFFER_FOR_PLAYBACK_MS, + BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, + C.LENGTH_UNSET, // no byte cap: the SABR cache bounds memory, time bounds the player + // MUST be true: with false, media3 prioritises its (huge, ~128MB) default byte target + // and ignores maxBufferMs, reading ~50s ahead = right at the pump cushion, so it + // starved at the edge. true makes maxBufferMs (time) the real limit. + true, + DEFAULT_BACK_BUFFER_DURATION_MS, + DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME); + } + @Override public void onPrepared() { preloadingEnabled = true; diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java index 549c974d0a..8cc59c2689 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java @@ -47,6 +47,12 @@ import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.StreamTypeUtil; +import org.schabi.newpipe.App; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; +import org.schabi.newpipe.player.datasource.SabrDataSource; +import org.schabi.newpipe.player.datasource.SabrSessionStore; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -437,12 +443,39 @@ private static MediaSource createYoutubeMediaSourceOfVideoStr .setUri(Uri.parse(stream.getContent())) .setCustomCacheKey(cacheKey) .build()); + case SABR: + return buildSabrMediaSource(stream, streamInfo, cacheKey, metadata); default: throw new IOException("Unsupported delivery method for YouTube contents: " + deliveryMethod); } } + @NonNull + private static MediaSource buildSabrMediaSource(@NonNull final Stream stream, + @NonNull final StreamInfo streamInfo, + @NonNull final String cacheKey, + @NonNull final MediaItemTag metadata) + throws IOException { + final String videoId = streamInfo.getId(); + final SabrSessionStore.Holder holder; + try { + holder = SabrSessionStore.getOrCreate(App.getApp(), videoId); + } catch (final ExtractionException e) { + throw new IOException("Could not start SABR session for " + videoId, e); + } + final YoutubeSabrFormat format = (stream instanceof AudioStream) + ? holder.audioFormat : holder.videoFormat; + final SabrDataSource.Factory factory = new SabrDataSource.Factory( + holder, format, new Localization("en", "US")); + return new ProgressiveMediaSource.Factory(factory).createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse("sabr://" + videoId + "/" + format.getItag())) + .setCustomCacheKey(cacheKey) + .build()); + } + @NonNull private static DashMediaSource buildYoutubeManualDashMediaSource( @NonNull final PlayerDataSource dataSource, From 8b55a702ca015b9feb6101510b10ef672c6c1f05 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 16:45:34 +0200 Subject: [PATCH 05/35] fix(sabr): cap cached sessions to 2 to stop the cross-video black screen --- .../newpipe/player/datasource/SabrSessionStore.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index e250bcc4d3..1bda112394 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -30,9 +30,11 @@ public final class SabrSessionStore { private static final Map SESSIONS = new ConcurrentHashMap<>(); - // Keep only the few most-recent sessions so the map + per-video segment caches + pump threads - // don't accumulate forever as the user browses videos. Mutated only under the class lock. - private static final int MAX_SESSIONS = 3; + // Current video plus one (next-item prefetch). Keeping more let abandoned sessions' pump threads + // linger and bleed into the new playback on a switch, leaving the decoder with no usable frame + // (black screen). Evicting the superseded session promptly (and stopping its pump) fixes that. + // Mutated only under the class lock. + private static final int MAX_SESSIONS = 2; private static final java.util.Deque ORDER = new java.util.ArrayDeque<>(); // Shared across videos so the PO-token cache (videoId-keyed, ~6h) is reused and a single // WebView is held instead of one per video. From 1c12209434a2a893dd093709d8f88cb721d82432 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 17:06:49 +0200 Subject: [PATCH 06/35] feat(sabr): per-segment data source for the chunk-based source (tier2) --- .../datasource/SabrSegmentDataSource.java | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java new file mode 100644 index 0000000000..6565155890 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -0,0 +1,140 @@ +package org.schabi.newpipe.player.datasource; + +import android.net.Uri; + +import androidx.annotation.Nullable; + +import androidx.media3.common.C; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.TransferListener; + +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; + +import java.io.IOException; + +/** + * Tier-2 chunk source helper: a {@link DataSource} that serves exactly ONE SABR segment (the init + * segment or one media segment) from the session cache, then ends. The chunk framework + * ({@code ChunkSampleStream}) opens one of these per chunk, so seeking is handled by the framework + * picking the chunk index, NOT by byte-skipping a continuous stream (which the v1 source could not + * land). + * + *

The segment is identified by the {@link DataSpec} uri: {@code sabrseg:///init} or + * {@code sabrseg:///}.

+ */ +public final class SabrSegmentDataSource implements DataSource { + + private static final long WAIT_MS = 250; + private static final long STALL_MS = 120_000; + + private final SabrSessionStore.Holder holder; + private final YoutubeSabrFormat format; + private final Localization localization; + + @Nullable + private Uri uri; + @Nullable + private byte[] data; + private int pos; + private boolean opened; + private volatile boolean canceled; + + public SabrSegmentDataSource(final SabrSessionStore.Holder holder, + final YoutubeSabrFormat format, + final Localization localization) { + this.holder = holder; + this.format = format; + this.localization = localization; + } + + @Override + public void addTransferListener(final TransferListener transferListener) { + // Bandwidth metering not wired for the SABR source. + } + + @Override + public long open(final DataSpec dataSpec) throws IOException { + this.uri = dataSpec.uri; + this.canceled = false; + this.pos = (int) Math.max(0, dataSpec.position); + final SabrSegmentRequest request = requestFromUri(dataSpec.uri); + this.data = awaitSegment(request); + this.opened = true; + final int remaining = data.length - pos; + return dataSpec.length == C.LENGTH_UNSET ? remaining : Math.min(dataSpec.length, remaining); + } + + @Override + public int read(final byte[] target, final int offset, final int length) { + if (length == 0) { + return 0; + } + if (data == null || pos >= data.length) { + return C.RESULT_END_OF_INPUT; + } + final int toCopy = Math.min(length, data.length - pos); + System.arraycopy(data, pos, target, offset, toCopy); + pos += toCopy; + return toCopy; + } + + private SabrSegmentRequest requestFromUri(final Uri u) throws IOException { + // sabrseg:/// + final String seg = u.getLastPathSegment(); + if (seg == null) { + throw new IOException("Bad SABR segment uri: " + u); + } + if ("init".equals(seg)) { + return SabrSegmentRequest.initialization(format); + } + try { + return SabrSegmentRequest.media(format, Integer.parseInt(seg)); + } catch (final NumberFormatException e) { + throw new IOException("Bad SABR segment uri: " + u, e); + } + } + + /** Block until the pump has cached this segment, or give up on a real stall / cancellation. */ + private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException { + final SabrStreamPump pump = holder.getPump(localization); + while (true) { + if (canceled) { + throw new IOException("SABR segment read canceled"); + } + pump.ensureStarted(); + final SabrMediaSegment segment = pump.getCached(request); + if (segment != null) { + return segment.getData(); + } + if (pump.isFatal()) { + throw new IOException("SABR pump fatal for itag=" + format.getItag()); + } + if (pump.millisSinceLastSegment() > STALL_MS) { + throw new IOException("SABR segment stalled for itag=" + format.getItag()); + } + try { + Thread.sleep(WAIT_MS); + } catch (final InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted awaiting SABR segment", ie); + } + } + } + + @Nullable + @Override + public Uri getUri() { + return uri; + } + + @Override + public void close() { + canceled = true; + data = null; + opened = false; + } +} From f914f741352670adbbfc384c55da49481d1421f3 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 17:14:57 +0200 Subject: [PATCH 07/35] feat(sabr): seekable chunk-based MediaSource core (tier2) --- .../player/datasource/SabrChunkSource.java | 153 ++++++++++ .../player/datasource/SabrMediaPeriod.java | 278 ++++++++++++++++++ .../player/datasource/SabrMediaSource.java | 101 +++++++ 3 files changed, 532 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaSource.java diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java new file mode 100644 index 0000000000..0c52d801de --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java @@ -0,0 +1,153 @@ +package org.schabi.newpipe.player.datasource; + +import android.net.Uri; + +import androidx.annotation.Nullable; + +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.datasource.DataSpec; +import androidx.media3.exoplayer.LoadingInfo; +import androidx.media3.exoplayer.SeekParameters; +import androidx.media3.exoplayer.source.chunk.BundledChunkExtractor; +import androidx.media3.exoplayer.source.chunk.Chunk; +import androidx.media3.exoplayer.source.chunk.ChunkExtractor; +import androidx.media3.exoplayer.source.chunk.ChunkHolder; +import androidx.media3.exoplayer.source.chunk.ChunkSource; +import androidx.media3.exoplayer.source.chunk.ContainerMediaChunk; +import androidx.media3.exoplayer.source.chunk.InitializationChunk; +import androidx.media3.exoplayer.source.chunk.MediaChunk; +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; +import androidx.media3.extractor.mp4.FragmentedMp4Extractor; + +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; + +import java.io.IOException; +import java.util.List; + +/** + * Tier-2: feeds the media3 chunk framework one SABR segment per chunk. Because the framework drives + * loading by chunk INDEX (mapped from time), seeking is time-based and real, unlike the v1 byte + * stream that could not land a seek. One {@link FragmentedMp4Extractor} is shared per track via a + * {@link BundledChunkExtractor}; the init segment is loaded once as an {@link InitializationChunk}, + * then each media segment is a {@link ContainerMediaChunk}. + */ +final class SabrChunkSource implements ChunkSource { + + private final SabrSessionStore.Holder holder; + private final YoutubeSabrFormat format; + private final Format trackFormat; + private final int trackType; + private final Localization localization; + private final ChunkExtractor chunkExtractor; + + private boolean initLoaded; + @Nullable + private IOException fatalError; + + SabrChunkSource(final SabrSessionStore.Holder holder, + final YoutubeSabrFormat format, + final Format trackFormat, + final int trackType, + final Localization localization) { + this.holder = holder; + this.format = format; + this.trackFormat = trackFormat; + this.trackType = trackType; + this.localization = localization; + this.chunkExtractor = new BundledChunkExtractor( + new FragmentedMp4Extractor(), trackType, trackFormat); + } + + @Override + public long getAdjustedSeekPositionUs(final long positionUs, final SeekParameters seekParameters) { + // Snap to the start of the segment that contains positionUs. + final int seq = holder.session.getStreamState() + .getSegmentNumberAtOrAfterTimeMs(format, positionUs / 1000); + final long startMs = holder.session.getStreamState().getSegmentStartMs(format, seq); + return Math.max(0, startMs) * 1000; + } + + @Override + public void maybeThrowError() throws IOException { + if (fatalError != null) { + throw fatalError; + } + } + + @Override + public int getPreferredQueueSize(final long playbackPositionUs, + final List queue) { + return queue.size(); + } + + @Override + public boolean shouldCancelLoad(final long playbackPositionUs, final Chunk loadingChunk, + final List queue) { + return false; + } + + @Override + public void getNextChunk(final LoadingInfo loadingInfo, final long loadPositionUs, + final List queue, final ChunkHolder out) { + if (!initLoaded) { + out.chunk = newInitChunk(); + return; + } + final int nextSeq; + if (queue.isEmpty()) { + nextSeq = holder.session.getStreamState() + .getSegmentNumberAtOrAfterTimeMs(format, loadPositionUs / 1000); + } else { + nextSeq = (int) (queue.get(queue.size() - 1).getNextChunkIndex()); + } + final long endSeq = holder.session.getStreamState().getEndSegment(format); + if (endSeq > 0 && nextSeq > endSeq) { + out.endOfStream = true; + return; + } + out.chunk = newMediaChunk(nextSeq); + } + + private Chunk newInitChunk() { + final DataSpec spec = new DataSpec(Uri.parse("sabrseg://" + format.getItag() + "/init")); + return new InitializationChunk( + new SabrSegmentDataSource(holder, format, localization), spec, trackFormat, + C.SELECTION_REASON_UNKNOWN, null, chunkExtractor); + } + + private Chunk newMediaChunk(final int seq) { + final long startMs = holder.session.getStreamState().getSegmentStartMs(format, seq); + final long endMs = holder.session.getStreamState().getSegmentEndMs(format, seq); + final long startUs = Math.max(0, startMs) * 1000; + final long endUs = (endMs > 0 ? endMs : startMs) * 1000; + final DataSpec spec = new DataSpec(Uri.parse("sabrseg://" + format.getItag() + "/" + seq)); + return new ContainerMediaChunk( + new SabrSegmentDataSource(holder, format, localization), spec, trackFormat, + C.SELECTION_REASON_UNKNOWN, null, + startUs, endUs, /* clippedStartTimeUs= */ startUs, /* clippedEndTimeUs= */ endUs, + /* chunkIndex= */ seq, /* chunkCount= */ 1, /* sampleOffsetUs= */ startUs, + chunkExtractor); + } + + @Override + public void onChunkLoadCompleted(final Chunk chunk) { + if (chunk instanceof InitializationChunk) { + initLoaded = true; + } + } + + @Override + public boolean onChunkLoadError(final Chunk chunk, final boolean cancelable, + final LoadErrorHandlingPolicy.LoadErrorInfo loadErrorInfo, + final LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + // Let the framework apply its retry/backoff policy. + return false; + } + + @Override + public void release() { + chunkExtractor.release(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java new file mode 100644 index 0000000000..12c16094c7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java @@ -0,0 +1,278 @@ +package org.schabi.newpipe.player.datasource; + +import androidx.annotation.Nullable; + +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.TrackGroup; +import androidx.media3.exoplayer.LoadingInfo; +import androidx.media3.exoplayer.SeekParameters; +import androidx.media3.exoplayer.drm.DrmSessionEventListener; +import androidx.media3.exoplayer.drm.DrmSessionManager; +import androidx.media3.exoplayer.source.MediaPeriod; +import androidx.media3.exoplayer.source.MediaSourceEventListener; +import androidx.media3.exoplayer.source.SampleStream; +import androidx.media3.exoplayer.source.SequenceableLoader; +import androidx.media3.exoplayer.source.TrackGroupArray; +import androidx.media3.exoplayer.source.chunk.ChunkSampleStream; +import androidx.media3.exoplayer.trackselection.ExoTrackSelection; +import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy; +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; + +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Tier-2 {@link MediaPeriod} for SABR: exposes the audio and video tracks and backs each selected + * one with a {@link ChunkSampleStream} over a {@link SabrChunkSource}. Seeking is handled by the + * chunk streams (time -> chunk index), so it actually lands, unlike the v1 byte-stream source. + */ +final class SabrMediaPeriod implements MediaPeriod, + SequenceableLoader.Callback> { + + private final SabrSessionStore.Holder holder; + private final Localization localization; + private final long durationUs; + private final Allocator allocator; + private final DrmSessionManager drmSessionManager; + private final DrmSessionEventListener.EventDispatcher drmEventDispatcher; + private final MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy = + new DefaultLoadErrorHandlingPolicy(); + + private final TrackGroupArray trackGroups; + private final YoutubeSabrFormat[] sabrFormats; + private final int[] trackTypes; + + private final List> streams = new ArrayList<>(); + private SequenceableLoader compositeLoader = new EmptyLoader(); + @Nullable + private MediaPeriod.Callback callback; + + SabrMediaPeriod(final SabrSessionStore.Holder holder, + final Format audioFormat, + final Format videoFormat, + final long durationUs, + final Allocator allocator, + final DrmSessionManager drmSessionManager, + final DrmSessionEventListener.EventDispatcher drmEventDispatcher, + final MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + final Localization localization) { + this.holder = holder; + this.localization = localization; + this.durationUs = durationUs; + this.allocator = allocator; + this.drmSessionManager = drmSessionManager; + this.drmEventDispatcher = drmEventDispatcher; + this.mediaSourceEventDispatcher = mediaSourceEventDispatcher; + this.sabrFormats = new YoutubeSabrFormat[]{holder.videoFormat, holder.audioFormat}; + this.trackTypes = new int[]{C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_AUDIO}; + this.trackGroups = new TrackGroupArray( + new TrackGroup("sabr-video", videoFormat), + new TrackGroup("sabr-audio", audioFormat)); + } + + @Override + public void prepare(final MediaPeriod.Callback cb, final long positionUs) { + this.callback = cb; + cb.onPrepared(this); + } + + @Override + public void maybeThrowPrepareError() { + } + + @Override + public TrackGroupArray getTrackGroups() { + return trackGroups; + } + + @Override + public long selectTracks(final ExoTrackSelection[] selections, final boolean[] mayRetainFlags, + final SampleStream[] outStreams, final boolean[] streamResetFlags, + final long positionUs) { + // Release streams no longer wanted; create streams for newly selected tracks. + for (int i = 0; i < selections.length; i++) { + if (outStreams[i] instanceof ChunkSampleStream && (selections[i] == null + || !mayRetainFlags[i])) { + @SuppressWarnings("unchecked") + final ChunkSampleStream s = + (ChunkSampleStream) outStreams[i]; + streams.remove(s); + s.release(); + outStreams[i] = null; + } + if (outStreams[i] == null && selections[i] != null) { + final ChunkSampleStream s = buildStream(selections[i], positionUs); + streams.add(s); + outStreams[i] = s; + streamResetFlags[i] = true; + } + } + rebuildCompositeLoader(); + return positionUs; + } + + private ChunkSampleStream buildStream(final ExoTrackSelection selection, + final long positionUs) { + final TrackGroup group = selection.getTrackGroup(); + final int groupIndex = trackGroups.indexOf(group); + final Format trackFormat = group.getFormat(0); + final SabrChunkSource chunkSource = new SabrChunkSource(holder, sabrFormats[groupIndex], + trackFormat, trackTypes[groupIndex], localization); + return new ChunkSampleStream<>(trackTypes[groupIndex], null, null, chunkSource, this, + allocator, positionUs, drmSessionManager, drmEventDispatcher, + loadErrorHandlingPolicy, mediaSourceEventDispatcher); + } + + private void rebuildCompositeLoader() { + // Simplest correct loader: drive each stream; report the min buffered / max load position. + compositeLoader = new SequenceableLoader() { + @Override + public long getBufferedPositionUs() { + long min = Long.MAX_VALUE; + for (final ChunkSampleStream s : streams) { + min = Math.min(min, s.getBufferedPositionUs()); + } + return streams.isEmpty() ? C.TIME_END_OF_SOURCE : min; + } + + @Override + public long getNextLoadPositionUs() { + long min = Long.MAX_VALUE; + for (final ChunkSampleStream s : streams) { + final long n = s.getNextLoadPositionUs(); + if (n != C.TIME_END_OF_SOURCE) { + min = Math.min(min, n); + } + } + return min == Long.MAX_VALUE ? C.TIME_END_OF_SOURCE : min; + } + + @Override + public boolean continueLoading(final LoadingInfo loadingInfo) { + boolean any = false; + for (final ChunkSampleStream s : streams) { + any |= s.continueLoading(loadingInfo); + } + return any; + } + + @Override + public boolean isLoading() { + for (final ChunkSampleStream s : streams) { + if (s.isLoading()) { + return true; + } + } + return false; + } + + @Override + public void reevaluateBuffer(final long positionUs) { + for (final ChunkSampleStream s : streams) { + s.reevaluateBuffer(positionUs); + } + } + }; + } + + @Override + public void discardBuffer(final long positionUs, final boolean toKeyframe) { + for (final ChunkSampleStream s : streams) { + s.discardBuffer(positionUs, toKeyframe); + } + } + + @Override + public long readDiscontinuity() { + return C.TIME_UNSET; + } + + @Override + public long seekToUs(final long positionUs) { + for (final ChunkSampleStream s : streams) { + s.seekToUs(positionUs); + } + return positionUs; + } + + @Override + public long getAdjustedSeekPositionUs(final long positionUs, final SeekParameters params) { + for (final ChunkSampleStream s : streams) { + return s.getAdjustedSeekPositionUs(positionUs, params); + } + return positionUs; + } + + @Override + public long getBufferedPositionUs() { + return compositeLoader.getBufferedPositionUs(); + } + + @Override + public long getNextLoadPositionUs() { + return compositeLoader.getNextLoadPositionUs(); + } + + @Override + public boolean continueLoading(final LoadingInfo loadingInfo) { + return compositeLoader.continueLoading(loadingInfo); + } + + @Override + public boolean isLoading() { + return compositeLoader.isLoading(); + } + + @Override + public void reevaluateBuffer(final long positionUs) { + compositeLoader.reevaluateBuffer(positionUs); + } + + @Override + public void onContinueLoadingRequested(final ChunkSampleStream source) { + if (callback != null) { + callback.onContinueLoadingRequested(this); + } + } + + void release() { + for (final ChunkSampleStream s : streams) { + s.release(); + } + streams.clear(); + } + + /** No-op loader used before any track is selected. */ + private static final class EmptyLoader implements SequenceableLoader { + @Override + public long getBufferedPositionUs() { + return C.TIME_END_OF_SOURCE; + } + + @Override + public long getNextLoadPositionUs() { + return C.TIME_END_OF_SOURCE; + } + + @Override + public boolean continueLoading(final LoadingInfo loadingInfo) { + return false; + } + + @Override + public boolean isLoading() { + return false; + } + + @Override + public void reevaluateBuffer(final long positionUs) { + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaSource.java new file mode 100644 index 0000000000..2d928f248b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaSource.java @@ -0,0 +1,101 @@ +package org.schabi.newpipe.player.datasource; + +import androidx.annotation.Nullable; + +import androidx.media3.common.Format; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MimeTypes; +import androidx.media3.datasource.TransferListener; +import androidx.media3.exoplayer.drm.DrmSessionManager; +import androidx.media3.exoplayer.source.BaseMediaSource; +import androidx.media3.exoplayer.source.MediaPeriod; +import androidx.media3.exoplayer.source.SinglePeriodTimeline; +import androidx.media3.exoplayer.upstream.Allocator; + +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; + +/** + * Tier-2 {@link androidx.media3.exoplayer.source.MediaSource} for SABR. Unlike the v1 + * ProgressiveMediaSource over a byte stream (which could not seek), this exposes a seekable + * single-period timeline and a {@link SabrMediaPeriod} backed by the chunk framework, so seeking is + * time-based and lands correctly. The session is created by the resolver and handed in. + */ +public final class SabrMediaSource extends BaseMediaSource { + + private final MediaItem mediaItem; + private final SabrSessionStore.Holder holder; + private final Localization localization; + private final Format audioFormat; + private final Format videoFormat; + private final long durationUs; + + public SabrMediaSource(final MediaItem mediaItem, + final SabrSessionStore.Holder holder, + final Localization localization) { + this.mediaItem = mediaItem; + this.holder = holder; + this.localization = localization; + this.audioFormat = toMedia3Format(holder.audioFormat); + this.videoFormat = toMedia3Format(holder.videoFormat); + this.durationUs = Math.max(holder.audioFormat.getApproxDurationMs(), + holder.videoFormat.getApproxDurationMs()) * 1000L; + } + + @Override + public MediaItem getMediaItem() { + return mediaItem; + } + + @Override + protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) { + refreshSourceInfo(new SinglePeriodTimeline(durationUs, /* isSeekable= */ true, + /* isDynamic= */ false, /* useLiveConfiguration= */ false, + /* manifest= */ null, mediaItem)); + } + + @Override + public void maybeThrowSourceInfoRefreshError() { + } + + @Override + public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator, + final long startPositionUs) { + return new SabrMediaPeriod(holder, audioFormat, videoFormat, durationUs, allocator, + DrmSessionManager.DRM_UNSUPPORTED, createDrmEventDispatcher(id), + createEventDispatcher(id), localization); + } + + @Override + public void releasePeriod(final MediaPeriod mediaPeriod) { + ((SabrMediaPeriod) mediaPeriod).release(); + } + + @Override + protected void releaseSourceInternal() { + } + + private static Format toMedia3Format(final YoutubeSabrFormat f) { + final String mime = f.getMimeType(); + String container = mime; + String codecs = null; + final int sc = mime.indexOf(';'); + if (sc > 0) { + container = mime.substring(0, sc).trim(); + } + final int ci = mime.indexOf("codecs="); + if (ci >= 0) { + codecs = mime.substring(ci + "codecs=".length()).replace("\"", "").trim(); + } + final Format.Builder b = new Format.Builder() + .setId(String.valueOf(f.getItag())) + .setContainerMimeType(container) + .setCodecs(codecs) + .setSampleMimeType(codecs != null ? MimeTypes.getMediaMimeType(codecs) : container) + .setAverageBitrate(f.getBitrate()); + if (f.isVideo()) { + b.setWidth(f.getWidth()).setHeight(f.getHeight()); + } + return b.build(); + } +} From 88af09447aff48761deda8f8a6863db0e2f9500a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 17:20:36 +0200 Subject: [PATCH 08/35] feat(sabr): wire chunk MediaSource into the resolver (tier2 wip) --- .../player/resolver/PlaybackResolver.java | 21 +++++++++---------- .../resolver/VideoPlaybackResolver.java | 5 ++++- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java index 8cc59c2689..4888be4ed6 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java @@ -51,7 +51,7 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; -import org.schabi.newpipe.player.datasource.SabrDataSource; +import org.schabi.newpipe.player.datasource.SabrMediaSource; import org.schabi.newpipe.player.datasource.SabrSessionStore; import androidx.annotation.NonNull; @@ -464,16 +464,15 @@ private static MediaSource buildSabrMediaSource(@NonNull final Stream stream, } catch (final ExtractionException e) { throw new IOException("Could not start SABR session for " + videoId, e); } - final YoutubeSabrFormat format = (stream instanceof AudioStream) - ? holder.audioFormat : holder.videoFormat; - final SabrDataSource.Factory factory = new SabrDataSource.Factory( - holder, format, new Localization("en", "US")); - return new ProgressiveMediaSource.Factory(factory).createMediaSource( - new MediaItem.Builder() - .setTag(metadata) - .setUri(Uri.parse("sabr://" + videoId + "/" + format.getItag())) - .setCustomCacheKey(cacheKey) - .build()); + // One source carries both tracks; media3 track selection picks audio-only when there's no + // video renderer (background/popup). Seeking is real because it's chunk-based, not a byte + // stream. The audio resolver path skips its own SABR source (see VideoPlaybackResolver). + final MediaItem mediaItem = new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse("sabr://" + videoId)) + .setCustomCacheKey(cacheKey) + .build(); + return new SabrMediaSource(mediaItem, holder, new Localization("en", "US")); } @NonNull diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java index ce6fef5d63..222d51ff59 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java @@ -147,9 +147,12 @@ public MediaSource resolve(@NonNull final StreamInfo info) { // Use the audio stream if there is no video stream, or // merge with audio stream in case if video does not contain audio + // SABR carries audio + video in one MediaSource, so don't add a separate audio source. + final boolean videoIsSabr = video != null && video.getDeliveryMethod() + == org.schabi.newpipe.extractor.stream.DeliveryMethod.SABR; final boolean videoHasMatchingAudio = video != null && !video.isVideoOnly() && audioTrack != null && audioTrack.equals(video.getAudioTrackId()); - if (audio != null && !videoHasMatchingAudio + if (audio != null && !videoHasMatchingAudio && !videoIsSabr && (video == null || video.isVideoOnly() || audioTrack != null)) { try { final MediaSource audioSource = PlaybackResolver.buildMediaSource( From 883661c58806f0ea1b0600c516eb936d626420c1 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 17:38:49 +0200 Subject: [PATCH 09/35] feat(sabr): self-contained webm/mp4 chunks, playback works (tier2) --- .../player/datasource/SabrChunkSource.java | 42 ++++++++--------- .../player/datasource/SabrMediaPeriod.java | 2 + .../datasource/SabrSegmentDataSource.java | 46 ++++++++++++++++++- 3 files changed, 65 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java index 0c52d801de..b77eae59c5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java @@ -18,7 +18,10 @@ import androidx.media3.exoplayer.source.chunk.InitializationChunk; import androidx.media3.exoplayer.source.chunk.MediaChunk; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; +import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.mkv.MatroskaExtractor; import androidx.media3.extractor.mp4.FragmentedMp4Extractor; +import androidx.media3.extractor.text.SubtitleParser; import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; @@ -40,9 +43,7 @@ final class SabrChunkSource implements ChunkSource { private final Format trackFormat; private final int trackType; private final Localization localization; - private final ChunkExtractor chunkExtractor; - private boolean initLoaded; @Nullable private IOException fatalError; @@ -56,8 +57,6 @@ final class SabrChunkSource implements ChunkSource { this.trackFormat = trackFormat; this.trackType = trackType; this.localization = localization; - this.chunkExtractor = new BundledChunkExtractor( - new FragmentedMp4Extractor(), trackType, trackFormat); } @Override @@ -91,10 +90,6 @@ public boolean shouldCancelLoad(final long playbackPositionUs, final Chunk loadi @Override public void getNextChunk(final LoadingInfo loadingInfo, final long loadPositionUs, final List queue, final ChunkHolder out) { - if (!initLoaded) { - out.chunk = newInitChunk(); - return; - } final int nextSeq; if (queue.isEmpty()) { nextSeq = holder.session.getStreamState() @@ -103,6 +98,9 @@ public void getNextChunk(final LoadingInfo loadingInfo, final long loadPositionU nextSeq = (int) (queue.get(queue.size() - 1).getNextChunkIndex()); } final long endSeq = holder.session.getStreamState().getEndSegment(format); + android.util.Log.i("SabrChunk", "itag=" + format.getItag() + " nextSeq=" + nextSeq + + " loadPosMs=" + (loadPositionUs / 1000) + " queue=" + queue.size() + + " endSeq=" + endSeq); if (endSeq > 0 && nextSeq > endSeq) { out.endOfStream = true; return; @@ -110,32 +108,31 @@ public void getNextChunk(final LoadingInfo loadingInfo, final long loadPositionU out.chunk = newMediaChunk(nextSeq); } - private Chunk newInitChunk() { - final DataSpec spec = new DataSpec(Uri.parse("sabrseg://" + format.getItag() + "/init")); - return new InitializationChunk( - new SabrSegmentDataSource(holder, format, localization), spec, trackFormat, - C.SELECTION_REASON_UNKNOWN, null, chunkExtractor); - } - private Chunk newMediaChunk(final int seq) { final long startMs = holder.session.getStreamState().getSegmentStartMs(format, seq); final long endMs = holder.session.getStreamState().getSegmentEndMs(format, seq); final long startUs = Math.max(0, startMs) * 1000; final long endUs = (endMs > 0 ? endMs : startMs) * 1000; final DataSpec spec = new DataSpec(Uri.parse("sabrseg://" + format.getItag() + "/" + seq)); + // Fresh extractor per chunk: the data source prepends the init, so each chunk is a complete + // init + one fragment. Absolute fragment timestamps -> sampleOffsetUs = 0. Pick the container + // by mime: YouTube ships VP9/Opus in WebM (Matroska) and AVC/AAC in fragmented mp4. + final String mime = format.getMimeType(); + final Extractor extractorImpl = mime != null && mime.contains("webm") + ? new MatroskaExtractor(SubtitleParser.Factory.UNSUPPORTED) + : new FragmentedMp4Extractor(SubtitleParser.Factory.UNSUPPORTED); + final ChunkExtractor extractor = new BundledChunkExtractor( + extractorImpl, trackType, trackFormat); return new ContainerMediaChunk( - new SabrSegmentDataSource(holder, format, localization), spec, trackFormat, - C.SELECTION_REASON_UNKNOWN, null, + new SabrSegmentDataSource(holder, format, localization, /* prependInit= */ true), + spec, trackFormat, C.SELECTION_REASON_UNKNOWN, null, startUs, endUs, /* clippedStartTimeUs= */ startUs, /* clippedEndTimeUs= */ endUs, - /* chunkIndex= */ seq, /* chunkCount= */ 1, /* sampleOffsetUs= */ startUs, - chunkExtractor); + /* chunkIndex= */ seq, /* chunkCount= */ 1, /* sampleOffsetUs= */ 0L, + extractor); } @Override public void onChunkLoadCompleted(final Chunk chunk) { - if (chunk instanceof InitializationChunk) { - initLoaded = true; - } } @Override @@ -148,6 +145,5 @@ public boolean onChunkLoadError(final Chunk chunk, final boolean cancelable, @Override public void release() { - chunkExtractor.release(); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java index 12c16094c7..077deda8c7 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java @@ -196,6 +196,8 @@ public long readDiscontinuity() { @Override public long seekToUs(final long positionUs) { + android.util.Log.i("SabrSeek", "seekToUs=" + (positionUs / 1000) + "ms streams=" + + streams.size()); for (final ChunkSampleStream s : streams) { s.seekToUs(positionUs); } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index 6565155890..b8d1cee932 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.player.datasource; import android.net.Uri; +import android.util.Log; import androidx.annotation.Nullable; @@ -34,6 +35,10 @@ public final class SabrSegmentDataSource implements DataSource { private final SabrSessionStore.Holder holder; private final YoutubeSabrFormat format; private final Localization localization; + // Prepend the init segment so each media chunk is a self-contained fmp4 (init + one fragment), + // which a fresh FragmentedMp4Extractor parses fully. SABR's init isn't a clean standalone atom + // boundary, so feeding it on its own (DASH-style InitializationChunk) hit an EOF mid-atom. + private final boolean prependInit; @Nullable private Uri uri; @@ -45,10 +50,12 @@ public final class SabrSegmentDataSource implements DataSource { public SabrSegmentDataSource(final SabrSessionStore.Holder holder, final YoutubeSabrFormat format, - final Localization localization) { + final Localization localization, + final boolean prependInit) { this.holder = holder; this.format = format; this.localization = localization; + this.prependInit = prependInit; } @Override @@ -62,7 +69,16 @@ public long open(final DataSpec dataSpec) throws IOException { this.canceled = false; this.pos = (int) Math.max(0, dataSpec.position); final SabrSegmentRequest request = requestFromUri(dataSpec.uri); - this.data = awaitSegment(request); + if (prependInit && !request.isInitializationSegment()) { + final byte[] init = awaitSegment(SabrSegmentRequest.initialization(format)); + final byte[] media = awaitSegment(request); + final byte[] both = new byte[init.length + media.length]; + System.arraycopy(init, 0, both, 0, init.length); + System.arraycopy(media, 0, both, init.length, media.length); + this.data = both; + } else { + this.data = awaitSegment(request); + } this.opened = true; final int remaining = data.length - pos; return dataSpec.length == C.LENGTH_UNSET ? remaining : Math.min(dataSpec.length, remaining); @@ -101,7 +117,14 @@ private SabrSegmentRequest requestFromUri(final Uri u) throws IOException { /** Block until the pump has cached this segment, or give up on a real stall / cancellation. */ private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException { final SabrStreamPump pump = holder.getPump(localization); + int waited = 0; while (true) { + if (waited > 0 && waited % 8 == 0) { + Log.i("SabrSeg", "WAIT itag=" + format.getItag() + " seq=" + + (request.isInitializationSegment() ? "init" : request.getSequenceNumber()) + + " sinceSeg=" + pump.millisSinceLastSegment()); + } + waited++; if (canceled) { throw new IOException("SABR segment read canceled"); } @@ -125,6 +148,25 @@ private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException } } + private static String box(final byte[] b, final int off) { + if (b == null || off < 0 || off + 8 > b.length) { + return "EOF@" + off; + } + final long size = ((b[off] & 0xFFL) << 24) | ((b[off + 1] & 0xFFL) << 16) + | ((b[off + 2] & 0xFFL) << 8) | (b[off + 3] & 0xFFL); + final String type = new String(b, off + 4, 4, java.nio.charset.StandardCharsets.US_ASCII); + return size + ":" + type; + } + + private static int nextBox(final byte[] b, final int off) { + if (b == null || off + 8 > b.length) { + return b == null ? 0 : b.length; + } + final long size = ((b[off] & 0xFFL) << 24) | ((b[off + 1] & 0xFFL) << 16) + | ((b[off + 2] & 0xFFL) << 8) | (b[off + 3] & 0xFFL); + return size <= 0 ? b.length : off + (int) size; + } + @Nullable @Override public Uri getUri() { From 2815600d492387264c0bec8312f966a35c1090f1 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 17:48:09 +0200 Subject: [PATCH 10/35] fix(sabr): track reader position so playback and seek keep feeding (tier2) --- .../newpipe/player/datasource/SabrSegmentDataSource.java | 7 +++++++ .../schabi/newpipe/player/datasource/SabrStreamPump.java | 9 +++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index b8d1cee932..72eac58ffa 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -131,6 +131,13 @@ private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException pump.ensureStarted(); final SabrMediaSegment segment = pump.getCached(request); if (segment != null) { + if (!segment.getHeader().isInitSegment()) { + // Tell the pump how far this track has been loaded so it keeps feeding ahead + // (and repositions after a seek). Without this readerHead stayed 0 and the pump + // throttled forever after the initial fill. + holder.setReaderPositionMs(format.getItag(), + segment.getHeader().getStartMs() + segment.getHeader().getDurationMs()); + } return segment.getData(); } if (pump.isFatal()) { diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java index 58af654672..c8d68365c7 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java @@ -119,8 +119,12 @@ private void loop() { session.setPlayHeadMs(holder.getReaderTailMs()); session.evictPlayed(); final long edgeMs = session.getStreamState().getMinBufferedEndMs(); - if (edgeMs - readerHeadMs > READAHEAD_CUSHION_MS - || session.getCachedBytes() > MAX_AHEAD_BYTES) { + final boolean throttled = edgeMs - readerHeadMs > READAHEAD_CUSHION_MS + || session.getCachedBytes() > MAX_AHEAD_BYTES; + Log.i(TAG, "head=" + readerHeadMs + " tail=" + holder.getReaderTailMs() + + " edge=" + edgeMs + " cacheKB=" + (session.getCachedBytes() / 1024) + + " throttled=" + throttled); + if (throttled) { Thread.sleep(IDLE_POLL_MS); continue; } @@ -130,6 +134,7 @@ private void loop() { // report on edge. session.getStreamState().setPlayerTimeMs(edgeMs); final List segments = session.pumpOnce(localization); + Log.i(TAG, "pumpOnce reported=" + edgeMs + " -> segs=" + segments.size()); if (segments.isEmpty()) { Thread.sleep(IDLE_POLL_MS); } else { From 1d6c535ded549e072eef0ec61176fd641a93d30a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 18:07:53 +0200 Subject: [PATCH 11/35] chore(sabr): drop tier2 debug logging --- .../player/datasource/SabrChunkSource.java | 4 --- .../player/datasource/SabrMediaPeriod.java | 2 -- .../datasource/SabrSegmentDataSource.java | 27 ------------------- .../player/datasource/SabrStreamPump.java | 4 --- 4 files changed, 37 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java index b77eae59c5..151fcbc7df 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrChunkSource.java @@ -15,7 +15,6 @@ import androidx.media3.exoplayer.source.chunk.ChunkHolder; import androidx.media3.exoplayer.source.chunk.ChunkSource; import androidx.media3.exoplayer.source.chunk.ContainerMediaChunk; -import androidx.media3.exoplayer.source.chunk.InitializationChunk; import androidx.media3.exoplayer.source.chunk.MediaChunk; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.extractor.Extractor; @@ -98,9 +97,6 @@ public void getNextChunk(final LoadingInfo loadingInfo, final long loadPositionU nextSeq = (int) (queue.get(queue.size() - 1).getNextChunkIndex()); } final long endSeq = holder.session.getStreamState().getEndSegment(format); - android.util.Log.i("SabrChunk", "itag=" + format.getItag() + " nextSeq=" + nextSeq - + " loadPosMs=" + (loadPositionUs / 1000) + " queue=" + queue.size() - + " endSeq=" + endSeq); if (endSeq > 0 && nextSeq > endSeq) { out.endOfStream = true; return; diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java index 077deda8c7..12c16094c7 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java @@ -196,8 +196,6 @@ public long readDiscontinuity() { @Override public long seekToUs(final long positionUs) { - android.util.Log.i("SabrSeek", "seekToUs=" + (positionUs / 1000) + "ms streams=" - + streams.size()); for (final ChunkSampleStream s : streams) { s.seekToUs(positionUs); } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index 72eac58ffa..f624ec7023 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -1,7 +1,6 @@ package org.schabi.newpipe.player.datasource; import android.net.Uri; -import android.util.Log; import androidx.annotation.Nullable; @@ -117,14 +116,7 @@ private SabrSegmentRequest requestFromUri(final Uri u) throws IOException { /** Block until the pump has cached this segment, or give up on a real stall / cancellation. */ private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException { final SabrStreamPump pump = holder.getPump(localization); - int waited = 0; while (true) { - if (waited > 0 && waited % 8 == 0) { - Log.i("SabrSeg", "WAIT itag=" + format.getItag() + " seq=" - + (request.isInitializationSegment() ? "init" : request.getSequenceNumber()) - + " sinceSeg=" + pump.millisSinceLastSegment()); - } - waited++; if (canceled) { throw new IOException("SABR segment read canceled"); } @@ -155,25 +147,6 @@ private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException } } - private static String box(final byte[] b, final int off) { - if (b == null || off < 0 || off + 8 > b.length) { - return "EOF@" + off; - } - final long size = ((b[off] & 0xFFL) << 24) | ((b[off + 1] & 0xFFL) << 16) - | ((b[off + 2] & 0xFFL) << 8) | (b[off + 3] & 0xFFL); - final String type = new String(b, off + 4, 4, java.nio.charset.StandardCharsets.US_ASCII); - return size + ":" + type; - } - - private static int nextBox(final byte[] b, final int off) { - if (b == null || off + 8 > b.length) { - return b == null ? 0 : b.length; - } - final long size = ((b[off] & 0xFFL) << 24) | ((b[off + 1] & 0xFFL) << 16) - | ((b[off + 2] & 0xFFL) << 8) | (b[off + 3] & 0xFFL); - return size <= 0 ? b.length : off + (int) size; - } - @Nullable @Override public Uri getUri() { diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java index c8d68365c7..27a6882f89 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java @@ -121,9 +121,6 @@ private void loop() { final long edgeMs = session.getStreamState().getMinBufferedEndMs(); final boolean throttled = edgeMs - readerHeadMs > READAHEAD_CUSHION_MS || session.getCachedBytes() > MAX_AHEAD_BYTES; - Log.i(TAG, "head=" + readerHeadMs + " tail=" + holder.getReaderTailMs() - + " edge=" + edgeMs + " cacheKB=" + (session.getCachedBytes() / 1024) - + " throttled=" + throttled); if (throttled) { Thread.sleep(IDLE_POLL_MS); continue; @@ -134,7 +131,6 @@ private void loop() { // report on edge. session.getStreamState().setPlayerTimeMs(edgeMs); final List segments = session.pumpOnce(localization); - Log.i(TAG, "pumpOnce reported=" + edgeMs + " -> segs=" + segments.size()); if (segments.isEmpty()) { Thread.sleep(IDLE_POLL_MS); } else { From 9d9de68fe91d57b877a44e1839d291d4ffe004e0 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 09:09:35 +0200 Subject: [PATCH 12/35] feat(sabr): respect the user-selected video quality and force AAC audio --- .../player/datasource/SabrSessionStore.java | 62 ++++++++++++++++--- .../player/resolver/PlaybackResolver.java | 6 +- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index 1bda112394..0d32bc9c06 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -148,7 +148,8 @@ public static void updatePlayerTime(@NonNull final String videoId, final long pl @NonNull public static Holder getOrCreate(@NonNull final Context context, - @NonNull final String videoId) + @NonNull final String videoId, + final int preferredVideoItag) throws IOException, ExtractionException { final Holder existing = SESSIONS.get(videoId); if (existing != null) { @@ -162,8 +163,8 @@ public static Holder getOrCreate(@NonNull final Context context, final Localization localization = new Localization("en", "US"); final ContentCountry contentCountry = new ContentCountry("US"); final YoutubeSabrInfo info = YoutubeSabrProbeFetch(videoId, localization, contentCountry); - final YoutubeSabrFormat audioFormat = info.findBestAudioFormat(); - final YoutubeSabrFormat videoFormat = pickHardwareFriendlyVideo(info); + final YoutubeSabrFormat audioFormat = pickAudioFormat(info); + final YoutubeSabrFormat videoFormat = pickVideoFormat(info, preferredVideoItag); if (audioFormat == null || videoFormat == null) { throw new IOException("SABR: could not select audio/video formats for " + videoId); } @@ -205,6 +206,54 @@ private static YoutubeSabrInfo YoutubeSabrProbeFetch(@NonNull final String video videoId, YoutubeSabrClientProfile.WEB, localization, contentCountry); } + // Force AAC (mp4) audio instead of the "best" (Opus/webm). honestly: Opus/webm audio just does + // NOT work through this chunk pipeline. it under-supplies the audio renderer -> AudioTrack + // underruns -> the play head freezes after ~2min, hundreds of rebuffers, phone cooks. i spent + // ~2h on this: ruled out fetch, cache, chunk timing, the media3 loading contract, buffer size... + // the data IS cached fine, so it's somewhere inside media3's Opus/webm extract->render with the + // way we chunk it, and i still have no fucking idea how to fix it. AAC (itag 140) is mp4, + // hardware-decoded, ~same bitrate (130 vs 136 kbps) and plays perfectly smooth. so: AAC until + // someone cracks the Opus path. (audio codec isn't a user-facing choice, so this isn't a + // band-aid on a user setting, just an internal pick.) + private static YoutubeSabrFormat pickAudioFormat(@NonNull final YoutubeSabrInfo info) { + YoutubeSabrFormat aac = null; + for (final YoutubeSabrFormat f : info.getFormats()) { + if (!f.isAudio()) { + continue; + } + final String mime = f.getMimeType(); + if (mime != null && mime.contains("mp4") && (aac == null + || f.getBitrate() > aac.getBitrate())) { + aac = f; + } + } + return aac != null ? aac : info.findBestAudioFormat(); + } + + /** Honour the user-selected quality when that format is present and hardware-decodable; + * otherwise fall back to the best hardware-friendly one. */ + private static YoutubeSabrFormat pickVideoFormat(@NonNull final YoutubeSabrInfo info, + final int preferredItag) { + if (preferredItag > 0) { + final boolean hwVp9 = hasHardwareDecoder("video/x-vnd.on2.vp9"); + final boolean hwAv1 = hasHardwareDecoder("video/av01"); + for (final YoutubeSabrFormat f : info.getFormats()) { + if (f.isVideo() && f.getItag() == preferredItag && isDecodable(f, hwVp9, hwAv1)) { + return f; + } + } + } + return pickHardwareFriendlyVideo(info); + } + + private static boolean isDecodable(@NonNull final YoutubeSabrFormat f, + final boolean hwVp9, final boolean hwAv1) { + final String codec = codecFamily(f.getMimeType()); + return "avc".equals(codec) + || ("vp9".equals(codec) && hwVp9) + || ("av1".equals(codec) && hwAv1); + } + /** * Pick the highest-resolution video format the device can decode in HARDWARE. The decoder is * chosen by ExoPlayer from the container bytes, so a codec the device only decodes in software @@ -212,7 +261,6 @@ private static YoutubeSabrInfo YoutubeSabrProbeFetch(@NonNull final String video * allow AVC always (universally HW), VP9 only when a HW VP9 decoder exists, AV1 only when a HW * AV1 decoder exists; otherwise fall back to the overall best (better some playback than none). */ - @NonNull private static YoutubeSabrFormat pickHardwareFriendlyVideo(@NonNull final YoutubeSabrInfo info) { final boolean hwVp9 = hasHardwareDecoder("video/x-vnd.on2.vp9"); final boolean hwAv1 = hasHardwareDecoder("video/av01"); @@ -221,11 +269,7 @@ private static YoutubeSabrFormat pickHardwareFriendlyVideo(@NonNull final Youtub if (!f.isVideo()) { continue; } - final String codec = codecFamily(f.getMimeType()); - final boolean decodable = "avc".equals(codec) - || ("vp9".equals(codec) && hwVp9) - || ("av1".equals(codec) && hwAv1); - if (!decodable) { + if (!isDecodable(f, hwVp9, hwAv1)) { continue; } if (best == null || f.getHeight() > best.getHeight() diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java index 4888be4ed6..e165b878d2 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java @@ -458,9 +458,13 @@ private static MediaSource buildSabrMediaSource(@NonNull final Stream stream, @NonNull final MediaItemTag metadata) throws IOException { final String videoId = streamInfo.getId(); + // Honour the user-selected video quality instead of forcing the highest (4K is heavy and + // hits the device VP9 decoder wall); audio-only playback passes 0 and keeps the best audio. + final int preferredVideoItag = + (stream instanceof VideoStream) ? ((VideoStream) stream).getItag() : 0; final SabrSessionStore.Holder holder; try { - holder = SabrSessionStore.getOrCreate(App.getApp(), videoId); + holder = SabrSessionStore.getOrCreate(App.getApp(), videoId, preferredVideoItag); } catch (final ExtractionException e) { throw new IOException("Could not start SABR session for " + videoId, e); } From e92de45537df14172bfdde113711a35fdb6e2eb1 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 09:09:35 +0200 Subject: [PATCH 13/35] perf(sabr): persist the PO token on disk and harden the mint timeout/retry --- .../datasource/WebViewPoTokenProvider.java | 56 +++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java b/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java index 70a62df7cc..6f9358dc21 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java @@ -51,7 +51,12 @@ public final class WebViewPoTokenProvider implements SabrPoTokenProvider { private static final String DESKTOP_UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"; private static final long TOKEN_TTL_MS = 6L * 60L * 60L * 1000L; // 6 hours - private static final long PIPELINE_TIMEOUT_MS = 45_000L; + // The WebView BotGuard mint can occasionally run long; a single 45s shot timing out returned a + // null token -> token-less SABR -> cold-start failure. 60s + one retry (in getPoToken) is robust. + private static final long PIPELINE_TIMEOUT_MS = 60_000L; + // Persist minted tokens across process restarts so an app cold-start doesn't pay the ~45s mint + // again while the videoId-bound token is still valid (<6h). + private static final String PREFS = "sabr_potoken_cache"; private static final int READY_RETRIES = 20; private static final long READY_POLL_MS = 250L; @@ -67,6 +72,7 @@ private static final class CachedToken { private final Context appContext; private final Handler mainHandler; + private final android.content.SharedPreferences prefs; private final Map cache = new ConcurrentHashMap<>(); // one lock per videoId so two callers (pre-warm + pump) don't both fire the ~45s WebView mint // for the same video. second one just waits and takes the cached token. @@ -75,6 +81,7 @@ private static final class CachedToken { public WebViewPoTokenProvider(final Context context) { this.appContext = context.getApplicationContext(); this.mainHandler = new Handler(Looper.getMainLooper()); + this.prefs = this.appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE); } @Nullable @@ -89,16 +96,28 @@ public byte[] getPoToken(final YoutubeSabrInfo info, final YoutubeSabrStreamStat final boolean forceRefresh) { final String videoId = info.getVideoId(); if (forceRefresh) { - // Server rejected the cached token (expired): drop it and mint a fresh one. + // Server rejected the cached token (expired): drop it (memory + disk) and mint fresh. cache.remove(videoId); + prefs.edit().remove(videoId).apply(); } synchronized (mintLocks.computeIfAbsent(videoId, k -> new Object())) { final long now = System.currentTimeMillis(); - final CachedToken cached = cache.get(videoId); + CachedToken cached = cache.get(videoId); + if (cached == null) { + cached = diskLoad(videoId); // survive process restart, skip the ~45s mint + if (cached != null) { + cache.put(videoId, cached); + } + } if (cached != null && now - cached.mintedAtMs < TOKEN_TTL_MS) { return cached.token; } - final String tokenB64 = mintBlocking(videoId); + // One retry: the BotGuard mint occasionally times out, and a single null killed playback. + String tokenB64 = mintBlocking(videoId); + if (tokenB64 == null || tokenB64.isEmpty()) { + Log.w(TAG, "PO token mint returned null, retrying once for " + videoId); + tokenB64 = mintBlocking(videoId); + } if (tokenB64 == null || tokenB64.isEmpty()) { return null; } @@ -110,10 +129,39 @@ public byte[] getPoToken(final YoutubeSabrInfo info, final YoutubeSabrStreamStat return null; } cache.put(videoId, new CachedToken(token, now)); + diskSave(videoId, tokenB64, now); return token; } } + @Nullable + private CachedToken diskLoad(final String videoId) { + final String v = prefs.getString(videoId, null); + if (v == null) { + return null; + } + final int sep = v.indexOf('|'); + if (sep <= 0) { + return null; + } + try { + final long mintedAt = Long.parseLong(v.substring(0, sep)); + if (System.currentTimeMillis() - mintedAt >= TOKEN_TTL_MS) { + prefs.edit().remove(videoId).apply(); + return null; + } + return new CachedToken(Base64.getUrlDecoder().decode(v.substring(sep + 1)), mintedAt); + } catch (final IllegalArgumentException e) { + return null; + } + } + + private void diskSave(final String videoId, final String tokenB64, final long mintedAt) { + // commit() (sync) not apply(): the token must hit disk before a fast force-stop/process kill, + // else an app cold-start re-mints (~45s) even though a valid token was just minted. + prefs.edit().putString(videoId, mintedAt + "|" + tokenB64).commit(); + } + @Nullable private String mintBlocking(final String videoId) { final CountDownLatch latch = new CountDownLatch(1); From a65c5b30deb4b43fe0849a5e110eda49df1b079f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 09:09:35 +0200 Subject: [PATCH 14/35] fix(sabr): ignore ended tracks when reporting the buffered position --- .../newpipe/player/datasource/SabrMediaPeriod.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java index 12c16094c7..aac403d267 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java @@ -135,11 +135,17 @@ private void rebuildCompositeLoader() { compositeLoader = new SequenceableLoader() { @Override public long getBufferedPositionUs() { + // Skip tracks already buffered to the end (END_OF_SOURCE = Long.MIN_VALUE), else a + // finished shorter track (audio) would collapse the min and make media3 think the + // whole period is buffered to the end, starving the still-loading video near the end. long min = Long.MAX_VALUE; for (final ChunkSampleStream s : streams) { - min = Math.min(min, s.getBufferedPositionUs()); + final long b = s.getBufferedPositionUs(); + if (b != C.TIME_END_OF_SOURCE) { + min = Math.min(min, b); + } } - return streams.isEmpty() ? C.TIME_END_OF_SOURCE : min; + return min == Long.MAX_VALUE ? C.TIME_END_OF_SOURCE : min; } @Override From d1e3216684ffb3590f396d70410ef6d883c03b52 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 09:58:29 +0200 Subject: [PATCH 15/35] fix(sabr): time segment stalls per-request so a throttled pump can't false-stall --- .../newpipe/player/datasource/SabrSegmentDataSource.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index f624ec7023..b33bc27980 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -116,6 +116,7 @@ private SabrSegmentRequest requestFromUri(final Uri u) throws IOException { /** Block until the pump has cached this segment, or give up on a real stall / cancellation. */ private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException { final SabrStreamPump pump = holder.getPump(localization); + final long waitStart = System.currentTimeMillis(); while (true) { if (canceled) { throw new IOException("SABR segment read canceled"); @@ -135,7 +136,12 @@ private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException if (pump.isFatal()) { throw new IOException("SABR pump fatal for itag=" + format.getItag()); } - if (pump.millisSinceLastSegment() > STALL_MS) { + // Stall = THIS segment hasn't arrived within STALL_MS of us actually waiting for it. Do + // NOT use the pump's "time since it last produced a segment": the pump legitimately stops + // producing while throttled (buffer full, edge far ahead), so that clock goes stale and + // the first cache miss after a long throttle false-stalls at ~STALL_MS. That was the + // recurring ~2min freeze on longer/higher-bitrate streams. + if (System.currentTimeMillis() - waitStart > STALL_MS) { throw new IOException("SABR segment stalled for itag=" + format.getItag()); } try { From dde6219be9383d18c103d31e30e870378e8ac13f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 10:03:04 +0200 Subject: [PATCH 16/35] chore(sabr): remove the dead v1 byte-stream data source --- .../player/datasource/SabrDataSource.java | 215 ------------------ .../player/datasource/SabrSessionStore.java | 2 +- .../player/datasource/SabrStreamPump.java | 11 +- 3 files changed, 2 insertions(+), 226 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrDataSource.java diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDataSource.java deleted file mode 100644 index 0afe457da0..0000000000 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDataSource.java +++ /dev/null @@ -1,215 +0,0 @@ -package org.schabi.newpipe.player.datasource; - -import android.net.Uri; -import android.util.Log; - -import androidx.annotation.Nullable; - -import androidx.media3.common.C; -import androidx.media3.datasource.DataSource; -import androidx.media3.datasource.DataSpec; -import androidx.media3.datasource.TransferListener; - -import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; - -import java.io.IOException; - -/** - * ExoPlayer {@link DataSource} exposing one SABR format (audio or video) as a continuous byte - * stream: init segment then media segments. It only reads the session's concurrent cache, which a - * single {@link SabrStreamPump} fills ahead of the play head; so the two data sources never touch - * the network nor block each other. We end the stream past the last segment, on a pump fatal error, - * or if the play head stays frozen long enough to call it a genuine stall. - * - *

v1: sequential read from the start, seeks skip forward, length unknown until end-of-stream.

- */ -public final class SabrDataSource implements DataSource { - - private static final String TAG = "SabrDataSource"; - - private static final long WAIT_MS = 250; - // only bail once the pump's been dry a while (bailing makes ExoPlayer re-open us, which unsticks - // the flow). be patient at cold start: the ~45s WebView mint = zero segments for a bit, and most - // underruns just sort themselves out once the pump catches up. - // do NOT EOF early on a stall: ExoPlayer re-opens at a byte offset and our v1 byte-skip seek - // fucks the fragmented container = frozen video. so we just ride the stall out. - private static final long STALL_MS = 120_000; - - private final SabrSessionStore.Holder holder; - private final YoutubeSabrFormat format; - private final Localization localization; - - @Nullable - private Uri uri; - @Nullable - private byte[] current; - private int currentPos; - private boolean initServed; - private int nextSeq; - private boolean ended; - private long skipRemaining; - private volatile boolean canceled; - - public SabrDataSource(final SabrSessionStore.Holder holder, - final YoutubeSabrFormat format, - final Localization localization) { - this.holder = holder; - this.format = format; - this.localization = localization; - } - - @Override - public void addTransferListener(final TransferListener transferListener) { - // Bandwidth metering not wired for the SABR v1 source. - } - - @Override - public long open(final DataSpec dataSpec) { - this.uri = dataSpec.uri; - this.current = null; - this.currentPos = 0; - this.initServed = false; - this.nextSeq = 1; // SABR media sequence numbers are 1-based (0 is rejected) - this.ended = false; - this.skipRemaining = Math.max(0, dataSpec.position); - this.canceled = false; - return C.LENGTH_UNSET; - } - - @Override - public int read(final byte[] target, final int offset, final int length) throws IOException { - if (length == 0) { - return 0; - } - if (ended) { - return C.RESULT_END_OF_INPUT; - } - // Drop bytes for a forward seek (v1 skips from the start). - while (skipRemaining > 0) { - if (!ensureBuffer()) { - return C.RESULT_END_OF_INPUT; - } - final int available = current.length - currentPos; - final int drop = (int) Math.min(available, skipRemaining); - currentPos += drop; - skipRemaining -= drop; - } - if (!ensureBuffer()) { - return C.RESULT_END_OF_INPUT; - } - final int available = current.length - currentPos; - final int toCopy = Math.min(length, available); - System.arraycopy(current, currentPos, target, offset, toCopy); - currentPos += toCopy; - return toCopy; - } - - /** - * Make sure {@link #current} has unread bytes, waiting for the pump to cache the next segment. - * - * @return false if the stream is exhausted - */ - private boolean ensureBuffer() throws IOException { - if (current != null && currentPos < current.length) { - return true; - } - final SabrStreamPump pump = holder.getPump(localization); - while (true) { - if (canceled) { - ended = true; - return false; - } - final SabrSegmentRequest request = initServed - ? SabrSegmentRequest.media(format, nextSeq) - : SabrSegmentRequest.initialization(format); - pump.ensureStarted(); - final SabrMediaSegment segment = pump.getCached(request); - if (segment != null) { - if (initServed) { - nextSeq++; - } else { - initServed = true; - } - if (!segment.getHeader().isInitSegment()) { - // tell the pump/eviction how far this track has actually read (never stale). - holder.setReaderPositionMs(format.getItag(), - segment.getHeader().getStartMs() + segment.getHeader().getDurationMs()); - } - current = segment.getData(); - currentPos = 0; - if (current.length == 0) { - continue; - } - return true; - } - if (holder.isBeyondEnd(request)) { - ended = true; - return false; - } - if (pump.isFatal()) { - // Surface a real error (not a clean EOF) so ExoPlayer reports a playback error - // instead of pretending the video ended. The session was evicted on fatal, so a - // retry rebuilds a fresh one. - throw new IOException("SABR pump fatal for itag=" + format.getItag() - + " at seq=" + nextSeq); - } - // not cached yet: pump's fetching or the server's pacing us. wait, don't signal EOF - // (that triggers a corrupting re-open). only bail if the pump's been fully dry long - // enough to be a real dead stall. - if (pump.millisSinceLastSegment() > STALL_MS) { - Log.i(TAG, "end of SABR stream (stalled) itag=" + format.getItag() - + " at seq=" + nextSeq); - ended = true; - return false; - } - try { - Thread.sleep(WAIT_MS); - } catch (final InterruptedException ie) { - Thread.currentThread().interrupt(); - if (canceled) { - ended = true; - return false; // clean cancellation (close/seek/release), not a playback error - } - throw new IOException("Interrupted during SABR wait", ie); - } - } - } - - @Nullable - @Override - public Uri getUri() { - return uri; - } - - @Override - public void close() { - // Unblock a read() that is waiting for the pump (it polls this flag), so ExoPlayer can - // release this loader thread promptly on stop/seek/track-change. - canceled = true; - current = null; - currentPos = 0; - } - - /** Factory binding a {@link SabrDataSource} to one shared session holder + format. */ - public static final class Factory implements DataSource.Factory { - private final SabrSessionStore.Holder holder; - private final YoutubeSabrFormat format; - private final Localization localization; - - public Factory(final SabrSessionStore.Holder holder, - final YoutubeSabrFormat format, - final Localization localization) { - this.holder = holder; - this.format = format; - this.localization = localization; - } - - @Override - public DataSource createDataSource() { - return new SabrDataSource(holder, format, localization); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index 0d32bc9c06..5b5e6bdb13 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -22,7 +22,7 @@ /** * Caches one shared {@link YoutubeSabrSession} per videoId so the audio and video - * {@link SabrDataSource}s drive the same session (a single SABR response carries both formats, so + * {@link SabrSegmentDataSource}s drive the same session (a single SABR response carries both formats, so * the session's segment cache serves both without doubling bandwidth). * *

v1: uses the best audio/video formats from the player response and a fixed en/US locale.

diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java index 27a6882f89..7b1c1c1546 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java @@ -18,7 +18,7 @@ * Single consumer of a {@link YoutubeSabrSession}: one daemon thread pumps the server-driven SABR * stream and fills the session's (concurrent) segment cache ahead of the play head. The server * paces us with policy-only responses once we are far enough ahead. Both the audio and video - * {@link SabrDataSource}s only read the cache, so they never fight over the session or block each + * {@link SabrSegmentDataSource}s only read the cache, so they never fight over the session or block each * other on a network round-trip, which is exactly what starved a track in the old on-demand approach. */ final class SabrStreamPump { @@ -45,7 +45,6 @@ final class SabrStreamPump { private volatile boolean stopped; private volatile boolean fatal; private volatile long lastReadMs; - private volatile long lastSegmentMs; private Thread thread; SabrStreamPump(@NonNull final YoutubeSabrSession session, @@ -68,7 +67,6 @@ void ensureStarted() { } stopped = false; started = true; - lastSegmentMs = System.currentTimeMillis(); thread = new Thread(this::loop, "SabrStreamPump"); thread.setDaemon(true); thread.start(); @@ -87,11 +85,6 @@ void stop() { } } - /** ms since the pump last grabbed a segment. basically "is this thing dead or what". */ - long millisSinceLastSegment() { - return System.currentTimeMillis() - lastSegmentMs; - } - @Nullable SabrMediaSegment getCached(@NonNull final SabrSegmentRequest request) { // revive the pump if it idled out: any read means playback is live again. @@ -133,8 +126,6 @@ private void loop() { final List segments = session.pumpOnce(localization); if (segments.isEmpty()) { Thread.sleep(IDLE_POLL_MS); - } else { - lastSegmentMs = System.currentTimeMillis(); } } catch (final InterruptedException e) { Thread.currentThread().interrupt(); From cf722e65e2c27ce23549cc1410bec9940a937af7 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 10:50:12 +0200 Subject: [PATCH 17/35] fix(sabr): adapt LoadControl and ChunkSampleStream to the media3 1.10 APIs --- .../newpipe/player/datasource/SabrMediaPeriod.java | 5 ++++- .../schabi/newpipe/player/helper/LoadController.java | 12 +++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java index aac403d267..885ca558ec 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaPeriod.java @@ -125,9 +125,12 @@ private ChunkSampleStream buildStream(final ExoTrackSelection s final Format trackFormat = group.getFormat(0); final SabrChunkSource chunkSource = new SabrChunkSource(holder, sabrFormats[groupIndex], trackFormat, trackTypes[groupIndex], localization); + // Last 3 args are new in media3 1.10 (handleInitialDiscontinuity, firstChunkStartTimeUs, + // downloadExecutor); false / TIME_UNSET / null reproduces the pre-1.10 behaviour. return new ChunkSampleStream<>(trackTypes[groupIndex], null, null, chunkSource, this, allocator, positionUs, drmSessionManager, drmEventDispatcher, - loadErrorHandlingPolicy, mediaSourceEventDispatcher); + loadErrorHandlingPolicy, mediaSourceEventDispatcher, + false, C.TIME_UNSET, null); } private void rebuildCompositeLoader() { diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java index 5ee0dacb2e..fa9abca983 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java @@ -21,16 +21,18 @@ public class LoadController extends DefaultLoadControl { private boolean preloadingEnabled = true; public LoadController() { + // media3 1.10 split every buffer param into a normal + a "ForLocalPlayback" variant; we use + // the same value for both so behaviour is unchanged whether the source is local or remote. super(new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE), - MIN_BUFFER_MS, - MAX_BUFFER_MS, - BUFFER_FOR_PLAYBACK_MS, - BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, + MIN_BUFFER_MS, MIN_BUFFER_MS, + MAX_BUFFER_MS, MAX_BUFFER_MS, + BUFFER_FOR_PLAYBACK_MS, BUFFER_FOR_PLAYBACK_MS, + BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, C.LENGTH_UNSET, // no byte cap: the SABR cache bounds memory, time bounds the player // MUST be true: with false, media3 prioritises its (huge, ~128MB) default byte target // and ignores maxBufferMs, reading ~50s ahead = right at the pump cushion, so it // starved at the edge. true makes maxBufferMs (time) the real limit. - true, + true, true, DEFAULT_BACK_BUFFER_DURATION_MS, DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME); } From 9b2f402c36c3c75dbaf77640bb162195a22014d7 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 11:47:26 +0200 Subject: [PATCH 18/35] docs(sabr): clarify the AAC-over-Opus comment, separate the pump false-stall --- .../player/datasource/SabrSessionStore.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index 5b5e6bdb13..745df7c9bf 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -208,13 +208,14 @@ private static YoutubeSabrInfo YoutubeSabrProbeFetch(@NonNull final String video // Force AAC (mp4) audio instead of the "best" (Opus/webm). honestly: Opus/webm audio just does // NOT work through this chunk pipeline. it under-supplies the audio renderer -> AudioTrack - // underruns -> the play head freezes after ~2min, hundreds of rebuffers, phone cooks. i spent - // ~2h on this: ruled out fetch, cache, chunk timing, the media3 loading contract, buffer size... - // the data IS cached fine, so it's somewhere inside media3's Opus/webm extract->render with the - // way we chunk it, and i still have no fucking idea how to fix it. AAC (itag 140) is mp4, - // hardware-decoded, ~same bitrate (130 vs 136 kbps) and plays perfectly smooth. so: AAC until - // someone cracks the Opus path. (audio codec isn't a user-facing choice, so this isn't a - // band-aid on a user setting, just an internal pick.) + // underruns -> constant rebuffering (hundreds vs ~2 on AAC, phone cooks). re-confirmed on media3 + // 1.10 AFTER fixing the separate ~2min pump false-stall, so it's its own bug, not that one. i + // spent ~2h on it: ruled out fetch, cache, chunk timing, the media3 loading contract, buffer + // size... the data IS cached fine, so it's somewhere inside media3's Opus/webm extract->render + // with the way we chunk it, and i still have no fucking idea how to fix it. AAC (itag 140) is + // mp4, hardware-decoded, ~same bitrate (130 vs 136 kbps) and plays perfectly smooth. so: AAC + // until someone cracks the Opus path. (audio codec isn't user-facing, so this isn't a band-aid + // on a user setting, just an internal pick.) private static YoutubeSabrFormat pickAudioFormat(@NonNull final YoutubeSabrInfo info) { YoutubeSabrFormat aac = null; for (final YoutubeSabrFormat f : info.getFormats()) { From f246f903f69943f0cf30b55b9736da6083c1c9c8 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 13:58:20 +0200 Subject: [PATCH 19/35] fix(sabr): rebuild the session when the user changes video quality/codec --- .../player/datasource/SabrSessionStore.java | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index 745df7c9bf..bcfdd57a96 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -146,19 +146,40 @@ public static void updatePlayerTime(@NonNull final String videoId, final long pl } } + // <=0 = audio-only / no preference -> any cached session is fine. Otherwise the session matches + // when the requested itag RESOLVES to the same format the session already holds. Comparing the + // raw itag is wrong: pickVideoFormat falls back when the requested itag isn't hw-decodable, so + // the session's format legitimately differs from the requested itag and we'd rebuild on every + // normal resolve (-> evict/rebuild loop -> endless buffering). Only a real quality change, which + // resolves to a different format, triggers a rebuild. + private static boolean sessionMatchesItag(@NonNull final Holder holder, + final int preferredVideoItag) { + if (preferredVideoItag <= 0) { + return true; + } + final YoutubeSabrFormat wanted = pickVideoFormat(holder.info, preferredVideoItag); + return wanted != null && wanted.getItag() == holder.videoFormat.getItag(); + } + @NonNull public static Holder getOrCreate(@NonNull final Context context, @NonNull final String videoId, final int preferredVideoItag) throws IOException, ExtractionException { final Holder existing = SESSIONS.get(videoId); - if (existing != null) { + if (existing != null && sessionMatchesItag(existing, preferredVideoItag)) { return existing; } synchronized (SabrSessionStore.class) { - final Holder racing = SESSIONS.get(videoId); - if (racing != null) { - return racing; + final Holder current = SESSIONS.get(videoId); + if (current != null) { + if (sessionMatchesItag(current, preferredVideoItag)) { + return current; + } + // Quality/codec change: the resolver re-asks with a different video itag for the same + // video. The cached session is locked to its formats, so returning it would re-prepare + // the player on the old codec and dead-buffer. Drop it (stops the pump) + rebuild below. + evict(videoId); } final Localization localization = new Localization("en", "US"); final ContentCountry contentCountry = new ContentCountry("US"); From 4a2d5598c0a8446bb039d120256a4195ece567c0 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 13:58:20 +0200 Subject: [PATCH 20/35] fix(sabr): keep a 30s back-buffer so short backward seeks land in cache --- .../newpipe/player/datasource/SabrStreamPump.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java index 7b1c1c1546..e9f7bd22a3 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java @@ -36,6 +36,11 @@ final class SabrStreamPump { // Hard byte ceiling on read-ahead so a high-bitrate (4K) stream can't OOM the heap: 50s of 4K is // ~160MB and crashed. ~100MB still covers the player's ~30s read-ahead, well under the OOM line. private static final long MAX_AHEAD_BYTES = 100L * 1024 * 1024; + // Keep this much already-played video in the cache so a short backward seek lands on cached + // segments instead of a hole (eviction used to drop everything the reader passed, so any rewind + // hit an evicted segment the pump never re-fetches -> dead buffer). Bounded, same order as the + // forward cushion. Rewinds beyond this still need a session re-request (separate follow-up). + private static final long BACK_BUFFER_MS = 30_000; private final YoutubeSabrSession session; private final SabrSessionStore.Holder holder; @@ -107,9 +112,10 @@ private void loop() { // freezes while buffering and that deadlocked the pump. readerHead = furthest // track read; readerTail = slowest track read (safe to evict below). final long readerHeadMs = holder.getReaderHeadMs(); - // evict everything both tracks have read past, EVERY round (even before we throttle - // below) or a full cache never drains and the throttle latches forever -> freeze. - session.setPlayHeadMs(holder.getReaderTailMs()); + // Evict what both tracks have read past, EVERY round (or a full cache never drains + // and the throttle latches forever -> freeze), but keep BACK_BUFFER_MS behind the + // reader so a short backward seek finds its segments cached instead of a hole. + session.setPlayHeadMs(Math.max(0, holder.getReaderTailMs() - BACK_BUFFER_MS)); session.evictPlayed(); final long edgeMs = session.getStreamState().getMinBufferedEndMs(); final boolean throttled = edgeMs - readerHeadMs > READAHEAD_CUSHION_MS From 8b726398a88804344e7db114a04c087bc885211a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 15:28:49 +0200 Subject: [PATCH 21/35] fix(sabr): re-fetch evicted segments on a backward seek past the back-buffer --- .../datasource/SabrSegmentDataSource.java | 22 +++++++++++++++++++ .../player/datasource/SabrStreamPump.java | 21 ++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index b33bc27980..168e2b990b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -30,6 +30,9 @@ public final class SabrSegmentDataSource implements DataSource { private static final long WAIT_MS = 250; private static final long STALL_MS = 120_000; + // After waiting this long for a media segment that's BEHIND the buffered edge, treat it as a + // backward seek onto an evicted segment and ask the pump to reposition the session there. + private static final long REFETCH_AFTER_MS = 2_000; private final SabrSessionStore.Holder holder; private final YoutubeSabrFormat format; @@ -117,6 +120,7 @@ private SabrSegmentRequest requestFromUri(final Uri u) throws IOException { private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException { final SabrStreamPump pump = holder.getPump(localization); final long waitStart = System.currentTimeMillis(); + long lastRefetchMs = 0; while (true) { if (canceled) { throw new IOException("SABR segment read canceled"); @@ -136,6 +140,24 @@ private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException if (pump.isFatal()) { throw new IOException("SABR pump fatal for itag=" + format.getItag()); } + // Backward seek to an evicted segment behind the buffered edge: the forward pump never + // re-fetches it, so it would never arrive. Drop our read position onto it (so eviction + + // pacing follow the rewind, not the stale pre-seek position) and ask the pump to + // reposition the session there. The edge check leaves a merely-slow forward fetch (the + // segment is still ahead of the edge) to the normal pump, so forward playback is untouched. + if (!request.isInitializationSegment()) { + final long now = System.currentTimeMillis(); + if (now - waitStart > REFETCH_AFTER_MS && now - lastRefetchMs > REFETCH_AFTER_MS) { + final long edgeMs = holder.session.getStreamState().getMinBufferedEndMs(); + final long segStartMs = holder.session.getStreamState() + .getSegmentStartMs(format, request.getSequenceNumber()); + if (segStartMs < edgeMs) { + holder.setReaderPositionMs(format.getItag(), segStartMs); + pump.requestRefetchFrom(request); + lastRefetchMs = now; + } + } + } // Stall = THIS segment hasn't arrived within STALL_MS of us actually waiting for it. Do // NOT use the pump's "time since it last produced a segment": the pump legitimately stops // producing while throttled (buffer full, edge far ahead), so that clock goes stale and diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java index e9f7bd22a3..f6628e6020 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java @@ -50,6 +50,9 @@ final class SabrStreamPump { private volatile boolean stopped; private volatile boolean fatal; private volatile long lastReadMs; + // Set by a reader blocked on an evicted segment behind the edge (backward seek); the loop + // repositions the session onto it next round. Single-slot: the latest rewind target wins. + private volatile SabrSegmentRequest pendingRefetch; private Thread thread; SabrStreamPump(@NonNull final YoutubeSabrSession session, @@ -101,6 +104,13 @@ boolean isFatal() { return fatal; } + /** A reader is blocked on an evicted segment behind the buffered edge (backward seek). Ask the + * loop to reposition the session onto it so the server re-sends from there. */ + void requestRefetchFrom(@NonNull final SabrSegmentRequest request) { + pendingRefetch = request; + ensureStarted(); + } + private void loop() { try { while (!stopped) { @@ -118,6 +128,17 @@ private void loop() { session.setPlayHeadMs(Math.max(0, holder.getReaderTailMs() - BACK_BUFFER_MS)); session.evictPlayed(); final long edgeMs = session.getStreamState().getMinBufferedEndMs(); + // Backward seek beyond the back-buffer: a reader is blocked on an evicted segment + // behind the edge. Reposition the session onto it (prepareForMediaSegment sets + // buffered=up-to-(seg-1) + playerTime=seg start, so the server re-sends from there) + // instead of fetching forward this round. Bypasses the throttle by design. + final SabrSegmentRequest refetch = pendingRefetch; + if (refetch != null) { + pendingRefetch = null; + session.prepareForRewind(refetch); + session.pumpOnce(localization); + continue; + } final boolean throttled = edgeMs - readerHeadMs > READAHEAD_CUSHION_MS || session.getCachedBytes() > MAX_AHEAD_BYTES; if (throttled) { From 440c7d877dd2d6d957923571afefc163154b3a01 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 18:01:20 +0200 Subject: [PATCH 22/35] fix(sabr): fall back to a decodable codec at the chosen resolution, not the highest --- .../player/datasource/SabrSessionStore.java | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index bcfdd57a96..34918fe075 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -256,16 +256,26 @@ private static YoutubeSabrFormat pickAudioFormat(@NonNull final YoutubeSabrInfo * otherwise fall back to the best hardware-friendly one. */ private static YoutubeSabrFormat pickVideoFormat(@NonNull final YoutubeSabrInfo info, final int preferredItag) { + final boolean hwVp9 = hasHardwareDecoder("video/x-vnd.on2.vp9"); + final boolean hwAv1 = hasHardwareDecoder("video/av01"); + int preferredHeight = 0; if (preferredItag > 0) { - final boolean hwVp9 = hasHardwareDecoder("video/x-vnd.on2.vp9"); - final boolean hwAv1 = hasHardwareDecoder("video/av01"); for (final YoutubeSabrFormat f : info.getFormats()) { - if (f.isVideo() && f.getItag() == preferredItag && isDecodable(f, hwVp9, hwAv1)) { - return f; + if (f.isVideo() && f.getItag() == preferredItag) { + if (isDecodable(f, hwVp9, hwAv1)) { + return f; + } + // Right resolution, wrong codec for this device (e.g. AV1 1080p with no HW AV1): + // remember the height so we fall back to a decodable codec at the SAME resolution. + preferredHeight = f.getHeight(); + break; } } } - return pickHardwareFriendlyVideo(info); + // Fall back to the highest decodable format AT the user's chosen resolution, not the absolute + // highest: otherwise an undecodable AV1 1080p pick would jump to VP9 4K (heavier, and on the + // Pixel it claims HW it can't sustain). preferredHeight 0 (no preference) = no cap. + return pickHardwareFriendlyVideo(info, preferredHeight); } private static boolean isDecodable(@NonNull final YoutubeSabrFormat f, @@ -283,7 +293,8 @@ private static boolean isDecodable(@NonNull final YoutubeSabrFormat f, * allow AVC always (universally HW), VP9 only when a HW VP9 decoder exists, AV1 only when a HW * AV1 decoder exists; otherwise fall back to the overall best (better some playback than none). */ - private static YoutubeSabrFormat pickHardwareFriendlyVideo(@NonNull final YoutubeSabrInfo info) { + private static YoutubeSabrFormat pickHardwareFriendlyVideo(@NonNull final YoutubeSabrInfo info, + final int maxHeight) { final boolean hwVp9 = hasHardwareDecoder("video/x-vnd.on2.vp9"); final boolean hwAv1 = hasHardwareDecoder("video/av01"); YoutubeSabrFormat best = null; @@ -294,6 +305,9 @@ private static YoutubeSabrFormat pickHardwareFriendlyVideo(@NonNull final Youtub if (!isDecodable(f, hwVp9, hwAv1)) { continue; } + if (maxHeight > 0 && f.getHeight() > maxHeight) { + continue; // don't exceed the user's chosen resolution + } if (best == null || f.getHeight() > best.getHeight() || (f.getHeight() == best.getHeight() && f.getBitrate() > best.getBitrate())) { best = f; From 672f62105d86cb07a9e0c08bc5a48b734576f7ca Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 18:36:20 +0200 Subject: [PATCH 23/35] fix(sabr): shrink the back-buffer when the cache is over budget so eviction can drain --- .../newpipe/player/datasource/SabrStreamPump.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java index f6628e6020..9ff39c4248 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java @@ -41,6 +41,11 @@ final class SabrStreamPump { // hit an evicted segment the pump never re-fetches -> dead buffer). Bounded, same order as the // forward cushion. Rewinds beyond this still need a session re-request (separate follow-up). private static final long BACK_BUFFER_MS = 30_000; + // Fallback back-buffer used when the cache is already over the byte budget: at high bitrate (4K) + // a 30s back-buffer + readahead exceeds MAX_AHEAD_BYTES, and since eviction can't drop segments + // within the back-buffer window the cache can't drain -> the pump throttles forever and stalls. + // Shrinking the back-buffer when over budget lets eviction free bytes so playback keeps fetching. + private static final long MIN_BACK_BUFFER_MS = 5_000; private final YoutubeSabrSession session; private final SabrSessionStore.Holder holder; @@ -123,9 +128,13 @@ private void loop() { // track read; readerTail = slowest track read (safe to evict below). final long readerHeadMs = holder.getReaderHeadMs(); // Evict what both tracks have read past, EVERY round (or a full cache never drains - // and the throttle latches forever -> freeze), but keep BACK_BUFFER_MS behind the - // reader so a short backward seek finds its segments cached instead of a hole. - session.setPlayHeadMs(Math.max(0, holder.getReaderTailMs() - BACK_BUFFER_MS)); + // and the throttle latches forever -> freeze), keeping BACK_BUFFER_MS behind the + // reader so a short backward seek finds its segments cached. But when the cache is + // already over the byte budget (high bitrate), shrink the back-buffer so eviction + // can actually drain it, otherwise the pump throttles forever and playback stalls. + final long backBufferMs = session.getCachedBytes() > MAX_AHEAD_BYTES + ? MIN_BACK_BUFFER_MS : BACK_BUFFER_MS; + session.setPlayHeadMs(Math.max(0, holder.getReaderTailMs() - backBufferMs)); session.evictPlayed(); final long edgeMs = session.getStreamState().getMinBufferedEndMs(); // Backward seek beyond the back-buffer: a reader is blocked on an evicted segment From 6d0f9981cd8982de1484d9a82e533458bb642c25 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 11 Jun 2026 18:43:54 +0200 Subject: [PATCH 24/35] fix(sabr): serve cold/forward seeks and rewind-after-end in the stream pump --- .../datasource/SabrSegmentDataSource.java | 13 ++++++++ .../player/datasource/SabrStreamPump.java | 32 ++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index 168e2b990b..3cd50e8c48 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -33,6 +33,10 @@ public final class SabrSegmentDataSource implements DataSource { // After waiting this long for a media segment that's BEHIND the buffered edge, treat it as a // backward seek onto an evicted segment and ask the pump to reposition the session there. private static final long REFETCH_AFTER_MS = 2_000; + // If a media segment is this far AHEAD of the buffered edge after REFETCH_AFTER_MS, it's a cold + // forward seek (SponsorBlock skip at start, resume-from-history): the pump fills forward from the + // edge and would take minutes to reach it, so jump the session onto it instead of waiting. + private static final long FORWARD_SEEK_AHEAD_MS = 30_000; private final SabrSessionStore.Holder holder; private final YoutubeSabrFormat format; @@ -152,9 +156,18 @@ private byte[] awaitSegment(final SabrSegmentRequest request) throws IOException final long segStartMs = holder.session.getStreamState() .getSegmentStartMs(format, request.getSequenceNumber()); if (segStartMs < edgeMs) { + // Backward seek onto an evicted segment behind the edge. holder.setReaderPositionMs(format.getItag(), segStartMs); pump.requestRefetchFrom(request); lastRefetchMs = now; + } else if (segStartMs > edgeMs + FORWARD_SEEK_AHEAD_MS) { + // Cold/forward seek far ahead of where the pump is filling (SponsorBlock skip + // at start, resume-from-history): jump the session onto it instead of waiting + // for the forward pump to crawl there. A merely-slow normal fetch (target just + // past the edge) stays on the pump, so steady playback is untouched. + holder.setReaderPositionMs(format.getItag(), segStartMs); + pump.requestForwardSeekTo(request); + lastRefetchMs = now; } } } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java index 9ff39c4248..70e169c696 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java @@ -58,6 +58,10 @@ final class SabrStreamPump { // Set by a reader blocked on an evicted segment behind the edge (backward seek); the loop // repositions the session onto it next round. Single-slot: the latest rewind target wins. private volatile SabrSegmentRequest pendingRefetch; + // Set by a reader blocked on a segment far AHEAD of the buffered edge (cold/forward seek: + // SponsorBlock skip at start, resume-from-history). The forward pump fills from edge 0 and would + // take minutes to reach it, so the loop jumps the session onto it next round. Single-slot. + private volatile SabrSegmentRequest pendingForwardSeek; private Thread thread; SabrStreamPump(@NonNull final YoutubeSabrSession session, @@ -116,10 +120,25 @@ void requestRefetchFrom(@NonNull final SabrSegmentRequest request) { ensureStarted(); } + /** A reader is blocked on a segment far ahead of the buffered edge (cold/forward seek, e.g. a + * SponsorBlock skip at the start). Ask the loop to jump the session onto it so the server streams + * from there instead of crawling forward from the start. */ + void requestForwardSeekTo(@NonNull final SabrSegmentRequest request) { + pendingForwardSeek = request; + ensureStarted(); + } + private void loop() { try { while (!stopped) { - if (System.currentTimeMillis() - lastReadMs > IDLE_STOP_MS || session.isComplete()) { + // Don't die on completion/idle while a reposition is pending: a backward seek after + // playback buffered to the end arrives with isComplete()=true, and breaking here + // meant the restarted pump exited before ever processing the refetch -> the reader + // waited forever (full buffer on rewind-after-end). prepareForRewind resets the + // buffered head, so isComplete() turns false again once the reposition runs. + if (pendingRefetch == null && pendingForwardSeek == null + && (System.currentTimeMillis() - lastReadMs > IDLE_STOP_MS + || session.isComplete())) { break; } try { @@ -148,6 +167,17 @@ private void loop() { session.pumpOnce(localization); continue; } + // Cold/forward seek (SponsorBlock skip, user seek far ahead): a reader is blocked + // on a segment far ahead of the edge. Jump the session onto it + // (prepareForForwardJump moves the buffered head to the target, so the edge-driven + // pacing follows the new position instead of ping-ponging back to the old span). + final SabrSegmentRequest forwardSeek = pendingForwardSeek; + if (forwardSeek != null) { + pendingForwardSeek = null; + session.prepareForForwardJump(forwardSeek); + session.pumpOnce(localization); + continue; + } final boolean throttled = edgeMs - readerHeadMs > READAHEAD_CUSHION_MS || session.getCachedBytes() > MAX_AHEAD_BYTES; if (throttled) { From 96a6d90c480ddf2bd7fac7fec467ba5037f882d5 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 11 Jun 2026 18:43:54 +0200 Subject: [PATCH 25/35] fix(sabr): re-enable the video track on the live source when returning from background --- app/src/main/java/org/schabi/newpipe/player/Player.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 8e6bc73aa5..273ef611df 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -5026,7 +5026,12 @@ private void useVideoSource(final boolean videoEnabled) { final SourceType sourceType = videoResolver.getStreamSourceType().orElse( SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY); - if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) { + // SABR is backed by a live, session-driven source: rebuilding it over the cached session on + // return-from-background re-prepares + cold-seeks and freezes playback (see + // ISSUE_SABR_RESUME_FREEZE.md). The session keeps both tracks buffered while backgrounded, so + // just re-enable the video track on the live source instead of a full reload. + if (!isCurrentStreamSabr() + && playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) { reloadPlayQueueManager(); } else { final StreamType streamType = info.getStreamType(); From d7c1d0d371b0f045bd3e786d264adaf53ec4a77d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 11 Jun 2026 18:43:54 +0200 Subject: [PATCH 26/35] fix(player): recover from surface-released decoder failures instead of crashing --- .../org/schabi/newpipe/player/Player.java | 56 ++++++++++++++++--- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 273ef611df..dfb4aebbdd 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -235,6 +235,10 @@ public final class Player implements private static final float[] PLAYBACK_SPEEDS = {0.1f, 0.3f, 0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f, 2.25f, 2.5f, 2.75f, 3.0f, 5.0f, 10.0f}; private static final int RENDERER_UNAVAILABLE = -1; + // Cooldown between automatic recoveries from a surface-released decoder-init failure, so a + // genuinely broken surface can't loop recover->fail forever. + private static final long SURFACE_ERROR_RECOVERY_COOLDOWN_MS = 10_000; + private long lastSurfaceErrorRecoveryMs; private static final int MAX_RETRY_COUNT = 2; /*////////////////////////////////////////////////////////////////////////// @@ -3189,17 +3193,35 @@ public void onPlayerError(@NonNull final PlaybackException error) { setRecovery(); reloadPlayQueueManager(); break; - case ERROR_CODE_DECODER_INIT_FAILED: - AlertDialog.Builder builder = new AlertDialog.Builder(getParentActivity()) - .setTitle(R.string.decoder_init_failure) - .setMessage(R.string.unable_to_decode_summary) - .setPositiveButton(R.string.ok, (dialog, which) -> { - // Handle "Yes" click - }); - builder.show(); - + case ERROR_CODE_DECODER_INIT_FAILED: { + final boolean surfaceReleased = isSurfaceReleasedError(error); + if (surfaceReleased && System.currentTimeMillis() - lastSurfaceErrorRecoveryMs + > SURFACE_ERROR_RECOVERY_COOLDOWN_MS) { + // The decoder died because the video surface was released under it (screen off / + // surface lifecycle race), NOT because the device lacks a decoder. Recover like a + // stream error instead of killing playback with the misleading "no hardware + // decoder, use VLC" dialog. Cooldown-bounded so a genuinely broken surface still + // falls through to shutdown below. + lastSurfaceErrorRecoveryMs = System.currentTimeMillis(); + setRecovery(); + reloadPlayQueueManager(); + break; + } + // Only show the dialog when a hosting activity exists AND the failure is really + // about decoding capability. getParentActivity() is null in the background/popup + // player, and AlertDialog.Builder(null) NPEs -> the app crashed on a decoder-init + // failure while backgrounded. The error notification below still surfaces it. + final AppCompatActivity parentActivity = getParentActivity(); + if (parentActivity != null && !surfaceReleased) { + new AlertDialog.Builder(parentActivity) + .setTitle(R.string.decoder_init_failure) + .setMessage(R.string.unable_to_decode_summary) + .setPositiveButton(R.string.ok, (dialog, which) -> { }) + .show(); + } onPlaybackShutdown(); break; + } default: // API, remote and renderer errors belong here: onPlaybackShutdown(); @@ -3248,6 +3270,22 @@ private void showMediaCodecWorkaroundHint(@NonNull final PlaybackException error } } + /** + * True when a decoder-init failure was caused by the video surface being released under the + * codec (screen off / surface lifecycle race) rather than by a missing/unsupported decoder. + */ + private static boolean isSurfaceReleasedError(@NonNull final PlaybackException error) { + Throwable cause = error.getCause(); + for (int depth = 0; cause != null && depth < 8; depth++, cause = cause.getCause()) { + final String message = cause.getMessage(); + if (cause instanceof IllegalArgumentException && message != null + && message.toLowerCase(Locale.US).contains("surface")) { + return true; + } + } + return false; + } + private void createErrorNotification(@NonNull final PlaybackException error) { final ErrorInfo errorInfo; if (currentMetadata == null) { From 224296d6255d1a7d8b14c13fc60028f32eaaca03 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 12 Jun 2026 14:41:53 +0200 Subject: [PATCH 27/35] fix(sabr): play the original audio track instead of the auto-dub --- .../player/datasource/SabrSessionStore.java | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index 34918fe075..79b5743f5b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -29,6 +29,9 @@ */ public final class SabrSessionStore { + // Debug: log the AAC audio-track candidates + the chosen one. Keep false outside debugging. + private static final boolean DIAG_AUDIO = false; + private static final Map SESSIONS = new ConcurrentHashMap<>(); // Current video plus one (next-item prefetch). Keeping more let abandoned sessions' pump threads // linger and bleed into the new playback on a switch, leaving the decoder with no usable frame @@ -244,11 +247,38 @@ private static YoutubeSabrFormat pickAudioFormat(@NonNull final YoutubeSabrInfo continue; } final String mime = f.getMimeType(); - if (mime != null && mime.contains("mp4") && (aac == null - || f.getBitrate() > aac.getBitrate())) { + if (mime == null || !mime.contains("mp4")) { + continue; + } + if (DIAG_AUDIO) { + System.out.println("SABR-AUDIO candidate itag=" + f.getItag() + + " trackId=" + f.getAudioTrackId() + + " name=" + f.getAudioTrackDisplayName() + + " default=" + f.isAudioDefault() + + " original=" + f.isOriginalAudio() + + " bitrate=" + f.getBitrate()); + } + if (aac == null) { + aac = f; + continue; + } + // Prefer the original-language track over an auto-dub, then the highest bitrate, so a + // dubbed default doesn't override the source audio. Falls back to plain highest-bitrate + // when no track is marked original (single-track videos). + final boolean preferForTrack = f.isOriginalAudio() && !aac.isOriginalAudio(); + final boolean preferForBitrate = f.isOriginalAudio() == aac.isOriginalAudio() + && f.getBitrate() > aac.getBitrate(); + if (preferForTrack || preferForBitrate) { aac = f; } } + if (DIAG_AUDIO && aac != null) { + System.out.println("SABR-AUDIO chosen video=" + info.getVideoId() + + " itag=" + aac.getItag() + + " trackId=" + aac.getAudioTrackId() + + " name=" + aac.getAudioTrackDisplayName() + + " original=" + aac.isOriginalAudio()); + } return aac != null ? aac : info.findBestAudioFormat(); } From fa151eab39d539240a6416963797060e8ed5a695 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 12 Jun 2026 14:41:53 +0200 Subject: [PATCH 28/35] fix(detail): stop reloading related videos on every resume --- .../newpipe/fragments/detail/VideoDetailFragment.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 4c444278f5..bb28e8794a 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -399,11 +399,12 @@ public void onResume() { setupBrightness(); - if (currentInfo != null) { - if (tabSettingsChanged) { - tabSettingsChanged = false; - initTabs(); - } + // Only rebuild the tabs when the tab settings actually changed. Doing it on every resume + // recreated the related-items (and description) fragments each time, so returning from the + // share sheet / Home reloaded the related videos. A new video goes through handleResult(). + if (currentInfo != null && tabSettingsChanged) { + tabSettingsChanged = false; + initTabs(); updateTabs(currentInfo); } From a1435c308964c0e8a9a207e0e5a0178bd149970f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 12 Jun 2026 19:38:24 +0200 Subject: [PATCH 29/35] feat(player): SABR multi-language audio track selector (show + switch) --- .../player/datasource/SabrSessionStore.java | 71 +++++++++++++++++-- .../player/resolver/PlaybackResolver.java | 44 ++++++++++++ .../resolver/VideoPlaybackResolver.java | 5 ++ 3 files changed, 113 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index 79b5743f5b..4e6a79756c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -5,6 +5,7 @@ import android.media.MediaCodecList; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.localization.ContentCountry; @@ -33,6 +34,8 @@ public final class SabrSessionStore { private static final boolean DIAG_AUDIO = false; private static final Map SESSIONS = new ConcurrentHashMap<>(); + // The user-selected audio track id per video, applied on the next (re)build of its session. + private static final Map PREFERRED_AUDIO = new ConcurrentHashMap<>(); // Current video plus one (next-item prefetch). Keeping more let abandoned sessions' pump threads // linger and bleed into the new playback on a switch, leaving the decoder with no usable frame // (black screen). Evicting the superseded session promptly (and stopping its pump) fixes that. @@ -164,30 +167,59 @@ private static boolean sessionMatchesItag(@NonNull final Holder holder, return wanted != null && wanted.getItag() == holder.videoFormat.getItag(); } + private static boolean sessionMatchesAudioTrack(@NonNull final Holder holder, + @Nullable final String preferredTrackId) { + // No explicit pick -> any cached track is fine (the default original). Otherwise the cached + // session must already stream the requested track, else rebuild. + return preferredTrackId == null + || preferredTrackId.equals(holder.audioFormat.getAudioTrackId()); + } + @NonNull + /** + * Set (or clear with {@code null}) the audio track the user picked for a video. Read by + * {@link #getOrCreate} so the next session (re)build streams that language; a different value + * than the cached session's track forces a rebuild. + */ + public static void setPreferredAudioTrack(@NonNull final String videoId, + @Nullable final String audioTrackId) { + if (DIAG_AUDIO) { + System.out.println("SABR-AUDIO setPreferred video=" + videoId + " track=" + audioTrackId); + } + if (audioTrackId == null) { + PREFERRED_AUDIO.remove(videoId); + } else { + PREFERRED_AUDIO.put(videoId, audioTrackId); + } + } + public static Holder getOrCreate(@NonNull final Context context, @NonNull final String videoId, final int preferredVideoItag) throws IOException, ExtractionException { + final String preferredAudioTrackId = PREFERRED_AUDIO.get(videoId); final Holder existing = SESSIONS.get(videoId); - if (existing != null && sessionMatchesItag(existing, preferredVideoItag)) { + if (existing != null && sessionMatchesItag(existing, preferredVideoItag) + && sessionMatchesAudioTrack(existing, preferredAudioTrackId)) { return existing; } synchronized (SabrSessionStore.class) { final Holder current = SESSIONS.get(videoId); if (current != null) { - if (sessionMatchesItag(current, preferredVideoItag)) { + if (sessionMatchesItag(current, preferredVideoItag) + && sessionMatchesAudioTrack(current, preferredAudioTrackId)) { return current; } - // Quality/codec change: the resolver re-asks with a different video itag for the same - // video. The cached session is locked to its formats, so returning it would re-prepare - // the player on the old codec and dead-buffer. Drop it (stops the pump) + rebuild below. + // Quality/codec OR audio-track change: the resolver re-asks with a different video + // itag or audio track for the same video. The cached session is locked to its + // formats, so returning it would re-prepare the player on the old pick and + // dead-buffer. Drop it (stops the pump) + rebuild below. evict(videoId); } final Localization localization = new Localization("en", "US"); final ContentCountry contentCountry = new ContentCountry("US"); final YoutubeSabrInfo info = YoutubeSabrProbeFetch(videoId, localization, contentCountry); - final YoutubeSabrFormat audioFormat = pickAudioFormat(info); + final YoutubeSabrFormat audioFormat = pickAudioFormat(info, preferredAudioTrackId); final YoutubeSabrFormat videoFormat = pickVideoFormat(info, preferredVideoItag); if (audioFormat == null || videoFormat == null) { throw new IOException("SABR: could not select audio/video formats for " + videoId); @@ -217,6 +249,21 @@ public static Holder getOrCreate(@NonNull final Context context, }, "SabrTokenPrewarm"); warm.setDaemon(true); warm.start(); + if (preferredAudioTrackId != null) { + // Mid-playback rebuild (audio-track switch): the player seeks to the saved position + // right after this returns. Pre-load both tracks' init metadata now so that cold + // seek maps the time to the correct segment. Without it the mapping uses the default + // 5000ms segment duration, overshoots the real segment count and dead-buffers. The + // PO token is already cached from the prior session, so this is a single fast fetch. + try { + session.fetchSegment(SabrSegmentRequest.initialization(audioFormat), + localization); + session.fetchSegment(SabrSegmentRequest.initialization(videoFormat), + localization); + } catch (final Exception ignored) { + // Best-effort; on failure the seek falls back to the previous behaviour. + } + } return holder; } } @@ -240,7 +287,8 @@ private static YoutubeSabrInfo YoutubeSabrProbeFetch(@NonNull final String video // mp4, hardware-decoded, ~same bitrate (130 vs 136 kbps) and plays perfectly smooth. so: AAC // until someone cracks the Opus path. (audio codec isn't user-facing, so this isn't a band-aid // on a user setting, just an internal pick.) - private static YoutubeSabrFormat pickAudioFormat(@NonNull final YoutubeSabrInfo info) { + private static YoutubeSabrFormat pickAudioFormat(@NonNull final YoutubeSabrInfo info, + @Nullable final String preferredTrackId) { YoutubeSabrFormat aac = null; for (final YoutubeSabrFormat f : info.getFormats()) { if (!f.isAudio()) { @@ -250,6 +298,11 @@ private static YoutubeSabrFormat pickAudioFormat(@NonNull final YoutubeSabrInfo if (mime == null || !mime.contains("mp4")) { continue; } + // When the user picked a language, only consider that track; otherwise fall through to + // the original-language preference below. + if (preferredTrackId != null && !preferredTrackId.equals(f.getAudioTrackId())) { + continue; + } if (DIAG_AUDIO) { System.out.println("SABR-AUDIO candidate itag=" + f.getItag() + " trackId=" + f.getAudioTrackId() @@ -279,6 +332,10 @@ private static YoutubeSabrFormat pickAudioFormat(@NonNull final YoutubeSabrInfo + " name=" + aac.getAudioTrackDisplayName() + " original=" + aac.isOriginalAudio()); } + if (aac == null && preferredTrackId != null) { + // The requested track has no mp4/AAC variant: fall back to the default original pick. + return pickAudioFormat(info, null); + } return aac != null ? aac : info.findBestAudioFormat(); } diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java index e165b878d2..7b96632f10 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java @@ -51,6 +51,7 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import org.schabi.newpipe.player.datasource.SabrMediaSource; import org.schabi.newpipe.player.datasource.SabrSessionStore; @@ -61,7 +62,10 @@ import java.io.IOException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.List; import java.util.Objects; +import java.util.Set; public interface PlaybackResolver extends Resolver { String TAG = PlaybackResolver.class.getSimpleName(); @@ -468,6 +472,7 @@ private static MediaSource buildSabrMediaSource(@NonNull final Stream stream, } catch (final ExtractionException e) { throw new IOException("Could not start SABR session for " + videoId, e); } + enrichSabrAudioTracks(streamInfo, holder.info); // One source carries both tracks; media3 track selection picks audio-only when there's no // video renderer (background/popup). Seeking is real because it's chunk-based, not a byte // stream. The audio resolver path skips its own SABR source (see VideoPlaybackResolver). @@ -479,6 +484,45 @@ private static MediaSource buildSabrMediaSource(@NonNull final Stream stream, return new SabrMediaSource(mediaItem, holder, new Localization("en", "US")); } + /** + * SABR's main player response only carries the original-language audio; the dubbed tracks live + * in the probe's {@link YoutubeSabrInfo}. Add the missing tracks (one {@link AudioStream} per + * audioTrackId) to the shared {@link StreamInfo} so the generic audio-track selector (also used + * by HLS) lists them. The added streams are UI markers; SABR playback still picks the format via + * the session, so they clone the original stream's content/format and only swap the track info. + */ + private static void enrichSabrAudioTracks(@NonNull final StreamInfo streamInfo, + @NonNull final YoutubeSabrInfo info) { + final List audioStreams = streamInfo.getAudioStreams(); + if (audioStreams.isEmpty()) { + return; + } + final AudioStream template = audioStreams.get(0); + final Set present = new HashSet<>(); + for (final AudioStream a : audioStreams) { + present.add(Objects.toString(a.getAudioTrackId(), "")); + } + for (final YoutubeSabrFormat f : info.getFormats()) { + final String trackId = f.getAudioTrackId(); + if (!f.isAudio() || trackId == null || !present.add(trackId)) { + continue; + } + final String langPart = trackId.split("\\.")[0]; + final String displayName = f.getAudioTrackDisplayName(); + audioStreams.add(new AudioStream.Builder() + .setId(template.getId() + "-" + trackId) + .setContent(template.getContent(), template.isUrl()) + .setMediaFormat(template.getFormat()) + .setAverageBitrate(f.getBitrate()) + .setItagItem(template.getItagItem()) + .setDeliveryMethod(DeliveryMethod.SABR) + .setAudioTrackId(trackId) + .setAudioTrackName(displayName != null ? displayName : langPart) + .setAudioLocale(langPart.split("-")[0]) + .build()); + } + } + @NonNull private static DashMediaSource buildYoutubeManualDashMediaSource( @NonNull final PlayerDataSource dataSource, diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java index 222d51ff59..331ea560bf 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java @@ -18,6 +18,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.player.datasource.SabrSessionStore; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.mediaitem.MediaItemTag; @@ -73,6 +74,10 @@ public MediaSource resolve(@NonNull final StreamInfo info) { return liveSource; } + // Hand the user-selected audio language to the SABR session store before it (re)builds the + // session for this video, so the switch actually changes the streamed track. + SabrSessionStore.setPreferredAudioTrack(info.getId(), audioTrack); + final List mediaSources = new ArrayList<>(); final List videoStreams = new ArrayList<>(info.getVideoStreams()); final List videoOnlyStreams = new ArrayList<>(info.getVideoOnlyStreams()); From 31f647e8ef4f192128f5c3767df46165d66c5052 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 12 Jun 2026 19:38:24 +0200 Subject: [PATCH 30/35] fix(player): keep the playback position when switching the SABR audio track --- .../org/schabi/newpipe/player/Player.java | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index dfb4aebbdd..16c6cedf16 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -239,6 +239,10 @@ public final class Player implements // genuinely broken surface can't loop recover->fail forever. private static final long SURFACE_ERROR_RECOVERY_COOLDOWN_MS = 10_000; private long lastSurfaceErrorRecoveryMs; + // One-shot: the next reload is an audio-track switch and must seek to the saved position rather + // than restart at 0. Scoped to the switch because that path pre-loads the SABR init metadata so + // the cold seek maps correctly; other SABR restarts stay at 0 (see shouldSeek). + private boolean seekOnNextSabrReload; private static final int MAX_RETRY_COUNT = 2; /*////////////////////////////////////////////////////////////////////////// @@ -821,6 +825,7 @@ public void handleIntent(@NonNull final Intent intent) { if (shouldSeek()) { simpleExoPlayer.seekTo(playQueue.getIndex(), newQueue.getItem().getRecoveryPosition()); } + seekOnNextSabrReload = false; simpleExoPlayer.setPlayWhenReady(playWhenReady); @@ -3415,15 +3420,18 @@ public void onPlaybackSynchronize(@NonNull final PlayQueueItem item, final boole } else { simpleExoPlayer.seekToDefaultPosition(currentPlayQueueIndex); } + seekOnNextSabrReload = false; } } public boolean shouldSeek() { - // our v1 SABR seek is a dumb byte-skip that can't land on a real position, so resuming - // mid-video just freezes the whole thing. so SABR always starts from 0, scrubbing can wait. - // honestly nobody died from rewatching an intro. plays fine from 0. + // SABR honours the saved position only on an audio-track switch: that path rebuilds the + // session and pre-loads the init metadata, so the cold seek maps the time to the right + // segment. Any other SABR (re)start stays at 0, because a cold seek before that metadata is + // loaded maps with the default segment duration and overshoots the segment count -> endless + // buffering. (Lifting this for resume needs the same metadata pre-load made general.) if (isCurrentStreamSabr()) { - return false; + return seekOnNextSabrReload; } return !prefs.getBoolean(context.getString(R.string.always_start_from_beginning_key), false); } @@ -4476,6 +4484,9 @@ private void buildAudioTrackMenu(@NonNull final List audioStreams) private void setAudioTrack(@Nullable final String audioTrackId) { saveStreamProgressState(); setRecovery(); + // This reload is a switch: keep the saved position instead of restarting at 0 (see + // shouldSeek). Consumed in the recovery-seek paths below. + seekOnNextSabrReload = true; videoResolver.setAudioTrack(audioTrackId); audioResolver.setAudioTrack(audioTrackId); reloadPlayQueueManager(); From 04570e0e70aa08f1ef089d50840271b3da25746e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 12 Jun 2026 20:46:31 +0200 Subject: [PATCH 31/35] fix(sabr): pre-load init metadata on cold-restore to avoid an audio discontinuity on a cold restart the player seeks to the saved position on a fresh SABR session whose init metadata isn't loaded, so the segment mapping uses the default 5000ms duration, picks the wrong audio segment and the renderer throws UnexpectedDiscontinuityException. eager-load both tracks' init metadata when a seek is likely to follow (audio switch, or a cached PO token meaning we played this recently). gated on the cached token so the first play (no token, starts at 0) doesn't block on the ~45s mint. --- .../player/datasource/SabrSessionStore.java | 14 ++++++-------- .../player/datasource/WebViewPoTokenProvider.java | 13 +++++++++++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index 4e6a79756c..3b84bd1188 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -10,7 +10,6 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrPoTokenProvider; import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrClientProfile; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; @@ -224,7 +223,7 @@ && sessionMatchesAudioTrack(current, preferredAudioTrackId)) { if (audioFormat == null || videoFormat == null) { throw new IOException("SABR: could not select audio/video formats for " + videoId); } - final SabrPoTokenProvider provider = provider(context); + final WebViewPoTokenProvider provider = provider(context); final YoutubeSabrSession session = new YoutubeSabrSession(info, audioFormat, videoFormat, provider); final Holder holder = new Holder(videoId, info, session, audioFormat, videoFormat); @@ -249,12 +248,11 @@ && sessionMatchesAudioTrack(current, preferredAudioTrackId)) { }, "SabrTokenPrewarm"); warm.setDaemon(true); warm.start(); - if (preferredAudioTrackId != null) { - // Mid-playback rebuild (audio-track switch): the player seeks to the saved position - // right after this returns. Pre-load both tracks' init metadata now so that cold - // seek maps the time to the correct segment. Without it the mapping uses the default - // 5000ms segment duration, overshoots the real segment count and dead-buffers. The - // PO token is already cached from the prior session, so this is a single fast fetch. + // Pre-load init metadata when a seek will follow (audio switch, or cold-restore: a + // cached token means we played this recently). Else the seek maps with the default + // 5000ms segment duration -> audio UnexpectedDiscontinuityException. The token gate keeps + // the first play (starts at 0) off the ~45s mint. + if (preferredAudioTrackId != null || provider.hasCachedToken(videoId)) { try { session.fetchSegment(SabrSegmentRequest.initialization(audioFormat), localization); diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java b/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java index 6f9358dc21..b7084a8974 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/WebViewPoTokenProvider.java @@ -134,6 +134,19 @@ public byte[] getPoToken(final YoutubeSabrInfo info, final YoutubeSabrStreamStat } } + /** + * True if a non-expired PO token for this video is already in memory or on disk, WITHOUT minting. + * Lets a caller pre-load metadata cheaply when we've recently played this video (cold-restore / + * re-resolve) while NOT blocking the first-ever play on the ~45s mint. + */ + public boolean hasCachedToken(final String videoId) { + final CachedToken mem = cache.get(videoId); + if (mem != null && System.currentTimeMillis() - mem.mintedAtMs < TOKEN_TTL_MS) { + return true; + } + return diskLoad(videoId) != null; + } + @Nullable private CachedToken diskLoad(final String videoId) { final String v = prefs.getString(videoId, null); From 2d26862a15189bef614e0a67486d8e0df2c1c0f4 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 12 Jun 2026 20:46:31 +0200 Subject: [PATCH 32/35] fix(player): guard NaN aspect ratio in ExpandableSurfaceView a 0x0 not-yet-resolved video gives a NaN aspect ratio that propagates to scaleX/scaleY and crashes onLayout with "Cannot set scaleX to NaN". sanitize the ratio at the setter and clamp the scale in onLayout. --- .../newpipe/views/ExpandableSurfaceView.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java b/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java index a6f43415f2..abf08ccf00 100644 --- a/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java +++ b/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java @@ -70,8 +70,9 @@ protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec @Override protected void onLayout(final boolean changed, final int left, final int top, final int right, final int bottom) { - setScaleX(scaleX); - setScaleY(scaleY); + // Defensive: never push a non-finite scale into the view (it throws and takes down layout). + setScaleX(Float.isFinite(scaleX) ? scaleX : 1.0f); + setScaleY(Float.isFinite(scaleY) ? scaleY : 1.0f); } /** @@ -102,11 +103,16 @@ public int getResizeMode() { } public void setAspectRatio(final float aspectRatio) { - if (videoAspectRatio == aspectRatio) { + // A 0x0 / not-yet-known video gives NaN (or Infinity) here; keep it as "no ratio" (0) so the + // measure path skips scaling instead of pushing NaN into setScaleX, which throws + // IllegalArgumentException and crashed the player on a cold restore while the SABR session + // was still resolving (no video dimensions yet). + final float sanitized = Float.isFinite(aspectRatio) ? aspectRatio : 0.0f; + if (videoAspectRatio == sanitized) { return; } - videoAspectRatio = aspectRatio; + videoAspectRatio = sanitized; requestLayout(); } } From e9daa7a97bcbc101c9bfbb8db35bf57af245cab7 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 14 Jun 2026 18:00:26 +0200 Subject: [PATCH 33/35] fix(player): keep the SABR position on quality change and error recovery --- app/src/main/java/org/schabi/newpipe/player/Player.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 16c6cedf16..d349925830 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -3196,6 +3196,8 @@ public void onPlayerError(@NonNull final PlaybackException error) { case ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT: case ERROR_CODE_UNSPECIFIED: setRecovery(); + // SABR: recover at the saved position, not 0 (see shouldSeek). + seekOnNextSabrReload = true; reloadPlayQueueManager(); break; case ERROR_CODE_DECODER_INIT_FAILED: { @@ -3209,6 +3211,8 @@ public void onPlayerError(@NonNull final PlaybackException error) { // falls through to shutdown below. lastSurfaceErrorRecoveryMs = System.currentTimeMillis(); setRecovery(); + // SABR: recover at the saved position, not 0 (see shouldSeek). + seekOnNextSabrReload = true; reloadPlayQueueManager(); break; } @@ -4285,6 +4289,10 @@ public boolean onMenuItemClick(@NonNull final MenuItem menuItem) { saveStreamProgressState(); //TODO added, check if good setRecovery(); + // Quality change is a reload, not a fresh start: keep the saved position (see shouldSeek). + // getOrCreate eager-loads the new format's init metadata (token is cached), so the seek + // maps to the right segment. + seekOnNextSabrReload = true; setSelectedIndex(menuItemIndex); reloadPlayQueueManager(); From 6cd5981893f95e9cf2039804e5b2904910909f41 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 14 Jun 2026 21:30:43 +0200 Subject: [PATCH 34/35] feat(sabr): stream the SABR POST response without buffering the whole body --- .../org/schabi/newpipe/DownloaderImpl.java | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java index 824bf00133..9c1e70e165 100644 --- a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java +++ b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java @@ -11,6 +11,8 @@ import org.schabi.newpipe.extractor.downloader.Downloader; import org.schabi.newpipe.extractor.downloader.Request; import org.schabi.newpipe.extractor.downloader.Response; +import org.schabi.newpipe.extractor.downloader.StreamingResponse; +import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.exceptions.ParsingException; import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; import org.schabi.newpipe.extractor.services.bilibili.BilibiliService; @@ -22,7 +24,9 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; import java.security.KeyManagementException; @@ -305,6 +309,52 @@ public Response execute(@NonNull final Request request) responseBodyToReturn, rawBodyBytes, latestUrl); } + /** + * Streaming POST: returns the body as a stream (okhttp {@code byteStream()}) instead of reading + * it whole, so a large SABR media batch (50-150MB at 4K) is not buffered into one byte[] (that + * was OOM-ing the 512MB heap). Mirrors execute()'s request building; caller closes the result. + */ + @Override + public StreamingResponse postStreaming(final String url, + @Nullable final Map> headers, + @Nullable final byte[] dataToSend, + @Nullable final Localization localization) + throws IOException, ReCaptchaException { + final Map> hdrs = headers == null ? Collections.emptyMap() : headers; + final RequestBody requestBody = RequestBody.create(null, + dataToSend == null ? new byte[0] : dataToSend); + final okhttp3.Request.Builder requestBuilder = new okhttp3.Request.Builder() + .method("POST", requestBody).url(url); + if (!hdrs.containsKey("User-Agent")) { + requestBuilder.header("User-Agent", USER_AGENT); + } + final String cookies = getCookies(url); + if (!hdrs.containsKey("Cookie") && !cookies.isEmpty()) { + requestBuilder.header("Cookie", cookies); + } + for (final Map.Entry> pair : hdrs.entrySet()) { + final List values = pair.getValue(); + if (values.size() > 1) { + requestBuilder.removeHeader(pair.getKey()); + for (final String value : values) { + requestBuilder.addHeader(pair.getKey(), value); + } + } else if (values.size() == 1) { + requestBuilder.header(pair.getKey(), values.get(0)); + } + } + final okhttp3.Response response = client.newCall(requestBuilder.build()).execute(); + if (response.code() == 429) { + response.close(); + throw new ReCaptchaException("reCaptcha Challenge requested", url); + } + final ResponseBody body = response.body(); + final InputStream stream = body == null + ? new ByteArrayInputStream(new byte[0]) : body.byteStream(); + // StreamingResponse.close() closes this stream -> closes the okhttp body + connection. + return new StreamingResponse(response.code(), response.headers().toMultimap(), stream); + } + public CancellableCall executeAsync(@NonNull final Request request, @NonNull final Downloader.AsyncCallback callback) { final String httpMethod = request.httpMethod(); final String url = request.url(); From 02c2e6e58f689391deaaafbe93bc7e45f4263277 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 14 Jun 2026 21:30:43 +0200 Subject: [PATCH 35/35] fix(sabr): size the back-buffer by bytes so 4k keeps its read-ahead budget --- .../player/datasource/SabrStreamPump.java | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java index 70e169c696..fee10560dc 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java @@ -46,6 +46,11 @@ final class SabrStreamPump { // within the back-buffer window the cache can't drain -> the pump throttles forever and stalls. // Shrinking the back-buffer when over budget lets eviction free bytes so playback keeps fetching. private static final long MIN_BACK_BUFFER_MS = 5_000; + // The back-buffer is sized by BYTES, not a fixed 30s: 30s of already-played video is ~60MB at 4K + // (mostly wasted, rewinds are rare) but only ~12MB at 1080p. Holding a constant ~16MB keeps + // low-res rewinds generous without ballooning the 4K heap. Rewinds past it re-fetch, so + // correctness is intact; read-ahead (what playback needs) is untouched, so no extra rebuffering. + private static final long BACK_BUFFER_BYTES = 16L * 1024 * 1024; private final YoutubeSabrSession session; private final SabrSessionStore.Holder holder; @@ -152,7 +157,7 @@ private void loop() { // already over the byte budget (high bitrate), shrink the back-buffer so eviction // can actually drain it, otherwise the pump throttles forever and playback stalls. final long backBufferMs = session.getCachedBytes() > MAX_AHEAD_BYTES - ? MIN_BACK_BUFFER_MS : BACK_BUFFER_MS; + ? MIN_BACK_BUFFER_MS : targetBackBufferMs(); session.setPlayHeadMs(Math.max(0, holder.getReaderTailMs() - backBufferMs)); session.evictPlayed(); final long edgeMs = session.getStreamState().getMinBufferedEndMs(); @@ -213,6 +218,20 @@ private void loop() { } } + /** Back-buffer duration for THIS stream's bitrate, so it holds ~{@link #BACK_BUFFER_BYTES} + * regardless of resolution. Clamped to [MIN, MAX]; falls back to the time-based default when the + * bitrate is unknown. */ + private long targetBackBufferMs() { + final long bitsPerSec = (long) holder.videoFormat.getBitrate() + + Math.max(0, holder.audioFormat.getBitrate()); + if (bitsPerSec <= 0) { + return BACK_BUFFER_MS; + } + final long bytesPerMs = Math.max(1, bitsPerSec / 8 / 1000); + return Math.max(MIN_BACK_BUFFER_MS, + Math.min(BACK_BUFFER_MS, BACK_BUFFER_BYTES / bytesPerMs)); + } + private static void sleepQuietly(final long ms) { try { Thread.sleep(ms);