Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResultEpisode>?
get() = viewModel.state.generatorState?.allMeta?.filterIsInstance<ResultEpisode>()
Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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) }
}

Expand Down Expand Up @@ -1627,6 +1639,7 @@ class GeneratorPlayer : FullScreenPlayer() {
}
loadLink(firstAvailableLink, false)
showPlayerMetadata()
stuckBufferingWatcher.start()
}

private fun showPlayerMetadata() {
Expand Down Expand Up @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int>(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<Long>()

// 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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@
<string name="autoplay_next_settings_des">Start the next episode when the current one ends</string>
<string name="start_paused_settings">Start videos paused</string>
<string name="start_paused_settings_des">Reduce stress when selecting sources</string>
<string name="auto_switch_stalled_source">Auto-switch stuck source</string>
<string name="auto_switch_stalled_source_des">Automatically switch to the next source when playback stalls or keeps buffering</string>
<string name="auto_switch_source_toast">Switched Source (Stuck Buffering)</string>
<string name="double_tap_to_seek_settings">Double tap to seek</string>
<string name="double_tap_to_pause_settings">Double tap to pause</string>
<string name="double_tap_to_seek_amount_settings">Player seek amount (Seconds)</string>
Expand Down
Loading
Loading