Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ class CloudSyncRepository @Inject constructor(
private val watchHistoryRepository: WatchHistoryRepository,
private val watchlistRepository: WatchlistRepository,
private val profileAvatarImageManager: ProfileAvatarImageManager,
private val invalidationBus: CloudSyncInvalidationBus
private val invalidationBus: CloudSyncInvalidationBus,
private val pluginDataStore: com.arflix.tv.data.local.PluginDataStore
) {
private val TAG = "CloudSync"
private val gson = Gson()
Expand Down Expand Up @@ -268,6 +269,7 @@ class CloudSyncRepository @Inject constructor(
val frameRateMatchingMode: String = "Off",
val autoPlayNext: Boolean = true,
val autoPlaySingleSource: Boolean = true,
val autoSkipFailedSource: Boolean = true,
val autoPlayMinQuality: String = "Any",
val trailerAutoPlay: Boolean = false,
val trailerSoundEnabled: Boolean = false,
Expand Down Expand Up @@ -370,6 +372,7 @@ class CloudSyncRepository @Inject constructor(
private fun frameRateMatchingModeKey() = profileManager.profileStringKey("frame_rate_matching_mode")
private fun autoPlayNextKey() = profileManager.profileBooleanKey("auto_play_next")
private fun autoPlaySingleSourceKey() = profileManager.profileBooleanKey("auto_play_single_source")
private fun autoSkipFailedSourceKey() = profileManager.profileBooleanKey("auto_skip_failed_source")
private fun autoPlayMinQualityKey() = profileManager.profileStringKey("auto_play_min_quality")
private fun includeSpecialsKey() = profileManager.profileBooleanKey("include_specials")

Expand Down Expand Up @@ -463,6 +466,7 @@ class CloudSyncRepository @Inject constructor(
),
autoPlayNext = prefs[autoPlayNextKeyFor(profile.id)] ?: true,
autoPlaySingleSource = prefs[autoPlaySingleSourceKeyFor(profile.id)] ?: true,
autoSkipFailedSource = prefs[autoSkipFailedSourceKeyFor(profile.id)] ?: true,
autoPlayMinQuality = normalizeAutoPlayMinQuality(
prefs[autoPlayMinQualityKeyFor(profile.id)] ?: "Any"
),
Expand All @@ -481,6 +485,7 @@ class CloudSyncRepository @Inject constructor(
root.put("frameRateMatchingMode", prefs[frameRateMatchingModeKey()] ?: "Off")
root.put("autoPlayNext", prefs[autoPlayNextKey()] ?: true)
root.put("autoPlaySingleSource", prefs[autoPlaySingleSourceKey()] ?: true)
root.put("autoSkipFailedSource", prefs[autoSkipFailedSourceKey()] ?: true)
root.put("autoPlayMinQuality", normalizeAutoPlayMinQuality(prefs[autoPlayMinQualityKey()] ?: "Any"))
root.put("includeSpecials", prefs[includeSpecialsKey()] ?: false)
root.put("dnsProvider", globalDnsProvider)
Expand Down Expand Up @@ -624,6 +629,16 @@ class CloudSyncRepository @Inject constructor(
root.put("iptvFavoriteGroups", JSONArray(gson.toJson(iptvRepository.observeFavoriteGroups().first())))
root.put("iptvFavoriteChannels", JSONArray(gson.toJson(iptvRepository.observeFavoriteChannels().first())))

// Plugin repositories and scrapers (sideload flavor)
runCatching {
val pluginRepos = pluginDataStore.repositories.first()
val pluginScrapers = pluginDataStore.scrapers.first()
val pluginsEnabled = pluginDataStore.pluginsEnabled.first()
root.put("pluginRepositories", JSONArray(gson.toJson(pluginRepos)))
root.put("pluginScrapers", JSONArray(gson.toJson(pluginScrapers)))
root.put("pluginsEnabled", pluginsEnabled)
}

// Informational
val isTraktLinked = traktRepository.hasTrakt()
root.put("traktLinked", isTraktLinked)
Expand Down Expand Up @@ -960,6 +975,7 @@ class CloudSyncRepository @Inject constructor(
val fallbackFrameRateMatchingMode = normalizeFrameRateMode(root.optString("frameRateMatchingMode", "Off"))
val fallbackAutoPlayNext = root.optBoolean("autoPlayNext", true)
val fallbackAutoPlaySingleSource = root.optBoolean("autoPlaySingleSource", true)
val fallbackAutoSkipFailedSource = root.optBoolean("autoSkipFailedSource", true)
val fallbackAutoPlayMinQuality = normalizeAutoPlayMinQuality(root.optString("autoPlayMinQuality", "Any"))
val fallbackIncludeSpecials = root.optBoolean("includeSpecials", false)
val fallbackSubtitleSettingsUpdatedAt = root.optLong("subtitleSettingsUpdatedAt", 0L)
Expand Down Expand Up @@ -1097,6 +1113,7 @@ class CloudSyncRepository @Inject constructor(
prefs[frameRateMatchingModeKeyFor(profileId)] = normalizeFrameRateMode(state.frameRateMatchingMode)
prefs[autoPlayNextKeyFor(profileId)] = state.autoPlayNext
prefs[autoPlaySingleSourceKeyFor(profileId)] = state.autoPlaySingleSource
prefs[autoSkipFailedSourceKeyFor(profileId)] = state.autoSkipFailedSource
prefs[autoPlayMinQualityKeyFor(profileId)] = normalizeAutoPlayMinQuality(state.autoPlayMinQuality)
prefs[includeSpecialsKeyFor(profileId)] = state.includeSpecials
}
Expand Down Expand Up @@ -1133,6 +1150,7 @@ class CloudSyncRepository @Inject constructor(
prefs[frameRateMatchingModeKeyFor(activeProfileId)] = fallbackFrameRateMatchingMode
prefs[autoPlayNextKeyFor(activeProfileId)] = fallbackAutoPlayNext
prefs[autoPlaySingleSourceKeyFor(activeProfileId)] = fallbackAutoPlaySingleSource
prefs[autoSkipFailedSourceKeyFor(activeProfileId)] = fallbackAutoSkipFailedSource
prefs[autoPlayMinQualityKeyFor(activeProfileId)] = fallbackAutoPlayMinQuality
prefs[includeSpecialsKeyFor(activeProfileId)] = fallbackIncludeSpecials
prefs[dnsProviderKeyFor(activeProfileId)] = root.optString("dnsProvider", "system").ifBlank { "system" }
Expand Down Expand Up @@ -1443,6 +1461,21 @@ class CloudSyncRepository @Inject constructor(
traktRepository.clearAllProfileCaches()
watchHistoryRepository.clearProfileCaches()

// Restore plugin repositories and scrapers
runCatching {
root.optJSONArray("pluginRepositories")?.toString()?.takeIf { it.isNotBlank() }?.let { json ->
val type = com.google.gson.reflect.TypeToken.getParameterized(List::class.java, com.arflix.tv.domain.model.PluginRepository::class.java).type
val repos: List<com.arflix.tv.domain.model.PluginRepository> = gson.fromJson(json, type) ?: emptyList()
if (repos.isNotEmpty()) pluginDataStore.saveRepositories(repos)
}
root.optJSONArray("pluginScrapers")?.toString()?.takeIf { it.isNotBlank() }?.let { json ->
val type = com.google.gson.reflect.TypeToken.getParameterized(List::class.java, com.arflix.tv.domain.model.ScraperInfo::class.java).type
val scrapers: List<com.arflix.tv.domain.model.ScraperInfo> = gson.fromJson(json, type) ?: emptyList()
if (scrapers.isNotEmpty()) pluginDataStore.saveScrapers(scrapers)
}
if (root.has("pluginsEnabled")) pluginDataStore.setPluginsEnabled(root.optBoolean("pluginsEnabled", false))
}.onFailure { AppLogger.recordException(it, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_plugins")) }

System.err.println("[CLOUD-SYNC] Full cloud restore applied successfully")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,12 @@ fun PlayerScreen(
// Error modal focus
var errorModalFocusIndex by remember { mutableIntStateOf(0) }

// Auto-skip failed source: countdown (-1 = inactive), cancelled flag
var autoSkipCountdown by remember { mutableIntStateOf(-1) }
var autoSkipCancelled by remember { mutableStateOf(false) }
// Derive from uiState so it always reflects the active profile setting reactively
val autoSkipEnabled = uiState.autoSkipFailedSource

// Buffering watchdog - detect stuck buffering
var bufferingStartTime by remember { mutableStateOf<Long?>(null) }
val bufferingTimeoutMs = 25_000L // Mid-playback timeout for stuck buffering
Expand Down Expand Up @@ -1957,6 +1963,30 @@ fun PlayerScreen(
}
}

// Auto-skip countdown: when error appears and there are more streams, start a 5-second timer.
// autoSkipCancelled is reset each time a new error triggers a new countdown, so pressing
// CANCEL SKIP only cancels this countdown — not all future ones.
LaunchedEffect(uiState.error, autoSkipEnabled, uiState.streams.size) {
if (uiState.error != null && autoSkipEnabled && uiState.streams.size > 1 && !uiState.isSetupError) {
autoSkipCancelled = false
autoSkipCountdown = 5
while (autoSkipCountdown > 0 && !autoSkipCancelled) {
delay(1_000L)
if (!autoSkipCancelled) autoSkipCountdown--
}
// Only advance if the countdown naturally reached zero (not cancelled)
if (!autoSkipCancelled && uiState.error != null) {
val advanced = tryAdvanceToNextStream(recordCurrentFailure = false)
if (!advanced) autoSkipCountdown = -1
} else {
autoSkipCountdown = -1
}
} else {
autoSkipCountdown = -1
}
}


// Request focus on the container when not showing controls
LaunchedEffect(showControls, showSubtitleMenu, showSourceMenu, showNextEpisodePrompt, uiState.error) {
if (!showControls && !showSubtitleMenu && !showSourceMenu && !showNextEpisodePrompt && uiState.error == null) {
Expand Down Expand Up @@ -3503,6 +3533,16 @@ fun PlayerScreen(
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
// Auto-skip countdown indicator
if (autoSkipCountdown > 0 && uiState.streams.size > 1 && !isSetup) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Skipping to next source in $autoSkipCountdown…",
style = ArflixTypography.caption,
color = TextSecondary.copy(alpha = 0.85f),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}

Spacer(modifier = Modifier.height(32.dp))
Expand All @@ -3517,6 +3557,15 @@ fun PlayerScreen(
onClick = { viewModel.retry() }
)
}
if (autoSkipCountdown > 0 && uiState.streams.size > 1 && !isSetup) {
ErrorButton(
text = "CANCEL SKIP",
icon = Icons.Default.Close,
isFocused = errorModalFocusIndex == 2,
isPrimary = false,
onClick = { autoSkipCancelled = true; autoSkipCountdown = -1 }
)
}
ErrorButton(
text = stringResource(R.string.back).uppercase(),
isFocused = if (isSetup) errorModalFocusIndex == 0 else errorModalFocusIndex == 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ data class PlayerUiState(
// "auto_play_next" DataStore setting so the player can respect the toggle
// and so the post-episode overlay can show a Continue/Cancel prompt.
val autoPlayNext: Boolean = true,
// Auto-skip to next source after 5 s on a playback error (mirrors profile setting)
val autoSkipFailedSource: Boolean = true,
// Volume boost in decibels. 0 = disabled, up to 15 dB. The player observes this
// and attaches a LoudnessEnhancer to the ExoPlayer audio session. Issue #88.
val volumeBoostDb: Int = 0,
Expand Down Expand Up @@ -247,6 +249,7 @@ class PlayerViewModel @Inject constructor(
private fun secondarySubtitleKey() = profileManager.profileStringKey("secondary_subtitle")
private fun frameRateMatchingModeKey() = profileManager.profileStringKey("frame_rate_matching_mode")
private fun autoPlayNextKey() = profileManager.profileBooleanKey("auto_play_next")
private fun autoSkipFailedSourceKey() = profileManager.profileBooleanKey("auto_skip_failed_source")
private fun showLoadingStatsKey() = profileManager.profileBooleanKey("show_loading_stats")
private val gson = Gson()
private val knownLanguageCodes = setOf(
Expand Down Expand Up @@ -377,6 +380,7 @@ class PlayerViewModel @Inject constructor(
val subStylized = prefs[profileManager.profileBooleanKey("subtitle_stylized")] ?: true
val subOffset = prefs[profileManager.profileStringKey("subtitle_offset")] ?: "Bottom"
val autoPlayNext = prefs[autoPlayNextKey()] ?: true
val autoSkipFailedSource = prefs[autoSkipFailedSourceKey()] ?: true
val showLoadingStats = prefs[showLoadingStatsKey()] ?: true
val volumeBoostDb = prefs[profileManager.profileStringKey("volume_boost_db")]
?.toIntOrNull()?.coerceIn(0, 15) ?: 0
Expand Down Expand Up @@ -420,6 +424,7 @@ class PlayerViewModel @Inject constructor(
subtitleStylized = subStylized,
subtitleOffset = subOffset,
autoPlayNext = autoPlayNext,
autoSkipFailedSource = autoSkipFailedSource,
showLoadingStats = showLoadingStats,
volumeBoostDb = volumeBoostDb
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ private fun tvGeneralRowsForSection(section: String): List<Int> {
"language" -> listOf(0, 3)
"subtitles" -> listOf(1, 2, 4, 5, 6, 7, 8, 9)
"ai_subtitles" -> listOf(28, 29, 30, 31, 32, 33)
"playback" -> listOf(10, 11, 12, 13, 14, 37, 34, 16, 15, 27)
"playback" -> listOf(10, 11, 38, 12, 13, 14, 37, 34, 16, 15, 27)
"appearance" -> listOf(17, 18, 20, 21, 24, 23, 22, 36)
"profiles" -> listOf(19)
"network" -> listOf(25, 26, 35)
Expand Down Expand Up @@ -898,6 +898,7 @@ fun SettingsScreen(
33 -> viewModel.startAiKeyServer()
34 -> viewModel.cycleTrailerDelay()
37 -> viewModel.setTrailerInCards(!uiState.trailerInCards)
38 -> viewModel.setAutoSkipFailedSource(!uiState.autoSkipFailedSource)
}
}
"iptv" -> {
Expand Down Expand Up @@ -1283,6 +1284,8 @@ fun SettingsScreen(
onDnsProviderClick = openDnsProviderPicker,
onAutoPlayToggle = { viewModel.setAutoPlayNext(it) },
onAutoPlaySingleSourceToggle = { viewModel.setAutoPlaySingleSource(it) },
autoSkipFailedSource = uiState.autoSkipFailedSource,
onAutoSkipFailedSourceToggle = { viewModel.setAutoSkipFailedSource(it) },
onAutoPlayMinQualityClick = { viewModel.cycleAutoPlayMinQuality() },
trailerAutoPlay = uiState.trailerAutoPlay,
onTrailerAutoPlayToggle = { viewModel.setTrailerAutoPlay(it) },
Expand Down Expand Up @@ -3513,6 +3516,13 @@ private fun MobileSettingsSubPage(
isFocused = false,
onClick = { viewModel.setAutoPlaySingleSource(!uiState.autoPlaySingleSource) }
)
MobileSettingsRow(
icon = Icons.Default.Schedule,
title = "Auto-skip failed sources",
value = if (uiState.autoSkipFailedSource) "On" else "Off",
isFocused = false,
onClick = { viewModel.setAutoSkipFailedSource(!uiState.autoSkipFailedSource) }
)
MobileSettingsRow(
icon = Icons.Default.HighQuality,
title = stringResource(R.string.auto_play_min_quality),
Expand Down Expand Up @@ -4613,6 +4623,8 @@ private fun TvGeneralSettingsRows(
frameRateMatchingMode: String,
autoPlayNext: Boolean,
autoPlaySingleSource: Boolean,
autoSkipFailedSource: Boolean = true,
onAutoSkipFailedSourceToggle: (Boolean) -> Unit = {},
autoPlayMinQuality: String,
subtitleSize: String = "Medium",
subtitleColor: String = "White",
Expand Down Expand Up @@ -4729,6 +4741,7 @@ private fun TvGeneralSettingsRows(
9 -> SettingsToggleRow(stringResource(R.string.filter_subtitles), stringResource(R.string.filter_subtitles_desc), filterSubtitlesByLanguage, focusedIndex == localIndex, onFilterSubtitlesByLanguageToggle, Modifier.settingsFocusSlot(localIndex))
10 -> SettingsToggleRow(stringResource(R.string.auto_play_next_title), stringResource(R.string.auto_play_desc), autoPlayNext, focusedIndex == localIndex, onAutoPlayToggle, Modifier.settingsFocusSlot(localIndex))
11 -> SettingsToggleRow(stringResource(R.string.autoplay), stringResource(R.string.autoplay_desc), autoPlaySingleSource, focusedIndex == localIndex, onAutoPlaySingleSourceToggle, Modifier.settingsFocusSlot(localIndex))
38 -> SettingsToggleRow("Auto-skip failed sources", "After 5 s on a broken source, skip to the next one automatically", autoSkipFailedSource, focusedIndex == localIndex, onAutoSkipFailedSourceToggle, Modifier.settingsFocusSlot(localIndex))
12 -> SettingsRow(Icons.Default.HighQuality, stringResource(R.string.auto_play_min_quality), stringResource(R.string.auto_play_quality_desc), autoPlayMinQuality, focusedIndex == localIndex, onAutoPlayMinQualityClick, Modifier.settingsFocusSlot(localIndex))
13 -> SettingsToggleRow(stringResource(R.string.trailer_auto_play), stringResource(R.string.trailer_desc), trailerAutoPlay, focusedIndex == localIndex, onTrailerAutoPlayToggle, Modifier.settingsFocusSlot(localIndex))
14 -> SettingsToggleRow(stringResource(R.string.trailer_sound), stringResource(R.string.trailer_sound_desc), trailerSoundEnabled, focusedIndex == localIndex, onTrailerSoundEnabledToggle, Modifier.settingsFocusSlot(localIndex))
Expand Down Expand Up @@ -4804,6 +4817,8 @@ private fun GeneralSettings(
frameRateMatchingMode: String,
autoPlayNext: Boolean,
autoPlaySingleSource: Boolean,
autoSkipFailedSource: Boolean = true,
onAutoSkipFailedSourceToggle: (Boolean) -> Unit = {},
autoPlayMinQuality: String,
subtitleSize: String = "Medium",
subtitleColor: String = "White",
Expand Down
Loading