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..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 @@ -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,11 @@ 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 + } else { + // Any same-episode mirror switch (user pick, error failover, auto-switch) + // must not inherit the previous source's armed stall clock. + stuckBufferingWatcher.resetStallTracking() } } @@ -1564,6 +1575,7 @@ class GeneratorPlayer : FullScreenPlayer() { override fun playerError(exception: Throwable) { currentSelectedLink?.let { link -> + StuckBufferingWatcher.recordHostFailure(link.first?.url) viewModel.modifyState { this.addError(link) } } @@ -1627,6 +1639,7 @@ class GeneratorPlayer : FullScreenPlayer() { } loadLink(firstAvailableLink, false) showPlayerMetadata() + stuckBufferingWatcher.start() } private fun showPlayerMetadata() { @@ -1714,9 +1727,30 @@ class GeneratorPlayer : FullScreenPlayer() { loadLink(nextLink.link, true) } + /** 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() + 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..708873472ae --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/StuckBufferingWatcher.kt @@ -0,0 +1,198 @@ +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 + + /** 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 + 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 + + /** Position where the last switch fired; progress past +[SWITCH_SUCCESS_PROGRESS_MS] resets the budget. */ + private var lastSwitchPositionMs: Long = 0L + + /** + * 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 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() + 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 + 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() + } + + internal fun tick() { + try { + if (!(enabled ?: isEnabled())) return + + val now = timeSource() + if (now - lastSwitchAtMs < SWITCH_COOLDOWN_MS) return + + val position = currentPosition() + val buffering = isBuffering() + updateBufferingWindow(buffering, now) + updateStallClock(position, buffering, 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 + } + + if (switchesThisSession >= MAX_SWITCHES_PER_SESSION) return + if (isStuck(now)) fireSwitch(position, now) + } catch (e: Exception) { + Log.e(TAG, "tick failed", e) + } + } + + 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, now: Long) { + when { + !buffering -> { + stuckSinceMs = 0L + lastProgressMs = position + } + position > lastProgressMs -> { + // buffering but still making progress (normal startup / fast network) + lastProgressMs = position + stuckSinceMs = 0L + } + stuckSinceMs == 0L -> stuckSinceMs = now + } + } + + 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 + 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)") + resetStallTracking() // hysteresis: stall clock + window restart after a switch + lastSwitchAtMs = now + lastSwitchPositionMs = position + switchesThisSession++ + onSwitchSource() + } +} 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..dc8e9a2ffa3 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 + 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) 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..880361be884 --- /dev/null +++ b/app/src/test/java/com/lagradost/cloudstream3/StuckBufferingWatcherTest.kt @@ -0,0 +1,185 @@ +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 `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 `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() + 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)