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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
9 changes: 8 additions & 1 deletion app/src/main/java/com/example/xtreamplayer/BrowseScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ internal fun BrowseScreen(
showVodBufferDialogState: MutableState<Boolean>,
showSubtitleAppearanceDialogState: MutableState<Boolean>,
showSubtitleCacheAutoClearDialogState: MutableState<Boolean>,
showSyncScheduleDialogState: MutableState<Boolean>,
showApiKeyDialogState: MutableState<Boolean>,
cacheClearNonceState: MutableState<Int>,
contentRepository: ContentRepository,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -492,6 +494,8 @@ Row(modifier = Modifier.fillMaxSize()) {
}
}
},
onToggleRefreshOnStartup = settingsViewModel::toggleRefreshOnStartup,
onOpenSyncSchedule = { showSyncScheduleDialog = true },
onToggleCheckUpdatesOnStartup = onToggleCheckUpdatesOnStartup,
onCheckForUpdates = { onCheckForUpdates() },
onClearCache = {
Expand Down Expand Up @@ -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 = {
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/com/example/xtreamplayer/LoginScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
96 changes: 80 additions & 16 deletions app/src/main/java/com/example/xtreamplayer/MainActivityUi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -316,6 +320,7 @@ fun RootScreen(
var updateCheckJob by remember { mutableStateOf<Job?>(null) }
var startupUpdateCheckEnabled by remember { mutableStateOf<Boolean?>(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) }
Expand All @@ -335,6 +340,8 @@ fun RootScreen(
var subtitleAppearancePreview by remember { mutableStateOf<SubtitleAppearanceSettings?>(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) }
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1005,6 +1026,7 @@ fun RootScreen(
!showVodBufferDialog &&
!showSubtitleAppearanceDialog &&
!showSubtitleCacheAutoClearDialog &&
!showSyncScheduleDialog &&
!showApiKeyDialog &&
!updateUiState.showDialog
BackHandler(enabled = shouldHandleRootBackForExit) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
)
}
}
}
}
}
Expand Down Expand Up @@ -2031,6 +2081,7 @@ fun RootScreen(
showVodBufferDialogState = showVodBufferDialogState,
showSubtitleAppearanceDialogState = showSubtitleAppearanceDialogState,
showSubtitleCacheAutoClearDialogState = showSubtitleCacheAutoClearDialogState,
showSyncScheduleDialogState = showSyncScheduleDialogState,
showApiKeyDialogState = showApiKeyDialogState,
cacheClearNonceState = cacheClearNonceState,
contentRepository = contentRepository,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 21 additions & 1 deletion app/src/main/java/com/example/xtreamplayer/SettingsScreens.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -74,6 +77,8 @@ fun SettingsScreen(
onOpenSubtitlesApiKey: () -> Unit,
onManageLists: () -> Unit,
onRefreshContent: () -> Unit,
onToggleRefreshOnStartup: () -> Unit,
onOpenSyncSchedule: () -> Unit,
onToggleCheckUpdatesOnStartup: () -> Unit,
onCheckForUpdates: () -> Unit,
onClearCache: () -> Unit,
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 18 additions & 28 deletions app/src/main/java/com/example/xtreamplayer/api/XtreamApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"))
}
}

Expand Down
Loading
Loading