From 8572f9bbc93eab0187334b603dde75007364c67d Mon Sep 17 00:00:00 2001 From: kalzEOS <49320606+kalzEOS@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:22:08 -0400 Subject: [PATCH] misc sync and startup fixes --- app/build.gradle.kts | 3 + .../com/example/xtreamplayer/BrowseScreen.kt | 9 +- .../com/example/xtreamplayer/LoginScreen.kt | 8 + .../example/xtreamplayer/MainActivityUi.kt | 96 ++++++-- .../example/xtreamplayer/SettingsScreens.kt | 22 +- .../com/example/xtreamplayer/api/XtreamApi.kt | 46 ++-- .../example/xtreamplayer/auth/AuthUiState.kt | 1 + .../xtreamplayer/auth/AuthViewModel.kt | 31 +-- .../xtreamplayer/content/ContentRepository.kt | 184 +++++++++------ .../content/ProgressiveSyncCoordinator.kt | 27 ++- .../settings/SettingsRepository.kt | 40 ++++ .../xtreamplayer/settings/SettingsState.kt | 12 + .../settings/SettingsViewModel.kt | 24 +- .../xtreamplayer/sync/ActiveSyncGuard.kt | 16 ++ .../xtreamplayer/sync/LibrarySyncWorker.kt | 62 +++++ .../xtreamplayer/sync/SyncScheduler.kt | 35 +++ .../xtreamplayer/ui/AutoSyncInfoDialog.kt | 91 ++++++++ .../example/xtreamplayer/ui/DialogControls.kt | 38 ++++ .../xtreamplayer/ui/SyncScheduleDialog.kt | 211 ++++++++++++++++++ gradle/libs.versions.toml | 2 + 20 files changed, 824 insertions(+), 134 deletions(-) create mode 100644 app/src/main/java/com/example/xtreamplayer/sync/ActiveSyncGuard.kt create mode 100644 app/src/main/java/com/example/xtreamplayer/sync/LibrarySyncWorker.kt create mode 100644 app/src/main/java/com/example/xtreamplayer/sync/SyncScheduler.kt create mode 100644 app/src/main/java/com/example/xtreamplayer/ui/AutoSyncInfoDialog.kt create mode 100644 app/src/main/java/com/example/xtreamplayer/ui/SyncScheduleDialog.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6a14300..0c6bb1a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -127,6 +127,9 @@ dependencies { implementation(libs.hilt.navigation.compose) kapt(libs.hilt.compiler) + // Background work + implementation(libs.androidx.work.runtime.ktx) + // Logging implementation(libs.timber) diff --git a/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt b/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt index 7b3ecb7..2d34ebe 100644 --- a/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt +++ b/app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt @@ -149,6 +149,7 @@ internal fun BrowseScreen( showVodBufferDialogState: MutableState, showSubtitleAppearanceDialogState: MutableState, showSubtitleCacheAutoClearDialogState: MutableState, + showSyncScheduleDialogState: MutableState, showApiKeyDialogState: MutableState, cacheClearNonceState: MutableState, contentRepository: ContentRepository, @@ -209,6 +210,7 @@ internal fun BrowseScreen( var showVodBufferDialog by showVodBufferDialogState var showSubtitleAppearanceDialog by showSubtitleAppearanceDialogState var showSubtitleCacheAutoClearDialog by showSubtitleCacheAutoClearDialogState + var showSyncScheduleDialog by showSyncScheduleDialogState var showApiKeyDialog by showApiKeyDialogState var cacheClearNonce by cacheClearNonceState val focusManager = LocalFocusManager.current @@ -492,6 +494,8 @@ Row(modifier = Modifier.fillMaxSize()) { } } }, + onToggleRefreshOnStartup = settingsViewModel::toggleRefreshOnStartup, + onOpenSyncSchedule = { showSyncScheduleDialog = true }, onToggleCheckUpdatesOnStartup = onToggleCheckUpdatesOnStartup, onCheckForUpdates = { onCheckForUpdates() }, onClearCache = { @@ -520,10 +524,13 @@ Row(modifier = Modifier.fillMaxSize()) { } Toast.makeText( context, - "Cache cleared ($sizeLabel)", + "Cache cleared ($sizeLabel). Resyncing library...", Toast.LENGTH_SHORT ) .show() + // Reset sync coordinator so it doesn't think the library is already + // indexed — this starts a fresh fast-start sync immediately. + progressiveSyncCoordinator?.resetAndResync() } }, onSignOut = { diff --git a/app/src/main/java/com/example/xtreamplayer/LoginScreen.kt b/app/src/main/java/com/example/xtreamplayer/LoginScreen.kt index 51177ea..c29cc7f 100644 --- a/app/src/main/java/com/example/xtreamplayer/LoginScreen.kt +++ b/app/src/main/java/com/example/xtreamplayer/LoginScreen.kt @@ -46,6 +46,9 @@ import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import android.content.Context @@ -424,6 +427,11 @@ private fun TvTextField( onValueChange = onValueChange, label = { Text(label) }, singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + autoCorrectEnabled = false, + capitalization = KeyboardCapitalization.None + ), modifier = Modifier .fillMaxWidth() .focusRequester(textFieldFocusRequester) diff --git a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt index 7c56ad4..319f2d8 100644 --- a/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt +++ b/app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt @@ -170,6 +170,8 @@ import com.example.xtreamplayer.ui.NextEpisodeThresholdDialog import com.example.xtreamplayer.ui.PlaybackSettingsDialog import com.example.xtreamplayer.ui.PlaybackSpeedDialog import com.example.xtreamplayer.ui.SubtitleCacheAutoClearDialog +import com.example.xtreamplayer.ui.AutoSyncInfoDialog +import com.example.xtreamplayer.ui.SyncScheduleDialog import com.example.xtreamplayer.ui.SubtitleAppearanceDialog import com.example.xtreamplayer.ui.SubtitleDialogState import com.example.xtreamplayer.ui.SubtitleOptionsDialog @@ -205,6 +207,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit @@ -289,8 +292,9 @@ fun RootScreen( val browseViewModel: BrowseViewModel = hiltViewModel() val playerViewModel: PlayerViewModel = hiltViewModel() val authState by authViewModel.uiState.collectAsStateWithLifecycle() - val savedConfig by authViewModel.savedConfig.collectAsStateWithLifecycle() - val savedConfigLoaded by authViewModel.savedConfigLoaded.collectAsStateWithLifecycle() + val savedConfigState by authViewModel.savedConfigState.collectAsStateWithLifecycle() + val savedConfig = savedConfigState.config + val savedConfigLoaded = savedConfigState.loaded val localPlaybackResumeRepository = remember { LocalPlaybackResumeRepository(context) } val vodPlaybackStateRepository = remember { VodPlaybackStateRepository(context) } val localPlaybackResumeEntries by @@ -316,6 +320,7 @@ fun RootScreen( var updateCheckJob by remember { mutableStateOf(null) } var startupUpdateCheckEnabled by remember { mutableStateOf(null) } var startupUpdateCheckHandled by remember { mutableStateOf(false) } + var startupRefreshHandled by remember { mutableStateOf(false) } val showApiKeyDialogState = remember { mutableStateOf(false) } var showApiKeyDialog by showApiKeyDialogState val showThemeDialogState = remember { mutableStateOf(false) } @@ -335,6 +340,8 @@ fun RootScreen( var subtitleAppearancePreview by remember { mutableStateOf(null) } val showSubtitleCacheAutoClearDialogState = remember { mutableStateOf(false) } var showSubtitleCacheAutoClearDialog by showSubtitleCacheAutoClearDialogState + val showSyncScheduleDialogState = remember { mutableStateOf(false) } + var showSyncScheduleDialog by showSyncScheduleDialogState var showPlaybackRecoveryDialog by remember { mutableStateOf(false) } var showLocalFilesGuest by remember { mutableStateOf(false) } val cacheClearNonceState = remember { mutableIntStateOf(0) } @@ -459,18 +466,22 @@ fun RootScreen( 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 - } + val completeState = com.example.xtreamplayer.content.ProgressiveSyncState( + phase = com.example.xtreamplayer.content.SyncPhase.COMPLETE, + fastStartReady = true, + fullIndexComplete = true, + lastSyncTimestamp = System.currentTimeMillis() + ) + // Trust the cache checkpoints over saved coordinator state when the cache says done. + // WorkManager completes a full reindex but doesn't write fullIndexComplete to + // SettingsRepository — hasFullIndex() catches that gap and prevents a redundant + // background sync on the next launch. + val effectiveState = when { + savedState != null && savedState.fullIndexComplete -> savedState + hasFullIndex -> completeState + savedState != null -> savedState + else -> null + } if (effectiveState != null) { coordinator.restoreState(effectiveState) @@ -875,6 +886,16 @@ fun RootScreen( checkForUpdates(UpdateCheckSource.STARTUP) } + LaunchedEffect(authState.isSignedIn, progressiveSyncCoordinator) { + if (startupRefreshHandled) return@LaunchedEffect + if (!authState.isSignedIn) return@LaunchedEffect + val coordinator = progressiveSyncCoordinator ?: return@LaunchedEffect + startupRefreshHandled = true + if (settings.refreshOnStartup) { + coordinator.resetAndResync() + } + } + fun startUpdateDownload(release: UpdateRelease) { if (updateUiState.inProgress) return updateUiState = updateUiState.copy(inProgress = true) @@ -1005,6 +1026,7 @@ fun RootScreen( !showVodBufferDialog && !showSubtitleAppearanceDialog && !showSubtitleCacheAutoClearDialog && + !showSyncScheduleDialog && !showApiKeyDialog && !updateUiState.showDialog BackHandler(enabled = shouldHandleRootBackForExit) { @@ -1907,8 +1929,13 @@ fun RootScreen( 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 isWorkerSyncActive by remember { + androidx.work.WorkManager.getInstance(context) + .getWorkInfosForUniqueWorkFlow("library_sync_periodic") + .map { infos -> infos.any { it.state == androidx.work.WorkInfo.State.RUNNING } } + }.collectAsStateWithLifecycle(initialValue = false) val shouldShowSyncUi = - !isPlaybackActiveLocal && (isProgressiveSyncActive || isLegacySyncActive) + !isPlaybackActiveLocal && (isProgressiveSyncActive || isLegacySyncActive || isWorkerSyncActive) // Progressive sync status indicators if (shouldShowSyncUi) { @@ -2000,6 +2027,29 @@ fun RootScreen( ) } } + + // WorkManager background sync indicator (no pause control) + if (isWorkerSyncActive && !isProgressiveSyncActive) { + Row( + modifier = Modifier + .background(colors.surfaceAlt, RoundedCornerShape(4.dp)) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = colors.textPrimary + ) + Spacer(Modifier.width(8.dp)) + Text( + "Background sync running...", + fontSize = 11.sp, + color = colors.textPrimary, + fontFamily = AppTheme.fontFamily + ) + } + } } } } @@ -2031,6 +2081,7 @@ fun RootScreen( showVodBufferDialogState = showVodBufferDialogState, showSubtitleAppearanceDialogState = showSubtitleAppearanceDialogState, showSubtitleCacheAutoClearDialogState = showSubtitleCacheAutoClearDialogState, + showSyncScheduleDialogState = showSyncScheduleDialogState, showApiKeyDialogState = showApiKeyDialogState, cacheClearNonceState = cacheClearNonceState, contentRepository = contentRepository, @@ -2142,6 +2193,19 @@ fun RootScreen( onDismiss = { showSubtitleCacheAutoClearDialog = false } ) } + if (showSyncScheduleDialog) { + var showAutoSyncInfoFromSchedule by remember { mutableStateOf(false) } + if (showAutoSyncInfoFromSchedule) { + AutoSyncInfoDialog(onDismiss = { showAutoSyncInfoFromSchedule = false }) + } else { + SyncScheduleDialog( + current = settings.syncScheduleInterval, + onIntervalChange = { settingsViewModel.setSyncScheduleInterval(it) }, + onDismiss = { showSyncScheduleDialog = false }, + onLearnMore = { showAutoSyncInfoFromSchedule = true } + ) + } + } if (showSubtitleAppearanceDialog) { SubtitleAppearanceDialog( initialSettings = settings.subtitleAppearance, @@ -2253,7 +2317,7 @@ fun RootScreen( } else { LoginScreen( authState = authState, - initialConfig = authState.activeConfig ?: savedConfig, + initialConfig = authState.activeConfig ?: authState.lastAttemptedConfig ?: savedConfig, onSignIn = { listName, baseUrl, username, password -> authViewModel.signIn( listName = listName, diff --git a/app/src/main/java/com/example/xtreamplayer/SettingsScreens.kt b/app/src/main/java/com/example/xtreamplayer/SettingsScreens.kt index 8287497..161863e 100644 --- a/app/src/main/java/com/example/xtreamplayer/SettingsScreens.kt +++ b/app/src/main/java/com/example/xtreamplayer/SettingsScreens.kt @@ -26,7 +26,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -50,6 +52,7 @@ import com.example.xtreamplayer.settings.vodBufferLabel import com.example.xtreamplayer.ui.theme.AppFont import com.example.xtreamplayer.ui.theme.AppTheme import com.example.xtreamplayer.settings.uiScaleDisplayPercent +import com.example.xtreamplayer.ui.AutoSyncInfoDialog import kotlin.math.roundToInt @Composable @@ -74,6 +77,8 @@ fun SettingsScreen( onOpenSubtitlesApiKey: () -> Unit, onManageLists: () -> Unit, onRefreshContent: () -> Unit, + onToggleRefreshOnStartup: () -> Unit, + onOpenSyncSchedule: () -> Unit, onToggleCheckUpdatesOnStartup: () -> Unit, onCheckForUpdates: () -> Unit, onClearCache: () -> Unit, @@ -106,7 +111,8 @@ fun SettingsScreen( SettingsAction("Manage lists", null, onManageLists) ) val libraryActions = listOf( - SettingsAction("Sync library", null, onRefreshContent) + SettingsAction("Sync library", null, onRefreshContent), + SettingsAction("Refresh on startup", flagLabel(settings.refreshOnStartup), onToggleRefreshOnStartup) ) val aboutActions = listOf( SettingsAction( @@ -117,6 +123,11 @@ fun SettingsScreen( SettingsAction("Check for updates", appVersionLabel, onCheckForUpdates) ) + var showAutoSyncInfo by remember { mutableStateOf(false) } + if (showAutoSyncInfo) { + AutoSyncInfoDialog(onDismiss = { showAutoSyncInfo = false }) + } + // Focus is managed by user navigation - no auto-focus on screen load Box( @@ -191,6 +202,15 @@ fun SettingsScreen( SettingsSectionHeader("Library") libraryActions.forEach { action -> renderAction(action) } + SettingsActionRow( + label = "Auto-sync interval", + value = settings.syncScheduleInterval.label, + focusRequester = null, + onMoveLeft = onMoveLeft, + onActivate = onOpenSyncSchedule, + fontFamily = settings.appFont.fontFamily, + secondaryText = "Refreshes your library in the background on a schedule, even when the app is closed. Open to change interval or tap \"How this works\" to learn more." + ) SettingsActionRow( label = "Clear cache", value = null, 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 e221bcf..d31c4d4 100644 --- a/app/src/main/java/com/example/xtreamplayer/api/XtreamApi.kt +++ b/app/src/main/java/com/example/xtreamplayer/api/XtreamApi.kt @@ -46,8 +46,8 @@ class XtreamApi( private companion object { const val MAX_SMALL_JSON_BYTES = 1L * 1024 * 1024 - const val MAX_BULK_RESPONSE_BYTES = 64L * 1024 * 1024 - const val MAX_BULK_ITEMS = 150_000 + const val MAX_BULK_RESPONSE_BYTES = 2L * 1024 * 1024 * 1024 + const val MAX_BULK_ITEMS = 1_000_000 const val INITIAL_BULK_ITEMS_CAPACITY = 1_000 val TIMESTAMP_PATTERNS = listOf( "yyyy-MM-dd HH:mm:ss", @@ -121,37 +121,27 @@ class XtreamApi( IllegalArgumentException("Invalid service URL") ) - var lastError: Exception? = null - repeat(3) { attempt -> - try { - val request = Request.Builder().url(url).get().build() - pageClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - return@withContext Result.failure( - IllegalStateException("Request failed: ${response.code}") - ) - } - val body = response.body ?: return@withContext Result.failure( - IllegalStateException("Empty response") + try { + val request = Request.Builder().url(url).get().build() + pageClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return@withContext Result.failure( + IllegalStateException("Request failed: ${response.code}") ) - body.charStream().use { stream -> - val reader = JsonReader(stream) - val pageData = parsePage(reader, section, offset, limit) - return@withContext Result.success(pageData) - } } - } catch (e: Exception) { - lastError = e - val isTimeout = e is SocketTimeoutException || e is SocketException - Timber.e(e, "Failed to fetch section page: section=$section, page=$page") - if (isTimeout && attempt < 2) { - delay(500L * (attempt + 1)) - } else { - return@withContext Result.failure(e) + val body = response.body ?: return@withContext Result.failure( + IllegalStateException("Empty response") + ) + body.charStream().use { stream -> + val reader = JsonReader(stream) + val pageData = parsePage(reader, section, offset, limit) + return@withContext Result.success(pageData) } } + } catch (e: Exception) { + Timber.e(e, "Failed to fetch section page: section=$section, page=$page") + return@withContext Result.failure(e) } - Result.failure(lastError ?: IllegalStateException("Request failed")) } } diff --git a/app/src/main/java/com/example/xtreamplayer/auth/AuthUiState.kt b/app/src/main/java/com/example/xtreamplayer/auth/AuthUiState.kt index a15981f..ea4d1eb 100644 --- a/app/src/main/java/com/example/xtreamplayer/auth/AuthUiState.kt +++ b/app/src/main/java/com/example/xtreamplayer/auth/AuthUiState.kt @@ -5,6 +5,7 @@ data class AuthUiState( val isLoading: Boolean = false, val errorMessage: String? = null, val activeConfig: AuthConfig? = null, + val lastAttemptedConfig: AuthConfig? = null, val isEditingList: Boolean = false, val autoSignInSuppressed: Boolean = false ) diff --git a/app/src/main/java/com/example/xtreamplayer/auth/AuthViewModel.kt b/app/src/main/java/com/example/xtreamplayer/auth/AuthViewModel.kt index 6465ac6..de6f2f8 100644 --- a/app/src/main/java/com/example/xtreamplayer/auth/AuthViewModel.kt +++ b/app/src/main/java/com/example/xtreamplayer/auth/AuthViewModel.kt @@ -5,13 +5,17 @@ import androidx.lifecycle.viewModelScope import com.example.xtreamplayer.api.XtreamApi import com.example.xtreamplayer.settings.SettingsState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject +data class SavedConfigState( + val loaded: Boolean = false, + val config: AuthConfig? = null +) + @HiltViewModel class AuthViewModel @Inject constructor( private val repository: AuthRepository, @@ -19,20 +23,16 @@ class AuthViewModel @Inject constructor( ) : ViewModel() { private val _uiState = kotlinx.coroutines.flow.MutableStateFlow(AuthUiState()) val uiState: StateFlow = _uiState - - private val _savedConfigLoaded = kotlinx.coroutines.flow.MutableStateFlow(false) - val savedConfigLoaded: StateFlow = _savedConfigLoaded - - val savedConfig: StateFlow = repository.authConfig.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = null - ) + private val _savedConfigState = MutableStateFlow(SavedConfigState()) + val savedConfigState: StateFlow = _savedConfigState init { viewModelScope.launch { - repository.authConfig.collect { - _savedConfigLoaded.value = true + repository.authConfig.collect { config -> + _savedConfigState.value = SavedConfigState( + loaded = true, + config = config + ) } } } @@ -47,7 +47,7 @@ class AuthViewModel @Inject constructor( return } if (!settings.autoSignIn || !settings.rememberLogin) return - val config = savedConfig.value ?: return + val config = _savedConfigState.value.config ?: return signInWithConfig(config, rememberLogin = true) } @@ -122,7 +122,8 @@ class AuthViewModel @Inject constructor( isSignedIn = false, isLoading = false, errorMessage = error.message ?: "Login failed", - activeConfig = null + activeConfig = null, + lastAttemptedConfig = config ) } } 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 e835793..fa6635d 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ContentRepository.kt @@ -39,7 +39,12 @@ class ContentRepository( const val FAST_START_PAGES = 2 const val BACKGROUND_PAGE_SIZE = 400 const val BACKGROUND_SAVE_INTERVAL = 25 - const val BACKGROUND_THROTTLE_MS = 200L + const val BACKGROUND_THROTTLE_MS = 0L + const val BULK_RETRY_MAX = 2 + const val BULK_RETRY_INITIAL_BACKOFF_MS = 2_000L + const val PAGE_RETRY_MAX = 3 + const val PAGE_RETRY_INITIAL_BACKOFF_MS = 500L + const val PARALLEL_PAGE_CONCURRENCY = 16 const val BOOST_PAGE_SIZE = 400 const val LIVE_EPG_CACHE_TTL_MS = 20_000L const val LIVE_EPG_STALE_CACHE_TTL_MS = 3 * 60_000L @@ -655,6 +660,41 @@ class ContentRepository( return lastResult } + private fun shouldRetryPageError(error: Throwable?): Boolean { + if (error == null) return false + if (error is java.io.IOException) return true + val msg = error.message?.lowercase().orEmpty() + return msg.contains("429") || msg.contains("503") || msg.contains("502") || + msg.contains("500") || msg.contains("408") || msg.contains("timeout") + } + + private fun shouldRetryBulkError(error: Throwable?): Boolean { + if (error == null) return false + if (error is java.io.IOException) return true + val msg = error.message?.lowercase().orEmpty() + // Size-limit errors are structural — retrying the same request won't change the response size + if (msg.contains("exceeded limit") || msg.contains("too large")) return false + return msg.contains("429") || msg.contains("503") || msg.contains("502") || + msg.contains("500") || msg.contains("408") || msg.contains("timeout") + } + + private suspend fun fetchSectionAllWithRetry( + section: Section, + authConfig: AuthConfig + ): Result> { + var backoff = BULK_RETRY_INITIAL_BACKOFF_MS + repeat(BULK_RETRY_MAX) { attempt -> + val result = api.fetchSectionAll(section, authConfig) + if (result.isSuccess) return result + val err = result.exceptionOrNull() + if (!shouldRetryBulkError(err)) return result + Timber.w("Bulk fetch attempt ${attempt + 1}/$BULK_RETRY_MAX failed for $section: ${err?.message}, retrying in ${backoff}ms") + delay(backoff) + backoff = (backoff * 2).coerceAtMost(16_000L) + } + return api.fetchSectionAll(section, authConfig) + } + private fun shouldRetryLiveEpgError(error: Throwable?): Boolean { if (error == null) return false if (error is java.io.IOException) return true @@ -1407,6 +1447,18 @@ class ContentRepository( onSectionStart: suspend (Section) -> Unit = {}, onSectionComplete: suspend (Section) -> Unit = {} ): Result { + // Pre-fetch all sections in parallel when using bulk mode — mirrors FredTV's approach. + val preFetchedBulk: Map>> = + if (useBulkFirst && sectionsToSync.size > 1) { + coroutineScope { + sectionsToSync + .map { section -> section to async { fetchSectionAllWithRetry(section, authConfig) } } + .associate { (section, deferred) -> section to deferred.await() } + } + } else { + emptyMap() + } + var lastError: Throwable? = null for ((sectionIndex, section) in sectionsToSync.withIndex()) { val stagingMode = fullReindex || useStaging @@ -1466,11 +1518,11 @@ class ContentRepository( } } - // Try bulk fetch first if requested + // Try bulk fetch first if requested (use pre-fetched result when available) var bulkSuccess = false val existingIds = allItems.asSequence().map { it.id }.toHashSet() if (useBulkFirst) { - val bulkResult = api.fetchSectionAll(section, authConfig) + val bulkResult = preFetchedBulk[section] ?: fetchSectionAllWithRetry(section, authConfig) if (bulkResult.isSuccess) { val bulkItems = bulkResult.getOrNull().orEmpty() if (bulkItems.isNotEmpty()) { @@ -1516,57 +1568,50 @@ class ContentRepository( } } - // Only do page-by-page if bulk didn't succeed + // Parallel page fallback: fire PARALLEL_PAGE_CONCURRENCY pages simultaneously. + // Only reached after bulk fetch has been tried and retried. if (!bulkSuccess) { - var page = startPage - Timber.d("Background sync: $section starting at page $page (checkpoint: ${checkpoint?.itemsIndexed} items)") + val effectivePageSize = if (useBulkFirst) fallbackPageSize else pageSize + var batchStart = startPage + var hitEnd = false + Timber.d("Background sync: $section starting parallel page fallback at page $batchStart") - while (true) { - // Check pause on every page for responsive pausing + while (!hitEnd) { if (checkPause()) { - saveProgressCheckpoint(page - 1) - Timber.i("Background sync paused: $section at page $page") + saveProgressCheckpoint(maxOf(0, batchStart - 1)) + Timber.i("Background sync paused: $section at batch starting page $batchStart") throw SyncPausedException() } - val effectivePageSize = if (useBulkFirst) fallbackPageSize else pageSize - val pageDataResult = - api.fetchSectionPage(section, authConfig, page, effectivePageSize) - if (pageDataResult.isFailure) { - Timber.w("Background sync: $section page $page failed: ${pageDataResult.exceptionOrNull()}") - throw pageDataResult.exceptionOrNull() - ?: IllegalStateException("Background sync failed") - } - - val pageData = pageDataResult.getOrThrow() - - if (pageData.items.isNotEmpty()) { - pageData.items.forEach { item -> - if (existingIds.add(item.id)) { - allItems.add(item) - } - } - } - - // Incremental save every 10 pages - if (page % BACKGROUND_SAVE_INTERVAL == 0) { - if (stagingMode) { - contentCache.updateSectionIndexIncrementalStagingWithCheckpoint( - section, authConfig, allItems, page, false - ) - } else { - // Transactional write: index + checkpoint atomically - 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) + // Fire a full batch of pages concurrently + val batch = coroutineScope { + (batchStart until batchStart + PARALLEL_PAGE_CONCURRENCY).map { page -> + async { + var backoff = PAGE_RETRY_INITIAL_BACKOFF_MS + for (attempt in 0 until PAGE_RETRY_MAX) { + val result = api.fetchSectionPage(section, authConfig, page, effectivePageSize) + if (result.isSuccess) return@async page to result.getOrThrow() + val err = result.exceptionOrNull() + if (attempt == PAGE_RETRY_MAX || !shouldRetryPageError(err)) { + Timber.w("Sync: $section page $page failed after ${attempt + 1} attempt(s): $err") + throw err ?: IllegalStateException("Page $page failed") + } + Timber.d("Sync: $section page $page attempt ${attempt + 1} failed, retrying in ${backoff}ms") + delay(backoff) + backoff = (backoff * 2).coerceAtMost(8_000L) } + throw IllegalStateException("Unreachable") } + }.awaitAll() + }.sortedBy { (page, _) -> page } + + for ((_, pageData) in batch) { + if (pageData.items.isEmpty() || pageData.endReached) { + hitEnd = true + break + } + pageData.items.forEach { item -> + if (existingIds.add(item.id)) allItems.add(item) } } @@ -1576,37 +1621,36 @@ class ContentRepository( sectionIndex = sectionIndex, totalSections = sectionsToSync.size, itemsIndexed = allItems.size, - progress = 0.5f, // Indeterminate for background + progress = 0.5f, phase = com.example.xtreamplayer.content.SyncPhase.BACKGROUND_FULL ) ) - if (pageData.items.isEmpty() || pageData.endReached) { - // Section complete - if (stagingMode) { - contentCache.updateSectionIndexIncrementalStagingWithCheckpoint( - section, authConfig, allItems, page, true - ) - contentCache.commitSectionIndexStaging(section, authConfig) - } else { - // 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) - } - } - Timber.i("Background sync complete: $section with ${allItems.size} items") - break + if (!hitEnd) { + saveProgressCheckpoint(batchStart + PARALLEL_PAGE_CONCURRENCY - 1) + batchStart += PARALLEL_PAGE_CONCURRENCY } + } - delay(throttleMs) // Throttle to avoid blocking UI - page++ + // Section complete + val lastPage = batchStart + PARALLEL_PAGE_CONCURRENCY - 1 + if (stagingMode) { + contentCache.updateSectionIndexIncrementalStagingWithCheckpoint( + section, authConfig, allItems, lastPage, true + ) + contentCache.commitSectionIndexStaging(section, authConfig) + } else { + contentCache.writeSectionIndexPartial(section, authConfig, allItems, lastPage, true) + } + val key = indexKey(section, authConfig) + sectionIndexMutex.withLock { + if (shouldKeepSectionIndexInMemory(allItems.size)) { + sectionIndexCache[key] = allItems + } else { + sectionIndexCache.remove(key) + } } + Timber.i("Background sync (parallel pages) complete: $section with ${allItems.size} items") } // end if (!bulkSuccess) } diff --git a/app/src/main/java/com/example/xtreamplayer/content/ProgressiveSyncCoordinator.kt b/app/src/main/java/com/example/xtreamplayer/content/ProgressiveSyncCoordinator.kt index 138fc41..8600fa3 100644 --- a/app/src/main/java/com/example/xtreamplayer/content/ProgressiveSyncCoordinator.kt +++ b/app/src/main/java/com/example/xtreamplayer/content/ProgressiveSyncCoordinator.kt @@ -3,6 +3,7 @@ package com.example.xtreamplayer.content import com.example.xtreamplayer.Section import com.example.xtreamplayer.auth.AuthConfig import com.example.xtreamplayer.settings.SettingsRepository +import com.example.xtreamplayer.sync.ActiveSyncGuard import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -142,6 +143,7 @@ class ProgressiveSyncCoordinator( settingsRepository.saveSyncState(_syncState.value, accountKey()) backgroundSyncJob = scope.launch { + ActiveSyncGuard.markActive() Timber.i("Starting background full sync") val sections = listOf(Section.SERIES, Section.MOVIES, Section.LIVE) @@ -165,7 +167,7 @@ class ProgressiveSyncCoordinator( _syncState.value.isPaused }, skipCompleted = if (fullReindex) false else !force, - throttleMs = throttleMs ?: 200L, + throttleMs = throttleMs ?: 0L, useBulkFirst = true, fallbackPageSize = 1000, fullReindex = fullReindex, @@ -188,6 +190,7 @@ class ProgressiveSyncCoordinator( } result.onSuccess { + ActiveSyncGuard.markInactive() if (_syncState.value.isPaused) { Timber.i("Background sync paused") updateSyncState { @@ -214,6 +217,7 @@ class ProgressiveSyncCoordinator( settingsRepository.saveSyncState(_syncState.value, accountKey()) }.onFailure { error -> + ActiveSyncGuard.markInactive() // Clear active sections on failure scope.launch { activeSyncMutex.withLock { activeSyncSections.clear() } @@ -445,7 +449,24 @@ class ProgressiveSyncCoordinator( } } // Faster, incremental sync that skips already indexed pages - startBackgroundFullSync(force = true, throttleMs = 100L, fullReindex = true) + startBackgroundFullSync(force = true, throttleMs = 0L, fullReindex = true) + } + + /** + * Full reset: cancels all syncs, clears persisted state, and starts fresh from fast-start. + * Called after cache is manually cleared so the coordinator doesn't think sync is done. + */ + suspend fun resetAndResync() { + cancelAllSyncsInternal() + updateSyncState { + ProgressiveSyncState( + phase = SyncPhase.IDLE, + fastStartReady = false, + fullIndexComplete = false + ) + } + settingsRepository.clearSyncState(accountKey()) + startFastStartSync() } /** @@ -530,6 +551,7 @@ class ProgressiveSyncCoordinator( if (disposed) return disposed = true Timber.d("Disposing ProgressiveSyncCoordinator") + ActiveSyncGuard.markInactive() cancelAllSyncsInternal() scopeJob.cancelAndJoin() } @@ -538,6 +560,7 @@ class ProgressiveSyncCoordinator( if (disposed) return disposed = true Timber.d("Disposing ProgressiveSyncCoordinator") + ActiveSyncGuard.markInactive() scopeJob.cancel() } } diff --git a/app/src/main/java/com/example/xtreamplayer/settings/SettingsRepository.kt b/app/src/main/java/com/example/xtreamplayer/settings/SettingsRepository.kt index 7f7890e..3e176fd 100644 --- a/app/src/main/java/com/example/xtreamplayer/settings/SettingsRepository.kt +++ b/app/src/main/java/com/example/xtreamplayer/settings/SettingsRepository.kt @@ -40,6 +40,8 @@ class SettingsRepository(private val context: Context) { ?: SubtitleCacheAutoClearOption.THIRTY_DAYS.intervalMs val matchFrameRate = prefs[Keys.MATCH_FRAME_RATE_ENABLED] ?: true val checkUpdatesOnStartup = prefs[Keys.CHECK_UPDATES_ON_STARTUP] ?: true + val refreshOnStartup = prefs[Keys.REFRESH_ON_STARTUP] ?: false + val syncScheduleInterval = parseSyncScheduleInterval(prefs[Keys.SYNC_SCHEDULE_INTERVAL]) val rememberLogin = prefs[Keys.REMEMBER_LOGIN] ?: true val autoSignIn = prefs[Keys.AUTO_SIGN_IN] ?: true val appTheme = parseAppTheme(prefs[Keys.APP_THEME]) @@ -62,6 +64,8 @@ class SettingsRepository(private val context: Context) { subtitleCacheAutoClearIntervalMs = subtitleCacheAutoClearIntervalMs, matchFrameRateEnabled = matchFrameRate, checkUpdatesOnStartup = checkUpdatesOnStartup, + refreshOnStartup = refreshOnStartup, + syncScheduleInterval = syncScheduleInterval, rememberLogin = rememberLogin, autoSignIn = autoSignIn, appTheme = appTheme, @@ -199,6 +203,18 @@ class SettingsRepository(private val context: Context) { } } + suspend fun setRefreshOnStartup(enabled: Boolean) { + context.dataStore.edit { prefs -> + prefs[Keys.REFRESH_ON_STARTUP] = enabled + } + } + + suspend fun setSyncScheduleInterval(interval: SyncScheduleInterval) { + context.dataStore.edit { prefs -> + prefs[Keys.SYNC_SCHEDULE_INTERVAL] = interval.name + } + } + suspend fun setRememberLogin(enabled: Boolean) { context.dataStore.edit { prefs -> prefs[Keys.REMEMBER_LOGIN] = enabled @@ -276,6 +292,11 @@ class SettingsRepository(private val context: Context) { return ClockFormatOption.values().firstOrNull { it.name == value } ?: ClockFormatOption.AM_PM } + private fun parseSyncScheduleInterval(value: String?): SyncScheduleInterval { + if (value == null) return SyncScheduleInterval.OFF + return SyncScheduleInterval.values().firstOrNull { it.name == value } ?: SyncScheduleInterval.OFF + } + private fun cacheBootSettings( appTheme: AppThemeOption? = null, appFont: AppFont? = null, @@ -319,6 +340,8 @@ class SettingsRepository(private val context: Context) { val SUBTITLE_CACHE_AUTO_CLEAR_LAST_RUN_MS = longPreferencesKey("subtitle_cache_auto_clear_last_run_ms") val MATCH_FRAME_RATE_ENABLED = booleanPreferencesKey("match_frame_rate_enabled") val CHECK_UPDATES_ON_STARTUP = booleanPreferencesKey("check_updates_on_startup") + val REFRESH_ON_STARTUP = booleanPreferencesKey("refresh_on_startup") + val SYNC_SCHEDULE_INTERVAL = stringPreferencesKey("sync_schedule_interval") val REMEMBER_LOGIN = booleanPreferencesKey("remember_login") val AUTO_SIGN_IN = booleanPreferencesKey("auto_sign_in") val APP_THEME = stringPreferencesKey("app_theme") @@ -359,6 +382,23 @@ class SettingsRepository(private val context: Context) { } } + /** + * Clear persisted sync state for an account so the next launch triggers a fresh sync. + */ + suspend fun clearSyncState(accountKey: String) { + context.dataStore.edit { prefs -> + val storedKey = prefs[Keys.SYNC_ACCOUNT_KEY] + if (storedKey == accountKey) { + prefs.remove(Keys.SYNC_PHASE) + prefs.remove(Keys.SYNC_FAST_START_READY) + prefs.remove(Keys.SYNC_FULL_COMPLETE) + prefs.remove(Keys.SYNC_LAST_TIMESTAMP) + prefs.remove(Keys.SYNC_PAUSED) + prefs.remove(Keys.SYNC_ACCOUNT_KEY) + } + } + } + /** * Load progressive sync state from DataStore */ diff --git a/app/src/main/java/com/example/xtreamplayer/settings/SettingsState.kt b/app/src/main/java/com/example/xtreamplayer/settings/SettingsState.kt index ccd4b94..5f25564 100644 --- a/app/src/main/java/com/example/xtreamplayer/settings/SettingsState.kt +++ b/app/src/main/java/com/example/xtreamplayer/settings/SettingsState.kt @@ -34,6 +34,16 @@ enum class SubtitleCacheAutoClearOption( THIRTY_DAYS(30L * 24L * 60L * 60L * 1000L, "30 days") } +enum class SyncScheduleInterval(val hours: Long, val label: String) { + OFF(0, "Off"), + EVERY_6_HOURS(6, "Every 6 hours"), + EVERY_12_HOURS(12, "Every 12 hours"), + EVERY_24_HOURS(24, "Every 24 hours"), + EVERY_7_DAYS(168, "Weekly") +} + +fun syncScheduleLabel(interval: SyncScheduleInterval): String = interval.label + fun subtitleAutoClearLabel(intervalMs: Long): String { return SubtitleCacheAutoClearOption.entries .firstOrNull { it.intervalMs == intervalMs } @@ -51,6 +61,8 @@ data class SettingsState( val subtitleCacheAutoClearIntervalMs: Long = SubtitleCacheAutoClearOption.THIRTY_DAYS.intervalMs, val matchFrameRateEnabled: Boolean = true, val checkUpdatesOnStartup: Boolean = true, + val refreshOnStartup: Boolean = false, + val syncScheduleInterval: SyncScheduleInterval = SyncScheduleInterval.OFF, val rememberLogin: Boolean = true, val autoSignIn: Boolean = true, val appTheme: AppThemeOption = AppThemeOption.DEFAULT, diff --git a/app/src/main/java/com/example/xtreamplayer/settings/SettingsViewModel.kt b/app/src/main/java/com/example/xtreamplayer/settings/SettingsViewModel.kt index 144fc20..3294c24 100644 --- a/app/src/main/java/com/example/xtreamplayer/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/example/xtreamplayer/settings/SettingsViewModel.kt @@ -1,20 +1,25 @@ package com.example.xtreamplayer.settings +import android.content.Context import androidx.lifecycle.ViewModel import com.example.xtreamplayer.ui.theme.AppFont import androidx.lifecycle.viewModelScope +import com.example.xtreamplayer.sync.SyncScheduler import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SettingsViewModel @Inject constructor( - private val repository: SettingsRepository + private val repository: SettingsRepository, + @ApplicationContext private val context: Context ) : ViewModel() { private var subtitleAppearanceSaveJob: Job? = null private var pendingSubtitleAppearance: SubtitleAppearanceSettings? = null @@ -22,6 +27,10 @@ class SettingsViewModel @Inject constructor( init { viewModelScope.launch { repository.migrateBootSettingsToDataStore() + // Restore any active background sync schedule after process restart. + val savedInterval = repository.settings.firstOrNull()?.syncScheduleInterval + ?: SyncScheduleInterval.OFF + SyncScheduler.schedule(context, savedInterval) } } @@ -101,6 +110,19 @@ class SettingsViewModel @Inject constructor( } } + fun toggleRefreshOnStartup() { + viewModelScope.launch { + repository.setRefreshOnStartup(!settings.value.refreshOnStartup) + } + } + + fun setSyncScheduleInterval(interval: SyncScheduleInterval) { + viewModelScope.launch { + repository.setSyncScheduleInterval(interval) + SyncScheduler.schedule(context, interval) + } + } + fun toggleRememberLogin() { viewModelScope.launch { repository.setRememberLogin(!settings.value.rememberLogin) diff --git a/app/src/main/java/com/example/xtreamplayer/sync/ActiveSyncGuard.kt b/app/src/main/java/com/example/xtreamplayer/sync/ActiveSyncGuard.kt new file mode 100644 index 0000000..d45bdd8 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/sync/ActiveSyncGuard.kt @@ -0,0 +1,16 @@ +package com.example.xtreamplayer.sync + +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Process-scoped flag that lets LibrarySyncWorker know when the in-app + * ProgressiveSyncCoordinator already has an active background sync running, + * so the two never write to the content cache simultaneously. + */ +object ActiveSyncGuard { + private val active = AtomicBoolean(false) + + fun markActive() { active.set(true) } + fun markInactive() { active.set(false) } + val isActive: Boolean get() = active.get() +} diff --git a/app/src/main/java/com/example/xtreamplayer/sync/LibrarySyncWorker.kt b/app/src/main/java/com/example/xtreamplayer/sync/LibrarySyncWorker.kt new file mode 100644 index 0000000..69a7ca4 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/sync/LibrarySyncWorker.kt @@ -0,0 +1,62 @@ +package com.example.xtreamplayer.sync + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.example.xtreamplayer.auth.AuthRepository +import com.example.xtreamplayer.content.ContentRepository +import com.example.xtreamplayer.Section +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import kotlinx.coroutines.flow.firstOrNull +import timber.log.Timber + +class LibrarySyncWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface SyncWorkerEntryPoint { + fun authRepository(): AuthRepository + fun contentRepository(): ContentRepository + } + + override suspend fun doWork(): Result { + val entryPoint = EntryPointAccessors.fromApplication( + applicationContext, + SyncWorkerEntryPoint::class.java + ) + val authRepository = entryPoint.authRepository() + val contentRepository = entryPoint.contentRepository() + + if (ActiveSyncGuard.isActive) { + Timber.d("LibrarySyncWorker: in-app sync already active, skipping this run") + return Result.success() + } + + val config = authRepository.authConfig.firstOrNull() ?: run { + Timber.d("LibrarySyncWorker: no auth config, skipping") + return Result.success() + } + + Timber.i("LibrarySyncWorker: starting scheduled sync") + return try { + contentRepository.syncBackgroundFull( + authConfig = config, + sectionsToSync = listOf(Section.MOVIES, Section.SERIES, Section.LIVE), + useBulkFirst = true, + skipCompleted = false, + fullReindex = true + ) + Timber.i("LibrarySyncWorker: sync complete") + Result.success() + } catch (e: Exception) { + Timber.e(e, "LibrarySyncWorker: sync failed") + Result.retry() + } + } +} diff --git a/app/src/main/java/com/example/xtreamplayer/sync/SyncScheduler.kt b/app/src/main/java/com/example/xtreamplayer/sync/SyncScheduler.kt new file mode 100644 index 0000000..9efb971 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/sync/SyncScheduler.kt @@ -0,0 +1,35 @@ +package com.example.xtreamplayer.sync + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.example.xtreamplayer.settings.SyncScheduleInterval +import java.util.concurrent.TimeUnit + +object SyncScheduler { + private const val WORK_NAME = "library_sync_periodic" + + fun schedule(context: Context, interval: SyncScheduleInterval) { + val workManager = WorkManager.getInstance(context) + if (interval == SyncScheduleInterval.OFF) { + workManager.cancelUniqueWork(WORK_NAME) + return + } + val request = + PeriodicWorkRequestBuilder(interval.hours, TimeUnit.HOURS) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .build() + workManager.enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + request + ) + } +} diff --git a/app/src/main/java/com/example/xtreamplayer/ui/AutoSyncInfoDialog.kt b/app/src/main/java/com/example/xtreamplayer/ui/AutoSyncInfoDialog.kt new file mode 100644 index 0000000..1a9f3f8 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/ui/AutoSyncInfoDialog.kt @@ -0,0 +1,91 @@ +package com.example.xtreamplayer.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties +import com.example.xtreamplayer.ui.theme.AppTheme + +@Composable +fun AutoSyncInfoDialog(onDismiss: () -> Unit) { + val colors = AppTheme.colors + val closeFocusRequester = remember { FocusRequester() } + + AppDialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.45f) + .clip(RoundedCornerShape(12.dp)) + .background(colors.background) + .border(1.dp, colors.borderStrong, RoundedCornerShape(12.dp)) + .padding(24.dp) + ) { + Column { + Text( + text = "About Auto-Sync", + color = colors.textPrimary, + fontSize = 20.sp, + fontFamily = AppTheme.fontFamily, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + InfoLine("What it does", "Runs a full library refresh (Movies, Series, and Live) on a repeating schedule, so your content stays up to date automatically.", colors) + Spacer(modifier = Modifier.height(12.dp)) + InfoLine("Works when app is closed", "Syncs happen in the background even when you're not using the app. Your library is updated by the time you open it.", colors) + Spacer(modifier = Modifier.height(12.dp)) + InfoLine("Requires network", "The sync only runs when your device has an active internet connection.", colors) + Spacer(modifier = Modifier.height(12.dp)) + InfoLine("Conflict-free", "If you're actively using the app and a sync is already running, the scheduled sync will skip that round and retry at the next interval.", colors) + Spacer(modifier = Modifier.height(12.dp)) + InfoLine("Manual sync still works", "You can always trigger an immediate sync from Settings using \"Sync library\".", colors) + + Spacer(modifier = Modifier.height(20.dp)) + + DialogCloseButton( + focusRequester = closeFocusRequester, + onDismiss = onDismiss + ) + } + } + } +} + +@Composable +private fun InfoLine(label: String, body: String, colors: com.example.xtreamplayer.ui.theme.AppColors) { + Column { + Text( + text = label, + color = colors.textPrimary, + fontSize = 13.sp, + fontFamily = AppTheme.fontFamily, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = body, + color = colors.textSecondary, + fontSize = 13.sp, + fontFamily = AppTheme.fontFamily + ) + } +} diff --git a/app/src/main/java/com/example/xtreamplayer/ui/DialogControls.kt b/app/src/main/java/com/example/xtreamplayer/ui/DialogControls.kt index e29112d..f9c59d4 100644 --- a/app/src/main/java/com/example/xtreamplayer/ui/DialogControls.kt +++ b/app/src/main/java/com/example/xtreamplayer/ui/DialogControls.kt @@ -149,6 +149,44 @@ fun RepeatableFocusableButton( } } +@Composable +fun DialogLearnMoreButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + label: String = "How this works" +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val colors = AppTheme.colors + + Box( + modifier = modifier + .fillMaxWidth() + .focusable(interactionSource = interactionSource) + .onKeyEvent { + if (it.type != KeyEventType.KeyDown) false + else when (it.key) { + Key.Enter, Key.NumPadEnter, Key.DirectionCenter -> { onClick(); true } + else -> false + } + } + .clickable(interactionSource = interactionSource, indication = null, onClick = onClick) + .clip(RoundedCornerShape(8.dp)) + .background(if (isFocused) colors.surfaceAlt else colors.surface) + .border(1.dp, if (isFocused) colors.accentAlt else colors.borderStrong, RoundedCornerShape(8.dp)) + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + color = if (isFocused) colors.textPrimary else colors.textSecondary, + fontSize = 14.sp, + fontFamily = AppTheme.fontFamily, + fontWeight = FontWeight.Medium + ) + } +} + @Composable fun DialogCloseButton( focusRequester: FocusRequester, diff --git a/app/src/main/java/com/example/xtreamplayer/ui/SyncScheduleDialog.kt b/app/src/main/java/com/example/xtreamplayer/ui/SyncScheduleDialog.kt new file mode 100644 index 0000000..e3b3383 --- /dev/null +++ b/app/src/main/java/com/example/xtreamplayer/ui/SyncScheduleDialog.kt @@ -0,0 +1,211 @@ +package com.example.xtreamplayer.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +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.Spacer +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties +import com.example.xtreamplayer.settings.SyncScheduleInterval +import com.example.xtreamplayer.ui.theme.AppTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun SyncScheduleDialog( + current: SyncScheduleInterval, + onIntervalChange: (SyncScheduleInterval) -> Unit, + onDismiss: () -> Unit, + onLearnMore: () -> Unit = {} +) { + val colors = AppTheme.colors + val options = remember { SyncScheduleInterval.entries.toList() } + val coroutineScope = rememberCoroutineScope() + val closeFocusRequester = remember { FocusRequester() } + val itemFocusRequesters = remember(options.size) { List(options.size) { FocusRequester() } } + val selectedIndex = options.indexOf(current).takeIf { it >= 0 } ?: 0 + val listState = rememberLazyListState() + + LaunchedEffect(options.size, selectedIndex, listState) { + if (options.isNotEmpty()) { + val targetIndex = selectedIndex.coerceIn(0, options.lastIndex) + listState.scrollToItem(targetIndex) + delay(16) + itemFocusRequesters.getOrNull(targetIndex)?.requestFocus() + ?: itemFocusRequesters.firstOrNull()?.requestFocus() + } else { + closeFocusRequester.requestFocus() + } + } + + AppDialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.45f) + .clip(RoundedCornerShape(12.dp)) + .background(colors.background) + .border(1.dp, colors.borderStrong, RoundedCornerShape(12.dp)) + .padding(24.dp) + ) { + Column { + Text( + text = "Auto-Sync Interval", + color = colors.textPrimary, + fontSize = 20.sp, + fontFamily = AppTheme.fontFamily, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + state = listState, + modifier = Modifier.weight(1f, fill = false) + ) { + itemsIndexed(options) { index, option -> + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + var keyDownArmed by remember { mutableStateOf(false) } + var keyClickHandled by remember { mutableStateOf(false) } + val isSelected = option == current + val borderColor = when { + isFocused && isSelected -> colors.focus + isFocused -> colors.accentAlt + isSelected -> colors.accentSelected + else -> colors.borderStrong + } + val backgroundColor = when { + isFocused && isSelected -> colors.panelBackground + isFocused -> colors.surfaceAlt + isSelected -> colors.panelBackground + else -> colors.surface + } + + LaunchedEffect(isFocused) { + if (!isFocused) { + keyDownArmed = false + keyClickHandled = false + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) + .border(1.dp, borderColor, RoundedCornerShape(8.dp)) + .focusRequester(itemFocusRequesters[index]) + .focusable(interactionSource = interactionSource) + .onKeyEvent { + val isSelectKey = + it.key == Key.Enter || + it.key == Key.NumPadEnter || + it.key == Key.DirectionCenter + when (it.type) { + KeyEventType.KeyDown -> { + if (isSelectKey) { keyDownArmed = true; true } else false + } + KeyEventType.KeyUp -> { + if (isSelectKey && keyDownArmed) { + keyDownArmed = false + keyClickHandled = true + onIntervalChange(option) + onDismiss() + coroutineScope.launch { + delay(120) + keyClickHandled = false + } + true + } else false + } + else -> false + } + } + .clickable( + interactionSource = interactionSource, + indication = null + ) { + if (keyClickHandled) { + keyClickHandled = false + } else { + onIntervalChange(option) + onDismiss() + } + } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (isSelected) { + Box( + modifier = Modifier + .size(8.dp) + .background(colors.success, CircleShape) + ) + Spacer(modifier = Modifier.width(12.dp)) + } + Text( + text = option.label, + color = colors.textPrimary, + fontSize = 14.sp, + fontFamily = AppTheme.fontFamily, + fontWeight = FontWeight.Medium + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + DialogLearnMoreButton(onClick = onLearnMore) + + Spacer(modifier = Modifier.height(8.dp)) + + DialogCloseButton( + focusRequester = closeFocusRequester, + onDismiss = onDismiss + ) + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 91237dd..56c5a5f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ coil = "2.6.0" hilt = "2.57" hiltNavigationCompose = "1.2.0" timber = "5.0.1" +workManager = "2.10.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -42,6 +43,7 @@ hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }