From 64499218ced7315a7edb12fd89be4f47f27440b6 Mon Sep 17 00:00:00 2001 From: TEMP Date: Fri, 25 Sep 2026 14:40:50 -0400 Subject: [PATCH 1/7] feat(player): auto-switch source when stuck buffering (opt-in) Off-by-default setting that reacts to a source stuck buffering: polls the player once per second and switches to the next ranked source when either buffering without position progress for 10s, or buffering >=15 of the last 60s. Guardrails: 60s cooldown between switches, max 3 per session. Reuses the existing nextMirror()/loadLink(sameEpisode=true) machinery, so position is preserved and no player code path changes. Also records per-host failure counts locally (used by a later ranking PR). With the setting off, behavior is unchanged. --- .../cloudstream3/ui/player/GeneratorPlayer.kt | 18 ++ .../ui/player/StuckBufferingWatcher.kt | 166 ++++++++++++++++++ .../ui/settings/SettingsPlayerScreen.kt | 6 + app/src/main/res/values/strings.xml | 3 + .../cloudstream3/StuckBufferingWatcherTest.kt | 134 ++++++++++++++ .../cloudstream4/DevicePreferenceStore.kt | 1 + 6 files changed, 328 insertions(+) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt create mode 100644 app/src/test/java/com/lagradost/cloudstream3/StuckBufferingWatcherTest.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index e4616c93bcf..d9fff2f6e8d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -187,6 +187,12 @@ class GeneratorPlayer : FullScreenPlayer() { private var isPlayerActive: AtomicBoolean = AtomicBoolean(false) private var isNextEpisode: Boolean = false // this is used to reset the watch time + private val stuckBufferingWatcher = StuckBufferingWatcher( + isBuffering = { currentPlayerStatus == CSPlayerLoading.IsBuffering }, + currentPosition = { player.getPosition() ?: 0L }, + onSwitchSource = ::autoSwitchFromStall, + ) + private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none private val allMeta: List? get() = viewModel.state.generatorState?.allMeta?.filterIsInstance() @@ -547,6 +553,7 @@ class GeneratorPlayer : FullScreenPlayer() { player.addTimeStamps(emptyList()) // clear stamps // Resets subtitle delay, as we watch some other content player.setSubtitleOffset(0) + stuckBufferingWatcher.reset() // new episode/link: restart the stall counters } } @@ -1564,6 +1571,7 @@ class GeneratorPlayer : FullScreenPlayer() { override fun playerError(exception: Throwable) { currentSelectedLink?.let { link -> + StuckBufferingWatcher.recordHostFailure(link.first?.url) viewModel.modifyState { this.addError(link) } } @@ -1627,6 +1635,7 @@ class GeneratorPlayer : FullScreenPlayer() { } loadLink(firstAvailableLink, false) showPlayerMetadata() + stuckBufferingWatcher.start() } private fun showPlayerMetadata() { @@ -1714,9 +1723,18 @@ class GeneratorPlayer : FullScreenPlayer() { loadLink(nextLink.link, true) } + /** Called by [StuckBufferingWatcher] when the current source is stuck buffering. */ + private fun autoSwitchFromStall() { + if (!hasNextMirror()) return + StuckBufferingWatcher.recordHostFailure(currentSelectedLink?.first?.url) + showToast(activity?.getString(R.string.auto_switch_source_toast)) + nextMirror() + } + override fun onDestroy() { ResultFragment.updateUI() currentVerifyLink?.cancel() + stuckBufferingWatcher.stop() super.onDestroy() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt new file mode 100644 index 00000000000..adbdc6fce88 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt @@ -0,0 +1,166 @@ +package com.lagradost.cloudstream3.ui.player + +import android.util.Log +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.setKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Watches the player for a stuck buffering state and asks to switch to the next ranked + * source. Two triggers: + * - a single stall: buffering with no position advance for [STALL_TIMEOUT_MS] + * - repeated short stalls: buffering for [BUFFERING_TICKS_THRESHOLD] of the last 60s + * + * Reuses the existing nextMirror()/loadLink(sameEpisode=true) machinery — no new player + * path, no probing, no health scoring (those are later PRs). Polls once per second while + * active, which is cheap compared to the decoding work. + * + * All automatic behavior is opt-in [PREF_KEY] (default off): with it off the watcher + * does nothing and today's behavior is untouched. + */ +class StuckBufferingWatcher( + private val isBuffering: () -> Boolean, + private val currentPosition: () -> Long, + private val onSwitchSource: () -> Unit, + /** Injectable for tests; defaults to wall clock. */ + private val timeSource: () -> Long = System::currentTimeMillis, + /** Injectable for tests; null means read the user setting. */ + private val enabled: Boolean? = null, +) { + companion object { + const val TAG = "StuckBuffering" + + private const val PREF_KEY = "source_auto_switch_enabled" + private const val FAILURE_KEY_PREFIX = "source_failure_host_" + + private const val POLL_INTERVAL_MS = 1_000L + private const val STALL_TIMEOUT_MS = 10_000L // buffering w/o progress -> stuck + private const val BUFFERING_WINDOW_MS = 60_000L + private const val BUFFERING_TICKS_THRESHOLD = 15 // ~25% of the time buffering + private const val SWITCH_COOLDOWN_MS = 60_000L + private const val MAX_SWITCHES_PER_SESSION = 3 + + fun isEnabled(): Boolean = try { + val ctx = CloudStreamApp.context ?: return false + PreferenceManager.getDefaultSharedPreferences(ctx) + .getBoolean(PREF_KEY, false) + } catch (_: Exception) { + false + } + + internal fun hostOrNull(url: String): String? = try { + url.substringAfter("//").substringBefore('/').substringBefore(':').lowercase() + .takeIf { it.contains('.') } // real hosters have a TLD; filters "about:blank" etc. + } catch (_: Exception) { + null + } + + /** Best-effort local per-host failure counter; safe no-op without a context. */ + fun recordHostFailure(url: String?) { + val host = url?.let { hostOrNull(it) } ?: return + val ctx = CloudStreamApp.context ?: return + try { + val key = FAILURE_KEY_PREFIX + host + ctx.setKey(key, (ctx.getKey(key) ?: 0) + 1) + } catch (_: Exception) { + // stats are best effort + } + } + } + + private var pollJob: Job? = null + private var lastProgressMs: Long = 0L + private var stuckSinceMs: Long = 0L + private val bufferingTicks = ArrayDeque() + // Start outside cooldown so the first tick can fire + private var lastSwitchAtMs: Long = timeSource() - SWITCH_COOLDOWN_MS + private var switchesThisSession: Int = 0 + + /** + * Start watching. Polls once per second; each tick checks whether the player is + * buffering and whether the position has advanced. Fires at most + * [MAX_SWITCHES_PER_SESSION] times, with [SWITCH_COOLDOWN_MS] between firings. + */ + fun start() { + pollJob?.cancel() + pollJob = CoroutineScope(Dispatchers.Main).launch { + while (isActive) { + delay(POLL_INTERVAL_MS) + tick() + } + } + } + + fun stop() { + pollJob?.cancel() + pollJob = null + reset() + } + + /** Reset session counters, e.g. when a new episode starts. */ + fun reset() { + switchesThisSession = 0 + stuckSinceMs = 0L + bufferingTicks.clear() + } + + internal fun tick() { + try { + if (!(enabled ?: isEnabled())) return + + val now = timeSource() + if (now - lastSwitchAtMs < SWITCH_COOLDOWN_MS) return + if (switchesThisSession >= MAX_SWITCHES_PER_SESSION) return + + val position = currentPosition() + val buffering = isBuffering() + if (buffering) { + bufferingTicks.addLast(now) + } + while (bufferingTicks.isNotEmpty() && now - bufferingTicks.first() > BUFFERING_WINDOW_MS) { + bufferingTicks.removeFirst() + } + + if (!buffering) { + stuckSinceMs = 0L + lastProgressMs = position + return + } + + if (position > lastProgressMs) { + // buffering but still making progress (normal startup / fast network) + lastProgressMs = position + stuckSinceMs = 0L + } else if (stuckSinceMs == 0L) { + stuckSinceMs = now + } + + val singleStall = stuckSinceMs != 0L && now - stuckSinceMs >= STALL_TIMEOUT_MS + // ponytail: repeated-stall trigger ignores user scrubbing; cooldown+session cap + // bound the damage, revisit only if scrub-heavy usage misfires + val repeatedStalls = bufferingTicks.size >= BUFFERING_TICKS_THRESHOLD + + if (singleStall || repeatedStalls) { + Log.i( + TAG, + "${if (singleStall) "Stuck buffering" else "Repeated buffering"} at ${position}ms, " + + "switching source (${switchesThisSession + 1}/$MAX_SWITCHES_PER_SESSION)" + ) + stuckSinceMs = 0L + bufferingTicks.clear() // hysteresis: window restarts after a switch + lastSwitchAtMs = now + switchesThisSession++ + onSwitchSource() + } + } catch (e: Exception) { + Log.e(TAG, "tick failed", e) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayerScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayerScreen.kt index 176eb1202e5..9522faef358 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayerScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayerScreen.kt @@ -147,6 +147,12 @@ object SettingsPlayerScreen : SearchableSettings { subtitle = stringResource(R.string.start_paused_settings_des), icon = painterResource(R.drawable.pause_24px), ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.sourceAutoSwitchEnabled, + title = stringResource(R.string.auto_switch_stalled_source), + subtitle = stringResource(R.string.auto_switch_stalled_source_des), + icon = painterResource(R.drawable.baseline_sync_24), + ), Preference.PreferenceItem.SwitchPreference( preference = settings.player.skipOpEnabled, title = stringResource(R.string.video_skip_op), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66eb3533c4c..e5f580ad488 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -160,6 +160,9 @@ Start the next episode when the current one ends Start videos paused Reduce stress when selecting sources + Switch source when stuck buffering + If a video keeps buffering without progress, automatically switch to the next source in your priority order. Off by default. + Stuck buffering, switched source Double tap to seek Double tap to pause Player seek amount (Seconds) diff --git a/app/src/test/java/com/lagradost/cloudstream3/StuckBufferingWatcherTest.kt b/app/src/test/java/com/lagradost/cloudstream3/StuckBufferingWatcherTest.kt new file mode 100644 index 00000000000..e826d7d5c07 --- /dev/null +++ b/app/src/test/java/com/lagradost/cloudstream3/StuckBufferingWatcherTest.kt @@ -0,0 +1,134 @@ +package com.lagradost.cloudstream3 + +import com.lagradost.cloudstream3.ui.player.StuckBufferingWatcher +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests the stall-detection tick logic of [StuckBufferingWatcher] with a fake clock. + * Uses the test-only constructor that skips the settings read and coroutine polling. + */ +class StuckBufferingWatcherTest { + + private class TestableWatcher( + var buffering: Boolean = false, + var position: Long = 0L, + var timeMs: Long = 0L, + ) { + val fired: MutableList = mutableListOf() + val watcher = StuckBufferingWatcher( + isBuffering = { buffering }, + currentPosition = { position }, + onSwitchSource = { fired.add(timeMs) }, + timeSource = { timeMs }, + enabled = true, + ) + + fun tick() = watcher.tick() + } + + private fun TestableWatcher.advance(ms: Long) { + timeMs += ms + tick() + } + + @Test + fun `does not fire for occasional short stalls`() { + val w = TestableWatcher() + // 1 buffering tick out of every 3, always making progress: ~33s -> 10 buffered ticks + repeat(11) { + w.buffering = true + w.advance(1_000) + w.buffering = false + w.advance(1_000) + w.advance(1_000) + } + assertTrue(w.fired.isEmpty()) + } + + @Test + fun `fires after ten seconds of buffering without progress`() { + val w = TestableWatcher() + w.buffering = true + w.position = 5_000 + w.advance(1_000) // progress tick: arms the baseline + w.advance(1_000) // no progress: stuck clock starts + assertTrue(w.fired.isEmpty()) + w.advance(10_000) // 10s stuck -> fire + assertEquals(1, w.fired.size) + } + + @Test + fun `fires when buffering dominates even with progress between stalls`() { + val w = TestableWatcher() + // buffering every tick (choppy stream), tiny progress each time: never "stuck", + // but 15 buffered seconds inside a minute -> fire + repeat(15) { + w.buffering = true + w.position += 250 + w.advance(1_000) + } + assertEquals(1, w.fired.size) + } + + @Test + fun `cooldown prevents immediate second switch`() { + val w = TestableWatcher() + w.buffering = true + w.position = 0 + w.advance(11_000) // arm + w.advance(11_000) // fire #1 + assertEquals(1, w.fired.size) + // stuck again right away + w.advance(11_000) + assertTrue("cooldown must block", w.fired.size == 1) + // after 60s cooldown, allowed again + w.advance(60_000) + w.advance(11_000) + assertEquals(2, w.fired.size) + } + + @Test + fun `max three switches per session`() { + val w = TestableWatcher() + w.buffering = true + w.position = 0 + repeat(3) { + w.advance(11_000) // arm + w.advance(11_000) // fire + w.advance(60_000) // wait out cooldown + } + assertEquals(3, w.fired.size) + w.advance(11_000) + w.advance(11_000) + w.advance(60_000) + w.advance(11_000) + w.advance(11_000) + assertEquals("session cap must hold", 3, w.fired.size) + } + + @Test + fun `not buffering resets the stuck clock`() { + val w = TestableWatcher() + w.buffering = true + w.position = 0 + w.advance(9_000) + w.buffering = false + w.advance(1_000) + w.buffering = true + w.advance(9_000) + assertTrue("clock must reset when playback recovers", w.fired.isEmpty()) + w.advance(10_000) + assertEquals(1, w.fired.size) + } + + @Test + fun `host extraction handles common url shapes`() { + assertEquals("vadsur.stream", StuckBufferingWatcher.hostOrNull("https://vadsur.stream/x/y.m3u8")) + assertEquals("vadsur.stream", StuckBufferingWatcher.hostOrNull("//vadsur.stream/x")) + assertEquals("cdn.example.com", StuckBufferingWatcher.hostOrNull("https://cdn.example.com:8443/a")) + assertNull(StuckBufferingWatcher.hostOrNull("about:blank")) + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt index 168b8259028..323b77d3298 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt @@ -168,6 +168,7 @@ class PlayerPreferences(preferences: PreferenceStore) { val tiktokEnabled = preferences.getBoolean("speedup_key", false) val autoPlayEnabled = preferences.getBoolean("autoplay_next_key", true) val startPaused = preferences.getBoolean("start_paused_key", false) + val sourceAutoSwitchEnabled = preferences.getBoolean("source_auto_switch_enabled", false) val skipOpEnabled = preferences.getBoolean("enable_skip_op_from_database", true) val autoRotateEnabled = preferences.getBoolean("auto_rotate_video_key", true) val rotateButtonEnabled = preferences.getBoolean("rotate_video_key", false) From 0983245cbbad5eba0d91d8c189aca12423581511 Mon Sep 17 00:00:00 2001 From: TEMP Date: Fri, 25 Sep 2026 22:56:24 -0400 Subject: [PATCH 2/7] fix(player): reset auto-switch budget after a successful switch The 3-switch session cap treated working and dead sources alike: a long-stalling episode burned the whole budget on mirrors that ultimately played fine. Now a switch that leads to 30s+ of playback progress past the switch point resets the counter, so working sources are effectively unlimited while an all-mirrors-dead cycle still stops after 3 instead of looping forever. --- .../ui/player/StuckBufferingWatcher.kt | 26 +++++++++++----- .../cloudstream3/StuckBufferingWatcherTest.kt | 30 +++++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt index adbdc6fce88..1db787f0ffe 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt @@ -46,6 +46,8 @@ class StuckBufferingWatcher( private const val BUFFERING_TICKS_THRESHOLD = 15 // ~25% of the time buffering private const val SWITCH_COOLDOWN_MS = 60_000L private const val MAX_SWITCHES_PER_SESSION = 3 + /** Watching this much content past a switch means it worked: budget resets. */ + private const val SWITCH_SUCCESS_PROGRESS_MS = 30_000L fun isEnabled(): Boolean = try { val ctx = CloudStreamApp.context ?: return false @@ -82,11 +84,16 @@ class StuckBufferingWatcher( // Start outside cooldown so the first tick can fire private var lastSwitchAtMs: Long = timeSource() - SWITCH_COOLDOWN_MS private var switchesThisSession: Int = 0 + /** Position where the last switch fired; progress past +[SWITCH_SUCCESS_PROGRESS_MS] resets the budget. */ + private var lastSwitchPositionMs: Long = Long.MIN_VALUE /** * Start watching. Polls once per second; each tick checks whether the player is * buffering and whether the position has advanced. Fires at most - * [MAX_SWITCHES_PER_SESSION] times, with [SWITCH_COOLDOWN_MS] between firings. + * [MAX_SWITCHES_PER_SESSION] times in a row, with [SWITCH_COOLDOWN_MS] between + * firings — but each switch that leads to real playback progress resets the + * counter, so working sources are effectively unlimited. Only a cycle where + * nothing ever plays is capped. */ fun start() { pollJob?.cancel() @@ -117,7 +124,6 @@ class StuckBufferingWatcher( val now = timeSource() if (now - lastSwitchAtMs < SWITCH_COOLDOWN_MS) return - if (switchesThisSession >= MAX_SWITCHES_PER_SESSION) return val position = currentPosition() val buffering = isBuffering() @@ -131,10 +137,7 @@ class StuckBufferingWatcher( if (!buffering) { stuckSinceMs = 0L lastProgressMs = position - return - } - - if (position > lastProgressMs) { + } else if (position > lastProgressMs) { // buffering but still making progress (normal startup / fast network) lastProgressMs = position stuckSinceMs = 0L @@ -142,11 +145,19 @@ class StuckBufferingWatcher( stuckSinceMs = now } + // A switch that got us watching again earns a fresh budget: position moved + // well past where the last switch fired. + if (position - lastSwitchPositionMs >= SWITCH_SUCCESS_PROGRESS_MS) { + switchesThisSession = 0 + } + val singleStall = stuckSinceMs != 0L && now - stuckSinceMs >= STALL_TIMEOUT_MS - // ponytail: repeated-stall trigger ignores user scrubbing; cooldown+session cap + // ponytail: repeated-stall trigger ignores user scrubbing; cooldown + budget-reset // bound the damage, revisit only if scrub-heavy usage misfires val repeatedStalls = bufferingTicks.size >= BUFFERING_TICKS_THRESHOLD + if (switchesThisSession >= MAX_SWITCHES_PER_SESSION) return + if (singleStall || repeatedStalls) { Log.i( TAG, @@ -156,6 +167,7 @@ class StuckBufferingWatcher( stuckSinceMs = 0L bufferingTicks.clear() // hysteresis: window restarts after a switch lastSwitchAtMs = now + lastSwitchPositionMs = position switchesThisSession++ onSwitchSource() } diff --git a/app/src/test/java/com/lagradost/cloudstream3/StuckBufferingWatcherTest.kt b/app/src/test/java/com/lagradost/cloudstream3/StuckBufferingWatcherTest.kt index e826d7d5c07..0ff5df9c033 100644 --- a/app/src/test/java/com/lagradost/cloudstream3/StuckBufferingWatcherTest.kt +++ b/app/src/test/java/com/lagradost/cloudstream3/StuckBufferingWatcherTest.kt @@ -90,6 +90,36 @@ class StuckBufferingWatcherTest { assertEquals(2, w.fired.size) } + @Test + fun `successful switch restores the budget`() { + val w = TestableWatcher() + w.buffering = true + w.position = 0 + repeat(3) { + w.advance(11_000) // arm + w.advance(11_000) // fire + w.advance(60_000) // wait out cooldown + } + assertEquals(3, w.fired.size) + // a dead cycle: nothing plays, budget must not come back + w.advance(11_000) + w.advance(11_000) + w.advance(60_000) + w.advance(11_000) + w.advance(11_000) + assertEquals("cap must hold while nothing plays", 3, w.fired.size) + // now the switch worked: playback continues well past the switch point + w.position = 40_000 + w.buffering = false + w.advance(1_000) + // and stalls again — full budget available + w.buffering = true + w.position = 40_000 + w.advance(11_000) + w.advance(11_000) + assertEquals("working switch must reset the cap", 4, w.fired.size) + } + @Test fun `max three switches per session`() { val w = TestableWatcher() From fb725f3d30bd457cd046f04740fd412e05fe8584 Mon Sep 17 00:00:00 2001 From: TEMP Date: Fri, 25 Sep 2026 23:11:43 -0400 Subject: [PATCH 3/7] refactor(player): split StuckBufferingWatcher.tick to lower complexity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeFactor flags tick() as a complex method (complexity 16). Extract the buffering-window bookkeeping, stall clock, trigger check and switch side effects into small helpers — no behavior change. --- .../ui/player/StuckBufferingWatcher.kt | 82 +++++++++++-------- 1 file changed, 47 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt index 1db787f0ffe..e25ca8c2a3e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt @@ -46,6 +46,7 @@ class StuckBufferingWatcher( private const val BUFFERING_TICKS_THRESHOLD = 15 // ~25% of the time buffering private const val SWITCH_COOLDOWN_MS = 60_000L private const val MAX_SWITCHES_PER_SESSION = 3 + /** Watching this much content past a switch means it worked: budget resets. */ private const val SWITCH_SUCCESS_PROGRESS_MS = 30_000L @@ -81,9 +82,11 @@ class StuckBufferingWatcher( private var lastProgressMs: Long = 0L private var stuckSinceMs: Long = 0L private val bufferingTicks = ArrayDeque() + // Start outside cooldown so the first tick can fire private var lastSwitchAtMs: Long = timeSource() - SWITCH_COOLDOWN_MS private var switchesThisSession: Int = 0 + /** Position where the last switch fired; progress past +[SWITCH_SUCCESS_PROGRESS_MS] resets the budget. */ private var lastSwitchPositionMs: Long = Long.MIN_VALUE @@ -127,23 +130,8 @@ class StuckBufferingWatcher( val position = currentPosition() val buffering = isBuffering() - if (buffering) { - bufferingTicks.addLast(now) - } - while (bufferingTicks.isNotEmpty() && now - bufferingTicks.first() > BUFFERING_WINDOW_MS) { - bufferingTicks.removeFirst() - } - - if (!buffering) { - stuckSinceMs = 0L - lastProgressMs = position - } else if (position > lastProgressMs) { - // buffering but still making progress (normal startup / fast network) - lastProgressMs = position - stuckSinceMs = 0L - } else if (stuckSinceMs == 0L) { - stuckSinceMs = now - } + updateBufferingWindow(buffering, now) + updateStallClock(position, buffering) // A switch that got us watching again earns a fresh budget: position moved // well past where the last switch fired. @@ -151,28 +139,52 @@ class StuckBufferingWatcher( switchesThisSession = 0 } - val singleStall = stuckSinceMs != 0L && now - stuckSinceMs >= STALL_TIMEOUT_MS - // ponytail: repeated-stall trigger ignores user scrubbing; cooldown + budget-reset - // bound the damage, revisit only if scrub-heavy usage misfires - val repeatedStalls = bufferingTicks.size >= BUFFERING_TICKS_THRESHOLD - if (switchesThisSession >= MAX_SWITCHES_PER_SESSION) return + if (isStuck(now, buffering)) fireSwitch(position, now) + } catch (e: Exception) { + Log.e(TAG, "tick failed", e) + } + } - if (singleStall || repeatedStalls) { - Log.i( - TAG, - "${if (singleStall) "Stuck buffering" else "Repeated buffering"} at ${position}ms, " + - "switching source (${switchesThisSession + 1}/$MAX_SWITCHES_PER_SESSION)" - ) + private fun updateBufferingWindow(buffering: Boolean, now: Long) { + if (buffering) { + bufferingTicks.addLast(now) + } + while (bufferingTicks.isNotEmpty() && now - bufferingTicks.first() > BUFFERING_WINDOW_MS) { + bufferingTicks.removeFirst() + } + } + + private fun updateStallClock(position: Long, buffering: Boolean) { + when { + !buffering -> { stuckSinceMs = 0L - bufferingTicks.clear() // hysteresis: window restarts after a switch - lastSwitchAtMs = now - lastSwitchPositionMs = position - switchesThisSession++ - onSwitchSource() + lastProgressMs = position } - } catch (e: Exception) { - Log.e(TAG, "tick failed", e) + position > lastProgressMs -> { + // buffering but still making progress (normal startup / fast network) + lastProgressMs = position + stuckSinceMs = 0L + } + stuckSinceMs == 0L -> stuckSinceMs = timeSource() } } + + private fun isStuck(now: Long, buffering: Boolean): Boolean { + val singleStall = stuckSinceMs != 0L && now - stuckSinceMs >= STALL_TIMEOUT_MS + // ponytail: repeated-stall trigger ignores user scrubbing; cooldown + budget-reset + // bound the damage, revisit only if scrub-heavy usage misfires + val repeatedStalls = bufferingTicks.size >= BUFFERING_TICKS_THRESHOLD + return singleStall || repeatedStalls + } + + private fun fireSwitch(position: Long, now: Long) { + Log.i(TAG, "Stuck at ${position}ms, switching source (${switchesThisSession + 1}/$MAX_SWITCHES_PER_SESSION)") + stuckSinceMs = 0L + bufferingTicks.clear() // hysteresis: window restarts after a switch + lastSwitchAtMs = now + lastSwitchPositionMs = position + switchesThisSession++ + onSwitchSource() + } } From c0ff7168d0a9e5e0a2f57529469af41d7f7668a6 Mon Sep 17 00:00:00 2001 From: TEMP Date: Fri, 25 Sep 2026 23:16:43 -0400 Subject: [PATCH 4/7] chore: polish auto-switch strings to match style --- app/src/main/res/values/strings.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e5f580ad488..dc8e9a2ffa3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -160,9 +160,9 @@ Start the next episode when the current one ends Start videos paused Reduce stress when selecting sources - Switch source when stuck buffering - If a video keeps buffering without progress, automatically switch to the next source in your priority order. Off by default. - Stuck buffering, switched source + Auto-switch stuck source + Automatically switch to the next source when playback stalls or keeps buffering + Switched Source (Stuck Buffering) Double tap to seek Double tap to pause Player seek amount (Seconds) From 56d13aa59e574ece18729ad82e904d50ba027282 Mon Sep 17 00:00:00 2001 From: TEMP Date: Fri, 25 Sep 2026 23:29:36 -0400 Subject: [PATCH 5/7] fix(player): manual source switch resets stall tracking The source picker goes through the same loadLink(sameEpisode=true) path as the auto-switch, so the stall clock and buffering window carried over: a source the user manually picked could be switched away from ~10s later because the previous source's stall history was still armed. Reset stall tracking on a manual pick; the switch budget is untouched (a manual pick is not the watcher's switch). --- .../cloudstream3/ui/player/GeneratorPlayer.kt | 3 +++ .../ui/player/StuckBufferingWatcher.kt | 9 ++++++++ .../cloudstream3/StuckBufferingWatcherTest.kt | 21 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index d9fff2f6e8d..6f847d44013 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -1402,6 +1402,9 @@ class GeneratorPlayer : FullScreenPlayer() { } if (init) { filteredLinks.getOrNull(sourceIndex)?.let { + // User's explicit pick: the previous source's stall history + // must not auto-switch away from it while it buffers up. + stuckBufferingWatcher.resetStallTracking() loadLink(it.link, true) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt index e25ca8c2a3e..92840ffffdd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt @@ -117,6 +117,15 @@ class StuckBufferingWatcher( /** Reset session counters, e.g. when a new episode starts. */ fun reset() { switchesThisSession = 0 + resetStallTracking() + } + + /** + * Clear the stall clock and buffering window without touching the switch budget — + * for when the user manually picks a source and the old source's stall history + * must not fire against the fresh one. + */ + fun resetStallTracking() { stuckSinceMs = 0L bufferingTicks.clear() } diff --git a/app/src/test/java/com/lagradost/cloudstream3/StuckBufferingWatcherTest.kt b/app/src/test/java/com/lagradost/cloudstream3/StuckBufferingWatcherTest.kt index 0ff5df9c033..880361be884 100644 --- a/app/src/test/java/com/lagradost/cloudstream3/StuckBufferingWatcherTest.kt +++ b/app/src/test/java/com/lagradost/cloudstream3/StuckBufferingWatcherTest.kt @@ -120,6 +120,27 @@ class StuckBufferingWatcherTest { assertEquals("working switch must reset the cap", 4, w.fired.size) } + @Test + fun `manual switch clears stall tracking but keeps budget`() { + val w = TestableWatcher() + w.buffering = true + w.position = 0 + // burn one switch + w.advance(11_000) // arm + w.advance(11_000) // fire (1/3) + assertEquals(1, w.fired.size) + // the old source's stall is mid-armed when the user picks a new source + w.watcher.resetStallTracking() + w.advance(9_000) // new source buffers 9s: under the stall timeout, no fire + assertEquals("fresh source must not inherit the stall clock", 1, w.fired.size) + // and the budget is untouched: still 2 switches left + w.advance(2_000) // 11s stuck + w.advance(60_000) // wait out cooldown + w.advance(11_000) + w.advance(11_000) + assertEquals(2, w.fired.size) + } + @Test fun `max three switches per session`() { val w = TestableWatcher() From 9c3d2b9812387356f3bee06ffb865e7027e620c4 Mon Sep 17 00:00:00 2001 From: TEMP Date: Fri, 25 Sep 2026 23:55:33 -0400 Subject: [PATCH 6/7] fix(player): guard auto-switch for live/torrent sources and edge cases - reset stall tracking on every same-episode mirror switch, not just manual picks: the error-failover path loaded fresh sources with the previous source's armed stall clock, risking a spurious switch ~10s later - skip auto-switch for live streams (fights live-edge/zapping) and for torrent/magnet sources (buffering is inherent, switching cannot help) - stop the watcher in onDestroyView: after the host view is torn down the status getter defaults to IsBuffering and position reads 0, which could fire a switch into a dying fragment - nits: use the tick's timestamp in updateStallClock, init lastSwitchPositionMs to 0 to avoid the Long.MIN_VALUE overflow --- .../cloudstream3/ui/player/GeneratorPlayer.kt | 19 ++++++++++++++++--- .../ui/player/StuckBufferingWatcher.kt | 8 ++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 6f847d44013..ce358bb74ae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -554,6 +554,10 @@ class GeneratorPlayer : FullScreenPlayer() { // Resets subtitle delay, as we watch some other content player.setSubtitleOffset(0) stuckBufferingWatcher.reset() // new episode/link: restart the stall counters + } else { + // Any same-episode mirror switch (user pick, error failover, auto-switch) + // must not inherit the previous source's armed stall clock. + stuckBufferingWatcher.resetStallTracking() } } @@ -1402,9 +1406,6 @@ class GeneratorPlayer : FullScreenPlayer() { } if (init) { filteredLinks.getOrNull(sourceIndex)?.let { - // User's explicit pick: the previous source's stall history - // must not auto-switch away from it while it buffers up. - stuckBufferingWatcher.resetStallTracking() loadLink(it.link, true) } } @@ -1729,11 +1730,23 @@ class GeneratorPlayer : FullScreenPlayer() { /** Called by [StuckBufferingWatcher] when the current source is stuck buffering. */ private fun autoSwitchFromStall() { if (!hasNextMirror()) return + // Live streams: mirror-switching fights the live-edge logic. + if ((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true) return + // Torrents buffer by nature; switching mirrors cannot help. + val type = currentSelectedLink?.first?.type + if (type == ExtractorLinkType.MAGNET || type == ExtractorLinkType.TORRENT) return StuckBufferingWatcher.recordHostFailure(currentSelectedLink?.first?.url) showToast(activity?.getString(R.string.auto_switch_source_toast)) nextMirror() } + override fun onDestroyView() { + // The host view (and with it the status/position we poll) may be torn down + // before onDestroy; stop watching so a dying fragment cannot fire a switch. + stuckBufferingWatcher.stop() + super.onDestroyView() + } + override fun onDestroy() { ResultFragment.updateUI() currentVerifyLink?.cancel() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt index 92840ffffdd..95be370191e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt @@ -88,7 +88,7 @@ class StuckBufferingWatcher( private var switchesThisSession: Int = 0 /** Position where the last switch fired; progress past +[SWITCH_SUCCESS_PROGRESS_MS] resets the budget. */ - private var lastSwitchPositionMs: Long = Long.MIN_VALUE + private var lastSwitchPositionMs: Long = 0L /** * Start watching. Polls once per second; each tick checks whether the player is @@ -140,7 +140,7 @@ class StuckBufferingWatcher( val position = currentPosition() val buffering = isBuffering() updateBufferingWindow(buffering, now) - updateStallClock(position, buffering) + updateStallClock(position, buffering, now) // A switch that got us watching again earns a fresh budget: position moved // well past where the last switch fired. @@ -164,7 +164,7 @@ class StuckBufferingWatcher( } } - private fun updateStallClock(position: Long, buffering: Boolean) { + private fun updateStallClock(position: Long, buffering: Boolean, now: Long) { when { !buffering -> { stuckSinceMs = 0L @@ -175,7 +175,7 @@ class StuckBufferingWatcher( lastProgressMs = position stuckSinceMs = 0L } - stuckSinceMs == 0L -> stuckSinceMs = timeSource() + stuckSinceMs == 0L -> stuckSinceMs = now } } From 15593c7b6d519aecd5be4e1b0c7a28439c7d7f4f Mon Sep 17 00:00:00 2001 From: TEMP Date: Sat, 26 Sep 2026 00:00:21 -0400 Subject: [PATCH 7/7] refactor(player): drop dead param, reuse resetStallTracking in fireSwitch isStuck() took a buffering param it never read (the stall clock already encodes it), and fireSwitch re-inlined the exact resetStallTracking() body. Net -3 lines, no behavior change. --- .../cloudstream3/ui/player/StuckBufferingWatcher.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt index 95be370191e..708873472ae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt @@ -149,7 +149,7 @@ class StuckBufferingWatcher( } if (switchesThisSession >= MAX_SWITCHES_PER_SESSION) return - if (isStuck(now, buffering)) fireSwitch(position, now) + if (isStuck(now)) fireSwitch(position, now) } catch (e: Exception) { Log.e(TAG, "tick failed", e) } @@ -179,7 +179,7 @@ class StuckBufferingWatcher( } } - private fun isStuck(now: Long, buffering: Boolean): Boolean { + private fun isStuck(now: Long): Boolean { val singleStall = stuckSinceMs != 0L && now - stuckSinceMs >= STALL_TIMEOUT_MS // ponytail: repeated-stall trigger ignores user scrubbing; cooldown + budget-reset // bound the damage, revisit only if scrub-heavy usage misfires @@ -189,8 +189,7 @@ class StuckBufferingWatcher( private fun fireSwitch(position: Long, now: Long) { Log.i(TAG, "Stuck at ${position}ms, switching source (${switchesThisSession + 1}/$MAX_SWITCHES_PER_SESSION)") - stuckSinceMs = 0L - bufferingTicks.clear() // hysteresis: window restarts after a switch + resetStallTracking() // hysteresis: stall clock + window restart after a switch lastSwitchAtMs = now lastSwitchPositionMs = position switchesThisSession++