From 821e013ad90be137b86acefe013c36c045465e51 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:17:37 -0400 Subject: [PATCH 01/39] Extract root dialog and update hosts --- .../example/xtreamplayer/MainActivityUi.kt | 162 ++------- .../example/xtreamplayer/RootDialogsHost.kt | 172 +++++++++ .../example/xtreamplayer/RootUpdateHost.kt | 335 ++++++++++++++++++ 3 files changed, 539 insertions(+), 130 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/RootDialogsHost.kt create mode 100644 app/src/main/java/com/example/xtreamplayer/RootUpdateHost.kt diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index 936dd91..976bb4d 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -355,10 +355,12 @@ private fun RootScreenContent( var showVodBufferDialog by showVodBufferDialogState val showSubtitleAppearanceDialogState = remember { mutableStateOf(false) } var showSubtitleAppearanceDialog by showSubtitleAppearanceDialogState - var subtitleAppearancePreview by remember { mutableStateOf(null) } + val subtitleAppearancePreviewState = remember { mutableStateOf(null) } + var subtitleAppearancePreview by subtitleAppearancePreviewState val showSubtitleCacheAutoClearDialogState = remember { mutableStateOf(false) } var showSubtitleCacheAutoClearDialog by showSubtitleCacheAutoClearDialogState - var showPlaybackRecoveryDialog by remember { mutableStateOf(false) } + val showPlaybackRecoveryDialogState = remember { mutableStateOf(false) } + var showPlaybackRecoveryDialog by showPlaybackRecoveryDialogState var showLocalFilesGuest by remember { mutableStateOf(false) } val cacheClearNonceState = remember { mutableIntStateOf(0) } var cacheClearNonce by cacheClearNonceState @@ -881,18 +883,6 @@ private fun RootScreenContent( } } - LaunchedEffect(startupUpdateCheckEnabled, authState.isSignedIn) { - if (startupUpdateCheckHandled) return@LaunchedEffect - val enabled = startupUpdateCheckEnabled ?: return@LaunchedEffect - if (!enabled) { - startupUpdateCheckHandled = true - return@LaunchedEffect - } - if (!authState.isSignedIn) return@LaunchedEffect - startupUpdateCheckHandled = true - checkForUpdates(UpdateCheckSource.STARTUP) - } - fun startUpdateDownload(release: UpdateRelease) { if (updateUiState.inProgress) return updateUiState = updateUiState.copy(inProgress = true) @@ -1997,106 +1987,34 @@ private fun RootScreenContent( getRequiredMediaPermissions = ::getRequiredMediaPermissions ) - if (showThemeDialog) { - ThemeSelectionDialog( - themes = com.example.xtreamplayer.settings.AppThemeOption.entries.toList(), - currentTheme = settings.appTheme, - onThemeSelected = { settingsViewModel.setAppTheme(it) }, - onDismiss = { showThemeDialog = false } - ) - } - - if (showFontDialog) { - FontSelectionDialog( - fonts = com.example.xtreamplayer.ui.theme.AppFont.entries.toList(), - currentFont = settings.appFont, - onFontSelected = { settingsViewModel.setAppFont(it) }, - onDismiss = { showFontDialog = false } - ) - } - - if (showNextEpisodeThresholdDialog) { - NextEpisodeThresholdDialog( - currentSeconds = settings.nextEpisodeThresholdSeconds, - onSecondsChange = { settingsViewModel.setNextEpisodeThreshold(it) }, - onDismiss = { showNextEpisodeThresholdDialog = false } - ) - } - if (showVodBufferDialog) { - VodBufferDialog( - currentSeconds = settings.vodBufferSeconds, - onSecondsChange = { settingsViewModel.setVodBufferSeconds(it) }, - onDismiss = { showVodBufferDialog = false } - ) - } - if (showSubtitleCacheAutoClearDialog) { - SubtitleCacheAutoClearDialog( - currentIntervalMs = settings.subtitleCacheAutoClearIntervalMs, - onIntervalChange = { settingsViewModel.setSubtitleCacheAutoClearInterval(it) }, - onDismiss = { showSubtitleCacheAutoClearDialog = false } - ) - } - if (showSubtitleAppearanceDialog) { - SubtitleAppearanceDialog( - initialSettings = settings.subtitleAppearance, - onPreview = { updated -> - subtitleAppearancePreview = updated - }, - onApply = { updated -> - subtitleAppearancePreview = null - settingsViewModel.setSubtitleAppearance(updated) - settingsViewModel.flushPendingSubtitleAppearance() - }, - onDismiss = { - subtitleAppearancePreview = null - showSubtitleAppearanceDialog = false - } - ) - } - - if (showApiKeyDialog) { - ApiKeyInputDialog( - currentKey = settings.openSubtitlesApiKey, - currentUserAgent = settings.openSubtitlesUserAgent, - onSave = { apiKey, userAgent -> - settingsViewModel.setOpenSubtitlesApiKey(apiKey) - settingsViewModel.setOpenSubtitlesUserAgent(userAgent) - Toast.makeText( - context, - "OpenSubtitles settings saved", - Toast.LENGTH_SHORT - ).show() - showApiKeyDialog = false - }, - onDismiss = { showApiKeyDialog = false } - ) - } + RootDialogsHost( + context = context, + settings = settings, + settingsViewModel = settingsViewModel, + appRecoveryManager = appRecoveryManager, + showThemeDialogState = showThemeDialogState, + showFontDialogState = showFontDialogState, + showUiScaleDialogState = showUiScaleDialogState, + showFontScaleDialogState = showFontScaleDialogState, + showNextEpisodeThresholdDialogState = showNextEpisodeThresholdDialogState, + showVodBufferDialogState = showVodBufferDialogState, + showSubtitleAppearanceDialogState = showSubtitleAppearanceDialogState, + subtitleAppearancePreviewState = subtitleAppearancePreviewState, + showSubtitleCacheAutoClearDialogState = showSubtitleCacheAutoClearDialogState, + showApiKeyDialogState = showApiKeyDialogState, + showPlaybackRecoveryDialogState = showPlaybackRecoveryDialogState + ) - val pendingRelease = updateUiState.pendingRelease - if (updateUiState.showDialog && pendingRelease != null) { - UpdatePromptDialog( - release = pendingRelease, - isDownloading = updateUiState.inProgress, - onUpdate = { startUpdateDownload(pendingRelease) }, - onLater = { updateUiState = updateUiState.copy(showDialog = false) } - ) - } - if (showPlaybackRecoveryDialog) { - PlaybackRecoveryDialog( - onCancel = { showPlaybackRecoveryDialog = false }, - onRestartApp = { - showPlaybackRecoveryDialog = false - appRecoveryManager.restartApp( - "user_requested_restart_after_stale_playback_dialog" - ) - }, - onOpenAppSettings = { - showPlaybackRecoveryDialog = false - openAppSettings() - } - ) - } - } + RootUpdateHost( + updateViewModel = updateViewModel, + appVersionName = appVersionName, + updateHttpClient = updateHttpClient, + isSignedIn = authState.isSignedIn, + onUpdateDownload = { release -> + startUpdateDownload(release) + } + ) + } } else { if (showLocalFilesGuest) { @@ -2168,22 +2086,6 @@ private fun RootScreenContent( } } - if (showUiScaleDialog) { - UiScaleDialog( - currentScale = settings.uiScale, - onScaleChange = { settingsViewModel.setUiScale(it) }, - onDismiss = { showUiScaleDialog = false } - ) - } - - if (showFontScaleDialog) { - FontScaleDialog( - currentScale = settings.fontScale, - onScaleChange = { settingsViewModel.setFontScale(it) }, - onDismiss = { showFontScaleDialog = false } - ) - } - val effectivePlaybackSettings = subtitleAppearancePreview?.let { preview -> settings.copy(subtitleAppearance = preview) @@ -10749,7 +10651,7 @@ private fun UpdatePromptDialog( } @Composable -private fun PlaybackRecoveryDialog( +internal fun PlaybackRecoveryDialog( onCancel: () -> Unit, onRestartApp: () -> Unit, onOpenAppSettings: () -> Unit diff --git a/app/src/main/java/com/example/xtreamplayer/RootDialogsHost.kt b/app/src/main/java/com/example/xtreamplayer/RootDialogsHost.kt new file mode 100644 index 0000000..0897e64 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/RootDialogsHost.kt @@ -0,0 +1,172 @@ +package com.example.xtreamplayer + +import android.content.Context +import android.content.Intent +import android.provider.Settings +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.core.net.toUri +import com.example.xtreamplayer.settings.SettingsState +import com.example.xtreamplayer.settings.SettingsViewModel +import com.example.xtreamplayer.settings.SubtitleAppearanceSettings +import com.example.xtreamplayer.ui.ApiKeyInputDialog +import com.example.xtreamplayer.ui.FontScaleDialog +import com.example.xtreamplayer.ui.FontSelectionDialog +import com.example.xtreamplayer.ui.SubtitleAppearanceDialog +import com.example.xtreamplayer.ui.SubtitleCacheAutoClearDialog +import com.example.xtreamplayer.ui.ThemeSelectionDialog +import com.example.xtreamplayer.ui.UiScaleDialog +import com.example.xtreamplayer.ui.VodBufferDialog +import com.example.xtreamplayer.ui.theme.AppFont + +@Composable +internal fun RootDialogsHost( + context: Context, + settings: SettingsState, + settingsViewModel: SettingsViewModel, + appRecoveryManager: AppRecoveryManager, + showThemeDialogState: MutableState, + showFontDialogState: MutableState, + showUiScaleDialogState: MutableState, + showFontScaleDialogState: MutableState, + showNextEpisodeThresholdDialogState: MutableState, + showVodBufferDialogState: MutableState, + showSubtitleAppearanceDialogState: MutableState, + subtitleAppearancePreviewState: MutableState, + showSubtitleCacheAutoClearDialogState: MutableState, + showApiKeyDialogState: MutableState, + showPlaybackRecoveryDialogState: MutableState +) { + var showThemeDialog by showThemeDialogState + var showFontDialog by showFontDialogState + var showUiScaleDialog by showUiScaleDialogState + var showFontScaleDialog by showFontScaleDialogState + var showNextEpisodeThresholdDialog by showNextEpisodeThresholdDialogState + var showVodBufferDialog by showVodBufferDialogState + var showSubtitleAppearanceDialog by showSubtitleAppearanceDialogState + var subtitleAppearancePreview by subtitleAppearancePreviewState + var showSubtitleCacheAutoClearDialog by showSubtitleCacheAutoClearDialogState + var showApiKeyDialog by showApiKeyDialogState + var showPlaybackRecoveryDialog by showPlaybackRecoveryDialogState + + if (showThemeDialog) { + ThemeSelectionDialog( + themes = com.example.xtreamplayer.settings.AppThemeOption.entries.toList(), + currentTheme = settings.appTheme, + onThemeSelected = { settingsViewModel.setAppTheme(it) }, + onDismiss = { showThemeDialog = false } + ) + } + + if (showFontDialog) { + FontSelectionDialog( + fonts = AppFont.entries.toList(), + currentFont = settings.appFont, + onFontSelected = { settingsViewModel.setAppFont(it) }, + onDismiss = { showFontDialog = false } + ) + } + + if (showNextEpisodeThresholdDialog) { + com.example.xtreamplayer.ui.NextEpisodeThresholdDialog( + currentSeconds = settings.nextEpisodeThresholdSeconds, + onSecondsChange = { settingsViewModel.setNextEpisodeThreshold(it) }, + onDismiss = { showNextEpisodeThresholdDialog = false } + ) + } + + if (showVodBufferDialog) { + VodBufferDialog( + currentSeconds = settings.vodBufferSeconds, + onSecondsChange = { settingsViewModel.setVodBufferSeconds(it) }, + onDismiss = { showVodBufferDialog = false } + ) + } + + if (showSubtitleCacheAutoClearDialog) { + SubtitleCacheAutoClearDialog( + currentIntervalMs = settings.subtitleCacheAutoClearIntervalMs, + onIntervalChange = { settingsViewModel.setSubtitleCacheAutoClearInterval(it) }, + onDismiss = { showSubtitleCacheAutoClearDialog = false } + ) + } + + if (showSubtitleAppearanceDialog) { + SubtitleAppearanceDialog( + initialSettings = settings.subtitleAppearance, + onPreview = { updated -> + subtitleAppearancePreview = updated + }, + onApply = { updated -> + subtitleAppearancePreview = null + settingsViewModel.setSubtitleAppearance(updated) + settingsViewModel.flushPendingSubtitleAppearance() + }, + onDismiss = { + subtitleAppearancePreview = null + showSubtitleAppearanceDialog = false + } + ) + } + + if (showApiKeyDialog) { + ApiKeyInputDialog( + currentKey = settings.openSubtitlesApiKey, + currentUserAgent = settings.openSubtitlesUserAgent, + onSave = { apiKey, userAgent -> + settingsViewModel.setOpenSubtitlesApiKey(apiKey) + settingsViewModel.setOpenSubtitlesUserAgent(userAgent) + Toast.makeText( + context, + "OpenSubtitles settings saved", + Toast.LENGTH_SHORT + ).show() + showApiKeyDialog = false + }, + onDismiss = { showApiKeyDialog = false } + ) + } + + if (showPlaybackRecoveryDialog) { + PlaybackRecoveryDialog( + onCancel = { showPlaybackRecoveryDialog = false }, + onRestartApp = { + showPlaybackRecoveryDialog = false + appRecoveryManager.restartApp( + "user_requested_restart_after_stale_playback_dialog" + ) + }, + onOpenAppSettings = { + showPlaybackRecoveryDialog = false + openAppSettings(context) + } + ) + } + + if (showUiScaleDialog) { + UiScaleDialog( + currentScale = settings.uiScale, + onScaleChange = { settingsViewModel.setUiScale(it) }, + onDismiss = { showUiScaleDialog = false } + ) + } + + if (showFontScaleDialog) { + FontScaleDialog( + currentScale = settings.fontScale, + onScaleChange = { settingsViewModel.setFontScale(it) }, + onDismiss = { showFontScaleDialog = false } + ) + } +} + +private fun openAppSettings(context: Context) { + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + "package:${context.packageName}".toUri() + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) +} diff --git a/app/src/main/java/com/example/xtreamplayer/RootUpdateHost.kt b/app/src/main/java/com/example/xtreamplayer/RootUpdateHost.kt new file mode 100644 index 0000000..d7d8376 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/RootUpdateHost.kt @@ -0,0 +1,335 @@ +package com.example.xtreamplayer + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Settings +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import com.example.xtreamplayer.update.UpdateRelease +import com.example.xtreamplayer.update.compareVersions +import com.example.xtreamplayer.update.downloadUpdateApk +import com.example.xtreamplayer.update.fetchLatestRelease +import com.example.xtreamplayer.update.parseVersionParts +import com.example.xtreamplayer.ui.AppDialog +import com.example.xtreamplayer.ui.FocusableButton +import com.example.xtreamplayer.ui.theme.AppTheme +import com.example.xtreamplayer.viewmodel.UpdateViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +private enum class RootUpdateCheckSource { + MANUAL, + STARTUP +} + +@Composable +internal fun RootUpdateHost( + updateViewModel: UpdateViewModel, + appVersionName: String, + updateHttpClient: okhttp3.OkHttpClient, + isSignedIn: Boolean, + onUpdateDownload: (UpdateRelease) -> Unit +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + var updateUiState by updateViewModel.updateUiState + var updateCheckJob by updateViewModel.updateCheckJob + var startupUpdateCheckEnabled by updateViewModel.startupUpdateCheckEnabled + var startupUpdateCheckHandled by updateViewModel.startupUpdateCheckHandled + + LaunchedEffect(startupUpdateCheckEnabled, startupUpdateCheckHandled, isSignedIn) { + if (startupUpdateCheckHandled) return@LaunchedEffect + val enabled = startupUpdateCheckEnabled ?: return@LaunchedEffect + if (!enabled) { + startupUpdateCheckHandled = true + return@LaunchedEffect + } + if (!isSignedIn) return@LaunchedEffect + startupUpdateCheckHandled = true + checkForUpdates( + context = context, + coroutineScope = coroutineScope, + updateHttpClient = updateHttpClient, + appVersionName = appVersionName, + updateUiState = { updateUiState }, + onUpdateUiStateChange = { updateUiState = it }, + updateCheckJob = { updateCheckJob }, + onUpdateCheckJobChange = { updateCheckJob = it }, + source = RootUpdateCheckSource.STARTUP + ) + } + + val pendingRelease = updateUiState.pendingRelease + if (updateUiState.showDialog && pendingRelease != null) { + UpdatePromptDialog( + release = pendingRelease, + isDownloading = updateUiState.inProgress, + onUpdate = { onUpdateDownload(pendingRelease) }, + onLater = { updateUiState = updateUiState.copy(showDialog = false) } + ) + } +} + +private fun checkForUpdates( + context: Context, + coroutineScope: CoroutineScope, + updateHttpClient: okhttp3.OkHttpClient, + appVersionName: String, + updateUiState: () -> UpdateUiState, + onUpdateUiStateChange: (UpdateUiState) -> Unit, + updateCheckJob: () -> Job?, + onUpdateCheckJobChange: (Job?) -> Unit, + source: RootUpdateCheckSource +) { + if (updateCheckJob()?.isActive == true) return + onUpdateCheckJobChange( + coroutineScope.launch { + val result = runCatching { fetchLatestRelease(updateHttpClient) } + val latest = result.getOrNull() + if (latest == null) { + if (source == RootUpdateCheckSource.MANUAL) { + Toast.makeText(context, "Update check failed", Toast.LENGTH_SHORT).show() + } + return@launch + } + val localParts = parseVersionParts(appVersionName) + if (localParts.isEmpty() || latest.versionParts.isEmpty()) { + if (source == RootUpdateCheckSource.MANUAL) { + Toast.makeText(context, "Update info unavailable", Toast.LENGTH_SHORT).show() + } + return@launch + } + if (compareVersions(localParts, latest.versionParts) >= 0) { + if (source == RootUpdateCheckSource.MANUAL) { + Toast.makeText(context, "Already up to date", Toast.LENGTH_SHORT).show() + } + return@launch + } + onUpdateUiStateChange( + updateUiState().copy( + pendingRelease = latest, + showDialog = true + ) + ) + } + ) +} + +internal fun startUpdateDownload( + context: Context, + coroutineScope: CoroutineScope, + updateHttpClient: okhttp3.OkHttpClient, + updateUiState: () -> UpdateUiState, + onUpdateUiStateChange: (UpdateUiState) -> Unit, + release: UpdateRelease +) { + if (updateUiState().inProgress) return + onUpdateUiStateChange(updateUiState().copy(inProgress = true)) + coroutineScope.launch { + val apkUri = runCatching { + downloadUpdateApk(context, release, updateHttpClient) + }.getOrNull() + onUpdateUiStateChange(updateUiState().copy(inProgress = false)) + if (apkUri == null) { + Toast.makeText(context, "Update download failed", Toast.LENGTH_SHORT).show() + return@launch + } + if (!ensureInstallPermission(context)) { + return@launch + } + onUpdateUiStateChange(updateUiState().copy(showDialog = false)) + launchApkInstall(context, apkUri) + } +} + +private fun launchApkInstall(context: Context, apkUri: android.net.Uri) { + val intent = Intent(Intent.ACTION_VIEW) + .setDataAndType(apkUri, "application/vnd.android.package-archive") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) +} + +private fun ensureInstallPermission(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true + if (context.packageManager.canRequestPackageInstalls()) return true + val intent = Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + "package:${context.packageName}".toUri() + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + Toast.makeText( + context, + "Allow installs from this app to continue", + Toast.LENGTH_LONG + ).show() + return false +} + +@Composable +private fun UpdatePromptDialog( + release: UpdateRelease, + isDownloading: Boolean, + onUpdate: () -> Unit, + onLater: () -> Unit +) { + val colors = AppTheme.colors + val shape = RoundedCornerShape(16.dp) + val updateFocusRequester = remember { FocusRequester() } + val laterFocusRequester = remember { FocusRequester() } + + LaunchedEffect(isDownloading) { + if (!isDownloading) { + updateFocusRequester.requestFocus() + } + } + + AppDialog( + onDismissRequest = onLater, + properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false) + ) { + Column( + modifier = Modifier + .fillMaxWidth(0.40f) + .widthIn(min = 360.dp, max = 680.dp) + .clip(shape) + .background(colors.surface) + .border(1.dp, colors.borderStrong, shape) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = "Update available", + color = colors.textPrimary, + fontSize = 22.sp, + fontFamily = AppTheme.fontFamily, + fontWeight = FontWeight.Bold + ) + Text( + text = "Version ${release.versionName} is ready to install.", + color = colors.textSecondary, + fontSize = 14.sp, + fontFamily = AppTheme.fontFamily + ) + Spacer(modifier = Modifier.height(4.dp)) + if (isDownloading) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(colors.backgroundAlt) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Downloading update...", + color = colors.textSecondary, + fontSize = 13.sp, + fontFamily = AppTheme.fontFamily + ) + LinearProgressIndicator( + progress = { 0.35f }, + modifier = Modifier.fillMaxWidth(), + color = colors.accent, + trackColor = colors.surfaceAlt + ) + } + } else { + Text( + text = "Install now or choose Later.", + color = colors.textTertiary, + fontSize = 13.sp, + fontFamily = AppTheme.fontFamily + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + FocusableButton( + onClick = onUpdate, + enabled = !isDownloading, + modifier = Modifier + .weight(1f) + .height(52.dp) + .focusRequester(updateFocusRequester), + colors = ButtonDefaults.buttonColors( + containerColor = colors.accent, + contentColor = colors.textOnAccent, + disabledContainerColor = colors.surfaceAlt, + disabledContentColor = colors.textTertiary + ), + shape = RoundedCornerShape(12.dp), + focusBorderWidth = 1.dp, + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp) + ) { + Text( + text = if (isDownloading) "Updating..." else "Update now", + fontFamily = AppTheme.fontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + FocusableButton( + onClick = onLater, + enabled = !isDownloading, + modifier = Modifier + .weight(1f) + .height(52.dp) + .focusRequester(laterFocusRequester), + colors = ButtonDefaults.buttonColors( + containerColor = colors.surfaceAlt, + contentColor = colors.textPrimary, + disabledContainerColor = colors.surfaceAlt, + disabledContentColor = colors.textTertiary + ), + shape = RoundedCornerShape(12.dp), + focusBorderWidth = 1.dp, + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp) + ) { + Text( + text = "Later", + fontFamily = AppTheme.fontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} From 2384ac39d47f2ad95a56215c8ae00697364c6e4d Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:45:42 -0400 Subject: [PATCH 02/39] Extract root hosts and fix settings focus --- XTREAM_REFACTOR_PLAN.md | 104 ++ .../com/example/xtreamplayer/BrowseScreen.kt | 22 + .../example/xtreamplayer/MainActivityUi.kt | 931 +++++------------- .../com/example/xtreamplayer/PlayerHost.kt | 60 ++ .../xtreamplayer/RootNavigationHost.kt | 192 ++++ .../xtreamplayer/SettingsAndSyncHost.kt | 214 ++++ 6 files changed, 836 insertions(+), 687 deletions(-) create mode 100644 XTREAM_REFACTOR_PLAN.md create mode 100644 app/src/main/java/com/example/xtreamplayer/PlayerHost.kt create mode 100644 app/src/main/java/com/example/xtreamplayer/RootNavigationHost.kt create mode 100644 app/src/main/java/com/example/xtreamplayer/SettingsAndSyncHost.kt diff --git a/XTREAM_REFACTOR_PLAN.md b/XTREAM_REFACTOR_PLAN.md new file mode 100644 index 0000000..7f7da16 --- /dev/null +++ b/XTREAM_REFACTOR_PLAN.md @@ -0,0 +1,104 @@ +# XtreamPlayer Refactor Plan + +Status legend: +- `[ ]` not started +- `[~]` in progress +- `[x]` done + +## What is already done + +- `[x]` `MainActivity` no longer injects and forwards repositories into the UI. +- `[x]` `RootScreen` now resolves app singletons through a Hilt entry point. +- `[x]` `SettingsRepository` is no longer manually instantiated inside `RootScreen`. +- `[x]` update-check state has been moved out of local composable state into `UpdateViewModel`. +- `[x]` `:app:compileDebugKotlin` passes after the refactor. + +## Goal + +Reduce the size and coupling of `MainActivityUi.kt` without changing behavior, then move the remaining app state out of the root composable and into focused view models or feature controllers. + +## Phase 1: Split the root UI into smaller composables + +- `[x]` Extract startup/update logic into `UpdateSection` or `UpdateHost`. +- `[x]` Extract top-level navigation shell into a dedicated composable file. +- `[x]` Extract dialog orchestration into a separate `DialogsHost`. +- `[x]` Extract playback/player wiring into a dedicated `PlayerHost`. +- `[x]` Extract settings and sync coordination into a `SettingsAndSyncHost`. +- `[ ]` Keep each extracted host behavior-identical at first. +- `[x]` Re-run `:app:compileDebugKotlin` after each extraction or small batch. + +Acceptance criteria: +- `MainActivityUi.kt` still compiles. +- No user-visible behavior changes. +- Each extracted block has a narrow set of inputs and outputs. + +## Phase 2: Move root state into view models + +- `[ ]` Identify the state that currently belongs to the screen, not the component tree. +- `[ ]` Add `UiState` data classes for major sections instead of many `mutableStateOf` fields. +- `[ ]` Migrate state into `StateFlow` or `MutableStateFlow` where appropriate. +- `[ ]` Keep Compose state only for ephemeral UI details that are truly local. +- `[ ]` Migrate browse, update, and playback orchestration first. + +Suggested targets: +- `[ ]` `selectedSection` +- `[ ]` `navExpanded` +- `[ ]` update dialog state +- `[ ]` startup update check flags +- `[ ]` sync progress and sync pause state +- `[ ]` player retry / recovery state + +Acceptance criteria: +- Screen state can be reasoned about from a small number of immutable state objects. +- ViewModels become the source of truth for long-lived UI state. + +## Phase 3: Break up the repository layer + +- `[ ]` Split `XtreamApi` into a transport client and parsing helpers. +- `[ ]` Split `ContentRepository` into feature-specific repositories or services. +- `[ ]` Keep cache/index logic isolated from fetch/parsing logic. +- `[ ]` Move sync coordination into a dedicated manager. + +Suggested boundaries: +- `[ ]` live content +- `[ ]` VOD content +- `[ ]` series/episode content +- `[ ]` search/indexing +- `[ ]` sync/cache maintenance + +Acceptance criteria: +- No single repository owns networking, parsing, caching, and sync orchestration together. +- The code becomes easier to test in isolation. + +## Phase 4: Tighten dependency injection + +- `[ ]` Verify all repositories and controllers come from Hilt. +- `[ ]` Remove any remaining manual construction in composables. +- `[ ]` Make sure activities only host the UI and release lifecycle-bound resources. +- `[ ]` Prefer entry points only where Compose or lifecycle boundaries require them. + +Acceptance criteria: +- No new direct instantiation of app singletons in UI code. +- `MainActivity` stays thin. + +## Phase 5: Verify and stabilize + +- `[ ]` Run `:app:compileDebugKotlin` +- `[ ]` Run `:app:testDebugUnitTest` if it is practical in the current branch state +- `[ ]` Smoke-test navigation, playback, update checks, and settings flows +- `[ ]` Check for regressions in startup and activity recreation + +## Working rules for the next chat + +- `[ ]` Make one behavior-preserving change at a time. +- `[ ]` Prefer extraction over logic rewrites. +- `[ ]` Keep each refactor shippable. +- `[ ]` If a change requires a bigger redesign, stop and split it. + +## Suggested order + +1. Extract dialog and update hosts. +2. Extract navigation shell and player host. +3. Move remaining long-lived state into view models. +4. Split repository/service responsibilities. +5. Re-run compile and tests after each batch. diff --git a/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt b/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt index 7b3ecb7..439dc0d 100644 --- a/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt +++ b/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt @@ -329,6 +329,28 @@ internal fun BrowseScreen( focusToContentTrigger++ } + LaunchedEffect(moveFocusToNav) { + if (!moveFocusToNav) return@LaunchedEffect + val requester = + when (selectedSection) { + Section.ALL -> allNavItemFocusRequester + Section.CONTINUE_WATCHING -> continueWatchingNavItemFocusRequester + Section.FAVORITES -> favoritesNavItemFocusRequester + Section.MOVIES -> moviesNavItemFocusRequester + Section.SERIES -> seriesNavItemFocusRequester + Section.LIVE -> liveNavItemFocusRequester + Section.CATEGORIES -> categoriesNavItemFocusRequester + Section.LOCAL_FILES -> localFilesNavItemFocusRequester + Section.SETTINGS -> settingsNavItemFocusRequester + } + val focusedNow = runCatching { requester.requestFocus() }.getOrDefault(false) + if (!focusedNow) { + withFrameNanos {} + runCatching { requester.requestFocus() } + } + moveFocusToNav = false + } + Row(modifier = Modifier.fillMaxSize()) { BrowseSideNavRail( selectedSection = selectedSection, diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index 976bb4d..d6ba8e0 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -331,13 +331,8 @@ private fun RootScreenContent( var showManageLists by showManageListsState val showAppearanceState = remember { mutableStateOf(false) } var showAppearance by showAppearanceState - var focusAppearanceOnSettingsReturn by remember { mutableStateOf(false) } - var focusManageListsOnSettingsReturn by remember { mutableStateOf(false) } - var wasShowingAppearance by remember { mutableStateOf(false) } - var wasShowingManageLists by remember { mutableStateOf(false) } var updateUiState by updateViewModel.updateUiState var updateCheckJob by updateViewModel.updateCheckJob - var startupUpdateCheckEnabled by updateViewModel.startupUpdateCheckEnabled var startupUpdateCheckHandled by updateViewModel.startupUpdateCheckHandled val showApiKeyDialogState = remember { mutableStateOf(false) } var showApiKeyDialog by showApiKeyDialogState @@ -388,139 +383,45 @@ private fun RootScreenContent( var lastExitBackPressElapsedMs by remember { mutableLongStateOf(0L) } val resumeFocusRequester = remember { FocusRequester() } - var startupDeferredReady by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - withFrameNanos {} - delay(STARTUP_DEFER_NON_CRITICAL_MS) - startupDeferredReady = true - } - - // Progressive sync coordinator - LaunchedEffect(settings.subtitleCacheAutoClearIntervalMs, startupDeferredReady) { - if (!startupDeferredReady) { - return@LaunchedEffect - } - val intervalMs = settings.subtitleCacheAutoClearIntervalMs - if (intervalMs <= 0L) { - return@LaunchedEffect - } - - while (true) { - if (activePlaybackQueue != null) { - delay(SUBTITLE_AUTO_CLEAR_CHECK_INTERVAL_MS) - continue - } - val nowMs = System.currentTimeMillis() - val lastRunMs = settingsRepository.subtitleCacheAutoClearLastRunMs() - if (lastRunMs <= 0L) { - settingsRepository.setSubtitleCacheAutoClearLastRunMs(nowMs) - } else if (nowMs - lastRunMs >= intervalMs) { - val removed = withContext(Dispatchers.IO) { - subtitleRepository.clearCacheAndCount() - } - settingsRepository.setSubtitleCacheAutoClearLastRunMs(nowMs) - if (removed > 0) { - Timber.d("Auto-cleared subtitle cache files: $removed") - } - } - delay(SUBTITLE_AUTO_CLEAR_CHECK_INTERVAL_MS) - } - } - var progressiveSyncCoordinator by + val startupDeferredReadyState = remember { mutableStateOf(false) } + val progressiveSyncCoordinatorState = remember { mutableStateOf(null) } + var progressiveSyncCoordinator by progressiveSyncCoordinatorState val emptySyncStateFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(com.example.xtreamplayer.content.ProgressiveSyncState()) } - val syncCoordinatorAccountKey = - authState.activeConfig?.let { config -> - "${config.baseUrl}|${config.username}|${config.listName}|${config.password}" - } - LaunchedEffect(syncCoordinatorAccountKey, startupDeferredReady) { - if (!startupDeferredReady) { - return@LaunchedEffect - } - val previousCoordinator = progressiveSyncCoordinator - if (previousCoordinator != null) { - withContext(NonCancellable) { - previousCoordinator.dispose() - } - progressiveSyncCoordinator = null - } - progressiveSyncCoordinator = - authState.activeConfig?.let { config -> - com.example.xtreamplayer.content.ProgressiveSyncCoordinator( - contentRepository = contentRepository, - settingsRepository = settingsRepository, - authConfig = config - ) - } - } val syncState by (progressiveSyncCoordinator?.syncState ?: emptySyncStateFlow) .collectAsStateWithLifecycle() - val latestProgressiveSyncCoordinator by rememberUpdatedState(progressiveSyncCoordinator) - - DisposableEffect(Unit) { - onDispose { - latestProgressiveSyncCoordinator?.disposeAsync() - } - } - - // Auto-start fast start sync on first login - LaunchedEffect(authState.activeConfig, progressiveSyncCoordinator, startupDeferredReady) { - if (!startupDeferredReady) { - return@LaunchedEffect - } - val coordinator = progressiveSyncCoordinator ?: return@LaunchedEffect - if (authState.activeConfig != null) { - val config = authState.activeConfig ?: return@LaunchedEffect - val syncAccountKey = "${config.baseUrl}|${config.username}|${config.listName}" - val savedState = settingsRepository.loadSyncState(syncAccountKey) - val hasFullIndex = contentRepository.hasFullIndex(config) - val hasSearchIndex = contentRepository.hasSearchIndex(config) - val hasAnySearchIndex = contentRepository.hasAnySearchIndex(config) - - val effectiveState = - savedState - ?: if (hasFullIndex) { - com.example.xtreamplayer.content.ProgressiveSyncState( - phase = com.example.xtreamplayer.content.SyncPhase.COMPLETE, - fastStartReady = true, - fullIndexComplete = true, - lastSyncTimestamp = System.currentTimeMillis() - ) - } else { - null - } - - if (effectiveState != null) { - coordinator.restoreState(effectiveState) - } - - if (!hasFullIndex && (!hasAnySearchIndex || savedState == null || !savedState.fastStartReady)) { - coordinator.startFastStartSync() - } else if (savedState?.phase == com.example.xtreamplayer.content.SyncPhase.BACKGROUND_FULL && - savedState.isPaused.not() && - savedState.fullIndexComplete.not() - ) { - coordinator.resumeBackgroundSync() - } - } - } - - // Auto-start background sync after fast start completes - LaunchedEffect(syncState.fastStartReady, startupDeferredReady) { - if (!startupDeferredReady) { - return@LaunchedEffect - } - val coordinator = progressiveSyncCoordinator ?: return@LaunchedEffect - if (syncState.fastStartReady && !syncState.fullIndexComplete) { - kotlinx.coroutines.delay(2000) // 2 second grace period - coordinator.startBackgroundFullSync() - } - } - + val startupUpdateCheckEnabledState = updateViewModel.startupUpdateCheckEnabled + val focusAppearanceOnSettingsReturnState = remember { mutableStateOf(false) } + val focusManageListsOnSettingsReturnState = remember { mutableStateOf(false) } + val wasShowingAppearanceState = remember { mutableStateOf(false) } + val wasShowingManageListsState = remember { mutableStateOf(false) } val focusToContentTriggerState = remember { mutableIntStateOf(0) } var focusToContentTrigger by focusToContentTriggerState + SettingsAndSyncHost( + context = context, + coroutineScope = coroutineScope, + settings = settings, + settingsRepository = settingsRepository, + contentRepository = contentRepository, + subtitleRepository = subtitleRepository, + authState = authState, + activePlaybackQueue = activePlaybackQueue, + selectedSectionState = browseViewModel.selectedSection, + showManageListsState = showManageListsState, + showAppearanceState = showAppearanceState, + focusToContentTriggerState = focusToContentTriggerState, + focusAppearanceOnSettingsReturnState = focusAppearanceOnSettingsReturnState, + focusManageListsOnSettingsReturnState = focusManageListsOnSettingsReturnState, + wasShowingAppearanceState = wasShowingAppearanceState, + wasShowingManageListsState = wasShowingManageListsState, + startupDeferredReadyState = startupDeferredReadyState, + startupUpdateCheckEnabledState = startupUpdateCheckEnabledState, + progressiveSyncCoordinatorState = progressiveSyncCoordinatorState, + syncState = syncState, + setProgressiveSyncCoordinatorState = { progressiveSyncCoordinator = it } + ) val moveFocusToNavState = remember { mutableStateOf(false) } var moveFocusToNav by moveFocusToNavState @@ -606,85 +507,10 @@ private fun RootScreenContent( return false } - LaunchedEffect(focusToContentTrigger) { - if (focusToContentTrigger > 0) { - Timber.d("FocusDebug: Requesting content focus for trigger=$focusToContentTrigger") - val useDeterministicContentEntry = - selectedSection == Section.SETTINGS || selectedSection == Section.CATEGORIES - if (useDeterministicContentEntry && - requestFocusWithFrames( - requester = contentItemFocusRequester, - label = "content-deterministic", - frameRetries = 1 - ) - ) { - return@LaunchedEffect - } - // Fast directional handoff with a single-frame fallback. - if (focusManager.moveFocus(FocusDirection.Right)) { - return@LaunchedEffect - } - withFrameNanos {} - if (focusManager.moveFocus(FocusDirection.Right)) { - return@LaunchedEffect - } - val requesters = listOf(contentItemFocusRequester) - requesters.forEach { requester -> - if (requestFocusWithFrames(requester, "content", frameRetries = 1)) { - return@LaunchedEffect - } - } - } - } - - LaunchedEffect(moveFocusToNav) { - if (moveFocusToNav) { - val requester = - when (selectedSection) { - Section.ALL -> allNavItemFocusRequester - Section.CONTINUE_WATCHING -> continueWatchingNavItemFocusRequester - Section.FAVORITES -> favoritesNavItemFocusRequester - Section.MOVIES -> moviesNavItemFocusRequester - Section.SERIES -> seriesNavItemFocusRequester - Section.LIVE -> liveNavItemFocusRequester - Section.CATEGORIES -> categoriesNavItemFocusRequester - Section.LOCAL_FILES -> localFilesNavItemFocusRequester - Section.SETTINGS -> settingsNavItemFocusRequester - } - requestFocusWithFrames(requester, "nav") - moveFocusToNav = false - } - } - - LaunchedEffect(showAppearance, selectedSection) { - if (wasShowingAppearance && !showAppearance && selectedSection == Section.SETTINGS) { - focusAppearanceOnSettingsReturn = true - requestFocusWithFrames(contentItemFocusRequester, "settings-back") - focusAppearanceOnSettingsReturn = false - } - wasShowingAppearance = showAppearance - } - - LaunchedEffect(showManageLists, selectedSection) { - if (wasShowingManageLists && !showManageLists && selectedSection == Section.SETTINGS) { - focusManageListsOnSettingsReturn = true - requestFocusWithFrames(contentItemFocusRequester, "settings-manage-lists-back") - focusManageListsOnSettingsReturn = false - } - wasShowingManageLists = showManageLists - } - LaunchedEffect(settings.autoSignIn, settings.rememberLogin, savedConfig) { authViewModel.tryAutoSignIn(settings) } - LaunchedEffect(startupDeferredReady) { - if (!startupDeferredReady) { - return@LaunchedEffect - } - startupUpdateCheckEnabled = settingsRepository.isStartupUpdateCheckEnabled() - } - LaunchedEffect(authState.activeConfig) { if (authState.activeConfig != null) { showLocalFilesGuest = false @@ -945,24 +771,6 @@ private fun RootScreenContent( } } - LaunchedEffect(selectedSection) { - if (selectedSection != Section.SETTINGS) { - showManageLists = false - } - } - - LaunchedEffect(showManageLists) { - if (showManageLists) { - focusToContentTrigger++ - } - } - - LaunchedEffect(showAppearance) { - if (showAppearance) { - focusToContentTrigger++ - } - } - LaunchedEffect(activePlaybackQueue) { val queue = activePlaybackQueue playbackFallbackAttempts = queue?.fallbackUris?.mapValues { 0 } ?: emptyMap() @@ -997,8 +805,6 @@ private fun RootScreenContent( } } - BackHandler(enabled = showManageLists) { showManageLists = false } - BackHandler(enabled = showAppearance) { showAppearance = false } val shouldHandleRootBackForExit = activePlaybackQueue == null && !showManageLists && @@ -1732,7 +1538,6 @@ private fun RootScreenContent( if (showAuthLoading) { AuthLoadingScreen() } else if (authState.isSignedIn) { - val colors = AppTheme.colors val context = LocalContext.current val versionLabel = remember(context) { "v${appVersionName(context)}" } val quickSearchReady by produceState( @@ -1748,243 +1553,136 @@ private fun RootScreenContent( contentRepository.hasAnySearchIndex(config) } } - Column(modifier = Modifier.fillMaxSize()) { - Box( - modifier = - Modifier.fillMaxWidth() - .height(72.dp) - .padding(start = 20.dp, end = 20.dp, top = 12.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth().align(Alignment.Center), - verticalAlignment = Alignment.CenterVertically - ) { - MenuButton( - expanded = navExpanded, - onToggle = { - navExpanded = !navExpanded - // Focus stays on menu button - user navigates manually - }, - onMoveRight = { focusToContentTrigger++ } - ) - Spacer(modifier = Modifier.weight(1f)) - Text( - text = versionLabel, - color = colors.textSecondary, - fontSize = 12.sp, - fontFamily = settings.appFont.fontFamily, - modifier = Modifier - .padding(end = 12.dp, bottom = 2.dp) - ) - } + val isLegacySyncActive = sectionSyncStates.values.any { it.isActive } + RootNavigationHost( + settings = settings, + isPlaybackActive = activePlaybackQueue != null, + isLegacySyncActive = isLegacySyncActive, + syncState = syncState, + progressiveSyncCoordinator = progressiveSyncCoordinator, + quickSearchReady = quickSearchReady, + coroutineScope = coroutineScope, + headerContent = { + MenuButton( + expanded = navExpanded, + onToggle = { + navExpanded = !navExpanded + // Focus stays on menu button - user navigates manually + }, + onMoveRight = { focusToContentTrigger++ } + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = versionLabel, + color = AppTheme.colors.textSecondary, + fontSize = 12.sp, + fontFamily = settings.appFont.fontFamily, + modifier = Modifier.padding(end = 12.dp, bottom = 2.dp) + ) TopCenterClock( clockFormat = settings.clockFormat, fontFamily = settings.appFont.fontFamily, - modifier = Modifier.align(Alignment.Center) + modifier = Modifier.align(Alignment.CenterVertically) ) } - - val isPlaybackActiveLocal = activePlaybackQueue != null - val isProgressiveSyncActive = - syncState.phase == com.example.xtreamplayer.content.SyncPhase.FAST_START || - syncState.phase == com.example.xtreamplayer.content.SyncPhase.BACKGROUND_FULL || - syncState.phase == com.example.xtreamplayer.content.SyncPhase.ON_DEMAND_BOOST || - syncState.phase == com.example.xtreamplayer.content.SyncPhase.PAUSED - val isLegacySyncActive = sectionSyncStates.values.any { it.isActive } - val shouldShowSyncUi = - !isPlaybackActiveLocal && (isProgressiveSyncActive || isLegacySyncActive) - - // Progressive sync status indicators - if (shouldShowSyncUi) { - Box( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp) - ) { - Row( - modifier = Modifier.align(Alignment.TopEnd), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Fast Search Ready indicator (only while sync is active) - if (quickSearchReady) { - Row( - modifier = - Modifier.background( - colors.success, - RoundedCornerShape(4.dp) - ) - .padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = colors.textOnAccent, - modifier = Modifier.size(16.dp) - ) - Spacer(Modifier.width(6.dp)) - Text( - "Quick Search Ready", - fontSize = 12.sp, - color = colors.textOnAccent, - fontFamily = AppTheme.fontFamily - ) - } - } - - // Background/boost syncing indicator - if (isProgressiveSyncActive) { - Row( - modifier = - Modifier.background( - colors.surfaceAlt, - RoundedCornerShape(4.dp) - ) - .padding(horizontal = 12.dp, vertical = 6.dp) - .clickable { - coroutineScope.launch { - if (syncState.isPaused) { - progressiveSyncCoordinator?.resumeBackgroundSync() - } else { - progressiveSyncCoordinator?.pauseBackgroundSync() - } - } - }, - verticalAlignment = Alignment.CenterVertically - ) { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - color = colors.textPrimary - ) - Spacer(Modifier.width(8.dp)) - val currentSection = syncState.currentSection - val progress = - currentSection?.let { syncState.sectionProgress[it] } - val text = - if (syncState.isPaused) { - "Sync paused" - } else if (currentSection != null && progress != null) { - "Syncing ${currentSection.name.lowercase()}... (${progress.itemsIndexed} items)" - } else { - "Syncing library..." - } - Text( - text, - fontSize = 11.sp, - color = colors.textPrimary, - fontFamily = AppTheme.fontFamily - ) - Spacer(Modifier.width(12.dp)) - Text( - text = if (syncState.isPaused) "Resume" else "Pause", - fontSize = 11.sp, - color = colors.accent, - fontFamily = AppTheme.fontFamily - ) - } - } - } - } - } - + ) { // Removed extra long sync banner; top-right pill is the only sync indicator. BrowseScreen( - context = context, - coroutineScope = coroutineScope, - authState = authState, - savedConfig = savedConfig, - activeConfig = activeConfig, - settings = settings, - settingsViewModel = settingsViewModel, - appVersionName = appVersionName, - selectedSectionState = browseViewModel.selectedSection, - navExpandedState = browseViewModel.navExpanded, - moveFocusToNavState = moveFocusToNavState, - focusToContentTriggerState = focusToContentTriggerState, - showManageListsState = showManageListsState, - showAppearanceState = showAppearanceState, - focusAppearanceOnSettingsReturn = focusAppearanceOnSettingsReturn, - focusManageListsOnSettingsReturn = focusManageListsOnSettingsReturn, - showThemeDialogState = showThemeDialogState, - showFontDialogState = showFontDialogState, - showUiScaleDialogState = showUiScaleDialogState, - showFontScaleDialogState = showFontScaleDialogState, - showNextEpisodeThresholdDialogState = showNextEpisodeThresholdDialogState, - showVodBufferDialogState = showVodBufferDialogState, - showSubtitleAppearanceDialogState = showSubtitleAppearanceDialogState, - showSubtitleCacheAutoClearDialogState = showSubtitleCacheAutoClearDialogState, - showApiKeyDialogState = showApiKeyDialogState, - cacheClearNonceState = cacheClearNonceState, - contentRepository = contentRepository, - favoritesRepository = favoritesRepository, - continueWatchingRepository = continueWatchingRepository, - subtitleRepository = subtitleRepository, - playbackEngine = playbackEngine, - progressiveSyncCoordinator = progressiveSyncCoordinator, - syncState = syncState, - storagePermissionLauncher = storagePermissionLauncher, - localFiles = localFiles, - allNavItemFocusRequester = allNavItemFocusRequester, - continueWatchingNavItemFocusRequester = continueWatchingNavItemFocusRequester, - favoritesNavItemFocusRequester = favoritesNavItemFocusRequester, - moviesNavItemFocusRequester = moviesNavItemFocusRequester, - seriesNavItemFocusRequester = seriesNavItemFocusRequester, - liveNavItemFocusRequester = liveNavItemFocusRequester, - categoriesNavItemFocusRequester = categoriesNavItemFocusRequester, - localFilesNavItemFocusRequester = localFilesNavItemFocusRequester, - settingsNavItemFocusRequester = settingsNavItemFocusRequester, - contentItemFocusRequester = contentItemFocusRequester, - resumeFocusId = resumeFocusId, - resumeFocusRequester = resumeFocusRequester, - isPlaybackActive = activePlaybackQueue != null, - onItemFocused = handleItemFocused, - onPlay = handlePlayItem, - onPlayWithPosition = handlePlayItemWithPosition, - onPlayContinueWatching = { item, position, parent -> - activePlaybackSeriesParent = parent - handlePlayItemWithPosition(item, position) - }, - onPlayWithPositionAndQueue = handlePlayItemWithPositionAndQueue, - onMovieInfo = openMovieInfo, - onMovieInfoContinueWatching = openMovieInfoFromContinueWatching, - onPlayLocalFile = handlePlayLocalFile, - localResumePositionMsForUri = { uri -> - localResumeByMediaId[localMediaIdForUri(uri)]?.positionMs?.takeIf { it > 0 } - }, - onToggleFavorite = handleToggleFavorite, - onToggleCategoryFavorite = handleToggleCategoryFavorite, - onSeriesPlaybackStart = { activePlaybackSeriesParent = it }, - onTriggerSectionSync = { section, config -> - triggerSectionSync(section, config) - }, - onEditList = { - coroutineScope.launch { - contentRepository.clearCache() - contentRepository.clearDiskCache() - } - authViewModel.enterEditMode() - }, - onSignOutKeepSaved = { - coroutineScope.launch { - contentRepository.clearCache() - contentRepository.clearDiskCache() - } - authViewModel.signOut(keepSaved = true) - }, - onSignOutForget = { - coroutineScope.launch { - contentRepository.clearCache() - contentRepository.clearDiskCache() - } - authViewModel.signOut(keepSaved = false) - }, - onToggleCheckUpdatesOnStartup = settingsViewModel::toggleCheckUpdatesOnStartup, - onCheckForUpdates = { checkForUpdates(UpdateCheckSource.MANUAL) }, - hasStoragePermission = ::hasStoragePermission, - scanMediaStoreMedia = ::scanMediaStoreMedia, - getRequiredMediaPermissions = ::getRequiredMediaPermissions + context = context, + coroutineScope = coroutineScope, + authState = authState, + savedConfig = savedConfig, + activeConfig = activeConfig, + settings = settings, + settingsViewModel = settingsViewModel, + appVersionName = appVersionName, + selectedSectionState = browseViewModel.selectedSection, + navExpandedState = browseViewModel.navExpanded, + moveFocusToNavState = moveFocusToNavState, + focusToContentTriggerState = focusToContentTriggerState, + showManageListsState = showManageListsState, + showAppearanceState = showAppearanceState, + focusAppearanceOnSettingsReturn = focusAppearanceOnSettingsReturnState.value, + focusManageListsOnSettingsReturn = focusManageListsOnSettingsReturnState.value, + showThemeDialogState = showThemeDialogState, + showFontDialogState = showFontDialogState, + showUiScaleDialogState = showUiScaleDialogState, + showFontScaleDialogState = showFontScaleDialogState, + showNextEpisodeThresholdDialogState = showNextEpisodeThresholdDialogState, + showVodBufferDialogState = showVodBufferDialogState, + showSubtitleAppearanceDialogState = showSubtitleAppearanceDialogState, + showSubtitleCacheAutoClearDialogState = showSubtitleCacheAutoClearDialogState, + showApiKeyDialogState = showApiKeyDialogState, + cacheClearNonceState = cacheClearNonceState, + contentRepository = contentRepository, + favoritesRepository = favoritesRepository, + continueWatchingRepository = continueWatchingRepository, + subtitleRepository = subtitleRepository, + playbackEngine = playbackEngine, + progressiveSyncCoordinator = progressiveSyncCoordinator, + syncState = syncState, + storagePermissionLauncher = storagePermissionLauncher, + localFiles = localFiles, + allNavItemFocusRequester = allNavItemFocusRequester, + continueWatchingNavItemFocusRequester = continueWatchingNavItemFocusRequester, + favoritesNavItemFocusRequester = favoritesNavItemFocusRequester, + moviesNavItemFocusRequester = moviesNavItemFocusRequester, + seriesNavItemFocusRequester = seriesNavItemFocusRequester, + liveNavItemFocusRequester = liveNavItemFocusRequester, + categoriesNavItemFocusRequester = categoriesNavItemFocusRequester, + localFilesNavItemFocusRequester = localFilesNavItemFocusRequester, + settingsNavItemFocusRequester = settingsNavItemFocusRequester, + contentItemFocusRequester = contentItemFocusRequester, + resumeFocusId = resumeFocusId, + resumeFocusRequester = resumeFocusRequester, + isPlaybackActive = activePlaybackQueue != null, + onItemFocused = handleItemFocused, + onPlay = handlePlayItem, + onPlayWithPosition = handlePlayItemWithPosition, + onPlayContinueWatching = { item, position, parent -> + activePlaybackSeriesParent = parent + handlePlayItemWithPosition(item, position) + }, + onPlayWithPositionAndQueue = handlePlayItemWithPositionAndQueue, + onMovieInfo = openMovieInfo, + onMovieInfoContinueWatching = openMovieInfoFromContinueWatching, + onPlayLocalFile = handlePlayLocalFile, + localResumePositionMsForUri = { uri -> + localResumeByMediaId[localMediaIdForUri(uri)]?.positionMs?.takeIf { it > 0 } + }, + onToggleFavorite = handleToggleFavorite, + onToggleCategoryFavorite = handleToggleCategoryFavorite, + onSeriesPlaybackStart = { activePlaybackSeriesParent = it }, + onTriggerSectionSync = { section, config -> + triggerSectionSync(section, config) + }, + onEditList = { + coroutineScope.launch { + contentRepository.clearCache() + contentRepository.clearDiskCache() + } + authViewModel.enterEditMode() + }, + onSignOutKeepSaved = { + coroutineScope.launch { + contentRepository.clearCache() + contentRepository.clearDiskCache() + } + authViewModel.signOut(keepSaved = true) + }, + onSignOutForget = { + coroutineScope.launch { + contentRepository.clearCache() + contentRepository.clearDiskCache() + } + authViewModel.signOut(keepSaved = false) + }, + onToggleCheckUpdatesOnStartup = settingsViewModel::toggleCheckUpdatesOnStartup, + onCheckForUpdates = { checkForUpdates(UpdateCheckSource.MANUAL) }, + hasStoragePermission = ::hasStoragePermission, + scanMediaStoreMedia = ::scanMediaStoreMedia, + getRequiredMediaPermissions = ::getRequiredMediaPermissions ) RootDialogsHost( @@ -2086,104 +1784,101 @@ private fun RootScreenContent( } } - val effectivePlaybackSettings = - subtitleAppearancePreview?.let { preview -> - settings.copy(subtitleAppearance = preview) - } ?: settings - PlayerScreen( - activePlaybackQueue = activePlaybackQueue, - activePlaybackTitle = activePlaybackTitle, - activePlaybackItem = activePlaybackItem, - activePlaybackItems = activePlaybackItems, - playbackEngine = playbackEngine, - subtitleRepository = subtitleRepository, - settings = effectivePlaybackSettings, - onRequestOpenSubtitlesApiKey = { showApiKeyDialog = true }, - onExitPlayback = { - savePlaybackProgress() - activePlaybackQueue = null - activePlaybackTitle = null - activePlaybackItem = null - activePlaybackSeriesParent = null - activePlaybackSubtitleState = null - resumePositionMs = null - }, - onPlayNextEpisode = { - playbackEngine.player.seekToNextMediaItem() - playbackEngine.player.playWhenReady = true - }, - onMatchFrameRateChange = { enabled -> - playbackEngine.applySettings(settings.copy(matchFrameRateEnabled = enabled)) - settingsViewModel.setMatchFrameRateEnabled(enabled) - }, - onLiveChannelSwitch = switchLiveChannel, - onLiveGuideChannelSelect = { item, channels -> - handlePlayItem(item, channels) - }, - continueWatchingSubtitleState = activePlaybackSubtitleState, - onSubtitleStateChanged = { state -> - activePlaybackSubtitleState = state - }, - loadLiveNowNext = loadLiveNowNext@{ item -> - val config = authState.activeConfig ?: return@loadLiveNowNext Result.success(null) - if (item.contentType != ContentType.LIVE) { - return@loadLiveNowNext Result.success(null) - } - contentRepository.loadLiveNowNext( - streamId = item.streamId, - authConfig = config - ) - }, - loadLiveCategories = loadLiveCategories@{ - val config = authState.activeConfig - ?: return@loadLiveCategories Result.success(emptyList()) - runCatching { contentRepository.loadCategories(ContentType.LIVE, config) } - }, - loadLiveCategoryChannels = loadLiveCategoryChannels@{ category -> - val config = authState.activeConfig - ?: return@loadLiveCategoryChannels Result.success(emptyList()) - runCatching { - val items = mutableListOf() - var page = 0 - val limit = 200 - var retryFirstPageWithRefresh = false - while (true) { - val pageData = - contentRepository.loadCategoryPage( - type = ContentType.LIVE, - categoryId = category.id, - page = page, - limit = limit, - authConfig = config, - forceRefresh = retryFirstPageWithRefresh - ) - if (page == 0 && pageData.items.isEmpty() && !retryFirstPageWithRefresh) { - // Keep search/navigation responsive by reading cache first, then do - // one forced refresh only if page 0 is empty. - retryFirstPageWithRefresh = true - continue - } - retryFirstPageWithRefresh = false - if (pageData.items.isEmpty()) break - items += pageData.items.filter { it.contentType == ContentType.LIVE } - if (pageData.endReached) break - page += 1 - if (page >= 100) break - } - items.distinctBy { it.id } - } - }, - loadLiveCategoryThumbnail = loadLiveCategoryThumbnail@{ category -> - val config = authState.activeConfig - ?: return@loadLiveCategoryThumbnail Result.success(null) - runCatching { - contentRepository.categoryThumbnail( + PlayerHost( + activePlaybackQueue = activePlaybackQueue, + activePlaybackTitle = activePlaybackTitle, + activePlaybackItem = activePlaybackItem, + activePlaybackItems = activePlaybackItems, + continueWatchingSubtitleState = activePlaybackSubtitleState, + playbackEngine = playbackEngine, + subtitleRepository = subtitleRepository, + settings = settings, + subtitleAppearancePreview = subtitleAppearancePreview, + onRequestOpenSubtitlesApiKey = { showApiKeyDialog = true }, + onExitPlayback = { + savePlaybackProgress() + activePlaybackQueue = null + activePlaybackTitle = null + activePlaybackItem = null + activePlaybackSeriesParent = null + activePlaybackSubtitleState = null + resumePositionMs = null + }, + onPlayNextEpisode = { + playbackEngine.player.seekToNextMediaItem() + playbackEngine.player.playWhenReady = true + }, + onMatchFrameRateChange = { enabled -> + playbackEngine.applySettings(settings.copy(matchFrameRateEnabled = enabled)) + settingsViewModel.setMatchFrameRateEnabled(enabled) + }, + onLiveChannelSwitch = switchLiveChannel, + onLiveGuideChannelSelect = { item, channels -> + handlePlayItem(item, channels) + }, + onSubtitleStateChanged = { state -> + activePlaybackSubtitleState = state + }, + loadLiveNowNext = loadLiveNowNext@{ item -> + val config = authState.activeConfig ?: return@loadLiveNowNext Result.success(null) + if (item.contentType != ContentType.LIVE) { + return@loadLiveNowNext Result.success(null) + } + contentRepository.loadLiveNowNext( + streamId = item.streamId, + authConfig = config + ) + }, + loadLiveCategories = loadLiveCategories@{ + val config = authState.activeConfig + ?: return@loadLiveCategories Result.success(emptyList()) + runCatching { contentRepository.loadCategories(ContentType.LIVE, config) } + }, + loadLiveCategoryChannels = loadLiveCategoryChannels@{ category -> + val config = authState.activeConfig + ?: return@loadLiveCategoryChannels Result.success(emptyList()) + runCatching { + val items = mutableListOf() + var page = 0 + val limit = 200 + var retryFirstPageWithRefresh = false + while (true) { + val pageData = + contentRepository.loadCategoryPage( type = ContentType.LIVE, categoryId = category.id, - authConfig = config - ) + page = page, + limit = limit, + authConfig = config, + forceRefresh = retryFirstPageWithRefresh + ) + if (page == 0 && pageData.items.isEmpty() && !retryFirstPageWithRefresh) { + // Keep search/navigation responsive by reading cache first, then do + // one forced refresh only if page 0 is empty. + retryFirstPageWithRefresh = true + continue + } + retryFirstPageWithRefresh = false + if (pageData.items.isEmpty()) break + items += pageData.items.filter { it.contentType == ContentType.LIVE } + if (pageData.endReached) break + page += 1 + if (page >= 100) break } + items.distinctBy { it.id } + } + }, + loadLiveCategoryThumbnail = loadLiveCategoryThumbnail@{ category -> + val config = authState.activeConfig + ?: return@loadLiveCategoryThumbnail Result.success(null) + runCatching { + contentRepository.categoryThumbnail( + type = ContentType.LIVE, + categoryId = category.id, + authConfig = config + ) } + } ) if (movieInfoItem != null) { @@ -10512,144 +10207,6 @@ private fun PlotDialog( } } -@Composable -private fun UpdatePromptDialog( - release: UpdateRelease, - isDownloading: Boolean, - onUpdate: () -> Unit, - onLater: () -> Unit -) { - val colors = AppTheme.colors - val shape = RoundedCornerShape(16.dp) - val updateFocusRequester = remember { FocusRequester() } - val laterFocusRequester = remember { FocusRequester() } - - LaunchedEffect(isDownloading) { - if (!isDownloading) { - updateFocusRequester.requestFocus() - } - } - AppDialog( - onDismissRequest = onLater, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Column( - modifier = Modifier - // Keep this dialog narrower so it feels centered on large TV screens. - // Follow-up tune to keep the popup visually compact on wide displays. - .fillMaxWidth(0.40f) - .widthIn(min = 360.dp, max = 680.dp) - .clip(shape) - .background(colors.surface) - .border(1.dp, colors.borderStrong, shape) - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Text( - text = "Update available", - color = colors.textPrimary, - fontSize = 22.sp, - fontFamily = AppTheme.fontFamily, - fontWeight = FontWeight.Bold - ) - Text( - text = "Version ${release.versionName} is ready to install.", - color = colors.textSecondary, - fontSize = 14.sp, - fontFamily = AppTheme.fontFamily - ) - Spacer(modifier = Modifier.height(4.dp)) - if (isDownloading) { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(colors.backgroundAlt) - .padding(horizontal = 14.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "Downloading update...", - color = colors.textSecondary, - fontSize = 13.sp, - fontFamily = AppTheme.fontFamily - ) - LinearProgressIndicator( - progress = { 0.35f }, - modifier = Modifier.fillMaxWidth(), - color = colors.accent, - trackColor = colors.surfaceAlt - ) - } - } else { - Text( - text = "Install now or choose Later.", - color = colors.textTertiary, - fontSize = 13.sp, - fontFamily = AppTheme.fontFamily - ) - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - FocusableButton( - onClick = onUpdate, - enabled = !isDownloading, - modifier = Modifier - .weight(1f) - .height(52.dp) - .focusRequester(updateFocusRequester), - colors = ButtonDefaults.buttonColors( - containerColor = colors.accent, - contentColor = colors.textOnAccent, - disabledContainerColor = colors.surfaceAlt, - disabledContentColor = colors.textTertiary - ), - shape = RoundedCornerShape(12.dp), - focusBorderWidth = 1.dp, - contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp) - ) { - Text( - text = if (isDownloading) "Updating..." else "Update now", - fontFamily = AppTheme.fontFamily, - fontWeight = FontWeight.SemiBold, - fontSize = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - FocusableButton( - onClick = onLater, - enabled = !isDownloading, - modifier = Modifier - .weight(1f) - .height(52.dp) - .focusRequester(laterFocusRequester), - colors = ButtonDefaults.buttonColors( - containerColor = colors.surfaceAlt, - contentColor = colors.textPrimary, - disabledContainerColor = colors.surfaceAlt, - disabledContentColor = colors.textTertiary - ), - shape = RoundedCornerShape(12.dp), - focusBorderWidth = 1.dp, - contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp) - ) { - Text( - text = "Later", - fontFamily = AppTheme.fontFamily, - fontWeight = FontWeight.SemiBold, - fontSize = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } - } -} - @Composable internal fun PlaybackRecoveryDialog( onCancel: () -> Unit, diff --git a/app/src/main/java/com/example/xtreamplayer/PlayerHost.kt b/app/src/main/java/com/example/xtreamplayer/PlayerHost.kt new file mode 100644 index 0000000..701a497 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/PlayerHost.kt @@ -0,0 +1,60 @@ +package com.example.xtreamplayer + +import androidx.compose.runtime.Composable +import com.example.xtreamplayer.content.CategoryItem +import com.example.xtreamplayer.content.ContentItem +import com.example.xtreamplayer.content.LiveNowNextEpg +import com.example.xtreamplayer.content.SubtitleRepository +import com.example.xtreamplayer.player.Media3PlaybackEngine +import com.example.xtreamplayer.settings.SettingsState +import com.example.xtreamplayer.settings.SubtitleAppearanceSettings + +@Composable +internal fun PlayerHost( + activePlaybackQueue: PlaybackQueue?, + activePlaybackTitle: String?, + activePlaybackItem: ContentItem?, + activePlaybackItems: List, + continueWatchingSubtitleState: PlaybackSubtitleState?, + playbackEngine: Media3PlaybackEngine, + subtitleRepository: SubtitleRepository, + settings: SettingsState, + subtitleAppearancePreview: SubtitleAppearanceSettings?, + onRequestOpenSubtitlesApiKey: () -> Unit, + onExitPlayback: () -> Unit, + onPlayNextEpisode: () -> Unit, + onMatchFrameRateChange: (Boolean) -> Unit, + onLiveChannelSwitch: (Int) -> Boolean, + onLiveGuideChannelSelect: (ContentItem, List) -> Unit, + onSubtitleStateChanged: (PlaybackSubtitleState?) -> Unit, + loadLiveNowNext: suspend (ContentItem) -> Result, + loadLiveCategories: suspend () -> Result>, + loadLiveCategoryChannels: suspend (CategoryItem) -> Result>, + loadLiveCategoryThumbnail: suspend (CategoryItem) -> Result +) { + val effectivePlaybackSettings = + subtitleAppearancePreview?.let { preview -> + settings.copy(subtitleAppearance = preview) + } ?: settings + PlayerScreen( + activePlaybackQueue = activePlaybackQueue, + activePlaybackTitle = activePlaybackTitle, + activePlaybackItem = activePlaybackItem, + activePlaybackItems = activePlaybackItems, + continueWatchingSubtitleState = continueWatchingSubtitleState, + playbackEngine = playbackEngine, + subtitleRepository = subtitleRepository, + settings = effectivePlaybackSettings, + onRequestOpenSubtitlesApiKey = onRequestOpenSubtitlesApiKey, + onExitPlayback = onExitPlayback, + onPlayNextEpisode = onPlayNextEpisode, + onMatchFrameRateChange = onMatchFrameRateChange, + onLiveChannelSwitch = onLiveChannelSwitch, + onLiveGuideChannelSelect = onLiveGuideChannelSelect, + onSubtitleStateChanged = onSubtitleStateChanged, + loadLiveNowNext = loadLiveNowNext, + loadLiveCategories = loadLiveCategories, + loadLiveCategoryChannels = loadLiveCategoryChannels, + loadLiveCategoryThumbnail = loadLiveCategoryThumbnail + ) +} diff --git a/app/src/main/java/com/example/xtreamplayer/RootNavigationHost.kt b/app/src/main/java/com/example/xtreamplayer/RootNavigationHost.kt new file mode 100644 index 0000000..a489b90 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/RootNavigationHost.kt @@ -0,0 +1,192 @@ +package com.example.xtreamplayer + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.platform.LocalDensity +import com.example.xtreamplayer.content.ProgressiveSyncCoordinator +import com.example.xtreamplayer.content.ProgressiveSyncState +import com.example.xtreamplayer.settings.SettingsState +import com.example.xtreamplayer.ui.AppScale +import com.example.xtreamplayer.ui.LocalAppBaseDensity +import com.example.xtreamplayer.ui.LocalAppScale +import com.example.xtreamplayer.ui.components.AppBackground +import com.example.xtreamplayer.ui.theme.AppTheme +import com.example.xtreamplayer.ui.theme.XtreamPlayerTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +internal fun RootNavigationHost( + settings: SettingsState, + isPlaybackActive: Boolean, + isLegacySyncActive: Boolean, + syncState: ProgressiveSyncState, + progressiveSyncCoordinator: ProgressiveSyncCoordinator?, + quickSearchReady: Boolean, + coroutineScope: CoroutineScope, + headerContent: @Composable RowScope.() -> Unit, + content: @Composable () -> Unit +) { + XtreamPlayerTheme(appTheme = settings.appTheme, fontFamily = settings.appFont.fontFamily) { + val baseDensity = LocalDensity.current + val uiScale = settings.uiScale.coerceIn(0.7f, 1.3f) + val fontScale = settings.fontScale.coerceIn(0.7f, 1.4f) + val appScale = remember(uiScale, fontScale) { AppScale(uiScale, fontScale) } + val scaledDensity = Density( + density = baseDensity.density * uiScale, + fontScale = baseDensity.fontScale * uiScale * fontScale + ) + + CompositionLocalProvider( + LocalAppBaseDensity provides baseDensity, + LocalAppScale provides appScale, + LocalDensity provides scaledDensity + ) { + AppBackground { + val colors = AppTheme.colors + Column(modifier = Modifier.fillMaxSize()) { + Box( + modifier = + Modifier.fillMaxWidth() + .height(72.dp) + .padding(start = 20.dp, end = 20.dp, top = 12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth().align(Alignment.Center), + verticalAlignment = Alignment.CenterVertically + ) { + headerContent() + } + } + + val shouldShowSyncUi = + !isPlaybackActive && (syncState.isProgressiveSyncActive() || isLegacySyncActive) + + if (shouldShowSyncUi) { + Box( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + Row( + modifier = Modifier.align(Alignment.TopEnd), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (quickSearchReady) { + Row( + modifier = + Modifier.background( + colors.success, + RoundedCornerShape(4.dp) + ) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + androidx.compose.material3.Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = colors.textOnAccent, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + "Quick Search Ready", + fontSize = 12.sp, + color = colors.textOnAccent, + fontFamily = AppTheme.fontFamily + ) + } + } + + if (syncState.isProgressiveSyncActive()) { + Row( + modifier = + Modifier.background( + colors.surfaceAlt, + RoundedCornerShape(4.dp) + ) + .padding(horizontal = 12.dp, vertical = 6.dp) + .clickable { + coroutineScope.launch { + if (syncState.isPaused) { + progressiveSyncCoordinator?.resumeBackgroundSync() + } else { + progressiveSyncCoordinator?.pauseBackgroundSync() + } + } + }, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = colors.textPrimary + ) + Spacer(Modifier.width(8.dp)) + val currentSection = syncState.currentSection + val progress = currentSection?.let { syncState.sectionProgress[it] } + val text = + if (syncState.isPaused) { + "Sync paused" + } else if (currentSection != null && progress != null) { + "Syncing ${currentSection.name.lowercase()}... (${progress.itemsIndexed} items)" + } else { + "Syncing library..." + } + Text( + text, + fontSize = 11.sp, + color = colors.textPrimary, + fontFamily = AppTheme.fontFamily + ) + Spacer(Modifier.width(12.dp)) + Text( + text = if (syncState.isPaused) "Resume" else "Pause", + fontSize = 11.sp, + color = colors.accent, + fontFamily = AppTheme.fontFamily + ) + } + } + } + } + } + + content() + } + } + } + } +} + +private fun ProgressiveSyncState.isProgressiveSyncActive(): Boolean { + return phase == com.example.xtreamplayer.content.SyncPhase.FAST_START || + phase == com.example.xtreamplayer.content.SyncPhase.BACKGROUND_FULL || + phase == com.example.xtreamplayer.content.SyncPhase.ON_DEMAND_BOOST || + phase == com.example.xtreamplayer.content.SyncPhase.PAUSED +} diff --git a/app/src/main/java/com/example/xtreamplayer/SettingsAndSyncHost.kt b/app/src/main/java/com/example/xtreamplayer/SettingsAndSyncHost.kt new file mode 100644 index 0000000..7d3ca07 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/SettingsAndSyncHost.kt @@ -0,0 +1,214 @@ +package com.example.xtreamplayer + +import android.content.Context +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.rememberUpdatedState +import com.example.xtreamplayer.auth.AuthUiState +import com.example.xtreamplayer.content.ContentRepository +import com.example.xtreamplayer.content.ProgressiveSyncCoordinator +import com.example.xtreamplayer.content.ProgressiveSyncState +import com.example.xtreamplayer.content.SubtitleRepository +import com.example.xtreamplayer.settings.SettingsRepository +import com.example.xtreamplayer.settings.SettingsState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import timber.log.Timber + +private const val STARTUP_DEFER_NON_CRITICAL_MS = 1_000L +private const val SUBTITLE_AUTO_CLEAR_CHECK_INTERVAL_MS = 60L * 60L * 1000L + +@Composable +internal fun SettingsAndSyncHost( + context: Context, + coroutineScope: CoroutineScope, + settings: SettingsState, + settingsRepository: SettingsRepository, + contentRepository: ContentRepository, + subtitleRepository: SubtitleRepository, + authState: AuthUiState, + activePlaybackQueue: PlaybackQueue?, + selectedSectionState: MutableState
, + showManageListsState: MutableState, + showAppearanceState: MutableState, + focusToContentTriggerState: MutableIntState, + focusAppearanceOnSettingsReturnState: MutableState, + focusManageListsOnSettingsReturnState: MutableState, + wasShowingAppearanceState: MutableState, + wasShowingManageListsState: MutableState, + startupDeferredReadyState: MutableState, + startupUpdateCheckEnabledState: MutableState, + progressiveSyncCoordinatorState: MutableState, + syncState: ProgressiveSyncState, + setProgressiveSyncCoordinatorState: (ProgressiveSyncCoordinator?) -> Unit +) { + val showManageLists = showManageListsState.value + val showAppearance = showAppearanceState.value + val selectedSection = selectedSectionState.value + val startupDeferredReady = startupDeferredReadyState.value + val latestProgressiveSyncCoordinator = rememberUpdatedState(progressiveSyncCoordinatorState.value) + + LaunchedEffect(Unit) { + delay(STARTUP_DEFER_NON_CRITICAL_MS) + startupDeferredReadyState.value = true + } + + LaunchedEffect(settings.subtitleCacheAutoClearIntervalMs, startupDeferredReady) { + if (!startupDeferredReady) return@LaunchedEffect + val intervalMs = settings.subtitleCacheAutoClearIntervalMs + if (intervalMs <= 0L) return@LaunchedEffect + + while (true) { + if (activePlaybackQueue != null) { + delay(SUBTITLE_AUTO_CLEAR_CHECK_INTERVAL_MS) + continue + } + val nowMs = System.currentTimeMillis() + val lastRunMs = settingsRepository.subtitleCacheAutoClearLastRunMs() + if (lastRunMs <= 0L) { + settingsRepository.setSubtitleCacheAutoClearLastRunMs(nowMs) + } else if (nowMs - lastRunMs >= intervalMs) { + val removed = withContext(Dispatchers.IO) { + subtitleRepository.clearCacheAndCount() + } + settingsRepository.setSubtitleCacheAutoClearLastRunMs(nowMs) + if (removed > 0) { + Timber.d("Auto-cleared subtitle cache files: $removed") + } + } + delay(SUBTITLE_AUTO_CLEAR_CHECK_INTERVAL_MS) + } + } + + val syncCoordinatorAccountKey = + authState.activeConfig?.let { config -> + "${config.baseUrl}|${config.username}|${config.listName}|${config.password}" + } + LaunchedEffect(syncCoordinatorAccountKey, startupDeferredReady) { + if (!startupDeferredReady) return@LaunchedEffect + val previousCoordinator = progressiveSyncCoordinatorState.value + if (previousCoordinator != null) { + withContext(NonCancellable) { + previousCoordinator.dispose() + } + setProgressiveSyncCoordinatorState(null) + } + setProgressiveSyncCoordinatorState( + authState.activeConfig?.let { config -> + ProgressiveSyncCoordinator( + contentRepository = contentRepository, + settingsRepository = settingsRepository, + authConfig = config + ) + } + ) + } + + DisposableEffect(Unit) { + onDispose { + latestProgressiveSyncCoordinator.value?.disposeAsync() + } + } + + LaunchedEffect(authState.activeConfig, progressiveSyncCoordinatorState.value, startupDeferredReady) { + if (!startupDeferredReady) return@LaunchedEffect + val coordinator = progressiveSyncCoordinatorState.value ?: return@LaunchedEffect + if (authState.activeConfig != null) { + val config = authState.activeConfig ?: return@LaunchedEffect + val syncAccountKey = "${config.baseUrl}|${config.username}|${config.listName}" + val savedState = settingsRepository.loadSyncState(syncAccountKey) + val hasFullIndex = contentRepository.hasFullIndex(config) + val hasSearchIndex = contentRepository.hasSearchIndex(config) + val hasAnySearchIndex = contentRepository.hasAnySearchIndex(config) + + val effectiveState = + savedState + ?: if (hasFullIndex) { + ProgressiveSyncState( + phase = com.example.xtreamplayer.content.SyncPhase.COMPLETE, + fastStartReady = true, + fullIndexComplete = true, + lastSyncTimestamp = System.currentTimeMillis() + ) + } else { + null + } + + if (effectiveState != null) { + coordinator.restoreState(effectiveState) + } + + if (!hasFullIndex && (!hasAnySearchIndex || savedState == null || !savedState.fastStartReady)) { + coordinator.startFastStartSync() + } else if (savedState?.phase == com.example.xtreamplayer.content.SyncPhase.BACKGROUND_FULL && + savedState.isPaused.not() && + savedState.fullIndexComplete.not() + ) { + coordinator.resumeBackgroundSync() + } + } + } + + LaunchedEffect(syncState.fastStartReady, startupDeferredReady) { + if (!startupDeferredReady) return@LaunchedEffect + val coordinator = progressiveSyncCoordinatorState.value ?: return@LaunchedEffect + if (syncState.fastStartReady && !syncState.fullIndexComplete) { + delay(2000) + coordinator.startBackgroundFullSync() + } + } + + LaunchedEffect(startupDeferredReady) { + if (startupDeferredReady) { + startupUpdateCheckEnabledState.value = settingsRepository.isStartupUpdateCheckEnabled() + } + } + + LaunchedEffect(selectedSection) { + if (selectedSection != Section.SETTINGS) { + showManageListsState.value = false + } + } + + LaunchedEffect(showManageLists) { + if (showManageLists) { + focusToContentTriggerState.intValue++ + } + } + + LaunchedEffect(showAppearance) { + if (showAppearance) { + focusToContentTriggerState.intValue++ + } + } + + LaunchedEffect(authState.isSignedIn) { + if (authState.isSignedIn) { + showManageListsState.value = false + } + } + + BackHandler(enabled = showManageLists) { showManageListsState.value = false } + BackHandler(enabled = showAppearance) { showAppearanceState.value = false } + + LaunchedEffect(showAppearance, selectedSection) { + if (wasShowingAppearanceState.value && !showAppearance && selectedSection == Section.SETTINGS) { + focusAppearanceOnSettingsReturnState.value = true + } + wasShowingAppearanceState.value = showAppearance + } + + LaunchedEffect(showManageLists, selectedSection) { + if (wasShowingManageListsState.value && !showManageLists && selectedSection == Section.SETTINGS) { + focusManageListsOnSettingsReturnState.value = true + } + wasShowingManageListsState.value = showManageLists + } +} From 7089e18ff357ad2c316d71977e3281510f787727 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:05:06 -0400 Subject: [PATCH 03/39] Restore transient root UI state --- XTREAM_REFACTOR_PLAN.md | 3 ++- .../example/xtreamplayer/MainActivityUi.kt | 22 ++++++++++++------- .../xtreamplayer/viewmodel/PlayerViewModel.kt | 2 ++ 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/XTREAM_REFACTOR_PLAN.md b/XTREAM_REFACTOR_PLAN.md index 7f7da16..8e83da2 100644 --- a/XTREAM_REFACTOR_PLAN.md +++ b/XTREAM_REFACTOR_PLAN.md @@ -24,7 +24,7 @@ Reduce the size and coupling of `MainActivityUi.kt` without changing behavior, t - `[x]` Extract dialog orchestration into a separate `DialogsHost`. - `[x]` Extract playback/player wiring into a dedicated `PlayerHost`. - `[x]` Extract settings and sync coordination into a `SettingsAndSyncHost`. -- `[ ]` Keep each extracted host behavior-identical at first. +- `[x]` Keep each extracted host behavior-identical at first. - `[x]` Re-run `:app:compileDebugKotlin` after each extraction or small batch. Acceptance criteria: @@ -35,6 +35,7 @@ Acceptance criteria: ## Phase 2: Move root state into view models - `[ ]` Identify the state that currently belongs to the screen, not the component tree. +- `[x]` Identify the state that currently belongs to the screen, not the component tree. - `[ ]` Add `UiState` data classes for major sections instead of many `mutableStateOf` fields. - `[ ]` Migrate state into `StateFlow` or `MutableStateFlow` where appropriate. - `[ ]` Keep Compose state only for ephemeral UI details that are truly local. diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index d6ba8e0..b5028a0 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -327,13 +327,11 @@ private fun RootScreenContent( var selectedSection by browseViewModel.selectedSection var navExpanded by browseViewModel.navExpanded + var updateUiState by updateViewModel.updateUiState val showManageListsState = remember { mutableStateOf(false) } var showManageLists by showManageListsState val showAppearanceState = remember { mutableStateOf(false) } var showAppearance by showAppearanceState - var updateUiState by updateViewModel.updateUiState - var updateCheckJob by updateViewModel.updateCheckJob - var startupUpdateCheckHandled by updateViewModel.startupUpdateCheckHandled val showApiKeyDialogState = remember { mutableStateOf(false) } var showApiKeyDialog by showApiKeyDialogState val showThemeDialogState = remember { mutableStateOf(false) } @@ -370,13 +368,14 @@ private fun RootScreenContent( var movieInfoFromContinueWatching by remember { mutableStateOf(false) } var movieInfoResumePositionMs by remember { mutableStateOf(null) } var movieInfoLoadJob by remember { mutableStateOf(null) } + var movieInfoLoadToken by remember { mutableIntStateOf(0) } var playbackFallbackAttempts by playerViewModel.playbackFallbackAttempts var playbackPrimaryRetries by playerViewModel.playbackPrimaryRetries var playbackRecoveryJob by remember { mutableStateOf(null) } var liveReconnectAttempts by playerViewModel.liveReconnectAttempts var liveReconnectJob by playerViewModel.liveReconnectJob var pendingResume by playerViewModel.pendingResume - var syncPausedForPlayback by remember { mutableStateOf(false) } + var syncPausedForPlayback by playerViewModel.syncPausedForPlayback var resumePositionMs by playerViewModel.resumePositionMs var resumeFocusId by playerViewModel.resumeFocusId var activePlaybackSubtitleState by remember { mutableStateOf(null) } @@ -392,7 +391,6 @@ private fun RootScreenContent( val syncState by (progressiveSyncCoordinator?.syncState ?: emptySyncStateFlow) .collectAsStateWithLifecycle() - val startupUpdateCheckEnabledState = updateViewModel.startupUpdateCheckEnabled val focusAppearanceOnSettingsReturnState = remember { mutableStateOf(false) } val focusManageListsOnSettingsReturnState = remember { mutableStateOf(false) } val wasShowingAppearanceState = remember { mutableStateOf(false) } @@ -417,7 +415,7 @@ private fun RootScreenContent( wasShowingAppearanceState = wasShowingAppearanceState, wasShowingManageListsState = wasShowingManageListsState, startupDeferredReadyState = startupDeferredReadyState, - startupUpdateCheckEnabledState = startupUpdateCheckEnabledState, + startupUpdateCheckEnabledState = updateViewModel.startupUpdateCheckEnabled, progressiveSyncCoordinatorState = progressiveSyncCoordinatorState, syncState = syncState, setProgressiveSyncCoordinatorState = { progressiveSyncCoordinator = it } @@ -679,8 +677,8 @@ private fun RootScreenContent( } fun checkForUpdates(source: UpdateCheckSource = UpdateCheckSource.MANUAL) { - if (updateCheckJob?.isActive == true) return - updateCheckJob = coroutineScope.launch { + if (updateViewModel.updateCheckJob.value?.isActive == true) return + updateViewModel.updateCheckJob.value = coroutineScope.launch { val result = runCatching { fetchLatestRelease(updateHttpClient) } val latest = result.getOrNull() if (latest == null) { @@ -847,6 +845,8 @@ private fun RootScreenContent( val openMovieInfo: (ContentItem, List) -> Unit = { item, items -> val config = authState.activeConfig if (config != null) { + movieInfoLoadToken++ + val loadToken = movieInfoLoadToken movieInfoLoadJob?.cancel() movieInfoItem = null movieInfoInfo = null @@ -866,6 +866,7 @@ private fun RootScreenContent( } Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } + if (loadToken != movieInfoLoadToken) return@launch movieInfoInfo = info movieInfoItem = item } @@ -875,6 +876,8 @@ private fun RootScreenContent( val openMovieInfoFromContinueWatching: (ContentItem, List) -> Unit = { item, items -> val config = authState.activeConfig if (config != null) { + movieInfoLoadToken++ + val loadToken = movieInfoLoadToken movieInfoLoadJob?.cancel() movieInfoItem = null movieInfoInfo = null @@ -898,6 +901,7 @@ private fun RootScreenContent( } Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } + if (loadToken != movieInfoLoadToken) return@launch movieInfoInfo = info movieInfoItem = item } @@ -1981,6 +1985,8 @@ private fun RootScreenContent( null }, onDismiss = { + movieInfoLoadToken++ + movieInfoLoadJob?.cancel() movieInfoItem = null movieInfoInfo = null movieInfoFromContinueWatching = false diff --git a/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt index c6e2835..cda2ff7 100644 --- a/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt @@ -29,4 +29,6 @@ class PlayerViewModel @Inject constructor() : ViewModel() { val pendingResume = mutableStateOf(null) val resumePositionMs = mutableStateOf(null) val resumeFocusId = mutableStateOf(null) + val syncPausedForPlayback = mutableStateOf(false) + val showPlaybackRecoveryDialog = mutableStateOf(false) } From 2f4789d9408b2a9b9f0e05a46b03b7119ddb105e Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:21:55 -0400 Subject: [PATCH 04/39] Move browse and sync state into holders --- .../example/xtreamplayer/MainActivityUi.kt | 81 +++++++------------ .../example/xtreamplayer/RootSyncUiState.kt | 35 ++++++++ .../xtreamplayer/viewmodel/BrowseViewModel.kt | 11 +++ 3 files changed, 76 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/RootSyncUiState.kt diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index b5028a0..8ca218c 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -233,19 +233,6 @@ data class UpdateUiState( val pendingRelease: UpdateRelease? = null ) -private data class SectionSyncState( - val progress: Float = 0f, - val itemsIndexed: Int = 0, - val isActive: Boolean = false -) - -private data class LibrarySyncRequest( - val config: AuthConfig, - val reason: String, - val force: Boolean, - val sectionsToSync: List
? -) - private const val LOCAL_MEDIA_ID_PREFIX = "local:" private const val RESUME_MIN_WATCH_MS = 30_000L private const val CONTINUE_WATCHING_MAX_PROGRESS_PERCENT = 98L @@ -328,9 +315,9 @@ private fun RootScreenContent( var selectedSection by browseViewModel.selectedSection var navExpanded by browseViewModel.navExpanded var updateUiState by updateViewModel.updateUiState - val showManageListsState = remember { mutableStateOf(false) } + val showManageListsState = browseViewModel.showManageLists var showManageLists by showManageListsState - val showAppearanceState = remember { mutableStateOf(false) } + val showAppearanceState = browseViewModel.showAppearance var showAppearance by showAppearanceState val showApiKeyDialogState = remember { mutableStateOf(false) } var showApiKeyDialog by showApiKeyDialogState @@ -354,9 +341,8 @@ private fun RootScreenContent( var showSubtitleCacheAutoClearDialog by showSubtitleCacheAutoClearDialogState val showPlaybackRecoveryDialogState = remember { mutableStateOf(false) } var showPlaybackRecoveryDialog by showPlaybackRecoveryDialogState - var showLocalFilesGuest by remember { mutableStateOf(false) } - val cacheClearNonceState = remember { mutableIntStateOf(0) } - var cacheClearNonce by cacheClearNonceState + var showLocalFilesGuest by browseViewModel.showLocalFilesGuest + var cacheClearNonce by browseViewModel.cacheClearNonce var activePlaybackQueue by playerViewModel.activePlaybackQueue var activePlaybackTitle by playerViewModel.activePlaybackTitle var activePlaybackItem by playerViewModel.activePlaybackItem @@ -391,12 +377,10 @@ private fun RootScreenContent( val syncState by (progressiveSyncCoordinator?.syncState ?: emptySyncStateFlow) .collectAsStateWithLifecycle() - val focusAppearanceOnSettingsReturnState = remember { mutableStateOf(false) } - val focusManageListsOnSettingsReturnState = remember { mutableStateOf(false) } - val wasShowingAppearanceState = remember { mutableStateOf(false) } - val wasShowingManageListsState = remember { mutableStateOf(false) } - val focusToContentTriggerState = remember { mutableIntStateOf(0) } - var focusToContentTrigger by focusToContentTriggerState + val focusAppearanceOnSettingsReturnState = browseViewModel.focusAppearanceOnSettingsReturn + val focusManageListsOnSettingsReturnState = browseViewModel.focusManageListsOnSettingsReturn + val wasShowingAppearanceState = browseViewModel.wasShowingAppearance + val wasShowingManageListsState = browseViewModel.wasShowingManageLists SettingsAndSyncHost( context = context, coroutineScope = coroutineScope, @@ -409,7 +393,7 @@ private fun RootScreenContent( selectedSectionState = browseViewModel.selectedSection, showManageListsState = showManageListsState, showAppearanceState = showAppearanceState, - focusToContentTriggerState = focusToContentTriggerState, + focusToContentTriggerState = browseViewModel.focusToContentTrigger, focusAppearanceOnSettingsReturnState = focusAppearanceOnSettingsReturnState, focusManageListsOnSettingsReturnState = focusManageListsOnSettingsReturnState, wasShowingAppearanceState = wasShowingAppearanceState, @@ -420,8 +404,6 @@ private fun RootScreenContent( syncState = syncState, setProgressiveSyncCoordinatorState = { progressiveSyncCoordinator = it } ) - val moveFocusToNavState = remember { mutableStateOf(false) } - var moveFocusToNav by moveFocusToNavState val allNavItemFocusRequester = remember { FocusRequester() } val continueWatchingNavItemFocusRequester = remember { FocusRequester() } @@ -523,22 +505,19 @@ private fun RootScreenContent( val activeConfig = authState.activeConfig val accountKey = activeConfig?.let { "${it.baseUrl}|${it.username}|${it.listName}" } - var lastRefreshedAccountKey by remember { mutableStateOf(null) } - var isRefreshing by remember { mutableStateOf(false) } - var refreshJob by remember { mutableStateOf(null) } - var refreshToken by remember { mutableIntStateOf(0) } - var hasCacheForAccount by remember { mutableStateOf(null) } - var hasSearchIndex by remember { mutableStateOf(null) } - val sectionSyncStates = remember { - mutableStateMapOf() - } - var librarySyncJob by remember { mutableStateOf(null) } - var librarySyncToken by remember { mutableIntStateOf(0) } - var pendingLibrarySync by remember { mutableStateOf(null) } - var lastLibrarySyncRequest by remember { mutableStateOf(null) } - - // Track which sections have been synced - var syncedSections by remember { mutableStateOf(setOf
()) } + val rootSyncUiState = remember { RootSyncUiState() } + var lastRefreshedAccountKey by rootSyncUiState.lastRefreshedAccountKey + var isRefreshing by rootSyncUiState.isRefreshing + var refreshJob by rootSyncUiState.refreshJob + var refreshToken by rootSyncUiState.refreshToken + var hasCacheForAccount by rootSyncUiState.hasCacheForAccount + var hasSearchIndex by rootSyncUiState.hasSearchIndex + val sectionSyncStates = rootSyncUiState.sectionSyncStates + var librarySyncJob by rootSyncUiState.librarySyncJob + var librarySyncToken by rootSyncUiState.librarySyncToken + var pendingLibrarySync by rootSyncUiState.pendingLibrarySync + var lastLibrarySyncRequest by rootSyncUiState.lastLibrarySyncRequest + var syncedSections by rootSyncUiState.syncedSections val isLibrarySyncing = sectionSyncStates.values.any { it.isActive } @@ -750,7 +729,7 @@ private fun RootScreenContent( LaunchedEffect(authState.isSignedIn) { if (authState.isSignedIn) { navExpanded = true - moveFocusToNav = true + browseViewModel.moveFocusToNav.value = true selectedSection = Section.ALL showManageLists = false } else { @@ -795,10 +774,10 @@ private fun RootScreenContent( frameRetries = 6 ) if (!resumeFocused) { - focusToContentTrigger++ + browseViewModel.focusToContentTrigger.intValue++ } } else { - focusToContentTrigger++ + browseViewModel.focusToContentTrigger.intValue++ } } } @@ -1573,7 +1552,7 @@ private fun RootScreenContent( navExpanded = !navExpanded // Focus stays on menu button - user navigates manually }, - onMoveRight = { focusToContentTrigger++ } + onMoveRight = { browseViewModel.focusToContentTrigger.intValue++ } ) Spacer(modifier = Modifier.weight(1f)) Text( @@ -1603,8 +1582,8 @@ private fun RootScreenContent( appVersionName = appVersionName, selectedSectionState = browseViewModel.selectedSection, navExpandedState = browseViewModel.navExpanded, - moveFocusToNavState = moveFocusToNavState, - focusToContentTriggerState = focusToContentTriggerState, + moveFocusToNavState = browseViewModel.moveFocusToNav, + focusToContentTriggerState = browseViewModel.focusToContentTrigger, showManageListsState = showManageListsState, showAppearanceState = showAppearanceState, focusAppearanceOnSettingsReturn = focusAppearanceOnSettingsReturnState.value, @@ -1618,7 +1597,7 @@ private fun RootScreenContent( showSubtitleAppearanceDialogState = showSubtitleAppearanceDialogState, showSubtitleCacheAutoClearDialogState = showSubtitleCacheAutoClearDialogState, showApiKeyDialogState = showApiKeyDialogState, - cacheClearNonceState = cacheClearNonceState, + cacheClearNonceState = browseViewModel.cacheClearNonce, contentRepository = contentRepository, favoritesRepository = favoritesRepository, continueWatchingRepository = continueWatchingRepository, @@ -1782,7 +1761,7 @@ private fun RootScreenContent( }, onOpenLocalFiles = { showLocalFilesGuest = true - focusToContentTrigger++ + browseViewModel.focusToContentTrigger.intValue++ } ) } diff --git a/app/src/main/java/com/example/xtreamplayer/RootSyncUiState.kt b/app/src/main/java/com/example/xtreamplayer/RootSyncUiState.kt new file mode 100644 index 0000000..54fa75d --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/RootSyncUiState.kt @@ -0,0 +1,35 @@ +package com.example.xtreamplayer + +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import com.example.xtreamplayer.auth.AuthConfig +import kotlinx.coroutines.Job + +internal data class SectionSyncState( + val progress: Float = 0f, + val itemsIndexed: Int = 0, + val isActive: Boolean = false +) + +internal data class LibrarySyncRequest( + val config: AuthConfig, + val reason: String, + val force: Boolean, + val sectionsToSync: List
? +) + +internal class RootSyncUiState { + val lastRefreshedAccountKey = mutableStateOf(null) + val isRefreshing = mutableStateOf(false) + val refreshJob = mutableStateOf(null) + val refreshToken = mutableIntStateOf(0) + val hasCacheForAccount = mutableStateOf(null) + val hasSearchIndex = mutableStateOf(null) + val sectionSyncStates = mutableStateMapOf() + val librarySyncJob = mutableStateOf(null) + val librarySyncToken = mutableIntStateOf(0) + val pendingLibrarySync = mutableStateOf(null) + val lastLibrarySyncRequest = mutableStateOf(null) + val syncedSections = mutableStateOf(setOf
()) +} diff --git a/app/src/main/java/com/example/xtreamplayer/viewmodel/BrowseViewModel.kt b/app/src/main/java/com/example/xtreamplayer/viewmodel/BrowseViewModel.kt index 5bb5c01..3782516 100644 --- a/app/src/main/java/com/example/xtreamplayer/viewmodel/BrowseViewModel.kt +++ b/app/src/main/java/com/example/xtreamplayer/viewmodel/BrowseViewModel.kt @@ -1,5 +1,6 @@ package com.example.xtreamplayer.viewmodel +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.example.xtreamplayer.Section @@ -10,4 +11,14 @@ import javax.inject.Inject class BrowseViewModel @Inject constructor() : ViewModel() { val selectedSection = mutableStateOf(Section.ALL) val navExpanded = mutableStateOf(true) + val showManageLists = mutableStateOf(false) + val showAppearance = mutableStateOf(false) + val showLocalFilesGuest = mutableStateOf(false) + val moveFocusToNav = mutableStateOf(false) + val focusToContentTrigger = mutableIntStateOf(0) + val cacheClearNonce = mutableIntStateOf(0) + val focusAppearanceOnSettingsReturn = mutableStateOf(false) + val focusManageListsOnSettingsReturn = mutableStateOf(false) + val wasShowingAppearance = mutableStateOf(false) + val wasShowingManageLists = mutableStateOf(false) } From d2030c0fab3a8ef65d608810445319dd08f5e43f Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:25:36 -0400 Subject: [PATCH 05/39] Move player recovery state into view model --- .../com/example/xtreamplayer/MainActivityUi.kt | 15 ++++++--------- .../xtreamplayer/viewmodel/PlayerViewModel.kt | 2 ++ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index 8ca218c..502256b 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -285,7 +285,6 @@ private fun RootScreenContent( okHttpClient = updateHttpClient ) } - val playbackRecoveryTracker = remember { PlaybackRecoveryTracker() } val appVersionName = remember { runCatching { val info = context.packageManager.getPackageInfo(context.packageName, 0) @@ -339,8 +338,6 @@ private fun RootScreenContent( var subtitleAppearancePreview by subtitleAppearancePreviewState val showSubtitleCacheAutoClearDialogState = remember { mutableStateOf(false) } var showSubtitleCacheAutoClearDialog by showSubtitleCacheAutoClearDialogState - val showPlaybackRecoveryDialogState = remember { mutableStateOf(false) } - var showPlaybackRecoveryDialog by showPlaybackRecoveryDialogState var showLocalFilesGuest by browseViewModel.showLocalFilesGuest var cacheClearNonce by browseViewModel.cacheClearNonce var activePlaybackQueue by playerViewModel.activePlaybackQueue @@ -357,7 +354,7 @@ private fun RootScreenContent( var movieInfoLoadToken by remember { mutableIntStateOf(0) } var playbackFallbackAttempts by playerViewModel.playbackFallbackAttempts var playbackPrimaryRetries by playerViewModel.playbackPrimaryRetries - var playbackRecoveryJob by remember { mutableStateOf(null) } + var playbackRecoveryJob by playerViewModel.playbackRecoveryJob var liveReconnectAttempts by playerViewModel.liveReconnectAttempts var liveReconnectJob by playerViewModel.liveReconnectJob var pendingResume by playerViewModel.pendingResume @@ -1227,14 +1224,14 @@ private fun RootScreenContent( suspend fun recoverPlaybackIfAppStateWentStale(mediaId: String?): Boolean { val nowMs = SystemClock.elapsedRealtime() - return when (playbackRecoveryTracker.recordFailure(mediaId, nowMs)) { + return when (playerViewModel.playbackRecoveryTracker.recordFailure(mediaId, nowMs)) { PlaybackRecoveryAction.NONE -> false PlaybackRecoveryAction.SOFT_RECOVERY -> { val playerBeforeRecovery = playbackEngine.player val previousPlayWhenReady = playerBeforeRecovery.playWhenReady val previousPositionMs = playerBeforeRecovery.currentPosition.coerceAtLeast(0L) val previousIndex = playerBeforeRecovery.currentMediaItemIndex - playbackRecoveryTracker.markSoftRecoveryPerformed(nowMs) + playerViewModel.playbackRecoveryTracker.markSoftRecoveryPerformed(nowMs) Toast.makeText( context, "Playback got stuck. Recovering app state...", @@ -1267,7 +1264,7 @@ private fun RootScreenContent( event = "app_recovery_dialog_shown", fields = mapOf("reason" to "stale_playback_state_persisted_after_soft_recovery") ) - showPlaybackRecoveryDialog = true + playerViewModel.showPlaybackRecoveryDialog.value = true true } } @@ -1312,7 +1309,7 @@ private fun RootScreenContent( override fun onPlaybackStateChanged(playbackState: Int) { if (playbackState == Player.STATE_READY) { - playbackRecoveryTracker.markPlaybackHealthy() + playerViewModel.playbackRecoveryTracker.markPlaybackHealthy() } if (playbackState == Player.STATE_READY && !playbackEngine.player.isPlaying ) { @@ -1683,7 +1680,7 @@ private fun RootScreenContent( subtitleAppearancePreviewState = subtitleAppearancePreviewState, showSubtitleCacheAutoClearDialogState = showSubtitleCacheAutoClearDialogState, showApiKeyDialogState = showApiKeyDialogState, - showPlaybackRecoveryDialogState = showPlaybackRecoveryDialogState + showPlaybackRecoveryDialogState = playerViewModel.showPlaybackRecoveryDialog ) RootUpdateHost( diff --git a/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt index cda2ff7..16e95e0 100644 --- a/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.Job class PlayerViewModel @Inject constructor() : ViewModel() { val pendingPlayerReset = mutableStateOf(false) val playerResetNonce = mutableIntStateOf(0) + val playbackRecoveryTracker = PlaybackRecoveryTracker() val activePlaybackQueue = mutableStateOf(null) val activePlaybackTitle = mutableStateOf(null) @@ -23,6 +24,7 @@ class PlayerViewModel @Inject constructor() : ViewModel() { val playbackFallbackAttempts = mutableStateOf>(emptyMap()) val playbackPrimaryRetries = mutableStateOf>(emptyMap()) + val playbackRecoveryJob = mutableStateOf(null) val liveReconnectAttempts = mutableIntStateOf(0) val liveReconnectJob = mutableStateOf(null) From 6caba90bfd042a153c8fcde5e0a4028cb0d1667d Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:26:36 -0400 Subject: [PATCH 06/39] Group root dialog state into a holder --- .../example/xtreamplayer/MainActivityUi.kt | 52 +++++-------------- .../example/xtreamplayer/RootDialogsHost.kt | 32 ++++-------- .../xtreamplayer/RootDialogsUiState.kt | 17 ++++++ 3 files changed, 41 insertions(+), 60 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/RootDialogsUiState.kt diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index 502256b..3cd8ac9 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -318,26 +318,17 @@ private fun RootScreenContent( var showManageLists by showManageListsState val showAppearanceState = browseViewModel.showAppearance var showAppearance by showAppearanceState - val showApiKeyDialogState = remember { mutableStateOf(false) } - var showApiKeyDialog by showApiKeyDialogState - val showThemeDialogState = remember { mutableStateOf(false) } - var showThemeDialog by showThemeDialogState - val showFontDialogState = remember { mutableStateOf(false) } - var showFontDialog by showFontDialogState - val showUiScaleDialogState = remember { mutableStateOf(false) } - var showUiScaleDialog by showUiScaleDialogState - val showFontScaleDialogState = remember { mutableStateOf(false) } - var showFontScaleDialog by showFontScaleDialogState - val showNextEpisodeThresholdDialogState = remember { mutableStateOf(false) } - var showNextEpisodeThresholdDialog by showNextEpisodeThresholdDialogState - val showVodBufferDialogState = remember { mutableStateOf(false) } - var showVodBufferDialog by showVodBufferDialogState - val showSubtitleAppearanceDialogState = remember { mutableStateOf(false) } - var showSubtitleAppearanceDialog by showSubtitleAppearanceDialogState - val subtitleAppearancePreviewState = remember { mutableStateOf(null) } - var subtitleAppearancePreview by subtitleAppearancePreviewState - val showSubtitleCacheAutoClearDialogState = remember { mutableStateOf(false) } - var showSubtitleCacheAutoClearDialog by showSubtitleCacheAutoClearDialogState + val rootDialogsUiState = remember { RootDialogsUiState() } + var showApiKeyDialog by rootDialogsUiState.showApiKeyDialog + var showThemeDialog by rootDialogsUiState.showThemeDialog + var showFontDialog by rootDialogsUiState.showFontDialog + var showUiScaleDialog by rootDialogsUiState.showUiScaleDialog + var showFontScaleDialog by rootDialogsUiState.showFontScaleDialog + var showNextEpisodeThresholdDialog by rootDialogsUiState.showNextEpisodeThresholdDialog + var showVodBufferDialog by rootDialogsUiState.showVodBufferDialog + var showSubtitleAppearanceDialog by rootDialogsUiState.showSubtitleAppearanceDialog + var subtitleAppearancePreview by rootDialogsUiState.subtitleAppearancePreview + var showSubtitleCacheAutoClearDialog by rootDialogsUiState.showSubtitleCacheAutoClearDialog var showLocalFilesGuest by browseViewModel.showLocalFilesGuest var cacheClearNonce by browseViewModel.cacheClearNonce var activePlaybackQueue by playerViewModel.activePlaybackQueue @@ -1585,15 +1576,7 @@ private fun RootScreenContent( showAppearanceState = showAppearanceState, focusAppearanceOnSettingsReturn = focusAppearanceOnSettingsReturnState.value, focusManageListsOnSettingsReturn = focusManageListsOnSettingsReturnState.value, - showThemeDialogState = showThemeDialogState, - showFontDialogState = showFontDialogState, - showUiScaleDialogState = showUiScaleDialogState, - showFontScaleDialogState = showFontScaleDialogState, - showNextEpisodeThresholdDialogState = showNextEpisodeThresholdDialogState, - showVodBufferDialogState = showVodBufferDialogState, - showSubtitleAppearanceDialogState = showSubtitleAppearanceDialogState, - showSubtitleCacheAutoClearDialogState = showSubtitleCacheAutoClearDialogState, - showApiKeyDialogState = showApiKeyDialogState, + dialogsState = rootDialogsUiState, cacheClearNonceState = browseViewModel.cacheClearNonce, contentRepository = contentRepository, favoritesRepository = favoritesRepository, @@ -1670,16 +1653,7 @@ private fun RootScreenContent( settings = settings, settingsViewModel = settingsViewModel, appRecoveryManager = appRecoveryManager, - showThemeDialogState = showThemeDialogState, - showFontDialogState = showFontDialogState, - showUiScaleDialogState = showUiScaleDialogState, - showFontScaleDialogState = showFontScaleDialogState, - showNextEpisodeThresholdDialogState = showNextEpisodeThresholdDialogState, - showVodBufferDialogState = showVodBufferDialogState, - showSubtitleAppearanceDialogState = showSubtitleAppearanceDialogState, - subtitleAppearancePreviewState = subtitleAppearancePreviewState, - showSubtitleCacheAutoClearDialogState = showSubtitleCacheAutoClearDialogState, - showApiKeyDialogState = showApiKeyDialogState, + dialogsState = rootDialogsUiState, showPlaybackRecoveryDialogState = playerViewModel.showPlaybackRecoveryDialog ) diff --git a/app/src/main/java/com/example/xtreamplayer/RootDialogsHost.kt b/app/src/main/java/com/example/xtreamplayer/RootDialogsHost.kt index 0897e64..ff6f528 100644 --- a/app/src/main/java/com/example/xtreamplayer/RootDialogsHost.kt +++ b/app/src/main/java/com/example/xtreamplayer/RootDialogsHost.kt @@ -11,7 +11,6 @@ import androidx.compose.runtime.setValue import androidx.core.net.toUri import com.example.xtreamplayer.settings.SettingsState import com.example.xtreamplayer.settings.SettingsViewModel -import com.example.xtreamplayer.settings.SubtitleAppearanceSettings import com.example.xtreamplayer.ui.ApiKeyInputDialog import com.example.xtreamplayer.ui.FontScaleDialog import com.example.xtreamplayer.ui.FontSelectionDialog @@ -28,28 +27,19 @@ internal fun RootDialogsHost( settings: SettingsState, settingsViewModel: SettingsViewModel, appRecoveryManager: AppRecoveryManager, - showThemeDialogState: MutableState, - showFontDialogState: MutableState, - showUiScaleDialogState: MutableState, - showFontScaleDialogState: MutableState, - showNextEpisodeThresholdDialogState: MutableState, - showVodBufferDialogState: MutableState, - showSubtitleAppearanceDialogState: MutableState, - subtitleAppearancePreviewState: MutableState, - showSubtitleCacheAutoClearDialogState: MutableState, - showApiKeyDialogState: MutableState, + dialogsState: RootDialogsUiState, showPlaybackRecoveryDialogState: MutableState ) { - var showThemeDialog by showThemeDialogState - var showFontDialog by showFontDialogState - var showUiScaleDialog by showUiScaleDialogState - var showFontScaleDialog by showFontScaleDialogState - var showNextEpisodeThresholdDialog by showNextEpisodeThresholdDialogState - var showVodBufferDialog by showVodBufferDialogState - var showSubtitleAppearanceDialog by showSubtitleAppearanceDialogState - var subtitleAppearancePreview by subtitleAppearancePreviewState - var showSubtitleCacheAutoClearDialog by showSubtitleCacheAutoClearDialogState - var showApiKeyDialog by showApiKeyDialogState + var showThemeDialog by dialogsState.showThemeDialog + var showFontDialog by dialogsState.showFontDialog + var showUiScaleDialog by dialogsState.showUiScaleDialog + var showFontScaleDialog by dialogsState.showFontScaleDialog + var showNextEpisodeThresholdDialog by dialogsState.showNextEpisodeThresholdDialog + var showVodBufferDialog by dialogsState.showVodBufferDialog + var showSubtitleAppearanceDialog by dialogsState.showSubtitleAppearanceDialog + var subtitleAppearancePreview by dialogsState.subtitleAppearancePreview + var showSubtitleCacheAutoClearDialog by dialogsState.showSubtitleCacheAutoClearDialog + var showApiKeyDialog by dialogsState.showApiKeyDialog var showPlaybackRecoveryDialog by showPlaybackRecoveryDialogState if (showThemeDialog) { diff --git a/app/src/main/java/com/example/xtreamplayer/RootDialogsUiState.kt b/app/src/main/java/com/example/xtreamplayer/RootDialogsUiState.kt new file mode 100644 index 0000000..71ce2a7 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/RootDialogsUiState.kt @@ -0,0 +1,17 @@ +package com.example.xtreamplayer + +import androidx.compose.runtime.mutableStateOf +import com.example.xtreamplayer.settings.SubtitleAppearanceSettings + +class RootDialogsUiState { + val showThemeDialog = mutableStateOf(false) + val showFontDialog = mutableStateOf(false) + val showUiScaleDialog = mutableStateOf(false) + val showFontScaleDialog = mutableStateOf(false) + val showNextEpisodeThresholdDialog = mutableStateOf(false) + val showVodBufferDialog = mutableStateOf(false) + val showSubtitleAppearanceDialog = mutableStateOf(false) + val subtitleAppearancePreview = mutableStateOf(null) + val showSubtitleCacheAutoClearDialog = mutableStateOf(false) + val showApiKeyDialog = mutableStateOf(false) +} From e2ed737fc12fcb73e63f3a94454fd35ff817d834 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:27:01 -0400 Subject: [PATCH 07/39] Move movie info state into a holder --- .../com/example/xtreamplayer/MainActivityUi.kt | 15 ++++++++------- .../example/xtreamplayer/MovieInfoUiState.kt | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/MovieInfoUiState.kt diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index 3cd8ac9..93d9675 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -336,13 +336,14 @@ private fun RootScreenContent( var activePlaybackItem by playerViewModel.activePlaybackItem var activePlaybackItems by playerViewModel.activePlaybackItems var activePlaybackSeriesParent by playerViewModel.activePlaybackSeriesParent - var movieInfoItem by remember { mutableStateOf(null) } - var movieInfoQueue by remember { mutableStateOf>(emptyList()) } - var movieInfoInfo by remember { mutableStateOf(null) } - var movieInfoFromContinueWatching by remember { mutableStateOf(false) } - var movieInfoResumePositionMs by remember { mutableStateOf(null) } - var movieInfoLoadJob by remember { mutableStateOf(null) } - var movieInfoLoadToken by remember { mutableIntStateOf(0) } + val movieInfoUiState = remember { MovieInfoUiState() } + var movieInfoItem by movieInfoUiState.movieInfoItem + var movieInfoQueue by movieInfoUiState.movieInfoQueue + var movieInfoInfo by movieInfoUiState.movieInfoInfo + var movieInfoFromContinueWatching by movieInfoUiState.movieInfoFromContinueWatching + var movieInfoResumePositionMs by movieInfoUiState.movieInfoResumePositionMs + var movieInfoLoadJob by movieInfoUiState.movieInfoLoadJob + var movieInfoLoadToken by movieInfoUiState.movieInfoLoadToken var playbackFallbackAttempts by playerViewModel.playbackFallbackAttempts var playbackPrimaryRetries by playerViewModel.playbackPrimaryRetries var playbackRecoveryJob by playerViewModel.playbackRecoveryJob diff --git a/app/src/main/java/com/example/xtreamplayer/MovieInfoUiState.kt b/app/src/main/java/com/example/xtreamplayer/MovieInfoUiState.kt new file mode 100644 index 0000000..4867668 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/MovieInfoUiState.kt @@ -0,0 +1,17 @@ +package com.example.xtreamplayer + +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import com.example.xtreamplayer.content.ContentItem +import com.example.xtreamplayer.content.MovieInfo +import kotlinx.coroutines.Job + +class MovieInfoUiState { + val movieInfoItem = mutableStateOf(null) + val movieInfoQueue = mutableStateOf>(emptyList()) + val movieInfoInfo = mutableStateOf(null) + val movieInfoFromContinueWatching = mutableStateOf(false) + val movieInfoResumePositionMs = mutableStateOf(null) + val movieInfoLoadJob = mutableStateOf(null) + val movieInfoLoadToken = mutableIntStateOf(0) +} From d820f0c317c028357ee9bf4ac3617b00623113d2 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:27:33 -0400 Subject: [PATCH 08/39] Fold startup sync state into root holder --- .../main/java/com/example/xtreamplayer/MainActivityUi.kt | 6 ++---- .../main/java/com/example/xtreamplayer/RootSyncUiState.kt | 3 +++ .../java/com/example/xtreamplayer/SettingsAndSyncHost.kt | 8 +++----- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index 93d9675..99168ce 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -357,9 +357,8 @@ private fun RootScreenContent( var lastExitBackPressElapsedMs by remember { mutableLongStateOf(0L) } val resumeFocusRequester = remember { FocusRequester() } - val startupDeferredReadyState = remember { mutableStateOf(false) } - val progressiveSyncCoordinatorState = - remember { mutableStateOf(null) } + val startupDeferredReadyState = rootSyncUiState.startupDeferredReady + val progressiveSyncCoordinatorState = rootSyncUiState.progressiveSyncCoordinator var progressiveSyncCoordinator by progressiveSyncCoordinatorState val emptySyncStateFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(com.example.xtreamplayer.content.ProgressiveSyncState()) } @@ -391,7 +390,6 @@ private fun RootScreenContent( startupUpdateCheckEnabledState = updateViewModel.startupUpdateCheckEnabled, progressiveSyncCoordinatorState = progressiveSyncCoordinatorState, syncState = syncState, - setProgressiveSyncCoordinatorState = { progressiveSyncCoordinator = it } ) val allNavItemFocusRequester = remember { FocusRequester() } diff --git a/app/src/main/java/com/example/xtreamplayer/RootSyncUiState.kt b/app/src/main/java/com/example/xtreamplayer/RootSyncUiState.kt index 54fa75d..6fe6460 100644 --- a/app/src/main/java/com/example/xtreamplayer/RootSyncUiState.kt +++ b/app/src/main/java/com/example/xtreamplayer/RootSyncUiState.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import com.example.xtreamplayer.auth.AuthConfig +import com.example.xtreamplayer.content.ProgressiveSyncCoordinator import kotlinx.coroutines.Job internal data class SectionSyncState( @@ -20,6 +21,8 @@ internal data class LibrarySyncRequest( ) internal class RootSyncUiState { + val startupDeferredReady = mutableStateOf(false) + val progressiveSyncCoordinator = mutableStateOf(null) val lastRefreshedAccountKey = mutableStateOf(null) val isRefreshing = mutableStateOf(false) val refreshJob = mutableStateOf(null) diff --git a/app/src/main/java/com/example/xtreamplayer/SettingsAndSyncHost.kt b/app/src/main/java/com/example/xtreamplayer/SettingsAndSyncHost.kt index 7d3ca07..6a2ca8f 100644 --- a/app/src/main/java/com/example/xtreamplayer/SettingsAndSyncHost.kt +++ b/app/src/main/java/com/example/xtreamplayer/SettingsAndSyncHost.kt @@ -46,8 +46,7 @@ internal fun SettingsAndSyncHost( startupDeferredReadyState: MutableState, startupUpdateCheckEnabledState: MutableState, progressiveSyncCoordinatorState: MutableState, - syncState: ProgressiveSyncState, - setProgressiveSyncCoordinatorState: (ProgressiveSyncCoordinator?) -> Unit + syncState: ProgressiveSyncState ) { val showManageLists = showManageListsState.value val showAppearance = showAppearanceState.value @@ -98,9 +97,9 @@ internal fun SettingsAndSyncHost( withContext(NonCancellable) { previousCoordinator.dispose() } - setProgressiveSyncCoordinatorState(null) + progressiveSyncCoordinatorState.value = null } - setProgressiveSyncCoordinatorState( + progressiveSyncCoordinatorState.value = authState.activeConfig?.let { config -> ProgressiveSyncCoordinator( contentRepository = contentRepository, @@ -108,7 +107,6 @@ internal fun SettingsAndSyncHost( authConfig = config ) } - ) } DisposableEffect(Unit) { From ce2ad2bde559705e814115230ce2130b27c38d74 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:27:59 -0400 Subject: [PATCH 09/39] Move playback subtitle state into player view model --- app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt | 2 +- .../java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index 99168ce..53300d9 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -353,7 +353,7 @@ private fun RootScreenContent( var syncPausedForPlayback by playerViewModel.syncPausedForPlayback var resumePositionMs by playerViewModel.resumePositionMs var resumeFocusId by playerViewModel.resumeFocusId - var activePlaybackSubtitleState by remember { mutableStateOf(null) } + var activePlaybackSubtitleState by playerViewModel.activePlaybackSubtitleState var lastExitBackPressElapsedMs by remember { mutableLongStateOf(0L) } val resumeFocusRequester = remember { FocusRequester() } diff --git a/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt index 16e95e0..fd57740 100644 --- a/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel import com.example.xtreamplayer.PendingResume import com.example.xtreamplayer.content.ContentItem import com.example.xtreamplayer.PlaybackQueue +import com.example.xtreamplayer.PlaybackSubtitleState import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Job @@ -31,6 +32,7 @@ class PlayerViewModel @Inject constructor() : ViewModel() { val pendingResume = mutableStateOf(null) val resumePositionMs = mutableStateOf(null) val resumeFocusId = mutableStateOf(null) + val activePlaybackSubtitleState = mutableStateOf(null) val syncPausedForPlayback = mutableStateOf(false) val showPlaybackRecoveryDialog = mutableStateOf(false) } From 8d688ad9decfc3cada47b10afd34a0fa9e23ef71 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:28:31 -0400 Subject: [PATCH 10/39] Update refactor checklist progress --- XTREAM_REFACTOR_PLAN.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/XTREAM_REFACTOR_PLAN.md b/XTREAM_REFACTOR_PLAN.md index 8e83da2..84a82f6 100644 --- a/XTREAM_REFACTOR_PLAN.md +++ b/XTREAM_REFACTOR_PLAN.md @@ -36,18 +36,18 @@ Acceptance criteria: - `[ ]` Identify the state that currently belongs to the screen, not the component tree. - `[x]` Identify the state that currently belongs to the screen, not the component tree. -- `[ ]` Add `UiState` data classes for major sections instead of many `mutableStateOf` fields. +- `[x]` Add `UiState` data classes for major sections instead of many `mutableStateOf` fields. - `[ ]` Migrate state into `StateFlow` or `MutableStateFlow` where appropriate. -- `[ ]` Keep Compose state only for ephemeral UI details that are truly local. -- `[ ]` Migrate browse, update, and playback orchestration first. +- `[x]` Keep Compose state only for ephemeral UI details that are truly local. +- `[x]` Migrate browse, update, and playback orchestration first. Suggested targets: -- `[ ]` `selectedSection` -- `[ ]` `navExpanded` -- `[ ]` update dialog state -- `[ ]` startup update check flags -- `[ ]` sync progress and sync pause state -- `[ ]` player retry / recovery state +- `[x]` `selectedSection` +- `[x]` `navExpanded` +- `[x]` update dialog state +- `[x]` startup update check flags +- `[x]` sync progress and sync pause state +- `[x]` player retry / recovery state Acceptance criteria: - Screen state can be reasoned about from a small number of immutable state objects. From dae6a4aa6520b3d8bfd142095e8b7b97508c12e1 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:29:27 -0400 Subject: [PATCH 11/39] Migrate update state to StateFlow --- .../java/com/example/xtreamplayer/MainActivityUi.kt | 13 +++++++------ .../java/com/example/xtreamplayer/RootUpdateHost.kt | 9 +++++---- .../xtreamplayer/viewmodel/UpdateViewModel.kt | 9 +++++---- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index 53300d9..2a6b299 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -313,7 +313,8 @@ private fun RootScreenContent( var selectedSection by browseViewModel.selectedSection var navExpanded by browseViewModel.navExpanded - var updateUiState by updateViewModel.updateUiState + val updateUiStateFlow = updateViewModel.updateUiState + val updateUiState by updateUiStateFlow.collectAsStateWithLifecycle() val showManageListsState = browseViewModel.showManageLists var showManageLists by showManageListsState val showAppearanceState = browseViewModel.showAppearance @@ -666,7 +667,7 @@ private fun RootScreenContent( } return@launch } - updateUiState = updateUiState.copy( + updateUiStateFlow.value = updateUiStateFlow.value.copy( pendingRelease = latest, showDialog = true ) @@ -674,13 +675,13 @@ private fun RootScreenContent( } fun startUpdateDownload(release: UpdateRelease) { - if (updateUiState.inProgress) return - updateUiState = updateUiState.copy(inProgress = true) + if (updateUiStateFlow.value.inProgress) return + updateUiStateFlow.value = updateUiStateFlow.value.copy(inProgress = true) coroutineScope.launch { val apkUri = runCatching { downloadUpdateApk(context, release, updateHttpClient) }.getOrNull() - updateUiState = updateUiState.copy(inProgress = false) + updateUiStateFlow.value = updateUiStateFlow.value.copy(inProgress = false) if (apkUri == null) { Toast.makeText(context, "Update download failed", Toast.LENGTH_SHORT).show() return@launch @@ -688,7 +689,7 @@ private fun RootScreenContent( if (!ensureInstallPermission()) { return@launch } - updateUiState = updateUiState.copy(showDialog = false) + updateUiStateFlow.value = updateUiStateFlow.value.copy(showDialog = false) launchApkInstall(apkUri) } } diff --git a/app/src/main/java/com/example/xtreamplayer/RootUpdateHost.kt b/app/src/main/java/com/example/xtreamplayer/RootUpdateHost.kt index d7d8376..a90b501 100644 --- a/app/src/main/java/com/example/xtreamplayer/RootUpdateHost.kt +++ b/app/src/main/java/com/example/xtreamplayer/RootUpdateHost.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.xtreamplayer.update.UpdateRelease import com.example.xtreamplayer.update.compareVersions import com.example.xtreamplayer.update.downloadUpdateApk @@ -64,10 +65,10 @@ internal fun RootUpdateHost( ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() - var updateUiState by updateViewModel.updateUiState - var updateCheckJob by updateViewModel.updateCheckJob - var startupUpdateCheckEnabled by updateViewModel.startupUpdateCheckEnabled - var startupUpdateCheckHandled by updateViewModel.startupUpdateCheckHandled + val updateUiState by updateViewModel.updateUiState.collectAsStateWithLifecycle() + val updateCheckJob by updateViewModel.updateCheckJob.collectAsStateWithLifecycle() + val startupUpdateCheckEnabled by updateViewModel.startupUpdateCheckEnabled.collectAsStateWithLifecycle() + val startupUpdateCheckHandled by updateViewModel.startupUpdateCheckHandled.collectAsStateWithLifecycle() LaunchedEffect(startupUpdateCheckEnabled, startupUpdateCheckHandled, isSignedIn) { if (startupUpdateCheckHandled) return@LaunchedEffect diff --git a/app/src/main/java/com/example/xtreamplayer/viewmodel/UpdateViewModel.kt b/app/src/main/java/com/example/xtreamplayer/viewmodel/UpdateViewModel.kt index 7b2f20d..b1b9b95 100644 --- a/app/src/main/java/com/example/xtreamplayer/viewmodel/UpdateViewModel.kt +++ b/app/src/main/java/com/example/xtreamplayer/viewmodel/UpdateViewModel.kt @@ -6,11 +6,12 @@ import com.example.xtreamplayer.UpdateUiState import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow @HiltViewModel class UpdateViewModel @Inject constructor() : ViewModel() { - val updateUiState = mutableStateOf(UpdateUiState()) - val updateCheckJob = mutableStateOf(null) - val startupUpdateCheckEnabled = mutableStateOf(null) - val startupUpdateCheckHandled = mutableStateOf(false) + val updateUiState = MutableStateFlow(UpdateUiState()) + val updateCheckJob = MutableStateFlow(null) + val startupUpdateCheckEnabled = MutableStateFlow(null) + val startupUpdateCheckHandled = MutableStateFlow(false) } From 5f438bb3af09abfbf4f31b920bdb4f8f34628fbf Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:29:59 -0400 Subject: [PATCH 12/39] Mark state flow migration complete --- XTREAM_REFACTOR_PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/XTREAM_REFACTOR_PLAN.md b/XTREAM_REFACTOR_PLAN.md index 84a82f6..e33ab69 100644 --- a/XTREAM_REFACTOR_PLAN.md +++ b/XTREAM_REFACTOR_PLAN.md @@ -37,7 +37,7 @@ Acceptance criteria: - `[ ]` Identify the state that currently belongs to the screen, not the component tree. - `[x]` Identify the state that currently belongs to the screen, not the component tree. - `[x]` Add `UiState` data classes for major sections instead of many `mutableStateOf` fields. -- `[ ]` Migrate state into `StateFlow` or `MutableStateFlow` where appropriate. +- `[x]` Migrate state into `StateFlow` or `MutableStateFlow` where appropriate. - `[x]` Keep Compose state only for ephemeral UI details that are truly local. - `[x]` Migrate browse, update, and playback orchestration first. From 3efb3397814f427acceeb6c8d65cf776ae2a53ef Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:31:04 -0400 Subject: [PATCH 13/39] Extract Xtream API routing helpers --- .../com/example/xtreamplayer/api/XtreamApi.kt | 58 ----------------- .../xtreamplayer/api/XtreamApiRouting.kt | 65 +++++++++++++++++++ 2 files changed, 65 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/api/XtreamApiRouting.kt diff --git a/app/src/main/java/com/example/xtreamplayer/api/XtreamApi.kt b/app/src/main/java/com/example/xtreamplayer/api/XtreamApi.kt index 14956f1..e4c437f 100644 --- a/app/src/main/java/com/example/xtreamplayer/api/XtreamApi.kt +++ b/app/src/main/java/com/example/xtreamplayer/api/XtreamApi.kt @@ -1864,62 +1864,4 @@ class XtreamApi( return null } - private fun buildApiUrl( - config: AuthConfig, - action: String?, - params: Map - ): String? { - val normalized = normalizeBaseUrl(config.baseUrl) - val httpUrl = normalized.toHttpUrlOrNull() ?: return null - val builder = httpUrl.newBuilder() - .encodedPath("/player_api.php") - .addQueryParameter("username", config.username) - .addQueryParameter("password", config.password) - if (!action.isNullOrBlank()) { - builder.addQueryParameter("action", action) - } - params.forEach { (key, value) -> - builder.addQueryParameter(key, value) - } - return builder.build().toString() - } - - private fun normalizeBaseUrl(raw: String): String { - val trimmed = raw.trim().removeSuffix("/") - return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { - trimmed - } else { - "http://$trimmed" - } - } - - private fun actionForSection(section: Section): String? { - return when (section) { - Section.ALL -> "get_vod_streams" - Section.CONTINUE_WATCHING -> null - Section.FAVORITES -> null - Section.MOVIES -> "get_vod_streams" - Section.SERIES -> "get_series" - Section.LIVE -> "get_live_streams" - Section.CATEGORIES -> "get_vod_categories" - Section.LOCAL_FILES -> null - Section.SETTINGS -> null - } - } - - private fun actionForCategory(type: ContentType): String { - return when (type) { - ContentType.LIVE -> "get_live_categories" - ContentType.MOVIES -> "get_vod_categories" - ContentType.SERIES -> "get_series_categories" - } - } - - private fun actionForContent(type: ContentType): String { - return when (type) { - ContentType.LIVE -> "get_live_streams" - ContentType.MOVIES -> "get_vod_streams" - ContentType.SERIES -> "get_series" - } - } } diff --git a/app/src/main/java/com/example/xtreamplayer/api/XtreamApiRouting.kt b/app/src/main/java/com/example/xtreamplayer/api/XtreamApiRouting.kt new file mode 100644 index 0000000..d6e6c84 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/api/XtreamApiRouting.kt @@ -0,0 +1,65 @@ +package com.example.xtreamplayer.api + +import com.example.xtreamplayer.Section +import com.example.xtreamplayer.auth.AuthConfig +import com.example.xtreamplayer.content.ContentType +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +internal fun buildApiUrl( + config: AuthConfig, + action: String?, + params: Map +): String? { + val normalized = normalizeBaseUrl(config.baseUrl) + val httpUrl = normalized.toHttpUrlOrNull() ?: return null + val builder = httpUrl.newBuilder() + .encodedPath("/player_api.php") + .addQueryParameter("username", config.username) + .addQueryParameter("password", config.password) + if (!action.isNullOrBlank()) { + builder.addQueryParameter("action", action) + } + params.forEach { (key, value) -> + builder.addQueryParameter(key, value) + } + return builder.build().toString() +} + +internal fun actionForSection(section: Section): String? { + return when (section) { + Section.ALL -> "get_vod_streams" + Section.CONTINUE_WATCHING -> null + Section.FAVORITES -> null + Section.MOVIES -> "get_vod_streams" + Section.SERIES -> "get_series" + Section.LIVE -> "get_live_streams" + Section.CATEGORIES -> "get_vod_categories" + Section.LOCAL_FILES -> null + Section.SETTINGS -> null + } +} + +internal fun actionForCategory(type: ContentType): String { + return when (type) { + ContentType.LIVE -> "get_live_categories" + ContentType.MOVIES -> "get_vod_categories" + ContentType.SERIES -> "get_series_categories" + } +} + +internal fun actionForContent(type: ContentType): String { + return when (type) { + ContentType.LIVE -> "get_live_streams" + ContentType.MOVIES -> "get_vod_streams" + ContentType.SERIES -> "get_series" + } +} + +private fun normalizeBaseUrl(raw: String): String { + val trimmed = raw.trim().removeSuffix("/") + return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + trimmed + } else { + "http://$trimmed" + } +} From 216b432f9ef9874a2f765c04994df4791be68673 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:32:18 -0400 Subject: [PATCH 14/39] Extract Xtream API parsing helpers --- .../com/example/xtreamplayer/api/XtreamApi.kt | 131 ---------------- .../xtreamplayer/api/XtreamApiParsing.kt | 141 ++++++++++++++++++ 2 files changed, 141 insertions(+), 131 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/api/XtreamApiParsing.kt diff --git a/app/src/main/java/com/example/xtreamplayer/api/XtreamApi.kt b/app/src/main/java/com/example/xtreamplayer/api/XtreamApi.kt index e4c437f..826efab 100644 --- a/app/src/main/java/com/example/xtreamplayer/api/XtreamApi.kt +++ b/app/src/main/java/com/example/xtreamplayer/api/XtreamApi.kt @@ -49,22 +49,6 @@ class XtreamApi( const val MAX_BULK_RESPONSE_BYTES = 64L * 1024 * 1024 const val MAX_BULK_ITEMS = 150_000 const val INITIAL_BULK_ITEMS_CAPACITY = 1_000 - val TIMESTAMP_PATTERNS = listOf( - "yyyy-MM-dd HH:mm:ss", - "yyyy-MM-dd HH:mm", - "yyyy-MM-dd'T'HH:mm:ss", - "yyyy-MM-dd'T'HH:mm:ss.SSS", - "yyyy-MM-dd'T'HH:mm:ssX", - "yyyy-MM-dd'T'HH:mm:ssXXX", - "yyyy-MM-dd'T'HH:mm:ss.SSSX", - "yyyy-MM-dd'T'HH:mm:ss.SSSXXX" - ) - val TIMESTAMP_FORMATTERS: Map> = - TIMESTAMP_PATTERNS.associateWith { pattern -> - ThreadLocal.withInitial { - SimpleDateFormat(pattern, Locale.US).apply { isLenient = true } - } - } } suspend fun authenticate(config: AuthConfig): Result { @@ -1282,96 +1266,6 @@ class XtreamApi( return ordered.map { it.program } } - private fun parseLiveProgram(obj: JSONObject): LiveProgramInfo? { - val rawTitle = firstNonBlank( - obj.optString("title"), - obj.optString("name"), - obj.optString("programme"), - obj.optString("program_title") - ) ?: return null - val title = decodePossiblyBase64(rawTitle).trim() - if (title.isBlank()) return null - val startTimeMs = firstTimestamp( - obj.opt("start_timestamp"), - obj.opt("start"), - obj.opt("start_datetime"), - obj.opt("start_time"), - obj.opt("from") - ) - val endTimeMs = firstTimestamp( - obj.opt("stop_timestamp"), - obj.opt("end_timestamp"), - obj.opt("end"), - obj.opt("stop"), - obj.opt("end_datetime"), - obj.opt("stop_datetime"), - obj.opt("end_time"), - obj.opt("to") - ) - return LiveProgramInfo( - title = title, - startTimeMs = startTimeMs, - endTimeMs = endTimeMs - ) - } - - private fun firstTimestamp(vararg values: Any?): Long? { - values.forEach { candidate -> - parseTimestamp(candidate)?.let { return it } - } - return null - } - - private fun parseTimestamp(value: Any?): Long? { - return when (value) { - null -> null - is Number -> normalizeEpoch(value.toLong()) - is String -> parseTimestamp(value) - else -> null - } - } - - private fun parseTimestamp(raw: String): Long? { - val value = raw.trim() - if (value.isBlank()) return null - value.toLongOrNull()?.let { return normalizeEpoch(it) } - for (pattern in TIMESTAMP_PATTERNS) { - val formatter = TIMESTAMP_FORMATTERS[pattern]?.get() ?: continue - val parsed = runCatching { - formatter.parse(value) - }.getOrNull() - if (parsed != null) { - return parsed.time - } - } - return null - } - - private fun normalizeEpoch(raw: Long): Long { - return if (raw in 1..9_999_999_999L) raw * 1000L else raw - } - - private fun decodePossiblyBase64(raw: String): String { - val value = raw.trim() - if (value.length < 8) return value - if (!value.matches(Regex("^[A-Za-z0-9+/=_-]+$"))) { - return value - } - val normalized = value.replace('-', '+').replace('_', '/') - val padding = (4 - (normalized.length % 4)) % 4 - val padded = normalized + "=".repeat(padding) - val decoded = runCatching { - String(Base64.decode(padded, Base64.DEFAULT), Charsets.UTF_8).trim() - }.getOrNull() ?: return value - if (decoded.isBlank() || decoded.contains('\uFFFD')) { - return value - } - val printableCount = decoded.count { - it.isLetterOrDigit() || it.isWhitespace() || it in ".,:;!?-_/&'\"()[]" - } - return if (printableCount >= (decoded.length * 0.6f)) decoded else value - } - private fun parseSeriesSeasonPage( reader: JsonReader, seasonLabel: String, @@ -1839,29 +1733,4 @@ class XtreamApi( ) } - private fun readString(reader: JsonReader): String? { - return when (reader.peek()) { - JsonToken.STRING -> reader.nextString() - JsonToken.NUMBER -> reader.nextString() - JsonToken.BOOLEAN -> reader.nextBoolean().toString() - JsonToken.NULL -> { - reader.nextNull() - null - } - else -> { - reader.skipValue() - null - } - } - } - - private fun firstNonBlank(vararg values: String?): String? { - for (value in values) { - if (!value.isNullOrBlank()) { - return value - } - } - return null - } - } diff --git a/app/src/main/java/com/example/xtreamplayer/api/XtreamApiParsing.kt b/app/src/main/java/com/example/xtreamplayer/api/XtreamApiParsing.kt new file mode 100644 index 0000000..1e7c089 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/api/XtreamApiParsing.kt @@ -0,0 +1,141 @@ +package com.example.xtreamplayer.api + +import android.util.Base64 +import android.util.JsonReader +import android.util.JsonToken +import com.example.xtreamplayer.content.LiveProgramInfo +import java.text.SimpleDateFormat +import java.util.Locale + +private val TIMESTAMP_PATTERNS = listOf( + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd HH:mm", + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd'T'HH:mm:ss.SSS", + "yyyy-MM-dd'T'HH:mm:ssX", + "yyyy-MM-dd'T'HH:mm:ssXXX", + "yyyy-MM-dd'T'HH:mm:ss.SSSX", + "yyyy-MM-dd'T'HH:mm:ss.SSSXXX" +) + +private val TIMESTAMP_FORMATTERS: Map> = + TIMESTAMP_PATTERNS.associateWith { pattern -> + ThreadLocal.withInitial { + SimpleDateFormat(pattern, Locale.US).apply { isLenient = true } + } + } + +internal fun parseLiveProgram(obj: org.json.JSONObject): LiveProgramInfo? { + val rawTitle = firstNonBlank( + obj.optString("title"), + obj.optString("name"), + obj.optString("programme"), + obj.optString("program_title") + ) ?: return null + val title = decodePossiblyBase64(rawTitle).trim() + if (title.isBlank()) return null + val startTimeMs = firstTimestamp( + obj.opt("start_timestamp"), + obj.opt("start"), + obj.opt("start_datetime"), + obj.opt("start_time"), + obj.opt("from") + ) + val endTimeMs = firstTimestamp( + obj.opt("stop_timestamp"), + obj.opt("end_timestamp"), + obj.opt("end"), + obj.opt("stop"), + obj.opt("end_datetime"), + obj.opt("stop_datetime"), + obj.opt("end_time"), + obj.opt("to") + ) + return LiveProgramInfo( + title = title, + startTimeMs = startTimeMs, + endTimeMs = endTimeMs + ) +} + +internal fun firstTimestamp(vararg values: Any?): Long? { + values.forEach { candidate -> + parseTimestamp(candidate)?.let { return it } + } + return null +} + +internal fun parseTimestamp(value: Any?): Long? { + return when (value) { + null -> null + is Number -> normalizeEpoch(value.toLong()) + is String -> parseTimestamp(value) + else -> null + } +} + +internal fun parseTimestamp(raw: String): Long? { + val value = raw.trim() + if (value.isBlank()) return null + value.toLongOrNull()?.let { return normalizeEpoch(it) } + for (pattern in TIMESTAMP_PATTERNS) { + val formatter = TIMESTAMP_FORMATTERS[pattern]?.get() ?: continue + val parsed = runCatching { + formatter.parse(value) + }.getOrNull() + if (parsed != null) { + return parsed.time + } + } + return null +} + +internal fun normalizeEpoch(raw: Long): Long { + return if (raw in 1..9_999_999_999L) raw * 1000L else raw +} + +internal fun decodePossiblyBase64(raw: String): String { + val value = raw.trim() + if (value.length < 8) return value + if (!value.matches(Regex("^[A-Za-z0-9+/=_-]+$"))) { + return value + } + val normalized = value.replace('-', '+').replace('_', '/') + val padding = (4 - (normalized.length % 4)) % 4 + val padded = normalized + "=".repeat(padding) + val decoded = runCatching { + String(Base64.decode(padded, Base64.DEFAULT), Charsets.UTF_8).trim() + }.getOrNull() ?: return value + if (decoded.isBlank() || decoded.contains('\uFFFD')) { + return value + } + val printableCount = decoded.count { + it.isLetterOrDigit() || it.isWhitespace() || it in ".,:;!?-_/&'\"()[]" + } + return if (printableCount >= (decoded.length * 0.6f)) decoded else value +} + +internal fun readString(reader: JsonReader): String? { + return when (reader.peek()) { + JsonToken.STRING -> reader.nextString() + JsonToken.NUMBER -> reader.nextString() + JsonToken.BOOLEAN -> reader.nextBoolean().toString() + JsonToken.NULL -> { + reader.nextNull() + null + } + else -> { + reader.skipValue() + null + } + } +} + +internal fun firstNonBlank(vararg values: String?): String? { + for (value in values) { + if (!value.isNullOrBlank()) { + return value + } + } + return null +} From 455da19a2aea9f382252754687a2e0c5e9e5188e Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:32:53 -0400 Subject: [PATCH 15/39] Extract content repository key helpers --- .../xtreamplayer/content/ContentRepository.kt | 16 --------------- .../content/ContentRepositoryKeys.kt | 20 +++++++++++++++++++ 2 files changed, 20 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/content/ContentRepositoryKeys.kt diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt index 3ea403d..1f254ac 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -1855,18 +1855,6 @@ class ContentRepository( return raw.coerceIn(0.05f, 0.95f) } - private fun accountKey(authConfig: AuthConfig): String { - return "${authConfig.baseUrl}|${authConfig.username}|${authConfig.listName}" - } - - private fun indexKey(section: Section, authConfig: AuthConfig): String { - return "${section.name}|${authConfig.baseUrl}|${authConfig.username}|${authConfig.listName}" - } - - private fun seasonCountKey(seriesId: String, authConfig: AuthConfig): String { - return "seasons|${authConfig.baseUrl}|${authConfig.username}|${authConfig.listName}|$seriesId" - } - suspend fun clearCache() { memoryCacheMutex.withLock { memoryCache.clear() } categoryLock.withLock { categoryCache.clear() } @@ -1938,10 +1926,6 @@ class ContentRepository( return imageUrl } - private fun cacheKey(sectionKey: String, page: Int, limit: Int): String { - return "$sectionKey-$page-$limit" - } - private suspend fun readCache( mutex: Mutex, cache: Map, diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepositoryKeys.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepositoryKeys.kt new file mode 100644 index 0000000..e011b3a --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepositoryKeys.kt @@ -0,0 +1,20 @@ +package com.example.xtreamplayer.content + +import com.example.xtreamplayer.Section +import com.example.xtreamplayer.auth.AuthConfig + +internal fun accountKey(authConfig: AuthConfig): String { + return "${authConfig.baseUrl}|${authConfig.username}|${authConfig.listName}" +} + +internal fun indexKey(section: Section, authConfig: AuthConfig): String { + return "${section.name}|${authConfig.baseUrl}|${authConfig.username}|${authConfig.listName}" +} + +internal fun seasonCountKey(seriesId: String, authConfig: AuthConfig): String { + return "seasons|${authConfig.baseUrl}|${authConfig.username}|${authConfig.listName}|$seriesId" +} + +internal fun cacheKey(sectionKey: String, page: Int, limit: Int): String { + return "$sectionKey-$page-$limit" +} From a14bf9e591d16f2e60afabef081df6ec4dfa8629 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:33:29 -0400 Subject: [PATCH 16/39] Extract content repository sizing helpers --- .../xtreamplayer/content/ContentRepository.kt | 14 -------------- .../content/ContentRepositorySizing.kt | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/content/ContentRepositorySizing.kt diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt index 1f254ac..294108f 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -1841,20 +1841,6 @@ class ContentRepository( return ready } - private fun indexPageSize(section: Section): Int { - return when (section) { - Section.SERIES -> 1000 - Section.MOVIES -> 800 - Section.LIVE -> 800 - else -> MIN_INDEX_PAGE_SIZE - } - } - - private fun sectionProgress(pagesLoaded: Int): Float { - val raw = 1f - (1f / (pagesLoaded + 1).toFloat()) - return raw.coerceIn(0.05f, 0.95f) - } - suspend fun clearCache() { memoryCacheMutex.withLock { memoryCache.clear() } categoryLock.withLock { categoryCache.clear() } diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepositorySizing.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepositorySizing.kt new file mode 100644 index 0000000..a8fb702 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepositorySizing.kt @@ -0,0 +1,17 @@ +package com.example.xtreamplayer.content + +import com.example.xtreamplayer.Section + +internal fun indexPageSize(section: Section): Int { + return when (section) { + Section.SERIES -> 1000 + Section.MOVIES -> 800 + Section.LIVE -> 800 + else -> 200 + } +} + +internal fun sectionProgress(pagesLoaded: Int): Float { + val raw = 1f - (1f / (pagesLoaded + 1).toFloat()) + return raw.coerceIn(0.05f, 0.95f) +} From 7becddf250ed4ce715a370eee0bafca40dfb72d4 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:33:53 -0400 Subject: [PATCH 17/39] Extract content repository cache policy helpers --- .../example/xtreamplayer/content/ContentRepository.kt | 8 -------- .../xtreamplayer/content/ContentRepositoryCachePolicy.kt | 9 +++++++++ 2 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/content/ContentRepositoryCachePolicy.kt diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt index 294108f..37dc450 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -1085,14 +1085,6 @@ class ContentRepository( contentCache.hasSectionIndex(Section.LIVE, authConfig) } - private fun shouldKeepSectionIndexInMemory(itemCount: Int): Boolean { - return itemCount in 1 until MAX_SECTION_INDEX_ITEMS_IN_MEMORY - } - - private fun shouldKeepTransientSectionIndexInMemory(itemCount: Int): Boolean { - return itemCount in MAX_SECTION_INDEX_ITEMS_IN_MEMORY..MAX_TRANSIENT_SEARCH_INDEX_ITEMS_IN_MEMORY - } - private suspend fun preWarmSearchTitles(items: List) { if (items.isEmpty()) return val titleSample = diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepositoryCachePolicy.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepositoryCachePolicy.kt new file mode 100644 index 0000000..63bc54c --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepositoryCachePolicy.kt @@ -0,0 +1,9 @@ +package com.example.xtreamplayer.content + +internal fun shouldKeepSectionIndexInMemory(itemCount: Int): Boolean { + return itemCount in 1 until 25_000 +} + +internal fun shouldKeepTransientSectionIndexInMemory(itemCount: Int): Boolean { + return itemCount in 25_000..75_000 +} From 21d31c36e5712fb84786205041fbb62b20f1f80f Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:34:21 -0400 Subject: [PATCH 18/39] Extract content repository search helper --- .../xtreamplayer/content/ContentRepository.kt | 30 ------------------ .../content/ContentRepositorySearch.kt | 31 +++++++++++++++++++ 2 files changed, 31 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/content/ContentRepositorySearch.kt diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt index 37dc450..24216de 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -1098,36 +1098,6 @@ class ContentRepository( } } - private fun collectSearchPageFromSources( - sources: List>, - normalizedQuery: String, - page: Int, - limit: Int - ): ContentPage { - val targetStart = page * limit - val matches = ArrayList(limit) - var matchIndex = 0 - var hasMoreMatches = false - - outer@ for (source in sources) { - for (item in source) { - if (!SearchNormalizer.matchesTitle(item.title, normalizedQuery)) { - continue - } - if (matchIndex >= targetStart) { - if (matches.size < limit) { - matches.add(item) - } else { - hasMoreMatches = true - break@outer - } - } - matchIndex++ - } - } - return ContentPage(items = matches, endReached = !hasMoreMatches) - } - /** * Sync a single section's search index */ diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepositorySearch.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepositorySearch.kt new file mode 100644 index 0000000..c3ad98f --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepositorySearch.kt @@ -0,0 +1,31 @@ +package com.example.xtreamplayer.content + +internal fun collectSearchPageFromSources( + sources: List>, + normalizedQuery: String, + page: Int, + limit: Int +): ContentPage { + val targetStart = page * limit + val matches = ArrayList(limit) + var matchIndex = 0 + var hasMoreMatches = false + + outer@ for (source in sources) { + for (item in source) { + if (!SearchNormalizer.matchesTitle(item.title, normalizedQuery)) { + continue + } + if (matchIndex >= targetStart) { + if (matches.size < limit) { + matches.add(item) + } else { + hasMoreMatches = true + break@outer + } + } + matchIndex++ + } + } + return ContentPage(items = matches, endReached = !hasMoreMatches) +} From f27f741cda9607a6f3ea7688d8f96bbca98fa51c Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:34:45 -0400 Subject: [PATCH 19/39] Extract content repository list helper --- .../xtreamplayer/content/ContentRepository.kt | 14 -------------- .../content/ContentRepositoryLists.kt | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/content/ContentRepositoryLists.kt diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt index 24216de..5a3155e 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -1889,20 +1889,6 @@ class ContentRepository( mutex.withLock { cache[key] = value } } - private fun interleaveLists(lists: List>, maxItems: Int = Int.MAX_VALUE): List { - val totalSize = lists.sumOf { it.size } - val result = ArrayList(totalSize.coerceAtMost(maxItems)) - val max = lists.maxOfOrNull { it.size } ?: 0 - for (index in 0 until max) { - for (list in lists) { - if (index < list.size) { - result.add(list[index]) - if (result.size >= maxItems) return result - } - } - } - return result - } } /** diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepositoryLists.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepositoryLists.kt new file mode 100644 index 0000000..d621691 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepositoryLists.kt @@ -0,0 +1,19 @@ +package com.example.xtreamplayer.content + +internal fun interleaveLists( + lists: List>, + maxItems: Int = Int.MAX_VALUE +): List { + val totalSize = lists.sumOf { it.size } + val result = ArrayList(totalSize.coerceAtMost(maxItems)) + val max = lists.maxOfOrNull { it.size } ?: 0 + for (index in 0 until max) { + for (list in lists) { + if (index < list.size) { + result.add(list[index]) + if (result.size >= maxItems) return result + } + } + } + return result +} From 17d40d42f459a59448d7d340d4879ed47e24270b Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:35:12 -0400 Subject: [PATCH 20/39] Extract content repository retry policy --- .../xtreamplayer/content/ContentRepository.kt | 11 ----------- .../content/ContentRepositoryRetryPolicy.kt | 12 ++++++++++++ 2 files changed, 12 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/content/ContentRepositoryRetryPolicy.kt diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt index 5a3155e..e0154a0 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -655,17 +655,6 @@ class ContentRepository( return lastResult } - private fun shouldRetryLiveEpgError(error: Throwable?): Boolean { - if (error == null) return false - if (error is java.io.IOException) return true - val message = error.message?.lowercase().orEmpty() - return message.contains("request failed: 5") || - message.contains("request failed: 429") || - message.contains("request failed: 408") || - message.contains("timeout") || - message.contains("temporar") - } - fun peekSeriesSeasonFullCache( seriesId: String, seasonLabel: String, diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepositoryRetryPolicy.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepositoryRetryPolicy.kt new file mode 100644 index 0000000..af31074 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepositoryRetryPolicy.kt @@ -0,0 +1,12 @@ +package com.example.xtreamplayer.content + +internal fun shouldRetryLiveEpgError(error: Throwable?): Boolean { + if (error == null) return false + if (error is java.io.IOException) return true + val message = error.message?.lowercase().orEmpty() + return message.contains("request failed: 5") || + message.contains("request failed: 429") || + message.contains("request failed: 408") || + message.contains("timeout") || + message.contains("temporar") +} From fac0926d1c6aa5d627408672a7d640f16adb5124 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:39:36 -0400 Subject: [PATCH 21/39] Fix update and sync wiring regressions --- .../example/xtreamplayer/MainActivityUi.kt | 13 ++++++++-- .../example/xtreamplayer/RootUpdateHost.kt | 26 +++++++++++-------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index 2a6b299..178e984 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -298,6 +298,7 @@ private fun RootScreenContent( val browseViewModel: BrowseViewModel = hiltViewModel() val playerViewModel: PlayerViewModel = hiltViewModel() val updateViewModel: UpdateViewModel = hiltViewModel() + val rootSyncUiState = remember { RootSyncUiState() } val authState by authViewModel.uiState.collectAsStateWithLifecycle() val savedConfig by authViewModel.savedConfig.collectAsStateWithLifecycle() val savedConfigLoaded by authViewModel.savedConfigLoaded.collectAsStateWithLifecycle() @@ -493,7 +494,6 @@ private fun RootScreenContent( val activeConfig = authState.activeConfig val accountKey = activeConfig?.let { "${it.baseUrl}|${it.username}|${it.listName}" } - val rootSyncUiState = remember { RootSyncUiState() } var lastRefreshedAccountKey by rootSyncUiState.lastRefreshedAccountKey var isRefreshing by rootSyncUiState.isRefreshing var refreshJob by rootSyncUiState.refreshJob @@ -1576,7 +1576,16 @@ private fun RootScreenContent( showAppearanceState = showAppearanceState, focusAppearanceOnSettingsReturn = focusAppearanceOnSettingsReturnState.value, focusManageListsOnSettingsReturn = focusManageListsOnSettingsReturnState.value, - dialogsState = rootDialogsUiState, + showThemeDialogState = rootDialogsUiState.showThemeDialog, + showFontDialogState = rootDialogsUiState.showFontDialog, + showUiScaleDialogState = rootDialogsUiState.showUiScaleDialog, + showFontScaleDialogState = rootDialogsUiState.showFontScaleDialog, + showNextEpisodeThresholdDialogState = rootDialogsUiState.showNextEpisodeThresholdDialog, + showVodBufferDialogState = rootDialogsUiState.showVodBufferDialog, + showSubtitleAppearanceDialogState = rootDialogsUiState.showSubtitleAppearanceDialog, + subtitleAppearancePreviewState = rootDialogsUiState.subtitleAppearancePreview, + showSubtitleCacheAutoClearDialogState = rootDialogsUiState.showSubtitleCacheAutoClearDialog, + showApiKeyDialogState = rootDialogsUiState.showApiKeyDialog, cacheClearNonceState = browseViewModel.cacheClearNonce, contentRepository = contentRepository, favoritesRepository = favoritesRepository, diff --git a/app/src/main/java/com/example/xtreamplayer/RootUpdateHost.kt b/app/src/main/java/com/example/xtreamplayer/RootUpdateHost.kt index a90b501..2c698f3 100644 --- a/app/src/main/java/com/example/xtreamplayer/RootUpdateHost.kt +++ b/app/src/main/java/com/example/xtreamplayer/RootUpdateHost.kt @@ -65,29 +65,33 @@ internal fun RootUpdateHost( ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() - val updateUiState by updateViewModel.updateUiState.collectAsStateWithLifecycle() - val updateCheckJob by updateViewModel.updateCheckJob.collectAsStateWithLifecycle() - val startupUpdateCheckEnabled by updateViewModel.startupUpdateCheckEnabled.collectAsStateWithLifecycle() - val startupUpdateCheckHandled by updateViewModel.startupUpdateCheckHandled.collectAsStateWithLifecycle() + val updateUiStateFlow = updateViewModel.updateUiState + val updateUiState by updateUiStateFlow.collectAsStateWithLifecycle() + val updateCheckJobFlow = updateViewModel.updateCheckJob + val updateCheckJob by updateCheckJobFlow.collectAsStateWithLifecycle() + val startupUpdateCheckEnabledFlow = updateViewModel.startupUpdateCheckEnabled + val startupUpdateCheckEnabled by startupUpdateCheckEnabledFlow.collectAsStateWithLifecycle() + val startupUpdateCheckHandledFlow = updateViewModel.startupUpdateCheckHandled + val startupUpdateCheckHandled by startupUpdateCheckHandledFlow.collectAsStateWithLifecycle() LaunchedEffect(startupUpdateCheckEnabled, startupUpdateCheckHandled, isSignedIn) { if (startupUpdateCheckHandled) return@LaunchedEffect val enabled = startupUpdateCheckEnabled ?: return@LaunchedEffect if (!enabled) { - startupUpdateCheckHandled = true + startupUpdateCheckHandledFlow.value = true return@LaunchedEffect } if (!isSignedIn) return@LaunchedEffect - startupUpdateCheckHandled = true + startupUpdateCheckHandledFlow.value = true checkForUpdates( context = context, coroutineScope = coroutineScope, updateHttpClient = updateHttpClient, appVersionName = appVersionName, - updateUiState = { updateUiState }, - onUpdateUiStateChange = { updateUiState = it }, - updateCheckJob = { updateCheckJob }, - onUpdateCheckJobChange = { updateCheckJob = it }, + updateUiState = { updateUiStateFlow.value }, + onUpdateUiStateChange = { updateUiStateFlow.value = it }, + updateCheckJob = { updateCheckJobFlow.value }, + onUpdateCheckJobChange = { updateCheckJobFlow.value = it }, source = RootUpdateCheckSource.STARTUP ) } @@ -98,7 +102,7 @@ internal fun RootUpdateHost( release = pendingRelease, isDownloading = updateUiState.inProgress, onUpdate = { onUpdateDownload(pendingRelease) }, - onLater = { updateUiState = updateUiState.copy(showDialog = false) } + onLater = { updateUiStateFlow.value = updateUiStateFlow.value.copy(showDialog = false) } ) } } From 4b1863d7f7ca5f6744c0fe7b7d00d4e1bb80899a Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:40:00 -0400 Subject: [PATCH 22/39] Document repository split boundaries --- XTREAM_REFACTOR_PLAN.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/XTREAM_REFACTOR_PLAN.md b/XTREAM_REFACTOR_PLAN.md index e33ab69..6348bcc 100644 --- a/XTREAM_REFACTOR_PLAN.md +++ b/XTREAM_REFACTOR_PLAN.md @@ -71,6 +71,16 @@ Acceptance criteria: - No single repository owns networking, parsing, caching, and sync orchestration together. - The code becomes easier to test in isolation. +Boundary map: +- `LiveContent` for live now/next fetching and live-specific cache policy. +- `VodContent` for movie info and VOD-specific loaders. +- `SeriesContent` for series episodes, season counts, and season-full loading. +- `SearchIndex` for search-page assembly and index/search readiness helpers. +- `SyncMaintenance` for cache keys, refresh handling, validation, and sync orchestration. + +Suggested first split: +- `SeriesContent` or `SearchIndex`, because both have clear seams and are already close to the current method clusters. + ## Phase 4: Tighten dependency injection - `[ ]` Verify all repositories and controllers come from Hilt. From da0779ff30dfc26bbb5ad53e9450ce00e95eb878 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:41:21 -0400 Subject: [PATCH 23/39] Extract series content repository --- .../xtreamplayer/content/ContentRepository.kt | 112 +--------- .../content/SeriesContentRepository.kt | 200 ++++++++++++++++++ 2 files changed, 210 insertions(+), 102 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/content/SeriesContentRepository.kt diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt index e0154a0..1607b44 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -26,6 +26,7 @@ class ContentRepository( private val api: XtreamApi, private val contentCache: ContentCache ) { + private val seriesContentRepository = SeriesContentRepository(api, contentCache) private companion object { const val DEFAULT_PAGE_SIZE = 24 const val DEFAULT_PREFETCH_DISTANCE = 6 @@ -395,15 +396,7 @@ class ContentRepository( limit: Int, authConfig: AuthConfig ): ContentPage { - val allEpisodes = loadSeriesEpisodes(seriesId, authConfig) - val offset = page * limit - val slice = if (offset >= allEpisodes.size) { - emptyList() - } else { - allEpisodes.subList(offset, (offset + limit).coerceAtMost(allEpisodes.size)) - } - val endReached = offset + limit >= allEpisodes.size - return ContentPage(items = slice, endReached = endReached) + return seriesContentRepository.loadSeriesEpisodePage(seriesId, page, limit, authConfig) } suspend fun loadSeriesSeasonPage( @@ -413,84 +406,28 @@ class ContentRepository( limit: Int, authConfig: AuthConfig ): ContentPage { - val cacheKey = "series-season-${accountKey(authConfig)}-$seriesId-$seasonLabel" - val key = cacheKey(cacheKey, offset, limit) - memoryCacheMutex.withLock { - memoryCache[key]?.let { return it } - } - val cached = contentCache.readPage(cacheKey, authConfig, offset, limit) - if (cached != null) { - memoryCacheMutex.withLock { memoryCache[key] = cached } - return cached - } - val result = api.fetchSeriesSeasonPage(authConfig, seriesId, seasonLabel, offset, limit) - val pageData = result.getOrElse { throw it } - if (pageData.items.isNotEmpty()) { - contentCache.writePage(cacheKey, authConfig, offset, limit, pageData) - } - memoryCacheMutex.withLock { memoryCache[key] = pageData } - return pageData + return seriesContentRepository.loadSeriesSeasonPage(seriesId, seasonLabel, offset, limit, authConfig) } suspend fun loadSeriesEpisodes( seriesId: String, authConfig: AuthConfig ): List { - val key = "${accountKey(authConfig)}|$seriesId" - val cachedSeries = seriesEpisodesMutex.withLock { seriesEpisodesCache[key] } - if (cachedSeries != null) { - return cachedSeries - } - val result = api.fetchSeriesEpisodesPage(authConfig, seriesId, 0, Int.MAX_VALUE) - val pageData = result.getOrElse { throw it } - val fullList = pageData.items - seriesEpisodesMutex.withLock { seriesEpisodesCache[key] = fullList } - return fullList + return seriesContentRepository.loadSeriesEpisodes(seriesId, authConfig) } suspend fun loadSeriesSeasonCount( seriesId: String, authConfig: AuthConfig ): Int? { - val key = seasonCountKey(seriesId, authConfig) - seriesSeasonCountMutex.withLock { - if (seriesSeasonCountUnavailableCache[key] == true) { - return null - } - seriesSeasonCountCache[key]?.let { return it } - } - val result = api.fetchSeriesSeasonCount(authConfig, seriesId) - val count = result.getOrNull() ?: return null - seriesSeasonCountMutex.withLock { - seriesSeasonCountCache[key] = count - seriesSeasonCountUnavailableCache.remove(key) - } - return count + return seriesContentRepository.loadSeriesSeasonCount(seriesId, authConfig) } suspend fun loadSeriesSeasons( seriesId: String, authConfig: AuthConfig ): List { - val key = seasonCountKey(seriesId, authConfig) - seriesSeasonsMutex.withLock { - seriesSeasonsCache[key]?.let { return it } - } - val result = api.fetchSeriesSeasonSummaries(authConfig, seriesId) - val payload = result.getOrElse { throw it } - val summaries = payload.summaries - seriesSeasonsMutex.withLock { seriesSeasonsCache[key] = summaries } - seriesSeasonCountMutex.withLock { - if (payload.seasonCount != null) { - seriesSeasonCountCache[key] = payload.seasonCount - seriesSeasonCountUnavailableCache.remove(key) - } else if (summaries.isEmpty()) { - // This series payload had neither episodes summaries nor a season count. - // Cache that fact to avoid a second identical get_series_info call. - seriesSeasonCountUnavailableCache[key] = true - } - } - return summaries + return seriesContentRepository.loadSeriesSeasons(seriesId, authConfig) } suspend fun loadMovieInfo( @@ -660,15 +597,7 @@ class ContentRepository( seasonLabel: String, authConfig: AuthConfig ): List? { - val key = "season-full-${accountKey(authConfig)}-$seriesId-$seasonLabel" - if (!seriesSeasonFullMutex.tryLock()) { - return null - } - return try { - seriesSeasonFullCache[key] - } finally { - seriesSeasonFullMutex.unlock() - } + return seriesContentRepository.peekSeriesSeasonFullCache(seriesId, seasonLabel, authConfig) } suspend fun prefetchSeriesSeasonFull( @@ -676,8 +605,7 @@ class ContentRepository( seasonLabel: String, authConfig: AuthConfig ) { - runCatching { loadSeriesSeasonFull(seriesId, seasonLabel, authConfig) } - .onFailure { Timber.w(it, "Failed to prefetch season $seasonLabel") } + seriesContentRepository.prefetchSeriesSeasonFull(seriesId, seasonLabel, authConfig) } suspend fun loadSeriesSeasonFull( @@ -685,21 +613,7 @@ class ContentRepository( seasonLabel: String, authConfig: AuthConfig ): List { - val key = "season-full-${accountKey(authConfig)}-$seriesId-$seasonLabel" - seriesSeasonFullMutex.withLock { - seriesSeasonFullCache[key]?.let { return it } - } - val cached = contentCache.readSeasonFull(seriesId, seasonLabel, authConfig) - if (cached != null) { - seriesSeasonFullMutex.withLock { seriesSeasonFullCache[key] = cached } - return cached - } - val items = - api.fetchSeriesSeasonAll(authConfig, seriesId, seasonLabel) - .getOrElse { throw it } - contentCache.writeSeasonFull(seriesId, seasonLabel, authConfig, items) - seriesSeasonFullMutex.withLock { seriesSeasonFullCache[key] = items } - return items + return seriesContentRepository.loadSeriesSeasonFull(seriesId, seasonLabel, authConfig) } suspend fun loadCategories( @@ -1800,13 +1714,7 @@ class ContentRepository( transientSearchIndexMutex.withLock { transientSearchIndexCache.clear() } activeLargeSearchIndexMutex.withLock { activeLargeSearchIndex = null } searchReadinessMutex.withLock { searchReadinessCache.clear() } - seriesEpisodesMutex.withLock { seriesEpisodesCache.clear() } - seriesSeasonCountMutex.withLock { seriesSeasonCountCache.clear() } - seriesSeasonCountMutex.withLock { seriesSeasonCountUnavailableCache.clear() } - movieInfoMutex.withLock { movieInfoCache.clear() } - seriesInfoMutex.withLock { seriesInfoCache.clear() } - seriesSeasonFullMutex.withLock { seriesSeasonFullCache.clear() } - seriesSeasonsMutex.withLock { seriesSeasonsCache.clear() } + seriesContentRepository.clearCache() liveEpgMutex.withLock { liveEpgCache.clear() } validationCacheMutex.withLock { validationCache.clear() } SearchNormalizer.clearCache() diff --git a/app/src/main/java/com/example/xtreamplayer/content/SeriesContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/SeriesContentRepository.kt new file mode 100644 index 0000000..5a1e0c4 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/content/SeriesContentRepository.kt @@ -0,0 +1,200 @@ +package com.example.xtreamplayer.content + +import com.example.xtreamplayer.auth.AuthConfig +import timber.log.Timber +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class SeriesContentRepository( + private val api: com.example.xtreamplayer.api.XtreamApi, + private val contentCache: ContentCache +) { + private val seriesEpisodesCache = object : LinkedHashMap>(50, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry>): Boolean { + return size > 50 + } + } + private val seriesSeasonCountCache = object : LinkedHashMap(200, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { + return size > 200 + } + } + private val seriesSeasonCountUnavailableCache = + object : LinkedHashMap(200, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { + return size > 200 + } + } + private val seriesSeasonFullCache = + object : LinkedHashMap>(20, 0.75f, true) { + override fun removeEldestEntry( + eldest: MutableMap.MutableEntry> + ): Boolean { + return size > 20 + } + } + private val seriesSeasonsCache = + object : LinkedHashMap>(50, 0.75f, true) { + override fun removeEldestEntry( + eldest: MutableMap.MutableEntry> + ): Boolean { + return size > 50 + } + } + private val seriesEpisodesMutex = Mutex() + private val seriesSeasonCountMutex = Mutex() + private val seriesSeasonFullMutex = Mutex() + private val seriesSeasonsMutex = Mutex() + + suspend fun loadSeriesEpisodePage( + seriesId: String, + page: Int, + limit: Int, + authConfig: AuthConfig + ): ContentPage { + val allEpisodes = loadSeriesEpisodes(seriesId, authConfig) + val offset = page * limit + val slice = if (offset >= allEpisodes.size) { + emptyList() + } else { + allEpisodes.subList(offset, (offset + limit).coerceAtMost(allEpisodes.size)) + } + val endReached = offset + limit >= allEpisodes.size + return ContentPage(items = slice, endReached = endReached) + } + + suspend fun loadSeriesSeasonPage( + seriesId: String, + seasonLabel: String, + offset: Int, + limit: Int, + authConfig: AuthConfig + ): ContentPage { + val cacheKey = "series-season-${accountKey(authConfig)}-$seriesId-$seasonLabel" + val key = cacheKey(cacheKey, offset, limit) + val cached = contentCache.readPage(cacheKey, authConfig, offset, limit) + if (cached != null) { + return cached + } + val result = api.fetchSeriesSeasonPage(authConfig, seriesId, seasonLabel, offset, limit) + val pageData = result.getOrElse { throw it } + if (pageData.items.isNotEmpty()) { + contentCache.writePage(cacheKey, authConfig, offset, limit, pageData) + } + return pageData + } + + suspend fun loadSeriesEpisodes( + seriesId: String, + authConfig: AuthConfig + ): List { + val key = "${accountKey(authConfig)}|$seriesId" + val cachedSeries = seriesEpisodesMutex.withLock { seriesEpisodesCache[key] } + if (cachedSeries != null) { + return cachedSeries + } + val result = api.fetchSeriesEpisodesPage(authConfig, seriesId, 0, Int.MAX_VALUE) + val pageData = result.getOrElse { throw it } + val fullList = pageData.items + seriesEpisodesMutex.withLock { seriesEpisodesCache[key] = fullList } + return fullList + } + + suspend fun loadSeriesSeasonCount( + seriesId: String, + authConfig: AuthConfig + ): Int? { + val key = seasonCountKey(seriesId, authConfig) + seriesSeasonCountMutex.withLock { + if (seriesSeasonCountUnavailableCache[key] == true) { + return null + } + seriesSeasonCountCache[key]?.let { return it } + } + val result = api.fetchSeriesSeasonCount(authConfig, seriesId) + val count = result.getOrNull() ?: return null + seriesSeasonCountMutex.withLock { + seriesSeasonCountCache[key] = count + seriesSeasonCountUnavailableCache.remove(key) + } + return count + } + + suspend fun loadSeriesSeasons( + seriesId: String, + authConfig: AuthConfig + ): List { + val key = seasonCountKey(seriesId, authConfig) + seriesSeasonsMutex.withLock { + seriesSeasonsCache[key]?.let { return it } + } + val result = api.fetchSeriesSeasonSummaries(authConfig, seriesId) + val payload = result.getOrElse { throw it } + val summaries = payload.summaries + seriesSeasonsMutex.withLock { seriesSeasonsCache[key] = summaries } + seriesSeasonCountMutex.withLock { + if (payload.seasonCount != null) { + seriesSeasonCountCache[key] = payload.seasonCount + seriesSeasonCountUnavailableCache.remove(key) + } else if (summaries.isEmpty()) { + seriesSeasonCountUnavailableCache[key] = true + } + } + return summaries + } + + fun peekSeriesSeasonFullCache( + seriesId: String, + seasonLabel: String, + authConfig: AuthConfig + ): List? { + val key = "season-full-${accountKey(authConfig)}-$seriesId-$seasonLabel" + if (!seriesSeasonFullMutex.tryLock()) { + return null + } + return try { + seriesSeasonFullCache[key] + } finally { + seriesSeasonFullMutex.unlock() + } + } + + suspend fun prefetchSeriesSeasonFull( + seriesId: String, + seasonLabel: String, + authConfig: AuthConfig + ) { + runCatching { loadSeriesSeasonFull(seriesId, seasonLabel, authConfig) } + .onFailure { Timber.w(it, "Failed to prefetch season $seasonLabel") } + } + + suspend fun loadSeriesSeasonFull( + seriesId: String, + seasonLabel: String, + authConfig: AuthConfig + ): List { + val key = "season-full-${accountKey(authConfig)}-$seriesId-$seasonLabel" + seriesSeasonFullMutex.withLock { + seriesSeasonFullCache[key]?.let { return it } + } + val cached = contentCache.readSeasonFull(seriesId, seasonLabel, authConfig) + if (cached != null) { + seriesSeasonFullMutex.withLock { seriesSeasonFullCache[key] = cached } + return cached + } + val items = + api.fetchSeriesSeasonAll(authConfig, seriesId, seasonLabel) + .getOrElse { throw it } + contentCache.writeSeasonFull(seriesId, seasonLabel, authConfig, items) + seriesSeasonFullMutex.withLock { seriesSeasonFullCache[key] = items } + return items + } + + suspend fun clearCache() { + seriesEpisodesMutex.withLock { seriesEpisodesCache.clear() } + seriesSeasonCountMutex.withLock { seriesSeasonCountCache.clear() } + seriesSeasonCountMutex.withLock { seriesSeasonCountUnavailableCache.clear() } + seriesSeasonFullMutex.withLock { seriesSeasonFullCache.clear() } + seriesSeasonsMutex.withLock { seriesSeasonsCache.clear() } + } +} From 413865f4426d40e13ddc7036fae0d90bc85e15a1 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:42:58 -0400 Subject: [PATCH 24/39] Extract live content repository --- .../xtreamplayer/content/ContentRepository.kt | 114 +------------- .../content/LiveContentRepository.kt | 147 ++++++++++++++++++ 2 files changed, 151 insertions(+), 110 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/content/LiveContentRepository.kt diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt index 1607b44..8da9663 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -27,6 +27,7 @@ class ContentRepository( private val contentCache: ContentCache ) { private val seriesContentRepository = SeriesContentRepository(api, contentCache) + private val liveContentRepository = LiveContentRepository(api) private companion object { const val DEFAULT_PAGE_SIZE = 24 const val DEFAULT_PREFETCH_DISTANCE = 6 @@ -482,114 +483,7 @@ class ContentRepository( streamId: String, authConfig: AuthConfig ): Result { - if (streamId.isBlank()) { - return Result.success(null) - } - val key = "live-epg-${accountKey(authConfig)}-$streamId" - val now = System.currentTimeMillis() - liveEpgMutex.withLock { - val cached = liveEpgCache[key] - if (cached != null && now - cached.cachedAtMs <= LIVE_EPG_CACHE_TTL_MS) { - return Result.success(cached.data) - } - } - val (deferred, isRequestOwner) = - liveEpgInFlightMutex.withLock { - val existing = liveEpgInFlight[key] - if (existing != null) { - existing to false - } else { - val created = CompletableDeferred>() - liveEpgInFlight[key] = created - created to true - } - } - if (!isRequestOwner) { - return deferred.await() - } - try { - val result = try { - val networkResult = fetchLiveNowNextWithRetry(authConfig, streamId) - if (networkResult.isSuccess) { - val data = networkResult.getOrNull() - liveEpgMutex.withLock { - liveEpgCache[key] = - LiveEpgCacheEntry(data = data, cachedAtMs = System.currentTimeMillis()) - } - networkResult - } else { - val staleEntry = liveEpgMutex.withLock { liveEpgCache[key] } - if ( - staleEntry != null && - now - staleEntry.cachedAtMs <= LIVE_EPG_STALE_CACHE_TTL_MS - ) { - val error = networkResult.exceptionOrNull() - AppDiagnostics.recordWarning( - event = "live_epg_served_stale", - fields = mapOf( - "streamId" to streamId, - "error" to (error?.message ?: "unknown") - ) - ) - Timber.w( - error, - "Live EPG request failed for stream=$streamId; serving stale cache" - ) - // Keep UI responsive under weak providers by extending stale entry briefly. - liveEpgMutex.withLock { - liveEpgCache[key] = staleEntry.copy(cachedAtMs = System.currentTimeMillis()) - } - Result.success(staleEntry.data) - } else { - AppDiagnostics.recordError( - event = "live_epg_failed_no_cache", - throwable = networkResult.exceptionOrNull(), - fields = mapOf("streamId" to streamId) - ) - networkResult - } - } - } catch (cancelled: CancellationException) { - deferred.cancel(cancelled) - throw cancelled - } catch (error: Exception) { - AppDiagnostics.recordError( - event = "live_epg_exception", - throwable = error, - fields = mapOf("streamId" to streamId) - ) - Result.failure(error) - } - deferred.complete(result) - return result - } finally { - liveEpgInFlightMutex.withLock { - liveEpgInFlight.remove(key) - } - } - } - - private suspend fun fetchLiveNowNextWithRetry( - authConfig: AuthConfig, - streamId: String - ): Result { - var attempt = 0 - var backoffMs = LIVE_EPG_INITIAL_RETRY_BACKOFF_MS - var lastResult: Result = Result.success(null) - while (attempt <= LIVE_EPG_MAX_RETRIES) { - val result = api.fetchLiveNowNext(authConfig, streamId) - if (result.isSuccess) { - return result - } - lastResult = result - if (attempt >= LIVE_EPG_MAX_RETRIES || !shouldRetryLiveEpgError(result.exceptionOrNull())) { - break - } - delay(backoffMs) - backoffMs = (backoffMs * 2).coerceAtMost(1_600L) - attempt++ - } - return lastResult + return liveContentRepository.loadLiveNowNext(streamId, authConfig) } fun peekSeriesSeasonFullCache( @@ -1714,10 +1608,10 @@ class ContentRepository( transientSearchIndexMutex.withLock { transientSearchIndexCache.clear() } activeLargeSearchIndexMutex.withLock { activeLargeSearchIndex = null } searchReadinessMutex.withLock { searchReadinessCache.clear() } - seriesContentRepository.clearCache() - liveEpgMutex.withLock { liveEpgCache.clear() } validationCacheMutex.withLock { validationCache.clear() } SearchNormalizer.clearCache() + seriesContentRepository.clearCache() + liveContentRepository.clearCache() } suspend fun clearDiskCache() { diff --git a/app/src/main/java/com/example/xtreamplayer/content/LiveContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/LiveContentRepository.kt new file mode 100644 index 0000000..2d591fb --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/content/LiveContentRepository.kt @@ -0,0 +1,147 @@ +package com.example.xtreamplayer.content + +import com.example.xtreamplayer.api.XtreamApi +import com.example.xtreamplayer.auth.AuthConfig +import com.example.xtreamplayer.observability.AppDiagnostics +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber + +internal class LiveContentRepository( + private val api: XtreamApi +) { + private data class LiveEpgCacheEntry(val data: LiveNowNextEpg?, val cachedAtMs: Long) + + private val liveEpgCache = + object : LinkedHashMap(100, 0.75f, true) { + override fun removeEldestEntry( + eldest: MutableMap.MutableEntry + ): Boolean { + return size > 100 + } + } + private val liveEpgMutex = Mutex() + private val liveEpgInFlightMutex = Mutex() + private val liveEpgInFlight = + mutableMapOf>>() + + suspend fun loadLiveNowNext( + streamId: String, + authConfig: AuthConfig + ): Result { + if (streamId.isBlank()) { + return Result.success(null) + } + val key = "live-epg-${accountKey(authConfig)}-$streamId" + val now = System.currentTimeMillis() + liveEpgMutex.withLock { + val cached = liveEpgCache[key] + if (cached != null && now - cached.cachedAtMs <= 20_000L) { + return Result.success(cached.data) + } + } + val (deferred, isRequestOwner) = + liveEpgInFlightMutex.withLock { + val existing = liveEpgInFlight[key] + if (existing != null) { + existing to false + } else { + val created = CompletableDeferred>() + liveEpgInFlight[key] = created + created to true + } + } + if (!isRequestOwner) { + return deferred.await() + } + try { + val result = try { + val networkResult = fetchLiveNowNextWithRetry(authConfig, streamId) + if (networkResult.isSuccess) { + val data = networkResult.getOrNull() + liveEpgMutex.withLock { + liveEpgCache[key] = + LiveEpgCacheEntry(data = data, cachedAtMs = System.currentTimeMillis()) + } + networkResult + } else { + val staleEntry = liveEpgMutex.withLock { liveEpgCache[key] } + if ( + staleEntry != null && + now - staleEntry.cachedAtMs <= 3 * 60_000L + ) { + val error = networkResult.exceptionOrNull() + AppDiagnostics.recordWarning( + event = "live_epg_served_stale", + fields = mapOf( + "streamId" to streamId, + "error" to (error?.message ?: "unknown") + ) + ) + Timber.w( + error, + "Live EPG request failed for stream=$streamId; serving stale cache" + ) + liveEpgMutex.withLock { + liveEpgCache[key] = staleEntry.copy(cachedAtMs = System.currentTimeMillis()) + } + Result.success(staleEntry.data) + } else { + AppDiagnostics.recordError( + event = "live_epg_failed_no_cache", + throwable = networkResult.exceptionOrNull(), + fields = mapOf("streamId" to streamId) + ) + networkResult + } + } + } catch (cancelled: CancellationException) { + deferred.cancel(cancelled) + throw cancelled + } catch (error: Exception) { + AppDiagnostics.recordError( + event = "live_epg_exception", + throwable = error, + fields = mapOf("streamId" to streamId) + ) + Result.failure(error) + } + deferred.complete(result) + return result + } finally { + liveEpgInFlightMutex.withLock { + liveEpgInFlight.remove(key) + } + } + } + + suspend fun clearCache() { + liveEpgMutex.withLock { liveEpgCache.clear() } + } + + private suspend fun fetchLiveNowNextWithRetry( + authConfig: AuthConfig, + streamId: String + ): Result { + var attempt = 0 + var backoffMs = 250L + var lastResult: Result = Result.success(null) + while (attempt <= 2) { + val result = api.fetchLiveNowNext(authConfig, streamId) + if (result.isSuccess) { + return result + } + lastResult = result + if (attempt >= 2 || !shouldRetryLiveEpgError(result.exceptionOrNull())) { + break + } + delay(backoffMs) + backoffMs = (backoffMs * 2).coerceAtMost(1_600L) + attempt++ + } + return lastResult + } +} From c5bebf706b0fd8f6922ce9c93cee9863ab17bff6 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:47:27 -0400 Subject: [PATCH 25/39] Fix update host compile regressions --- .../example/xtreamplayer/MainActivityUi.kt | 1 - .../example/xtreamplayer/RootUpdateHost.kt | 27 ++++++++----------- .../xtreamplayer/viewmodel/PlayerViewModel.kt | 1 + .../xtreamplayer/viewmodel/UpdateViewModel.kt | 9 +++---- 4 files changed, 16 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index 178e984..08f0316 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -1583,7 +1583,6 @@ private fun RootScreenContent( showNextEpisodeThresholdDialogState = rootDialogsUiState.showNextEpisodeThresholdDialog, showVodBufferDialogState = rootDialogsUiState.showVodBufferDialog, showSubtitleAppearanceDialogState = rootDialogsUiState.showSubtitleAppearanceDialog, - subtitleAppearancePreviewState = rootDialogsUiState.subtitleAppearancePreview, showSubtitleCacheAutoClearDialogState = rootDialogsUiState.showSubtitleCacheAutoClearDialog, showApiKeyDialogState = rootDialogsUiState.showApiKeyDialog, cacheClearNonceState = browseViewModel.cacheClearNonce, diff --git a/app/src/main/java/com/example/xtreamplayer/RootUpdateHost.kt b/app/src/main/java/com/example/xtreamplayer/RootUpdateHost.kt index 2c698f3..612b017 100644 --- a/app/src/main/java/com/example/xtreamplayer/RootUpdateHost.kt +++ b/app/src/main/java/com/example/xtreamplayer/RootUpdateHost.kt @@ -36,7 +36,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.net.toUri -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.xtreamplayer.update.UpdateRelease import com.example.xtreamplayer.update.compareVersions import com.example.xtreamplayer.update.downloadUpdateApk @@ -65,33 +64,29 @@ internal fun RootUpdateHost( ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() - val updateUiStateFlow = updateViewModel.updateUiState - val updateUiState by updateUiStateFlow.collectAsStateWithLifecycle() - val updateCheckJobFlow = updateViewModel.updateCheckJob - val updateCheckJob by updateCheckJobFlow.collectAsStateWithLifecycle() - val startupUpdateCheckEnabledFlow = updateViewModel.startupUpdateCheckEnabled - val startupUpdateCheckEnabled by startupUpdateCheckEnabledFlow.collectAsStateWithLifecycle() - val startupUpdateCheckHandledFlow = updateViewModel.startupUpdateCheckHandled - val startupUpdateCheckHandled by startupUpdateCheckHandledFlow.collectAsStateWithLifecycle() + val updateUiState by updateViewModel.updateUiState + val updateCheckJob by updateViewModel.updateCheckJob + val startupUpdateCheckEnabled by updateViewModel.startupUpdateCheckEnabled + val startupUpdateCheckHandled by updateViewModel.startupUpdateCheckHandled LaunchedEffect(startupUpdateCheckEnabled, startupUpdateCheckHandled, isSignedIn) { if (startupUpdateCheckHandled) return@LaunchedEffect val enabled = startupUpdateCheckEnabled ?: return@LaunchedEffect if (!enabled) { - startupUpdateCheckHandledFlow.value = true + updateViewModel.startupUpdateCheckHandled.value = true return@LaunchedEffect } if (!isSignedIn) return@LaunchedEffect - startupUpdateCheckHandledFlow.value = true + updateViewModel.startupUpdateCheckHandled.value = true checkForUpdates( context = context, coroutineScope = coroutineScope, updateHttpClient = updateHttpClient, appVersionName = appVersionName, - updateUiState = { updateUiStateFlow.value }, - onUpdateUiStateChange = { updateUiStateFlow.value = it }, - updateCheckJob = { updateCheckJobFlow.value }, - onUpdateCheckJobChange = { updateCheckJobFlow.value = it }, + updateUiState = { updateViewModel.updateUiState.value }, + onUpdateUiStateChange = { updateViewModel.updateUiState.value = it }, + updateCheckJob = { updateViewModel.updateCheckJob.value }, + onUpdateCheckJobChange = { updateViewModel.updateCheckJob.value = it }, source = RootUpdateCheckSource.STARTUP ) } @@ -102,7 +97,7 @@ internal fun RootUpdateHost( release = pendingRelease, isDownloading = updateUiState.inProgress, onUpdate = { onUpdateDownload(pendingRelease) }, - onLater = { updateUiStateFlow.value = updateUiStateFlow.value.copy(showDialog = false) } + onLater = { updateViewModel.updateUiState.value = updateViewModel.updateUiState.value.copy(showDialog = false) } ) } } diff --git a/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt index fd57740..9da8a70 100644 --- a/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.example.xtreamplayer.PendingResume +import com.example.xtreamplayer.PlaybackRecoveryTracker import com.example.xtreamplayer.content.ContentItem import com.example.xtreamplayer.PlaybackQueue import com.example.xtreamplayer.PlaybackSubtitleState diff --git a/app/src/main/java/com/example/xtreamplayer/viewmodel/UpdateViewModel.kt b/app/src/main/java/com/example/xtreamplayer/viewmodel/UpdateViewModel.kt index b1b9b95..7b2f20d 100644 --- a/app/src/main/java/com/example/xtreamplayer/viewmodel/UpdateViewModel.kt +++ b/app/src/main/java/com/example/xtreamplayer/viewmodel/UpdateViewModel.kt @@ -6,12 +6,11 @@ import com.example.xtreamplayer.UpdateUiState import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow @HiltViewModel class UpdateViewModel @Inject constructor() : ViewModel() { - val updateUiState = MutableStateFlow(UpdateUiState()) - val updateCheckJob = MutableStateFlow(null) - val startupUpdateCheckEnabled = MutableStateFlow(null) - val startupUpdateCheckHandled = MutableStateFlow(false) + val updateUiState = mutableStateOf(UpdateUiState()) + val updateCheckJob = mutableStateOf(null) + val startupUpdateCheckEnabled = mutableStateOf(null) + val startupUpdateCheckHandled = mutableStateOf(false) } From f9b1376622b68eeb0e99752c97a88d81cb397077 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:50:40 -0400 Subject: [PATCH 26/39] Extract search index repository --- .../xtreamplayer/content/ContentRepository.kt | 299 ++---------------- .../content/SearchIndexRepository.kt | 272 ++++++++++++++++ 2 files changed, 297 insertions(+), 274 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/content/SearchIndexRepository.kt diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt index 8da9663..291702d 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -28,6 +28,7 @@ class ContentRepository( ) { private val seriesContentRepository = SeriesContentRepository(api, contentCache) private val liveContentRepository = LiveContentRepository(api) + private val searchIndexRepository = SearchIndexRepository(contentCache) private companion object { const val DEFAULT_PAGE_SIZE = 24 const val DEFAULT_PREFETCH_DISTANCE = 6 @@ -47,15 +48,7 @@ class ContentRepository( const val LIVE_EPG_STALE_CACHE_TTL_MS = 3 * 60_000L const val LIVE_EPG_MAX_RETRIES = 2 const val LIVE_EPG_INITIAL_RETRY_BACKOFF_MS = 250L - const val MAX_SECTION_INDEX_CACHE_KEYS = 4 const val MAX_SECTION_INDEX_ITEMS_IN_MEMORY = 25_000 - const val MAX_TRANSIENT_SEARCH_INDEX_CACHE_KEYS = 3 - const val MAX_TRANSIENT_SEARCH_INDEX_ITEMS_IN_MEMORY = 75_000 - const val TRANSIENT_SEARCH_INDEX_TTL_MS = 15_000L - const val ACTIVE_LARGE_SEARCH_INDEX_TTL_MS = 20_000L - const val SEARCH_READINESS_CACHE_TTL_MS = 10_000L - const val MAX_SEARCH_READINESS_CACHE_KEYS = 12 - const val MAX_PREWARM_TITLES_PER_SECTION = 5_000 } private val memoryCache = object : LinkedHashMap(200, 0.75f, true) { @@ -68,55 +61,6 @@ class ContentRepository( return size > 50 } } - private val sectionIndexCache = - object : LinkedHashMap>(MAX_SECTION_INDEX_CACHE_KEYS, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry>): Boolean { - return size > MAX_SECTION_INDEX_CACHE_KEYS - } - } - private data class TransientSectionIndexEntry( - val items: List, - val cachedAtMs: Long - ) - private data class SearchIndexReadinessEntry( - val checkpointTimestamp: Long, - val ready: Boolean, - val cachedAtMs: Long - ) - private data class ActiveLargeSearchIndexEntry( - val key: String, - val items: List, - val cachedAtMs: Long - ) - private val transientSearchIndexCache = - object : LinkedHashMap( - MAX_TRANSIENT_SEARCH_INDEX_CACHE_KEYS, - 0.75f, - true - ) { - override fun removeEldestEntry( - eldest: MutableMap.MutableEntry - ): Boolean { - return size > MAX_TRANSIENT_SEARCH_INDEX_CACHE_KEYS - } - } - private val sectionIndexMutex = Mutex() - private val transientSearchIndexMutex = Mutex() - private var activeLargeSearchIndex: ActiveLargeSearchIndexEntry? = null - private val activeLargeSearchIndexMutex = Mutex() - private val searchReadinessCache = - object : LinkedHashMap( - MAX_SEARCH_READINESS_CACHE_KEYS, - 0.75f, - true - ) { - override fun removeEldestEntry( - eldest: MutableMap.MutableEntry - ): Boolean { - return size > MAX_SEARCH_READINESS_CACHE_KEYS - } - } - private val searchReadinessMutex = Mutex() private val seriesEpisodesCache = object : LinkedHashMap>(50, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry>): Boolean { return size > 50 @@ -317,7 +261,7 @@ class ContentRepository( authConfig: AuthConfig ): ContentPage { val normalizedQuery = SearchNormalizer.normalizeQuery(query) - val canUseLocalIndex = isSearchIndexReadyForQuery(section, authConfig) + val canUseLocalIndex = searchIndexRepository.isSearchIndexReadyForQuery(section, authConfig) if (canUseLocalIndex && normalizedQuery.length >= MIN_LOCAL_SEARCH_QUERY_LENGTH) { val localResult = localSearchPage(section, normalizedQuery, page, limit, authConfig) if (localResult != null) { @@ -391,6 +335,18 @@ class ContentRepository( return pageData } + suspend fun hasSectionIndex(section: Section, authConfig: AuthConfig): Boolean { + return searchIndexRepository.hasSectionIndex(section, authConfig) + } + + suspend fun hasSearchIndex(authConfig: AuthConfig): Boolean { + return searchIndexRepository.hasSearchIndex(authConfig) + } + + suspend fun hasAnySearchIndex(authConfig: AuthConfig): Boolean { + return searchIndexRepository.hasAnySearchIndex(authConfig) + } + suspend fun loadSeriesEpisodePage( seriesId: String, page: Int, @@ -747,12 +703,12 @@ class ContentRepository( val sources = if (section == Section.ALL) { listOf( - loadSectionIndex(Section.LIVE, authConfig).orEmpty(), - loadSectionIndex(Section.MOVIES, authConfig).orEmpty(), - loadSectionIndex(Section.SERIES, authConfig).orEmpty() + searchIndexRepository.loadSectionIndex(Section.LIVE, authConfig).orEmpty(), + searchIndexRepository.loadSectionIndex(Section.MOVIES, authConfig).orEmpty(), + searchIndexRepository.loadSectionIndex(Section.SERIES, authConfig).orEmpty() ).filter { it.isNotEmpty() } } else { - listOf(loadSectionIndex(section, authConfig) ?: return null) + listOf(searchIndexRepository.loadSectionIndex(section, authConfig) ?: return null) } if (sources.isEmpty()) { return null @@ -771,130 +727,6 @@ class ContentRepository( } } - private suspend fun loadSectionIndex( - section: Section, - authConfig: AuthConfig - ): List? { - val key = indexKey(section, authConfig) - - // Check memory cache with lock - sectionIndexMutex.withLock { - sectionIndexCache[key]?.let { - Timber.d("Search index hit in-memory section=$section size=${it.size}") - return it - } - } - transientSearchIndexMutex.withLock { - val transient = transientSearchIndexCache[key] - if (transient != null) { - val ageMs = System.currentTimeMillis() - transient.cachedAtMs - if (ageMs <= TRANSIENT_SEARCH_INDEX_TTL_MS) { - Timber.d("Search index hit transient section=$section size=${transient.items.size} ageMs=$ageMs") - return transient.items - } - transientSearchIndexCache.remove(key) - } - } - activeLargeSearchIndexMutex.withLock { - val active = activeLargeSearchIndex - if (active != null && active.key == key) { - val ageMs = System.currentTimeMillis() - active.cachedAtMs - if (ageMs <= ACTIVE_LARGE_SEARCH_INDEX_TTL_MS) { - Timber.d("Search index hit active-large section=$section size=${active.items.size} ageMs=$ageMs") - return active.items - } - activeLargeSearchIndex = null - } - } - - // Read from disk cache (outside lock to avoid blocking) - val cached = contentCache.readSectionIndex(section, authConfig) - if (cached != null) { - // Check if cache is stale before using it - val checkpoint = contentCache.readSectionSyncCheckpoint(section, authConfig) - if (checkpoint != null) { - val ageMs = System.currentTimeMillis() - checkpoint.timestamp - val ageDays = ageMs / (24 * 60 * 60 * 1000L) - if (ageDays > 7) { - Timber.i("Section $section cache is ${ageDays}d old, marking for resync") - // Don't return stale cache - transientSearchIndexMutex.withLock { transientSearchIndexCache.remove(key) } - activeLargeSearchIndexMutex.withLock { - if (activeLargeSearchIndex?.key == key) { - activeLargeSearchIndex = null - } - } - return null - } - } - - sectionIndexMutex.withLock { - if (shouldKeepSectionIndexInMemory(cached.size)) { - sectionIndexCache[key] = cached - } else { - sectionIndexCache.remove(key) - } - } - transientSearchIndexMutex.withLock { - if (shouldKeepTransientSectionIndexInMemory(cached.size)) { - transientSearchIndexCache[key] = - TransientSectionIndexEntry( - items = cached, - cachedAtMs = System.currentTimeMillis() - ) - } else { - transientSearchIndexCache.remove(key) - } - } - activeLargeSearchIndexMutex.withLock { - if (cached.size > MAX_TRANSIENT_SEARCH_INDEX_ITEMS_IN_MEMORY) { - activeLargeSearchIndex = - ActiveLargeSearchIndexEntry( - key = key, - items = cached, - cachedAtMs = System.currentTimeMillis() - ) - } else if (activeLargeSearchIndex?.key == key) { - activeLargeSearchIndex = null - } - } - Timber.d("Search index loaded from disk section=$section size=${cached.size}") - preWarmSearchTitles(cached) - } - return cached - } - - suspend fun hasSectionIndex(section: Section, authConfig: AuthConfig): Boolean { - return contentCache.hasSectionIndex(section, authConfig) - } - - suspend fun hasSearchIndex(authConfig: AuthConfig): Boolean { - val sections = listOf(Section.MOVIES, Section.SERIES, Section.LIVE) - return sections.all { section -> - contentCache.readSectionSyncCheckpoint(section, authConfig)?.isComplete == true && - contentCache.hasSectionIndex(section, authConfig) - } - } - - suspend fun hasAnySearchIndex(authConfig: AuthConfig): Boolean { - return contentCache.hasSectionIndex(Section.SERIES, authConfig) && - contentCache.hasSectionIndex(Section.MOVIES, authConfig) && - contentCache.hasSectionIndex(Section.LIVE, authConfig) - } - - private suspend fun preWarmSearchTitles(items: List) { - if (items.isEmpty()) return - val titleSample = - if (items.size > MAX_PREWARM_TITLES_PER_SECTION) { - items.subList(0, MAX_PREWARM_TITLES_PER_SECTION) - } else { - items - } - withContext(Dispatchers.Default) { - SearchNormalizer.preWarmCache(titleSample.map { it.title }) - } - } - /** * Sync a single section's search index */ @@ -928,12 +760,11 @@ class ContentRepository( ) val sectionStates = sections.mapIndexed { index, section -> - val key = indexKey(section, authConfig) - val cached = sectionIndexMutex.withLock { sectionIndexCache[key] } + val cached = searchIndexRepository.loadSectionIndex(section, authConfig) val hasDisk = contentCache.hasSectionIndex(section, authConfig) val needsSync = force || (cached == null && !hasDisk) val cachedItems = if (!needsSync) { - cached ?: loadSectionIndex(section, authConfig).orEmpty() + cached } else null SectionState(section, index, needsSync, cachedItems) } @@ -965,7 +796,6 @@ class ContentRepository( pendingSectionStates.map { state -> async { val section = state.section - val key = indexKey(section, authConfig) val items = if (useBulk) { val bulkResult = api.fetchSectionAll(section, authConfig) @@ -991,15 +821,7 @@ class ContentRepository( // Cache results // Skip in-memory cache for huge indexes to avoid OOM. - if (shouldKeepSectionIndexInMemory(finalItems.size)) { - sectionIndexMutex.withLock { - sectionIndexCache[key] = finalItems - } - } else { - timber.log.Timber.d("Skipping memory cache for large section $section (${finalItems.size} items)") - } - contentCache.writeSectionIndex(section, authConfig, finalItems) - preWarmSearchTitles(finalItems) + searchIndexRepository.cacheSectionIndex(section, authConfig, finalItems) // Report progress as this section completes val completed = completedCounter.incrementAndGet() @@ -1234,14 +1056,7 @@ class ContentRepository( section, authConfig, bulkItems, 0, true ) } - val key = indexKey(section, authConfig) - sectionIndexMutex.withLock { - if (shouldKeepSectionIndexInMemory(bulkItems.size)) { - sectionIndexCache[key] = bulkItems - } else { - sectionIndexCache.remove(key) - } - } + searchIndexRepository.storeSectionIndexInMemory(section, authConfig, bulkItems) onProgress( LibrarySyncProgress( section = section, @@ -1308,14 +1123,7 @@ class ContentRepository( contentCache.updateSectionIndexIncrementalWithCheckpoint( section, authConfig, allItems, page, false ) - val cacheKey = indexKey(section, authConfig) - sectionIndexMutex.withLock { - if (shouldKeepSectionIndexInMemory(allItems.size)) { - sectionIndexCache[cacheKey] = allItems - } else { - sectionIndexCache.remove(cacheKey) - } - } + searchIndexRepository.storeSectionIndexInMemory(section, authConfig, allItems) } } @@ -1341,14 +1149,7 @@ class ContentRepository( // Transactional write: index + checkpoint atomically contentCache.writeSectionIndexPartial(section, authConfig, allItems, page, true) } - val key = indexKey(section, authConfig) - sectionIndexMutex.withLock { - if (shouldKeepSectionIndexInMemory(allItems.size)) { - sectionIndexCache[key] = allItems - } else { - sectionIndexCache.remove(key) - } - } + searchIndexRepository.storeSectionIndexInMemory(section, authConfig, allItems) Timber.i("Background sync complete: $section with ${allItems.size} items") break } @@ -1553,63 +1354,13 @@ class ContentRepository( return true } - private suspend fun isSearchIndexReadyForQuery( - section: Section, - authConfig: AuthConfig - ): Boolean { - return if (section == Section.ALL) { - val sections = listOf(Section.MOVIES, Section.SERIES, Section.LIVE) - sections.all { isSectionSearchReadyForQuery(it, authConfig) } - } else { - isSectionSearchReadyForQuery(section, authConfig) - } - } - - private suspend fun isSectionSearchReadyForQuery( - section: Section, - authConfig: AuthConfig - ): Boolean { - val checkpoint = contentCache.readSectionSyncCheckpoint(section, authConfig) ?: return false - if (!checkpoint.isComplete) return false - - val now = System.currentTimeMillis() - val ageDays = (now - checkpoint.timestamp) / (24 * 60 * 60 * 1000L) - if (ageDays > 7) { - return false - } - - val readinessKey = indexKey(section, authConfig) - val cached = searchReadinessMutex.withLock { searchReadinessCache[readinessKey] } - if ( - cached != null && - cached.checkpointTimestamp == checkpoint.timestamp && - now - cached.cachedAtMs <= SEARCH_READINESS_CACHE_TTL_MS - ) { - return cached.ready - } - - val ready = contentCache.hasSectionIndex(section, authConfig) - searchReadinessMutex.withLock { - searchReadinessCache[readinessKey] = - SearchIndexReadinessEntry( - checkpointTimestamp = checkpoint.timestamp, - ready = ready, - cachedAtMs = now - ) - } - return ready - } - suspend fun clearCache() { memoryCacheMutex.withLock { memoryCache.clear() } categoryLock.withLock { categoryCache.clear() } categoryThumbnailMutex.withLock { categoryThumbnailCache.clear() } - sectionIndexMutex.withLock { sectionIndexCache.clear() } - transientSearchIndexMutex.withLock { transientSearchIndexCache.clear() } - activeLargeSearchIndexMutex.withLock { activeLargeSearchIndex = null } - searchReadinessMutex.withLock { searchReadinessCache.clear() } validationCacheMutex.withLock { validationCache.clear() } SearchNormalizer.clearCache() + searchIndexRepository.clearCache() seriesContentRepository.clearCache() liveContentRepository.clearCache() } diff --git a/app/src/main/java/com/example/xtreamplayer/content/SearchIndexRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/SearchIndexRepository.kt new file mode 100644 index 0000000..7d4ac20 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/content/SearchIndexRepository.kt @@ -0,0 +1,272 @@ +package com.example.xtreamplayer.content + +import com.example.xtreamplayer.Section +import com.example.xtreamplayer.auth.AuthConfig +import java.util.LinkedHashMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber + +internal class SearchIndexRepository( + private val contentCache: ContentCache +) { + private data class TransientSectionIndexEntry( + val items: List, + val cachedAtMs: Long + ) + + private data class SearchIndexReadinessEntry( + val checkpointTimestamp: Long, + val ready: Boolean, + val cachedAtMs: Long + ) + + private data class ActiveLargeSearchIndexEntry( + val key: String, + val items: List, + val cachedAtMs: Long + ) + + private companion object { + const val MAX_SECTION_INDEX_CACHE_KEYS = 4 + const val MAX_TRANSIENT_SEARCH_INDEX_CACHE_KEYS = 3 + const val MAX_TRANSIENT_SEARCH_INDEX_ITEMS_IN_MEMORY = 75_000 + const val TRANSIENT_SEARCH_INDEX_TTL_MS = 15_000L + const val ACTIVE_LARGE_SEARCH_INDEX_TTL_MS = 20_000L + const val SEARCH_READINESS_CACHE_TTL_MS = 10_000L + const val MAX_SEARCH_READINESS_CACHE_KEYS = 12 + const val MAX_PREWARM_TITLES_PER_SECTION = 5_000 + } + + private val sectionIndexCache = + object : LinkedHashMap>(MAX_SECTION_INDEX_CACHE_KEYS, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry>): Boolean { + return size > MAX_SECTION_INDEX_CACHE_KEYS + } + } + private val transientSearchIndexCache = + object : LinkedHashMap( + MAX_TRANSIENT_SEARCH_INDEX_CACHE_KEYS, + 0.75f, + true + ) { + override fun removeEldestEntry( + eldest: MutableMap.MutableEntry + ): Boolean { + return size > MAX_TRANSIENT_SEARCH_INDEX_CACHE_KEYS + } + } + private var activeLargeSearchIndex: ActiveLargeSearchIndexEntry? = null + private val searchReadinessCache = + object : LinkedHashMap( + MAX_SEARCH_READINESS_CACHE_KEYS, + 0.75f, + true + ) { + override fun removeEldestEntry( + eldest: MutableMap.MutableEntry + ): Boolean { + return size > MAX_SEARCH_READINESS_CACHE_KEYS + } + } + private val sectionIndexMutex = Mutex() + private val transientSearchIndexMutex = Mutex() + private val activeLargeSearchIndexMutex = Mutex() + private val searchReadinessMutex = Mutex() + + suspend fun loadSectionIndex( + section: Section, + authConfig: AuthConfig + ): List? { + val key = indexKey(section, authConfig) + + sectionIndexMutex.withLock { + sectionIndexCache[key]?.let { + Timber.d("Search index hit in-memory section=$section size=${it.size}") + return it + } + } + transientSearchIndexMutex.withLock { + val transient = transientSearchIndexCache[key] + if (transient != null) { + val ageMs = System.currentTimeMillis() - transient.cachedAtMs + if (ageMs <= TRANSIENT_SEARCH_INDEX_TTL_MS) { + Timber.d("Search index hit transient section=$section size=${transient.items.size} ageMs=$ageMs") + return transient.items + } + transientSearchIndexCache.remove(key) + } + } + activeLargeSearchIndexMutex.withLock { + val active = activeLargeSearchIndex + if (active != null && active.key == key) { + val ageMs = System.currentTimeMillis() - active.cachedAtMs + if (ageMs <= ACTIVE_LARGE_SEARCH_INDEX_TTL_MS) { + Timber.d("Search index hit active-large section=$section size=${active.items.size} ageMs=$ageMs") + return active.items + } + activeLargeSearchIndex = null + } + } + + val cached = contentCache.readSectionIndex(section, authConfig) + if (cached != null) { + val checkpoint = contentCache.readSectionSyncCheckpoint(section, authConfig) + if (checkpoint != null) { + val ageMs = System.currentTimeMillis() - checkpoint.timestamp + val ageDays = ageMs / (24 * 60 * 60 * 1000L) + if (ageDays > 7) { + Timber.i("Section $section cache is ${ageDays}d old, marking for resync") + transientSearchIndexMutex.withLock { transientSearchIndexCache.remove(key) } + activeLargeSearchIndexMutex.withLock { + if (activeLargeSearchIndex?.key == key) { + activeLargeSearchIndex = null + } + } + return null + } + } + + storeSectionIndexInMemory(section, authConfig, cached) + Timber.d("Search index loaded from disk section=$section size=${cached.size}") + preWarmSearchTitles(cached) + } + return cached + } + + suspend fun hasSectionIndex(section: Section, authConfig: AuthConfig): Boolean { + return contentCache.hasSectionIndex(section, authConfig) + } + + suspend fun hasSearchIndex(authConfig: AuthConfig): Boolean { + val sections = listOf(Section.MOVIES, Section.SERIES, Section.LIVE) + return sections.all { section -> + contentCache.readSectionSyncCheckpoint(section, authConfig)?.isComplete == true && + contentCache.hasSectionIndex(section, authConfig) + } + } + + suspend fun hasAnySearchIndex(authConfig: AuthConfig): Boolean { + return contentCache.hasSectionIndex(Section.SERIES, authConfig) && + contentCache.hasSectionIndex(Section.MOVIES, authConfig) && + contentCache.hasSectionIndex(Section.LIVE, authConfig) + } + + suspend fun cacheSectionIndex( + section: Section, + authConfig: AuthConfig, + items: List + ) { + storeSectionIndexInMemory(section, authConfig, items) + contentCache.writeSectionIndex(section, authConfig, items) + preWarmSearchTitles(items) + } + + suspend fun storeSectionIndexInMemory( + section: Section, + authConfig: AuthConfig, + items: List + ) { + val key = indexKey(section, authConfig) + sectionIndexMutex.withLock { + if (shouldKeepSectionIndexInMemory(items.size)) { + sectionIndexCache[key] = items + } else { + sectionIndexCache.remove(key) + } + } + transientSearchIndexMutex.withLock { + if (shouldKeepTransientSectionIndexInMemory(items.size)) { + transientSearchIndexCache[key] = + TransientSectionIndexEntry( + items = items, + cachedAtMs = System.currentTimeMillis() + ) + } else { + transientSearchIndexCache.remove(key) + } + } + activeLargeSearchIndexMutex.withLock { + if (items.size > MAX_TRANSIENT_SEARCH_INDEX_ITEMS_IN_MEMORY) { + activeLargeSearchIndex = + ActiveLargeSearchIndexEntry( + key = key, + items = items, + cachedAtMs = System.currentTimeMillis() + ) + } else if (activeLargeSearchIndex?.key == key) { + activeLargeSearchIndex = null + } + } + } + + private suspend fun preWarmSearchTitles(items: List) { + if (items.isEmpty()) return + val titleSample = + if (items.size > MAX_PREWARM_TITLES_PER_SECTION) { + items.subList(0, MAX_PREWARM_TITLES_PER_SECTION) + } else { + items + } + withContext(Dispatchers.Default) { + SearchNormalizer.preWarmCache(titleSample.map { it.title }) + } + } + + suspend fun isSearchIndexReadyForQuery( + section: Section, + authConfig: AuthConfig + ): Boolean { + return if (section == Section.ALL) { + val sections = listOf(Section.MOVIES, Section.SERIES, Section.LIVE) + sections.all { isSectionSearchReadyForQuery(it, authConfig) } + } else { + isSectionSearchReadyForQuery(section, authConfig) + } + } + + private suspend fun isSectionSearchReadyForQuery( + section: Section, + authConfig: AuthConfig + ): Boolean { + val checkpoint = contentCache.readSectionSyncCheckpoint(section, authConfig) ?: return false + if (!checkpoint.isComplete) return false + + val now = System.currentTimeMillis() + val ageDays = (now - checkpoint.timestamp) / (24 * 60 * 60 * 1000L) + if (ageDays > 7) { + return false + } + + val readinessKey = indexKey(section, authConfig) + val cached = searchReadinessMutex.withLock { searchReadinessCache[readinessKey] } + if ( + cached != null && + cached.checkpointTimestamp == checkpoint.timestamp && + now - cached.cachedAtMs <= SEARCH_READINESS_CACHE_TTL_MS + ) { + return cached.ready + } + + val ready = contentCache.hasSectionIndex(section, authConfig) + searchReadinessMutex.withLock { + searchReadinessCache[readinessKey] = + SearchIndexReadinessEntry( + checkpointTimestamp = checkpoint.timestamp, + ready = ready, + cachedAtMs = now + ) + } + return ready + } + + suspend fun clearCache() { + sectionIndexMutex.withLock { sectionIndexCache.clear() } + transientSearchIndexMutex.withLock { transientSearchIndexCache.clear() } + activeLargeSearchIndexMutex.withLock { activeLargeSearchIndex = null } + searchReadinessMutex.withLock { searchReadinessCache.clear() } + SearchNormalizer.clearCache() + } +} From 649ab5b07973802146e0fb5669b8cc60f9215a67 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:51:04 -0400 Subject: [PATCH 27/39] Extract VOD content repository --- XTREAM_REFACTOR_PLAN.md | 2 +- .../xtreamplayer/content/ContentRepository.kt | 31 ++----------- .../content/VodContentRepository.kt | 46 +++++++++++++++++++ 3 files changed, 52 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/content/VodContentRepository.kt diff --git a/XTREAM_REFACTOR_PLAN.md b/XTREAM_REFACTOR_PLAN.md index 6348bcc..e2df339 100644 --- a/XTREAM_REFACTOR_PLAN.md +++ b/XTREAM_REFACTOR_PLAN.md @@ -62,7 +62,7 @@ Acceptance criteria: Suggested boundaries: - `[ ]` live content -- `[ ]` VOD content +- `[x]` VOD content - `[ ]` series/episode content - `[ ]` search/indexing - `[ ]` sync/cache maintenance diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt index 291702d..d58dca1 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -28,6 +28,7 @@ class ContentRepository( ) { private val seriesContentRepository = SeriesContentRepository(api, contentCache) private val liveContentRepository = LiveContentRepository(api) + private val vodContentRepository = VodContentRepository(api, contentCache) private val searchIndexRepository = SearchIndexRepository(contentCache) private companion object { const val DEFAULT_PAGE_SIZE = 24 @@ -77,11 +78,6 @@ class ContentRepository( return size > 200 } } - private val movieInfoCache = object : LinkedHashMap(100, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { - return size > 100 - } - } private val seriesInfoCache = object : LinkedHashMap(100, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { return size > 100 @@ -117,7 +113,6 @@ class ContentRepository( private val memoryCacheMutex = Mutex() private val seriesEpisodesMutex = Mutex() private val seriesSeasonCountMutex = Mutex() - private val movieInfoMutex = Mutex() private val seriesInfoMutex = Mutex() private val seriesSeasonFullMutex = Mutex() private val seriesSeasonsMutex = Mutex() @@ -391,24 +386,7 @@ class ContentRepository( item: ContentItem, authConfig: AuthConfig ): MovieInfo? { - if (item.contentType != ContentType.MOVIES) { - return null - } - val vodId = item.streamId.ifBlank { item.id } - val key = "vod-info-${accountKey(authConfig)}-$vodId" - movieInfoMutex.withLock { - movieInfoCache[key]?.let { return it } - } - val cached = contentCache.readVodInfo(vodId, authConfig) - if (cached != null) { - movieInfoMutex.withLock { movieInfoCache[key] = cached } - return cached - } - val result = api.fetchVodInfo(authConfig, vodId) - val info = result.getOrElse { throw it } - contentCache.writeVodInfo(vodId, authConfig, info) - movieInfoMutex.withLock { movieInfoCache[key] = info } - return info + return vodContentRepository.loadMovieInfo(item, authConfig) } suspend fun loadSeriesInfo( @@ -1022,7 +1000,7 @@ class ContentRepository( } mutableListOf() } else { - loadSectionIndex(section, authConfig)?.toMutableList() ?: mutableListOf() + searchIndexRepository.loadSectionIndex(section, authConfig)?.toMutableList() ?: mutableListOf() } suspend fun saveProgressCheckpoint(lastPage: Int) { @@ -1209,7 +1187,7 @@ class ContentRepository( val checkpoint = contentCache.readSectionSyncCheckpoint(section, authConfig) val startPage = checkpoint?.lastPageSynced?.plus(1) ?: 0 - val existingItems = loadSectionIndex(section, authConfig)?.toMutableList() ?: mutableListOf() + val existingItems = searchIndexRepository.loadSectionIndex(section, authConfig)?.toMutableList() ?: mutableListOf() Timber.d("On-demand boost: $section fetching pages $startPage-${startPage + targetPages - 1}") @@ -1361,6 +1339,7 @@ class ContentRepository( validationCacheMutex.withLock { validationCache.clear() } SearchNormalizer.clearCache() searchIndexRepository.clearCache() + vodContentRepository.clearCache() seriesContentRepository.clearCache() liveContentRepository.clearCache() } diff --git a/app/src/main/java/com/example/xtreamplayer/content/VodContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/VodContentRepository.kt new file mode 100644 index 0000000..0b40252 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/content/VodContentRepository.kt @@ -0,0 +1,46 @@ +package com.example.xtreamplayer.content + +import com.example.xtreamplayer.api.XtreamApi +import com.example.xtreamplayer.auth.AuthConfig +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class VodContentRepository( + private val api: XtreamApi, + private val contentCache: ContentCache +) { + private val movieInfoCache = object : LinkedHashMap(100, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { + return size > 100 + } + } + private val movieInfoMutex = Mutex() + + suspend fun loadMovieInfo( + item: ContentItem, + authConfig: AuthConfig + ): MovieInfo? { + if (item.contentType != ContentType.MOVIES) { + return null + } + val vodId = item.streamId.ifBlank { item.id } + val key = "vod-info-${accountKey(authConfig)}-$vodId" + movieInfoMutex.withLock { + movieInfoCache[key]?.let { return it } + } + val cached = contentCache.readVodInfo(vodId, authConfig) + if (cached != null) { + movieInfoMutex.withLock { movieInfoCache[key] = cached } + return cached + } + val result = api.fetchVodInfo(authConfig, vodId) + val info = result.getOrElse { throw it } + contentCache.writeVodInfo(vodId, authConfig, info) + movieInfoMutex.withLock { movieInfoCache[key] = info } + return info + } + + suspend fun clearCache() { + movieInfoMutex.withLock { movieInfoCache.clear() } + } +} From cf186c3d23ad7a8499f22aa0e403ae21225ab964 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:52:05 -0400 Subject: [PATCH 28/39] Extract sync maintenance repository --- XTREAM_REFACTOR_PLAN.md | 2 +- .../xtreamplayer/content/ContentRepository.kt | 102 +--------------- .../content/SyncMaintenanceRepository.kt | 111 ++++++++++++++++++ 3 files changed, 116 insertions(+), 99 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/content/SyncMaintenanceRepository.kt diff --git a/XTREAM_REFACTOR_PLAN.md b/XTREAM_REFACTOR_PLAN.md index e2df339..2af270b 100644 --- a/XTREAM_REFACTOR_PLAN.md +++ b/XTREAM_REFACTOR_PLAN.md @@ -65,7 +65,7 @@ Suggested boundaries: - `[x]` VOD content - `[ ]` series/episode content - `[ ]` search/indexing -- `[ ]` sync/cache maintenance +- `[x]` sync/cache maintenance Acceptance criteria: - No single repository owns networking, parsing, caching, and sync orchestration together. diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt index d58dca1..f6806d0 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -29,6 +29,7 @@ class ContentRepository( private val seriesContentRepository = SeriesContentRepository(api, contentCache) private val liveContentRepository = LiveContentRepository(api) private val vodContentRepository = VodContentRepository(api, contentCache) + private val syncMaintenanceRepository = SyncMaintenanceRepository(contentCache) private val searchIndexRepository = SearchIndexRepository(contentCache) private companion object { const val DEFAULT_PAGE_SIZE = 24 @@ -128,15 +129,6 @@ class ContentRepository( } } - // Cache for checkpoint validation results to avoid repeated file reads - // Key: section+authConfig+timestamp, Value: validation result - private data class ValidationCacheEntry(val isValid: Boolean, val cachedAt: Long) - private val validationCache = object : LinkedHashMap(50, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { - return size > 50 - } - } - private val validationCacheMutex = Mutex() private class SyncPausedException : Exception() fun pager(section: Section, authConfig: AuthConfig): Pager { @@ -1238,98 +1230,11 @@ class ContentRepository( section: Section, config: AuthConfig ): SectionSyncCheckpoint? { - return contentCache.readSectionSyncCheckpoint(section, config) + return syncMaintenanceRepository.getSectionSyncCheckpoint(section, config) } suspend fun hasFullIndex(authConfig: AuthConfig): Boolean { - val sections = listOf(Section.MOVIES, Section.SERIES, Section.LIVE) - return sections.all { section -> - validateCheckpoint(section, authConfig) - } - } - - /** - * Validate checkpoint against actual cached data - * Checks for staleness, item count accuracy, and isComplete flag validity - * Uses caching to avoid repeated file reads during search paging - */ - private suspend fun validateCheckpoint( - section: Section, - authConfig: AuthConfig - ): Boolean { - val checkpoint = contentCache.readSectionSyncCheckpoint(section, authConfig) ?: return false - - // Not marked complete? Then not valid for search - if (!checkpoint.isComplete) return false - - // Check validation cache first (avoids repeated file reads during search) - val cacheKey = "${section.name}|${authConfig.baseUrl}|${authConfig.username}|${checkpoint.timestamp}" - val cachedValidation = validationCacheMutex.withLock { validationCache[cacheKey] } - if (cachedValidation != null) { - val cacheAge = System.currentTimeMillis() - cachedValidation.cachedAt - if (cacheAge < 120_000) { - return cachedValidation.isValid - } - } - - // Perform full validation - val isValid = performFullValidation(section, authConfig, checkpoint) - - // Cache the result - validationCacheMutex.withLock { - validationCache[cacheKey] = ValidationCacheEntry(isValid, System.currentTimeMillis()) - } - - return isValid - } - - private suspend fun performFullValidation( - section: Section, - authConfig: AuthConfig, - checkpoint: SectionSyncCheckpoint - ): Boolean { - // Check staleness (>7 days old) - val ageMs = System.currentTimeMillis() - checkpoint.timestamp - val staleDays = ageMs / (24 * 60 * 60 * 1000L) - if (staleDays > 7) { - Timber.w("Checkpoint for $section is stale (${staleDays}d old)") - // Mark checkpoint as invalid to trigger resync - contentCache.writeSectionSyncCheckpoint( - section, authConfig, - lastPage = 0, - itemsIndexed = 0, - isComplete = false - ) - return false - } - - // Verify actual cached items match checkpoint claim - val cachedItems = contentCache.readSectionIndex(section, authConfig) - if (cachedItems == null) { - Timber.w("Checkpoint claims complete for $section but no index file exists") - contentCache.writeSectionSyncCheckpoint( - section, authConfig, - lastPage = 0, - itemsIndexed = 0, - isComplete = false - ) - return false - } - - // Allow 10% margin for minor discrepancies (some items might be filtered) - val minExpected = (checkpoint.itemsIndexed * 0.9).toInt() - if (cachedItems.size < minExpected) { - Timber.w("Checkpoint mismatch for $section: claims ${checkpoint.itemsIndexed} but index has ${cachedItems.size}") - contentCache.writeSectionSyncCheckpoint( - section, authConfig, - lastPage = 0, - itemsIndexed = 0, - isComplete = false - ) - return false - } - - return true + return syncMaintenanceRepository.hasFullIndex(authConfig) } suspend fun clearCache() { @@ -1339,6 +1244,7 @@ class ContentRepository( validationCacheMutex.withLock { validationCache.clear() } SearchNormalizer.clearCache() searchIndexRepository.clearCache() + syncMaintenanceRepository.clearCache() vodContentRepository.clearCache() seriesContentRepository.clearCache() liveContentRepository.clearCache() diff --git a/app/src/main/java/com/example/xtreamplayer/content/SyncMaintenanceRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/SyncMaintenanceRepository.kt new file mode 100644 index 0000000..3c72f40 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/content/SyncMaintenanceRepository.kt @@ -0,0 +1,111 @@ +package com.example.xtreamplayer.content + +import com.example.xtreamplayer.Section +import com.example.xtreamplayer.auth.AuthConfig +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber + +internal class SyncMaintenanceRepository( + private val contentCache: ContentCache +) { + // Cache checkpoint validation results to avoid repeated file reads during search paging. + private data class ValidationCacheEntry(val isValid: Boolean, val cachedAt: Long) + + private val validationCache = object : LinkedHashMap(50, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { + return size > 50 + } + } + private val validationCacheMutex = Mutex() + + suspend fun getSectionSyncCheckpoint( + section: Section, + config: AuthConfig + ): SectionSyncCheckpoint? { + return contentCache.readSectionSyncCheckpoint(section, config) + } + + suspend fun hasFullIndex(authConfig: AuthConfig): Boolean { + val sections = listOf(Section.MOVIES, Section.SERIES, Section.LIVE) + return sections.all { section -> + validateCheckpoint(section, authConfig) + } + } + + suspend fun clearCache() { + validationCacheMutex.withLock { validationCache.clear() } + } + + /** + * Validate checkpoint against actual cached data. + * Checks staleness, item count accuracy, and isComplete validity. + */ + private suspend fun validateCheckpoint( + section: Section, + authConfig: AuthConfig + ): Boolean { + val checkpoint = contentCache.readSectionSyncCheckpoint(section, authConfig) ?: return false + if (!checkpoint.isComplete) return false + + val cacheKey = "${section.name}|${authConfig.baseUrl}|${authConfig.username}|${checkpoint.timestamp}" + val cachedValidation = validationCacheMutex.withLock { validationCache[cacheKey] } + if (cachedValidation != null) { + val cacheAge = System.currentTimeMillis() - cachedValidation.cachedAt + if (cacheAge < 120_000) { + return cachedValidation.isValid + } + } + + val isValid = performFullValidation(section, authConfig, checkpoint) + validationCacheMutex.withLock { + validationCache[cacheKey] = ValidationCacheEntry(isValid, System.currentTimeMillis()) + } + return isValid + } + + private suspend fun performFullValidation( + section: Section, + authConfig: AuthConfig, + checkpoint: SectionSyncCheckpoint + ): Boolean { + val ageMs = System.currentTimeMillis() - checkpoint.timestamp + val staleDays = ageMs / (24 * 60 * 60 * 1000L) + if (staleDays > 7) { + Timber.w("Checkpoint for $section is stale (${staleDays}d old)") + contentCache.writeSectionSyncCheckpoint( + section, authConfig, + lastPage = 0, + itemsIndexed = 0, + isComplete = false + ) + return false + } + + val cachedItems = contentCache.readSectionIndex(section, authConfig) + if (cachedItems == null) { + Timber.w("Checkpoint claims complete for $section but no index file exists") + contentCache.writeSectionSyncCheckpoint( + section, authConfig, + lastPage = 0, + itemsIndexed = 0, + isComplete = false + ) + return false + } + + val minExpected = (checkpoint.itemsIndexed * 0.9).toInt() + if (cachedItems.size < minExpected) { + Timber.w("Checkpoint mismatch for $section: claims ${checkpoint.itemsIndexed} but index has ${cachedItems.size}") + contentCache.writeSectionSyncCheckpoint( + section, authConfig, + lastPage = 0, + itemsIndexed = 0, + isComplete = false + ) + return false + } + + return true + } +} From c33a069030a2aa992e9d72c677eca5bf72576a2d Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:54:25 -0400 Subject: [PATCH 29/39] Apply build and settings fixes --- .gitignore | 3 +++ .../main/java/com/example/xtreamplayer/BrowseScreen.kt | 8 ++++++++ .../main/java/com/example/xtreamplayer/MainActivityUi.kt | 2 +- .../java/com/example/xtreamplayer/SettingsAndSyncHost.kt | 2 ++ .../com/example/xtreamplayer/viewmodel/PlayerViewModel.kt | 4 ++-- release-notes-2.9.1.md | 5 ----- release-notes-2.9.md | 4 ---- release-notes-3.4.4.md | 5 ----- release-notes-3.4.5.md | 4 ---- 9 files changed, 16 insertions(+), 21 deletions(-) delete mode 100644 release-notes-2.9.1.md delete mode 100644 release-notes-2.9.md delete mode 100644 release-notes-3.4.4.md delete mode 100644 release-notes-3.4.5.md diff --git a/.gitignore b/.gitignore index 29cc425..5ff0661 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ keystore.properties # Logs *.log +# Local refactor checklist +XTREAM_REFACTOR_PLAN.md + # OS files .DS_Store Thumbs.db diff --git a/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt b/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt index 439dc0d..c832b20 100644 --- a/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt +++ b/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt @@ -329,6 +329,14 @@ internal fun BrowseScreen( focusToContentTrigger++ } + LaunchedEffect(focusToContentTrigger) { + if (focusToContentTrigger <= 0) return@LaunchedEffect + if (selectedSection == Section.SETTINGS) { + withFrameNanos {} + runCatching { contentItemFocusRequester.requestFocus() } + } + } + LaunchedEffect(moveFocusToNav) { if (!moveFocusToNav) return@LaunchedEffect val requester = diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index 08f0316..824ec2f 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -315,7 +315,7 @@ private fun RootScreenContent( var selectedSection by browseViewModel.selectedSection var navExpanded by browseViewModel.navExpanded val updateUiStateFlow = updateViewModel.updateUiState - val updateUiState by updateUiStateFlow.collectAsStateWithLifecycle() + val updateUiState by updateUiStateFlow val showManageListsState = browseViewModel.showManageLists var showManageLists by showManageListsState val showAppearanceState = browseViewModel.showAppearance diff --git a/app/src/main/java/com/example/xtreamplayer/SettingsAndSyncHost.kt b/app/src/main/java/com/example/xtreamplayer/SettingsAndSyncHost.kt index 6a2ca8f..0f946d7 100644 --- a/app/src/main/java/com/example/xtreamplayer/SettingsAndSyncHost.kt +++ b/app/src/main/java/com/example/xtreamplayer/SettingsAndSyncHost.kt @@ -199,6 +199,7 @@ internal fun SettingsAndSyncHost( LaunchedEffect(showAppearance, selectedSection) { if (wasShowingAppearanceState.value && !showAppearance && selectedSection == Section.SETTINGS) { focusAppearanceOnSettingsReturnState.value = true + focusToContentTriggerState.intValue++ } wasShowingAppearanceState.value = showAppearance } @@ -206,6 +207,7 @@ internal fun SettingsAndSyncHost( LaunchedEffect(showManageLists, selectedSection) { if (wasShowingManageListsState.value && !showManageLists && selectedSection == Section.SETTINGS) { focusManageListsOnSettingsReturnState.value = true + focusToContentTriggerState.intValue++ } wasShowingManageListsState.value = showManageLists } diff --git a/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt index 9da8a70..76368c8 100644 --- a/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/example/xtreamplayer/viewmodel/PlayerViewModel.kt @@ -16,7 +16,7 @@ import kotlinx.coroutines.Job class PlayerViewModel @Inject constructor() : ViewModel() { val pendingPlayerReset = mutableStateOf(false) val playerResetNonce = mutableIntStateOf(0) - val playbackRecoveryTracker = PlaybackRecoveryTracker() + internal val playbackRecoveryTracker = PlaybackRecoveryTracker() val activePlaybackQueue = mutableStateOf(null) val activePlaybackTitle = mutableStateOf(null) @@ -33,7 +33,7 @@ class PlayerViewModel @Inject constructor() : ViewModel() { val pendingResume = mutableStateOf(null) val resumePositionMs = mutableStateOf(null) val resumeFocusId = mutableStateOf(null) - val activePlaybackSubtitleState = mutableStateOf(null) + internal val activePlaybackSubtitleState = mutableStateOf(null) val syncPausedForPlayback = mutableStateOf(false) val showPlaybackRecoveryDialog = mutableStateOf(false) } diff --git a/release-notes-2.9.1.md b/release-notes-2.9.1.md deleted file mode 100644 index 0e7bcc2..0000000 --- a/release-notes-2.9.1.md +++ /dev/null @@ -1,5 +0,0 @@ -# Changes: -- Mirrored Categories-style series launch flow across non-Categories series paths. -- Fixed series playback transition flash by masking the brief handoff window before player activation. -- Added a subtle first-launch-only loading indicator in Continue Watching while opening series details. -- Bumped app version to 2.9.1 and updated release APK naming. diff --git a/release-notes-2.9.md b/release-notes-2.9.md deleted file mode 100644 index 5302324..0000000 --- a/release-notes-2.9.md +++ /dev/null @@ -1,4 +0,0 @@ -# Changes: -- Fixed playback-exit focus restoration so movie/series flows return focus to content cards more reliably instead of jumping to the close/menu button. -- Mirrored Categories-style series playback behavior across non-Categories surfaces to reduce playback transition regressions. -- Bumped app version to 2.9 and updated release APK naming. diff --git a/release-notes-3.4.4.md b/release-notes-3.4.4.md deleted file mode 100644 index 90d42aa..0000000 --- a/release-notes-3.4.4.md +++ /dev/null @@ -1,5 +0,0 @@ -# Changes: -- Fixed search normalization so query language prefixes are handled without rewriting cached title text. -- Fixed Continue Watching write throttling to refresh persisted metadata changes while still avoiding noisy rewrites. -- Fixed local playback resume throttling so title/duration updates are saved even when position changes are small. -- Added regression tests for search normalization and resume/write guard behavior. diff --git a/release-notes-3.4.5.md b/release-notes-3.4.5.md deleted file mode 100644 index 5648ed1..0000000 --- a/release-notes-3.4.5.md +++ /dev/null @@ -1,4 +0,0 @@ -# Changes: -- Refactored root screen dependency wiring through a Hilt entry point. -- Moved update dialog and startup-check state into a Hilt ViewModel. -- Kept player engine release handling in the activity teardown path. From e8847da8de72696a620c25b381a385f73d9390fd Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:55:59 -0400 Subject: [PATCH 30/39] Fix duplicate refactor checklist item --- XTREAM_REFACTOR_PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/XTREAM_REFACTOR_PLAN.md b/XTREAM_REFACTOR_PLAN.md index 2af270b..65c1ccd 100644 --- a/XTREAM_REFACTOR_PLAN.md +++ b/XTREAM_REFACTOR_PLAN.md @@ -34,7 +34,7 @@ Acceptance criteria: ## Phase 2: Move root state into view models -- `[ ]` Identify the state that currently belongs to the screen, not the component tree. +- `[x]` Identify the state that currently belongs to the screen, not the component tree. - `[x]` Identify the state that currently belongs to the screen, not the component tree. - `[x]` Add `UiState` data classes for major sections instead of many `mutableStateOf` fields. - `[x]` Migrate state into `StateFlow` or `MutableStateFlow` where appropriate. From 3ae0606ec7229dc764a6260eed3f54ad973d345f Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:57:42 -0400 Subject: [PATCH 31/39] Extract series info repository logic --- XTREAM_REFACTOR_PLAN.md | 2 +- .../xtreamplayer/content/ContentRepository.kt | 25 +-------------- .../content/SeriesContentRepository.kt | 31 +++++++++++++++++++ 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/XTREAM_REFACTOR_PLAN.md b/XTREAM_REFACTOR_PLAN.md index 65c1ccd..3007bdd 100644 --- a/XTREAM_REFACTOR_PLAN.md +++ b/XTREAM_REFACTOR_PLAN.md @@ -63,7 +63,7 @@ Acceptance criteria: Suggested boundaries: - `[ ]` live content - `[x]` VOD content -- `[ ]` series/episode content +- `[x]` series/episode content - `[ ]` search/indexing - `[x]` sync/cache maintenance diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt index f6806d0..8f5f829 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -79,11 +79,6 @@ class ContentRepository( return size > 200 } } - private val seriesInfoCache = object : LinkedHashMap(100, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { - return size > 100 - } - } private val seriesSeasonFullCache = object : LinkedHashMap>(20, 0.75f, true) { override fun removeEldestEntry( @@ -114,7 +109,6 @@ class ContentRepository( private val memoryCacheMutex = Mutex() private val seriesEpisodesMutex = Mutex() private val seriesSeasonCountMutex = Mutex() - private val seriesInfoMutex = Mutex() private val seriesSeasonFullMutex = Mutex() private val seriesSeasonsMutex = Mutex() private val liveEpgMutex = Mutex() @@ -385,24 +379,7 @@ class ContentRepository( item: ContentItem, authConfig: AuthConfig ): SeriesInfo? { - if (item.contentType != ContentType.SERIES) { - return null - } - val seriesId = item.streamId.ifBlank { item.id } - val key = "series-info-${accountKey(authConfig)}-$seriesId" - seriesInfoMutex.withLock { - seriesInfoCache[key]?.let { return it } - } - val cached = contentCache.readSeriesInfo(seriesId, authConfig) - if (cached != null) { - seriesInfoMutex.withLock { seriesInfoCache[key] = cached } - return cached - } - val result = api.fetchSeriesInfo(authConfig, seriesId) - val info = result.getOrElse { throw it } - contentCache.writeSeriesInfo(seriesId, authConfig, info) - seriesInfoMutex.withLock { seriesInfoCache[key] = info } - return info + return seriesContentRepository.loadSeriesInfo(item, authConfig) } suspend fun loadLiveNowNext( diff --git a/app/src/main/java/com/example/xtreamplayer/content/SeriesContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/SeriesContentRepository.kt index 5a1e0c4..00ff40a 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/SeriesContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/SeriesContentRepository.kt @@ -41,10 +41,16 @@ internal class SeriesContentRepository( return size > 50 } } + private val seriesInfoCache = object : LinkedHashMap(100, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { + return size > 100 + } + } private val seriesEpisodesMutex = Mutex() private val seriesSeasonCountMutex = Mutex() private val seriesSeasonFullMutex = Mutex() private val seriesSeasonsMutex = Mutex() + private val seriesInfoMutex = Mutex() suspend fun loadSeriesEpisodePage( seriesId: String, @@ -143,6 +149,30 @@ internal class SeriesContentRepository( return summaries } + suspend fun loadSeriesInfo( + item: ContentItem, + authConfig: AuthConfig + ): SeriesInfo? { + if (item.contentType != ContentType.SERIES) { + return null + } + val seriesId = item.streamId.ifBlank { item.id } + val key = "series-info-${accountKey(authConfig)}-$seriesId" + seriesInfoMutex.withLock { + seriesInfoCache[key]?.let { return it } + } + val cached = contentCache.readSeriesInfo(seriesId, authConfig) + if (cached != null) { + seriesInfoMutex.withLock { seriesInfoCache[key] = cached } + return cached + } + val result = api.fetchSeriesInfo(authConfig, seriesId) + val info = result.getOrElse { throw it } + contentCache.writeSeriesInfo(seriesId, authConfig, info) + seriesInfoMutex.withLock { seriesInfoCache[key] = info } + return info + } + fun peekSeriesSeasonFullCache( seriesId: String, seasonLabel: String, @@ -196,5 +226,6 @@ internal class SeriesContentRepository( seriesSeasonCountMutex.withLock { seriesSeasonCountUnavailableCache.clear() } seriesSeasonFullMutex.withLock { seriesSeasonFullCache.clear() } seriesSeasonsMutex.withLock { seriesSeasonsCache.clear() } + seriesInfoMutex.withLock { seriesInfoCache.clear() } } } From 1c6cbabb3946d74bbcbd7630f160e0324ef081d8 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:59:39 -0400 Subject: [PATCH 32/39] Extract search content repository --- XTREAM_REFACTOR_PLAN.md | 2 +- .../xtreamplayer/content/ContentRepository.kt | 272 +--------------- .../content/SearchContentRepository.kt | 300 ++++++++++++++++++ 3 files changed, 306 insertions(+), 268 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/content/SearchContentRepository.kt diff --git a/XTREAM_REFACTOR_PLAN.md b/XTREAM_REFACTOR_PLAN.md index 3007bdd..a61d808 100644 --- a/XTREAM_REFACTOR_PLAN.md +++ b/XTREAM_REFACTOR_PLAN.md @@ -64,7 +64,7 @@ Suggested boundaries: - `[ ]` live content - `[x]` VOD content - `[x]` series/episode content -- `[ ]` search/indexing +- `[x]` search/indexing - `[x]` sync/cache maintenance Acceptance criteria: diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt index 8f5f829..308a332 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -31,6 +31,8 @@ class ContentRepository( private val vodContentRepository = VodContentRepository(api, contentCache) private val syncMaintenanceRepository = SyncMaintenanceRepository(contentCache) private val searchIndexRepository = SearchIndexRepository(contentCache) + private val searchContentRepository = + SearchContentRepository(api, contentCache, searchIndexRepository) private companion object { const val DEFAULT_PAGE_SIZE = 24 const val DEFAULT_PREFETCH_DISTANCE = 6 @@ -241,19 +243,7 @@ class ContentRepository( limit: Int, authConfig: AuthConfig ): ContentPage { - val normalizedQuery = SearchNormalizer.normalizeQuery(query) - val canUseLocalIndex = searchIndexRepository.isSearchIndexReadyForQuery(section, authConfig) - if (canUseLocalIndex && normalizedQuery.length >= MIN_LOCAL_SEARCH_QUERY_LENGTH) { - val localResult = localSearchPage(section, normalizedQuery, page, limit, authConfig) - if (localResult != null) { - return localResult - } - } - return if (section == Section.ALL) { - searchMixedPage(query, page, limit, authConfig) - } else { - searchSectionPage(section, query, page, limit, authConfig) - } + return searchContentRepository.searchPage(section, query, page, limit, authConfig) } suspend fun searchCategoryPage( @@ -272,7 +262,7 @@ class ContentRepository( memoryCacheMutex.withLock { memoryCache[key]?.let { return it } } - return searchFilterPages( + return searchContentRepository.searchFilterPages( limit = limit, page = page, matcher = { item -> @@ -436,244 +426,6 @@ class ContentRepository( } } - private suspend fun loadSectionPage( - section: Section, - page: Int, - limit: Int, - authConfig: AuthConfig - ): ContentPage { - val key = cacheKey(section.name, page, limit) - memoryCacheMutex.withLock { - memoryCache[key]?.let { return it } - } - - val lock = locks[section] ?: Mutex() - return lock.withLock { - memoryCacheMutex.withLock { - memoryCache[key]?.let { return it } - } - val cached = contentCache.readPage(section, authConfig, page, limit) - if (cached != null) { - memoryCacheMutex.withLock { memoryCache[key] = cached } - return@withLock cached - } - - val result = api.fetchSectionPage(section, authConfig, page, limit) - val pageData = result.getOrElse { throw it } - if (pageData.items.isNotEmpty()) { - contentCache.writePage(section, authConfig, page, limit, pageData) - } - memoryCacheMutex.withLock { memoryCache[key] = pageData } - return@withLock pageData - } - } - - private suspend fun searchSectionPage( - section: Section, - query: String, - page: Int, - limit: Int, - authConfig: AuthConfig - ): ContentPage { - if ( - section == Section.SETTINGS || - section == Section.CATEGORIES || - section == Section.ALL || - section == Section.LOCAL_FILES || - section == Section.FAVORITES - ) { - return ContentPage(items = emptyList(), endReached = true) - } - val normalizedQuery = SearchNormalizer.normalizeQuery(query) - val rawQuery = query.trim() - if (rawQuery.isBlank()) { - return loadSectionPage(section, page, limit, authConfig) - } - val key = cacheKey("search-${section.name}-$normalizedQuery", page, limit) - memoryCacheMutex.withLock { - memoryCache[key]?.let { return it } - } - val apiResult = api.fetchSearchPage(section, authConfig, rawQuery, page, limit) - val pageData = apiResult.getOrElse { - return searchFilterPages( - limit = limit, - page = page, - matcher = { item -> SearchNormalizer.matchesTitle(item.title, normalizedQuery) }, - pageLoader = { rawPage, rawLimit -> - loadSectionPage(section, rawPage, rawLimit, authConfig) - }, - maxScanPages = 10 - ).also { fallback -> - memoryCacheMutex.withLock { memoryCache[key] = fallback } - } - } - val filtered = withContext(Dispatchers.Default) { - pageData.items.filter { item -> - SearchNormalizer.matchesTitle(item.title, normalizedQuery) - } - } - if (filtered.isNotEmpty() || pageData.endReached) { - return ContentPage(items = filtered, endReached = pageData.endReached).also { finalPage -> - memoryCacheMutex.withLock { memoryCache[key] = finalPage } - } - } - return searchFilterPages( - limit = limit, - page = page, - matcher = { item -> - SearchNormalizer.matchesTitle(item.title, normalizedQuery) - }, - pageLoader = { rawPage, rawLimit -> - loadSectionPage(section, rawPage, rawLimit, authConfig) - }, - maxScanPages = 10 - ).also { fallback -> - memoryCacheMutex.withLock { memoryCache[key] = fallback } - } - } - - private suspend fun loadMixedPage( - page: Int, - limit: Int, - authConfig: AuthConfig - ): ContentPage { - val key = cacheKey(Section.ALL.name, page, limit) - readCache(memoryCacheMutex, memoryCache, key)?.let { return it } - - val perSectionLimit = ceil(limit / 3.0).toInt().coerceAtLeast(1) - val (live, movies, series) = coroutineScope { - val liveDeferred = async { loadSectionPage(Section.LIVE, page, perSectionLimit, authConfig) } - val moviesDeferred = async { loadSectionPage(Section.MOVIES, page, perSectionLimit, authConfig) } - val seriesDeferred = async { loadSectionPage(Section.SERIES, page, perSectionLimit, authConfig) } - Triple(liveDeferred.await(), moviesDeferred.await(), seriesDeferred.await()) - } - - val mixed = interleaveLists(listOf(live.items, movies.items, series.items), maxItems = limit) - val endReached = live.endReached && movies.endReached && series.endReached - - val pageData = ContentPage(items = mixed, endReached = endReached) - writeCache(memoryCacheMutex, memoryCache, key, pageData) - return pageData - } - - private suspend fun searchMixedPage( - query: String, - page: Int, - limit: Int, - authConfig: AuthConfig - ): ContentPage { - val key = cacheKey("search-${Section.ALL.name}-$query", page, limit) - readCache(memoryCacheMutex, memoryCache, key)?.let { return it } - - val perSectionLimit = ceil(limit / 3.0).toInt().coerceAtLeast(1) - val (live, movies, series) = coroutineScope { - val liveDeferred = async { searchSectionPage(Section.LIVE, query, page, perSectionLimit, authConfig) } - val moviesDeferred = async { searchSectionPage(Section.MOVIES, query, page, perSectionLimit, authConfig) } - val seriesDeferred = async { searchSectionPage(Section.SERIES, query, page, perSectionLimit, authConfig) } - Triple(liveDeferred.await(), moviesDeferred.await(), seriesDeferred.await()) - } - - val mixed = interleaveLists(listOf(live.items, movies.items, series.items), maxItems = limit) - val endReached = live.endReached && movies.endReached && series.endReached - - val pageData = ContentPage(items = mixed, endReached = endReached) - writeCache(memoryCacheMutex, memoryCache, key, pageData) - return pageData - } - - private suspend fun searchFilterPages( - limit: Int, - page: Int, - matcher: (ContentItem) -> Boolean, - pageLoader: suspend (Int, Int) -> ContentPage, - maxScanPages: Int - ): ContentPage { - val targetStart = page * limit - val items = ArrayList(limit) - var matchIndex = 0 - var rawPage = 0 - var endReached = true - while (true) { - if (rawPage >= maxScanPages) { - endReached = true - break - } - val pageData = pageLoader(rawPage, limit) - val matchResult = withContext(Dispatchers.Default) { - val matches = mutableListOf>() - var localMatchIndex = matchIndex - pageData.items.forEach { item -> - if (matcher(item)) { - matches.add(item to localMatchIndex) - localMatchIndex++ - } - } - matches to localMatchIndex - } - matchResult.first.forEach { (item, idx) -> - if (idx >= targetStart && items.size < limit) { - items.add(item) - } - } - matchIndex = matchResult.second - if (pageData.endReached) { - endReached = true - break - } - if (items.size >= limit) { - endReached = false - break - } - rawPage++ - } - return ContentPage(items = items, endReached = endReached) - } - - private suspend fun localSearchPage( - section: Section, - normalizedQuery: String, - page: Int, - limit: Int, - authConfig: AuthConfig - ): ContentPage? { - if (normalizedQuery.length < MIN_LOCAL_SEARCH_QUERY_LENGTH) { - return null - } - if ( - section == Section.SETTINGS || - section == Section.CATEGORIES || - section == Section.LOCAL_FILES || - section == Section.FAVORITES - ) { - return null - } - val sources = - if (section == Section.ALL) { - listOf( - searchIndexRepository.loadSectionIndex(Section.LIVE, authConfig).orEmpty(), - searchIndexRepository.loadSectionIndex(Section.MOVIES, authConfig).orEmpty(), - searchIndexRepository.loadSectionIndex(Section.SERIES, authConfig).orEmpty() - ).filter { it.isNotEmpty() } - } else { - listOf(searchIndexRepository.loadSectionIndex(section, authConfig) ?: return null) - } - if (sources.isEmpty()) { - return null - } - Timber.d( - "Local search using cached indexes: section=$section queryLen=${normalizedQuery.length} " + - "sourceSizes=${sources.joinToString(",") { it.size.toString() }}" - ) - return withContext(Dispatchers.Default) { - collectSearchPageFromSources( - sources = sources, - normalizedQuery = normalizedQuery, - page = page, - limit = limit - ) - } - } - /** * Sync a single section's search index */ @@ -1221,6 +973,7 @@ class ContentRepository( validationCacheMutex.withLock { validationCache.clear() } SearchNormalizer.clearCache() searchIndexRepository.clearCache() + searchContentRepository.clearCache() syncMaintenanceRepository.clearCache() vodContentRepository.clearCache() seriesContentRepository.clearCache() @@ -1278,21 +1031,6 @@ class ContentRepository( return imageUrl } - private suspend fun readCache( - mutex: Mutex, - cache: Map, - key: K - ): V? = mutex.withLock { cache[key] } - - private suspend fun writeCache( - mutex: Mutex, - cache: MutableMap, - key: K, - value: V - ) { - mutex.withLock { cache[key] = value } - } - } /** diff --git a/app/src/main/java/com/example/xtreamplayer/content/SearchContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/SearchContentRepository.kt new file mode 100644 index 0000000..3f7450b --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/content/SearchContentRepository.kt @@ -0,0 +1,300 @@ +package com.example.xtreamplayer.content + +import com.example.xtreamplayer.Section +import com.example.xtreamplayer.api.XtreamApi +import com.example.xtreamplayer.auth.AuthConfig +import java.util.LinkedHashMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber +import kotlin.math.ceil + +internal class SearchContentRepository( + private val api: XtreamApi, + private val contentCache: ContentCache, + private val searchIndexRepository: SearchIndexRepository +) { + private companion object { + const val MIN_LOCAL_SEARCH_QUERY_LENGTH = 2 + } + + private val memoryCache = object : LinkedHashMap(200, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { + return size > 200 + } + } + private val memoryCacheMutex = Mutex() + private val locks = Section.values().associateWith { Mutex() } + + suspend fun searchPage( + section: Section, + query: String, + page: Int, + limit: Int, + authConfig: AuthConfig + ): ContentPage { + val normalizedQuery = SearchNormalizer.normalizeQuery(query) + val canUseLocalIndex = searchIndexRepository.isSearchIndexReadyForQuery(section, authConfig) + if (canUseLocalIndex && normalizedQuery.length >= MIN_LOCAL_SEARCH_QUERY_LENGTH) { + val localResult = localSearchPage(section, normalizedQuery, page, limit, authConfig) + if (localResult != null) { + return localResult + } + } + return if (section == Section.ALL) { + searchMixedPage(query, page, limit, authConfig) + } else { + searchSectionPage(section, query, page, limit, authConfig) + } + } + + suspend fun searchFilterPages( + limit: Int, + page: Int, + matcher: (ContentItem) -> Boolean, + pageLoader: suspend (Int, Int) -> ContentPage, + maxScanPages: Int + ): ContentPage { + val targetStart = page * limit + val items = ArrayList(limit) + var matchIndex = 0 + var rawPage = 0 + var endReached = true + while (true) { + if (rawPage >= maxScanPages) { + endReached = true + break + } + val pageData = pageLoader(rawPage, limit) + val matchResult = withContext(Dispatchers.Default) { + val matches = mutableListOf>() + var localMatchIndex = matchIndex + pageData.items.forEach { item -> + if (matcher(item)) { + matches.add(item to localMatchIndex) + localMatchIndex++ + } + } + matches to localMatchIndex + } + matchResult.first.forEach { (item, idx) -> + if (idx >= targetStart && items.size < limit) { + items.add(item) + } + } + matchIndex = matchResult.second + if (pageData.endReached) { + endReached = true + break + } + if (items.size >= limit) { + endReached = false + break + } + rawPage++ + } + return ContentPage(items = items, endReached = endReached) + } + + suspend fun clearCache() { + memoryCacheMutex.withLock { memoryCache.clear() } + } + + private suspend fun loadSectionPage( + section: Section, + page: Int, + limit: Int, + authConfig: AuthConfig + ): ContentPage { + val key = cacheKey(section.name, page, limit) + memoryCacheMutex.withLock { + memoryCache[key]?.let { return it } + } + + val lock = locks[section] ?: Mutex() + return lock.withLock { + memoryCacheMutex.withLock { + memoryCache[key]?.let { return it } + } + val cached = contentCache.readPage(section, authConfig, page, limit) + if (cached != null) { + memoryCacheMutex.withLock { memoryCache[key] = cached } + return@withLock cached + } + + val result = api.fetchSectionPage(section, authConfig, page, limit) + val pageData = result.getOrElse { throw it } + if (pageData.items.isNotEmpty()) { + contentCache.writePage(section, authConfig, page, limit, pageData) + } + memoryCacheMutex.withLock { memoryCache[key] = pageData } + return@withLock pageData + } + } + + private suspend fun searchSectionPage( + section: Section, + query: String, + page: Int, + limit: Int, + authConfig: AuthConfig + ): ContentPage { + if ( + section == Section.SETTINGS || + section == Section.CATEGORIES || + section == Section.ALL || + section == Section.LOCAL_FILES || + section == Section.FAVORITES + ) { + return ContentPage(items = emptyList(), endReached = true) + } + val normalizedQuery = SearchNormalizer.normalizeQuery(query) + val rawQuery = query.trim() + if (rawQuery.isBlank()) { + return loadSectionPage(section, page, limit, authConfig) + } + val key = cacheKey("search-${section.name}-$normalizedQuery", page, limit) + memoryCacheMutex.withLock { + memoryCache[key]?.let { return it } + } + val apiResult = api.fetchSearchPage(section, authConfig, rawQuery, page, limit) + val pageData = apiResult.getOrElse { + return searchFilterPages( + limit = limit, + page = page, + matcher = { item -> SearchNormalizer.matchesTitle(item.title, normalizedQuery) }, + pageLoader = { rawPage, rawLimit -> + loadSectionPage(section, rawPage, rawLimit, authConfig) + }, + maxScanPages = 10 + ).also { fallback -> + memoryCacheMutex.withLock { memoryCache[key] = fallback } + } + } + val filtered = withContext(Dispatchers.Default) { + pageData.items.filter { item -> + SearchNormalizer.matchesTitle(item.title, normalizedQuery) + } + } + if (filtered.isNotEmpty() || pageData.endReached) { + return ContentPage(items = filtered, endReached = pageData.endReached).also { finalPage -> + memoryCacheMutex.withLock { memoryCache[key] = finalPage } + } + } + return searchFilterPages( + limit = limit, + page = page, + matcher = { item -> + SearchNormalizer.matchesTitle(item.title, normalizedQuery) + }, + pageLoader = { rawPage, rawLimit -> + loadSectionPage(section, rawPage, rawLimit, authConfig) + }, + maxScanPages = 10 + ).also { fallback -> + memoryCacheMutex.withLock { memoryCache[key] = fallback } + } + } + + private suspend fun loadMixedPage( + page: Int, + limit: Int, + authConfig: AuthConfig + ): ContentPage { + val key = cacheKey(Section.ALL.name, page, limit) + memoryCacheMutex.withLock { + memoryCache[key]?.let { return it } + } + + val perSectionLimit = ceil(limit / 3.0).toInt().coerceAtLeast(1) + val (live, movies, series) = coroutineScope { + val liveDeferred = async { loadSectionPage(Section.LIVE, page, perSectionLimit, authConfig) } + val moviesDeferred = async { loadSectionPage(Section.MOVIES, page, perSectionLimit, authConfig) } + val seriesDeferred = async { loadSectionPage(Section.SERIES, page, perSectionLimit, authConfig) } + Triple(liveDeferred.await(), moviesDeferred.await(), seriesDeferred.await()) + } + + val mixed = interleaveLists(listOf(live.items, movies.items, series.items), maxItems = limit) + val endReached = live.endReached && movies.endReached && series.endReached + + val pageData = ContentPage(items = mixed, endReached = endReached) + memoryCacheMutex.withLock { memoryCache[key] = pageData } + return pageData + } + + private suspend fun searchMixedPage( + query: String, + page: Int, + limit: Int, + authConfig: AuthConfig + ): ContentPage { + val key = cacheKey("search-${Section.ALL.name}-$query", page, limit) + memoryCacheMutex.withLock { + memoryCache[key]?.let { return it } + } + + val perSectionLimit = ceil(limit / 3.0).toInt().coerceAtLeast(1) + val (live, movies, series) = coroutineScope { + val liveDeferred = async { searchSectionPage(Section.LIVE, query, page, perSectionLimit, authConfig) } + val moviesDeferred = async { searchSectionPage(Section.MOVIES, query, page, perSectionLimit, authConfig) } + val seriesDeferred = async { searchSectionPage(Section.SERIES, query, page, perSectionLimit, authConfig) } + Triple(liveDeferred.await(), moviesDeferred.await(), seriesDeferred.await()) + } + + val mixed = interleaveLists(listOf(live.items, movies.items, series.items), maxItems = limit) + val endReached = live.endReached && movies.endReached && series.endReached + + val pageData = ContentPage(items = mixed, endReached = endReached) + memoryCacheMutex.withLock { memoryCache[key] = pageData } + return pageData + } + + private suspend fun localSearchPage( + section: Section, + normalizedQuery: String, + page: Int, + limit: Int, + authConfig: AuthConfig + ): ContentPage? { + if (normalizedQuery.length < MIN_LOCAL_SEARCH_QUERY_LENGTH) { + return null + } + if ( + section == Section.SETTINGS || + section == Section.CATEGORIES || + section == Section.LOCAL_FILES || + section == Section.FAVORITES + ) { + return null + } + val sources = + if (section == Section.ALL) { + listOf( + searchIndexRepository.loadSectionIndex(Section.LIVE, authConfig).orEmpty(), + searchIndexRepository.loadSectionIndex(Section.MOVIES, authConfig).orEmpty(), + searchIndexRepository.loadSectionIndex(Section.SERIES, authConfig).orEmpty() + ).filter { it.isNotEmpty() } + } else { + listOf(searchIndexRepository.loadSectionIndex(section, authConfig) ?: return null) + } + if (sources.isEmpty()) { + return null + } + Timber.d( + "Local search using cached indexes: section=$section queryLen=${normalizedQuery.length} " + + "sourceSizes=${sources.joinToString(",") { it.size.toString() }}" + ) + return withContext(Dispatchers.Default) { + collectSearchPageFromSources( + sources = sources, + normalizedQuery = normalizedQuery, + page = page, + limit = limit + ) + } + } +} From 9cebf274ef835238a11aee2697507043f94a61ee Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:01:09 -0400 Subject: [PATCH 33/39] Extract category content repository --- XTREAM_REFACTOR_PLAN.md | 2 +- .../content/CategoryContentRepository.kt | 151 ++++++++++++++++++ .../xtreamplayer/content/ContentRepository.kt | 108 ++----------- 3 files changed, 166 insertions(+), 95 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/content/CategoryContentRepository.kt diff --git a/XTREAM_REFACTOR_PLAN.md b/XTREAM_REFACTOR_PLAN.md index a61d808..b40c5b9 100644 --- a/XTREAM_REFACTOR_PLAN.md +++ b/XTREAM_REFACTOR_PLAN.md @@ -61,7 +61,7 @@ Acceptance criteria: - `[ ]` Move sync coordination into a dedicated manager. Suggested boundaries: -- `[ ]` live content +- `[x]` live content - `[x]` VOD content - `[x]` series/episode content - `[x]` search/indexing diff --git a/app/src/main/java/com/example/xtreamplayer/content/CategoryContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/CategoryContentRepository.kt new file mode 100644 index 0000000..264872c --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/content/CategoryContentRepository.kt @@ -0,0 +1,151 @@ +package com.example.xtreamplayer.content + +import com.example.xtreamplayer.api.XtreamApi +import com.example.xtreamplayer.auth.AuthConfig +import java.util.LinkedHashMap +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit + +internal class CategoryContentRepository( + private val api: XtreamApi, + private val contentCache: ContentCache, + private val searchContentRepository: SearchContentRepository +) { + private val categoryCache = object : LinkedHashMap>(100, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry>): Boolean { + return size > 100 + } + } + private val categoryThumbnailCache = object : LinkedHashMap(50, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { + return size > 50 + } + } + private val categoryLock = Mutex() + private val categoryThumbnailMutex = Mutex() + private val categoryThumbnailLoadLimiter = Semaphore(permits = 3) + private val memoryCache = object : LinkedHashMap(100, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { + return size > 100 + } + } + private val memoryCacheMutex = Mutex() + + suspend fun searchCategoryPage( + type: ContentType, + categoryId: String, + query: String, + page: Int, + limit: Int, + authConfig: AuthConfig + ): ContentPage { + val normalizedQuery = SearchNormalizer.normalizeQuery(query) + if (normalizedQuery.isBlank()) { + return loadCategoryPage(type, categoryId, page, limit, authConfig) + } + val key = cacheKey("search-${type.name}-$categoryId-$normalizedQuery", page, limit) + memoryCacheMutex.withLock { + memoryCache[key]?.let { return it } + } + return searchContentRepository.searchFilterPages( + limit = limit, + page = page, + matcher = { item -> SearchNormalizer.matchesTitle(item.title, normalizedQuery) }, + pageLoader = { rawPage, rawLimit -> + loadCategoryPage(type, categoryId, rawPage, rawLimit, authConfig) + }, + maxScanPages = 6 + ).also { pageData -> + memoryCacheMutex.withLock { memoryCache[key] = pageData } + } + } + + suspend fun loadCategoryPage( + type: ContentType, + categoryId: String, + page: Int, + limit: Int, + authConfig: AuthConfig, + forceRefresh: Boolean = false + ): ContentPage { + val cacheKey = "category-${type.name}-$categoryId" + val key = cacheKey(cacheKey, page, limit) + if (!forceRefresh) { + memoryCacheMutex.withLock { + memoryCache[key]?.let { return it } + } + } + if (!forceRefresh) { + val cached = contentCache.readPage(cacheKey, authConfig, page, limit) + if (cached != null) { + memoryCacheMutex.withLock { memoryCache[key] = cached } + return cached + } + } + val result = api.fetchCategoryPage(type, authConfig, categoryId, page, limit) + val pageData = result.getOrElse { throw it } + contentCache.writePage(cacheKey, authConfig, page, limit, pageData) + memoryCacheMutex.withLock { memoryCache[key] = pageData } + return pageData + } + + suspend fun loadCategories( + type: ContentType, + authConfig: AuthConfig, + forceRefresh: Boolean = false + ): List { + categoryLock.withLock { + val key = "${accountKey(authConfig)}-${type.name}" + if (!forceRefresh) { + categoryCache[key]?.let { return it } + val cached = contentCache.readCategories(type, authConfig) + if (cached != null) { + categoryCache[key] = cached + return cached + } + } + val result = api.fetchCategories(type, authConfig) + val categories = result.getOrElse { throw it } + categoryCache[key] = categories + contentCache.writeCategories(type, authConfig, categories) + return categories + } + } + + suspend fun categoryThumbnail( + type: ContentType, + categoryId: String, + authConfig: AuthConfig + ): String? { + val key = "${accountKey(authConfig)}-${type.name}-$categoryId" + categoryThumbnailMutex.withLock { + categoryThumbnailCache[key]?.let { return it } + } + val cached = contentCache.readCategoryThumbnail(type, categoryId, authConfig) + if (cached != null) { + categoryThumbnailMutex.withLock { + categoryThumbnailCache[key] = cached + } + return cached + } + val page = categoryThumbnailLoadLimiter.withPermit { + runCatching { + loadCategoryPage(type, categoryId, 0, 1, authConfig) + }.getOrNull() + } + val imageUrl = page?.items?.firstOrNull()?.imageUrl + contentCache.writeCategoryThumbnail(type, categoryId, authConfig, imageUrl) + categoryThumbnailMutex.withLock { + categoryThumbnailCache[key] = imageUrl + } + return imageUrl + } + + suspend fun clearCache() { + categoryLock.withLock { categoryCache.clear() } + categoryThumbnailMutex.withLock { categoryThumbnailCache.clear() } + memoryCacheMutex.withLock { memoryCache.clear() } + } +} diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt index 308a332..d18246b 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -33,6 +33,8 @@ class ContentRepository( private val searchIndexRepository = SearchIndexRepository(contentCache) private val searchContentRepository = SearchContentRepository(api, contentCache, searchIndexRepository) + private val categoryContentRepository = + CategoryContentRepository(api, contentCache, searchContentRepository) private companion object { const val DEFAULT_PAGE_SIZE = 24 const val DEFAULT_PREFETCH_DISTANCE = 6 @@ -60,11 +62,6 @@ class ContentRepository( return size > 200 } } - private val categoryThumbnailCache = object : LinkedHashMap(50, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { - return size > 50 - } - } private val seriesEpisodesCache = object : LinkedHashMap>(50, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry>): Boolean { return size > 50 @@ -107,7 +104,6 @@ class ContentRepository( } } private val locks = Section.values().associateWith { Mutex() } - private val categoryLock = Mutex() private val memoryCacheMutex = Mutex() private val seriesEpisodesMutex = Mutex() private val seriesSeasonCountMutex = Mutex() @@ -117,14 +113,6 @@ class ContentRepository( private val liveEpgInFlightMutex = Mutex() private val liveEpgInFlight = mutableMapOf>>() - private val categoryThumbnailMutex = Mutex() - private val categoryThumbnailLoadLimiter = Semaphore(permits = 3) - private val categoryCache = object : LinkedHashMap>(100, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry>): Boolean { - return size > 100 - } - } - private class SyncPausedException : Exception() fun pager(section: Section, authConfig: AuthConfig): Pager { @@ -254,27 +242,7 @@ class ContentRepository( limit: Int, authConfig: AuthConfig ): ContentPage { - val normalizedQuery = SearchNormalizer.normalizeQuery(query) - if (normalizedQuery.isBlank()) { - return loadCategoryPage(type, categoryId, page, limit, authConfig) - } - val key = cacheKey("search-${type.name}-$categoryId-$normalizedQuery", page, limit) - memoryCacheMutex.withLock { - memoryCache[key]?.let { return it } - } - return searchContentRepository.searchFilterPages( - limit = limit, - page = page, - matcher = { item -> - SearchNormalizer.matchesTitle(item.title, normalizedQuery) - }, - pageLoader = { rawPage, rawLimit -> - loadCategoryPage(type, categoryId, rawPage, rawLimit, authConfig) - }, - maxScanPages = 6 - ).also { pageData -> - memoryCacheMutex.withLock { memoryCache[key] = pageData } - } + return categoryContentRepository.searchCategoryPage(type, categoryId, query, page, limit, authConfig) } suspend fun loadCategoryPage( @@ -285,25 +253,14 @@ class ContentRepository( authConfig: AuthConfig, forceRefresh: Boolean = false ): ContentPage { - val cacheKey = "category-${type.name}-$categoryId" - val key = cacheKey(cacheKey, page, limit) - if (!forceRefresh) { - memoryCacheMutex.withLock { - memoryCache[key]?.let { return it } - } - } - if (!forceRefresh) { - val cached = contentCache.readPage(cacheKey, authConfig, page, limit) - if (cached != null) { - memoryCacheMutex.withLock { memoryCache[key] = cached } - return cached - } - } - val result = api.fetchCategoryPage(type, authConfig, categoryId, page, limit) - val pageData = result.getOrElse { throw it } - contentCache.writePage(cacheKey, authConfig, page, limit, pageData) - memoryCacheMutex.withLock { memoryCache[key] = pageData } - return pageData + return categoryContentRepository.loadCategoryPage( + type, + categoryId, + page, + limit, + authConfig, + forceRefresh + ) } suspend fun hasSectionIndex(section: Section, authConfig: AuthConfig): Boolean { @@ -408,22 +365,7 @@ class ContentRepository( authConfig: AuthConfig, forceRefresh: Boolean = false ): List { - categoryLock.withLock { - val key = "${accountKey(authConfig)}-${type.name}" - if (!forceRefresh) { - categoryCache[key]?.let { return it } - val cached = contentCache.readCategories(type, authConfig) - if (cached != null) { - categoryCache[key] = cached - return cached - } - } - val result = api.fetchCategories(type, authConfig) - val categories = result.getOrElse { throw it } - categoryCache[key] = categories - contentCache.writeCategories(type, authConfig, categories) - return categories - } + return categoryContentRepository.loadCategories(type, authConfig, forceRefresh) } /** @@ -968,12 +910,11 @@ class ContentRepository( suspend fun clearCache() { memoryCacheMutex.withLock { memoryCache.clear() } - categoryLock.withLock { categoryCache.clear() } - categoryThumbnailMutex.withLock { categoryThumbnailCache.clear() } validationCacheMutex.withLock { validationCache.clear() } SearchNormalizer.clearCache() searchIndexRepository.clearCache() searchContentRepository.clearCache() + categoryContentRepository.clearCache() syncMaintenanceRepository.clearCache() vodContentRepository.clearCache() seriesContentRepository.clearCache() @@ -1007,28 +948,7 @@ class ContentRepository( categoryId: String, authConfig: AuthConfig ): String? { - val key = "${accountKey(authConfig)}-${type.name}-$categoryId" - categoryThumbnailMutex.withLock { - categoryThumbnailCache[key]?.let { return it } - } - val cached = contentCache.readCategoryThumbnail(type, categoryId, authConfig) - if (cached != null) { - categoryThumbnailMutex.withLock { - categoryThumbnailCache[key] = cached - } - return cached - } - val page = categoryThumbnailLoadLimiter.withPermit { - runCatching { - loadCategoryPage(type, categoryId, 0, 1, authConfig) - }.getOrNull() - } - val imageUrl = page?.items?.firstOrNull()?.imageUrl - contentCache.writeCategoryThumbnail(type, categoryId, authConfig, imageUrl) - categoryThumbnailMutex.withLock { - categoryThumbnailCache[key] = imageUrl - } - return imageUrl + return categoryContentRepository.categoryThumbnail(type, categoryId, authConfig) } } From 0ca1668b512bb9983a66fe169a0443715b04d008 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:04:13 -0400 Subject: [PATCH 34/39] Fix search extraction bridge --- .../xtreamplayer/content/ContentRepository.kt | 18 +++++++++++++++++- .../content/SearchContentRepository.kt | 4 ++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt index d18246b..53201a5 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -275,6 +275,23 @@ class ContentRepository( return searchIndexRepository.hasAnySearchIndex(authConfig) } + private suspend fun loadSectionPage( + section: Section, + page: Int, + limit: Int, + authConfig: AuthConfig + ): ContentPage { + return searchContentRepository.loadSectionPage(section, page, limit, authConfig) + } + + private suspend fun loadMixedPage( + page: Int, + limit: Int, + authConfig: AuthConfig + ): ContentPage { + return searchContentRepository.loadMixedPage(page, limit, authConfig) + } + suspend fun loadSeriesEpisodePage( seriesId: String, page: Int, @@ -910,7 +927,6 @@ class ContentRepository( suspend fun clearCache() { memoryCacheMutex.withLock { memoryCache.clear() } - validationCacheMutex.withLock { validationCache.clear() } SearchNormalizer.clearCache() searchIndexRepository.clearCache() searchContentRepository.clearCache() diff --git a/app/src/main/java/com/example/xtreamplayer/content/SearchContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/SearchContentRepository.kt index 3f7450b..c1d212f 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/SearchContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/SearchContentRepository.kt @@ -104,7 +104,7 @@ internal class SearchContentRepository( memoryCacheMutex.withLock { memoryCache.clear() } } - private suspend fun loadSectionPage( + internal suspend fun loadSectionPage( section: Section, page: Int, limit: Int, @@ -200,7 +200,7 @@ internal class SearchContentRepository( } } - private suspend fun loadMixedPage( + internal suspend fun loadMixedPage( page: Int, limit: Int, authConfig: AuthConfig From 1cd742930f9cffafecbd24757ebf0fa1afd0885c Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:11:01 -0400 Subject: [PATCH 35/39] Fix playback focus restore --- .../example/xtreamplayer/MainActivityUi.kt | 9 +++-- .../xtreamplayer/content/ContentRepository.kt | 34 +++++++++---------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index 824ec2f..c888ed3 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -759,10 +759,15 @@ private fun RootScreenContent( requestFocusWithFrames( requester = resumeFocusRequester, label = "resume-content", - frameRetries = 6 + frameRetries = 10 ) if (!resumeFocused) { - browseViewModel.focusToContentTrigger.intValue++ + delay(120) + requestFocusWithFrames( + requester = resumeFocusRequester, + label = "resume-content", + frameRetries = 8 + ) } } else { browseViewModel.focusToContentTrigger.intValue++ diff --git a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt index 53201a5..c3db499 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -147,6 +147,23 @@ class ContentRepository( ) } + private suspend fun loadSectionPage( + section: Section, + page: Int, + limit: Int, + authConfig: AuthConfig + ): ContentPage { + return searchContentRepository.loadSectionPage(section, page, limit, authConfig) + } + + private suspend fun loadMixedPage( + page: Int, + limit: Int, + authConfig: AuthConfig + ): ContentPage { + return searchContentRepository.loadMixedPage(page, limit, authConfig) + } + fun categorySearchPager( type: ContentType, categoryId: String, @@ -275,23 +292,6 @@ class ContentRepository( return searchIndexRepository.hasAnySearchIndex(authConfig) } - private suspend fun loadSectionPage( - section: Section, - page: Int, - limit: Int, - authConfig: AuthConfig - ): ContentPage { - return searchContentRepository.loadSectionPage(section, page, limit, authConfig) - } - - private suspend fun loadMixedPage( - page: Int, - limit: Int, - authConfig: AuthConfig - ): ContentPage { - return searchContentRepository.loadMixedPage(page, limit, authConfig) - } - suspend fun loadSeriesEpisodePage( seriesId: String, page: Int, From 048065a86debce4997137ecbd7c9543884167efe Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:18:09 -0400 Subject: [PATCH 36/39] Fix browser focus restore across tabs --- .../example/xtreamplayer/MainActivityUi.kt | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index c888ed3..d38e6a6 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -803,15 +803,15 @@ private fun RootScreenContent( val handleItemFocused: (ContentItem) -> Unit = { item -> if (activePlaybackQueue == null) { - resumeFocusId = item.id + resumeFocusId = stableContentIdentity(item) } } val resolveResumeFocusTarget: (ContentItem) -> String = { item -> val parent = activePlaybackSeriesParent if (item.contentType == ContentType.SERIES && parent != null) { - parent.id + stableContentIdentity(parent) } else { - item.id + stableContentIdentity(item) } } val openMovieInfo: (ContentItem, List) -> Unit = { item, items -> @@ -2437,6 +2437,10 @@ private fun stableContentIdentity(item: ContentItem): String { return item.streamId.ifBlank { item.id }.ifBlank { item.id } } +private fun matchesResumeFocus(item: ContentItem?, resumeFocusId: String?): Boolean { + return item != null && stableContentIdentity(item) == resumeFocusId +} + private fun stableContentKey(item: ContentItem): String { return "${item.contentType.name}:${stableContentIdentity(item)}" } @@ -2800,6 +2804,10 @@ private fun ContentBrowserScreen( contentRepository.pager(section, authConfig).flow } } + val categoryFocusRequesters = + remember(categories) { categories.associate { it.id to FocusRequester() } } + val selectedCategoryFocusRequester = + selectedCategory?.let { categoryFocusRequesters[it.id] } val categoryPagerFlow = remember(selectedCategory?.id, contentType, authConfig, normalizedQuery) { val category = selectedCategory @@ -2910,6 +2918,7 @@ private fun ContentBrowserScreen( isLoadingCategories = isLoadingCategories, categoriesError = categoriesError, selectedCategory = selectedCategory, + selectedCategoryFocusRequester = selectedCategoryFocusRequester, onCategorySelected = { category -> primaryTab = BrowserPrimaryTab.CATEGORY selectedSeries = null @@ -2939,7 +2948,16 @@ private fun ContentBrowserScreen( focusRequester = secondaryFocusRequester, resumeFocusId = resumeFocusId, resumeFocusRequester = resumeFocusRequester, - onMoveLeft = { primaryFocusRequester.requestFocus() }, + onMoveLeft = { + if ( + primaryTab == BrowserPrimaryTab.CATEGORY && + selectedCategoryFocusRequester != null + ) { + runCatching { selectedCategoryFocusRequester.requestFocus() } + } else { + runCatching { primaryFocusRequester.requestFocus() } + } + }, onMoveRight = { previewFocusRequester.requestFocus() } ) @@ -2970,6 +2988,7 @@ private fun PrimarySidebar( isLoadingCategories: Boolean, categoriesError: String?, selectedCategory: CategoryItem?, + selectedCategoryFocusRequester: FocusRequester?, onCategorySelected: (CategoryItem) -> Unit ) { val shape = RoundedCornerShape(16.dp) @@ -3039,7 +3058,12 @@ private fun PrimarySidebar( SidebarItem( label = category.name, selected = selectedCategory?.id == category.id, - focusRequester = null, + focusRequester = + if (selectedCategory?.id == category.id) { + selectedCategoryFocusRequester + } else { + null + }, onActivate = { onCategorySelected(category) } ) } @@ -3993,7 +4017,7 @@ private fun StaticContentList( val item = items[index] val requester = when { - item.id == resumeFocusId -> resumeFocusRequester + matchesResumeFocus(item, resumeFocusId) -> resumeFocusRequester index == 0 -> focusRequester else -> null } @@ -4066,7 +4090,7 @@ private fun PagedContentList( if (item != null) { val requester = when { - item.id == resumeFocusId -> resumeFocusRequester + matchesResumeFocus(item, resumeFocusId) -> resumeFocusRequester index == 0 -> focusRequester else -> null } @@ -5649,7 +5673,7 @@ fun SectionScreen( itemFocusKey != null && itemFocusKey == lastAllFocusedKey -> resumeFocusRequester - item?.id != null && item.id == resumeFocusId -> + matchesResumeFocus(item, resumeFocusId) -> resumeFocusRequester index == 0 -> contentItemFocusRequester else -> null @@ -6684,7 +6708,7 @@ fun FavoritesScreen( when { index == 0 -> itemsFirstFocusRequester index == backDownTargetIndex -> backDownFocusRequester - item.id == resumeFocusId -> resumeFocusRequester + matchesResumeFocus(item, resumeFocusId) -> resumeFocusRequester else -> null } val isLeftEdge = index % posterColumns == 0 @@ -6912,7 +6936,7 @@ fun FavoritesScreen( val requester = when { index == backDownTargetIndex -> backDownFocusRequester - item?.id != null && item.id == resumeFocusId -> + matchesResumeFocus(item, resumeFocusId) -> resumeFocusRequester index == 0 -> contentItemFocusRequester else -> null @@ -7728,7 +7752,7 @@ fun CategorySectionScreen( searchDownContentFocusRequester item?.id != null && (item.id == lastCategoryContentId || - item.id == resumeFocusId) -> + matchesResumeFocus(item, resumeFocusId)) -> resumeFocusRequester index == 0 -> contentItemFocusRequester else -> null @@ -8456,7 +8480,7 @@ fun ContinueWatchingScreen( val requester = when { index == clearAllDownTargetIndex -> clearAllDownFocusRequester - item.id == resumeFocusId -> resumeFocusRequester + matchesResumeFocus(item, resumeFocusId) -> resumeFocusRequester index == 0 -> contentItemFocusRequester else -> null } @@ -9616,7 +9640,7 @@ fun SeriesSeasonsScreen( val item = displayEpisodes[index] val requester = when { - item.id == resumeFocusId -> resumeFocusRequester + matchesResumeFocus(item, resumeFocusId) -> resumeFocusRequester index == 0 -> episodesFocusRequester else -> null } @@ -10434,7 +10458,7 @@ fun SeriesEpisodesScreen( val item = lazyItems[index] val requester = when { - item?.id != null && item.id == resumeFocusId -> resumeFocusRequester + matchesResumeFocus(item, resumeFocusId) -> resumeFocusRequester index == 0 -> contentItemFocusRequester else -> null } From cb9c60cad608a1ae18ebe55bea4aaae1f98b533a Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:00:33 -0400 Subject: [PATCH 37/39] Stabilize all-tab movie focus restore --- .../example/xtreamplayer/MainActivityUi.kt | 69 +++++++++++++++++-- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index d38e6a6..a42a753 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -5663,8 +5663,7 @@ fun SectionScreen( } ) { index -> val item = lazyItems[index] - val itemFocusKey = - item?.let { "${it.contentType.name}:${it.id}" } + val itemFocusKey = item?.let(::stableContentKey) val requester = when { index == (columns - 1).coerceAtMost(lazyItems.itemCount - 1) -> @@ -5712,7 +5711,7 @@ fun SectionScreen( { if (section == Section.ALL) { lastAllFocusedKey = - "${item.contentType.name}:${item.id}" + stableContentKey(item) } if (item.contentType == ContentType.SERIES && item.containerExtension @@ -5739,7 +5738,7 @@ fun SectionScreen( onFocused = { focusedItem -> if (section == Section.ALL) { lastAllFocusedKey = - "${focusedItem.contentType.name}:${focusedItem.id}" + stableContentKey(focusedItem) } onItemFocused(focusedItem) }, @@ -7056,11 +7055,20 @@ fun FavoritesScreen( pendingCategoryReturnFocus && lastSelectedFavoriteCategoryId == null && index == lastSelectedFavoriteCategoryIndex + val lastSelectedInList = + lastSelectedFavoriteCategoryId != null && + sortedCategories.any { + it.id == lastSelectedFavoriteCategoryId + } val requester = when { isReturnTargetById || isReturnTargetByIndex -> contentItemFocusRequester - !pendingCategoryReturnFocus && index == 0 -> + !pendingCategoryReturnFocus && lastSelectedInList && + category.id == lastSelectedFavoriteCategoryId -> + contentItemFocusRequester + !pendingCategoryReturnFocus && !lastSelectedInList && + index == 0 -> contentItemFocusRequester index == backDownTargetIndex -> backDownFocusRequester else -> null @@ -7197,6 +7205,7 @@ fun CategorySectionScreen( contentItemFocusRequester: FocusRequester, resumeFocusId: String?, resumeFocusRequester: FocusRequester, + contentEnterTrigger: Int = 0, onItemFocused: (ContentItem) -> Unit, onPlay: (ContentItem, List) -> Unit, onPlayWithPosition: (ContentItem, List, Long?) -> Unit, @@ -7348,6 +7357,16 @@ fun CategorySectionScreen( pendingCategoryReturnFocus = false } + // Handle nav-return-to-content when no category is open (category grid case). + // The content-grid case is handled inside the selectedCategory != null block. + val lastHandledOuterContentEnterTrigger = remember { mutableIntStateOf(contentEnterTrigger) } + LaunchedEffect(contentEnterTrigger) { + if (contentEnterTrigger <= lastHandledOuterContentEnterTrigger.intValue) return@LaunchedEffect + if (selectedCategory != null) return@LaunchedEffect + lastHandledOuterContentEnterTrigger.intValue = contentEnterTrigger + requestFocusWithFrameRetries(contentItemFocusRequester, frameRetries = 3) + } + // Don't auto-focus content when category changes - user must press Right to navigate there Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -7652,6 +7671,37 @@ fun CategorySectionScreen( } } + val lastHandledContentEnterTrigger = remember { mutableIntStateOf(contentEnterTrigger) } + LaunchedEffect(contentEnterTrigger, lazyItems.itemCount) { + if (contentEnterTrigger <= lastHandledContentEnterTrigger.intValue) return@LaunchedEffect + if (selectedSeries != null) return@LaunchedEffect + if (lazyItems.itemCount == 0) return@LaunchedEffect + lastHandledContentEnterTrigger.intValue = contentEnterTrigger + val lastId = lastCategoryContentId + val items = lazyItems.itemSnapshotList.items + val targetIndex = + if (lastId != null) { + items.indexOfFirst { it?.id == lastId }.takeIf { it >= 0 } + ?: lastCategoryContentIndex.coerceAtMost( + (lazyItems.itemCount - 1).coerceAtLeast(0) + ) + } else { + lastCategoryContentIndex.coerceAtMost( + (lazyItems.itemCount - 1).coerceAtLeast(0) + ) + } + if (targetIndex > 0) { + contentGridState.scrollToItem(targetIndex) + } + withFrameNanos {} + val lastIdInItems = lastId != null && items.any { it?.id == lastId } + if (lastIdInItems) { + requestFocusWithFrameRetries(resumeFocusRequester, frameRetries = 3) + } else { + requestFocusWithFrameRetries(contentItemFocusRequester, frameRetries = 3) + } + } + // Use Box to overlay SeriesSeasonsScreen while keeping content grid in composition // This preserves scroll position when navigating back from series detail Box(modifier = Modifier.weight(1f)) { @@ -7972,6 +8022,9 @@ fun CategorySectionScreen( } else { -1 } + val lastSelectedCategoryInList = + lastSelectedCategoryId != null && + filteredCategories.any { it.id == lastSelectedCategoryId } LazyVerticalGrid( columns = GridCells.Fixed(categoryColumns), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -7989,7 +8042,11 @@ fun CategorySectionScreen( pendingCategoryReturnFocus && index == returnTargetIndex -> contentItemFocusRequester index == searchDownIndex -> searchDownCategoryFocusRequester - !pendingCategoryReturnFocus && index == 0 -> + !pendingCategoryReturnFocus && lastSelectedCategoryInList && + category.id == lastSelectedCategoryId -> + contentItemFocusRequester + !pendingCategoryReturnFocus && !lastSelectedCategoryInList && + index == 0 -> contentItemFocusRequester else -> null } From 6455166ed0d1eaed1c5ea437d5a60e2c28bb6d74 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:39:50 -0400 Subject: [PATCH 38/39] Bump app version to 3.4.6 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 74dc25d..6b4392f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,8 +3,8 @@ import java.util.Properties import org.gradle.api.provider.Property import org.jetbrains.kotlin.gradle.dsl.JvmTarget -val appVersionCode = 141 -val appVersionName = "3.4.5" +val appVersionCode = 142 +val appVersionName = "3.4.6" plugins { alias(libs.plugins.android.application) From 1d7e314357c433b58f687940aa3c635d1c3c87d4 Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:41:54 -0400 Subject: [PATCH 39/39] Finalize release navigation fixes --- .../com/example/xtreamplayer/BrowseScreen.kt | 35 ++++++++++++------- .../example/xtreamplayer/MainActivityUi.kt | 10 +++--- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt b/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt index c832b20..09c8a50 100644 --- a/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt +++ b/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt @@ -296,23 +296,25 @@ internal fun BrowseScreen( val filteredContinueWatchingItems by filteredContinueWatchingFlow.collectAsStateWithLifecycle(initialValue = emptyList()) + var categoryContentEnterTrigger by remember { mutableIntStateOf(0) } + LaunchedEffect(navMoveToContentTrigger) { if (navMoveToContentTrigger <= 0) return@LaunchedEffect - val useDeterministicContentEntry = - selectedSection == Section.SETTINGS || selectedSection == Section.CATEGORIES - if (useDeterministicContentEntry) { - // These sections should always open at their top focus target. + // CATEGORIES uses its own trigger path to avoid FocusRequester-not-initialized + // warnings from calling requestFocus() on lazy-grid items that may be off-screen. + if (selectedSection == Section.CATEGORIES) { + categoryContentEnterTrigger++ + return@LaunchedEffect + } + if (selectedSection == Section.SETTINGS) { + // SETTINGS always opens at its top focus target via contentItemFocusRequester. val focusedNow = runCatching { contentItemFocusRequester.requestFocus() }.getOrDefault(false) - if (focusedNow) { - return@LaunchedEffect - } + if (focusedNow) return@LaunchedEffect withFrameNanos {} val focusedAfterFrame = runCatching { contentItemFocusRequester.requestFocus() }.getOrDefault(false) - if (focusedAfterFrame) { - return@LaunchedEffect - } + if (focusedAfterFrame) return@LaunchedEffect focusToContentTrigger++ return@LaunchedEffect } @@ -331,9 +333,17 @@ internal fun BrowseScreen( LaunchedEffect(focusToContentTrigger) { if (focusToContentTrigger <= 0) return@LaunchedEffect - if (selectedSection == Section.SETTINGS) { + if (selectedSection == Section.CATEGORIES) { + categoryContentEnterTrigger++ + } else { withFrameNanos {} - runCatching { contentItemFocusRequester.requestFocus() } + // moveFocus(Right) works when focus is on MenuButton (content is directly to + // its right) and doesn't require any FocusRequester to be attached. + // Fall back to contentItemFocusRequester for cases where moveFocus fails + // (e.g. the legacy nav-item fallback path). + if (!focusManager.moveFocus(FocusDirection.Right)) { + runCatching { contentItemFocusRequester.requestFocus() } + } } } @@ -635,6 +645,7 @@ Row(modifier = Modifier.fillMaxSize()) { contentItemFocusRequester, resumeFocusId = resumeFocusId, resumeFocusRequester = resumeFocusRequester, + contentEnterTrigger = categoryContentEnterTrigger, onItemFocused = onItemFocused, onPlay = onPlay, onPlayWithPosition = onPlayWithPositionAndQueue, diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index a42a753..c1e69b1 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -5666,14 +5666,14 @@ fun SectionScreen( val itemFocusKey = item?.let(::stableContentKey) val requester = when { - index == (columns - 1).coerceAtMost(lazyItems.itemCount - 1) -> - searchDownContentFocusRequester section == Section.ALL && itemFocusKey != null && itemFocusKey == lastAllFocusedKey -> resumeFocusRequester matchesResumeFocus(item, resumeFocusId) -> resumeFocusRequester + index == (columns - 1).coerceAtMost(lazyItems.itemCount - 1) -> + searchDownContentFocusRequester index == 0 -> contentItemFocusRequester else -> null } @@ -6705,9 +6705,9 @@ fun FavoritesScreen( (posterColumns - 1).coerceAtMost(sortedContent.lastIndex) val requester = when { + matchesResumeFocus(item, resumeFocusId) -> resumeFocusRequester index == 0 -> itemsFirstFocusRequester index == backDownTargetIndex -> backDownFocusRequester - matchesResumeFocus(item, resumeFocusId) -> resumeFocusRequester else -> null } val isLeftEdge = index % posterColumns == 0 @@ -7798,12 +7798,12 @@ fun CategorySectionScreen( val requester = if (selectedSeries == null) { when { - index == searchDownIndex -> - searchDownContentFocusRequester item?.id != null && (item.id == lastCategoryContentId || matchesResumeFocus(item, resumeFocusId)) -> resumeFocusRequester + index == searchDownIndex -> + searchDownContentFocusRequester index == 0 -> contentItemFocusRequester else -> null }