feat: offline state management

This commit is contained in:
tapframe 2026-04-13 00:59:34 +05:30
parent 8c6e444e5f
commit d9a6f0dfd9
9 changed files with 584 additions and 24 deletions

View file

@ -72,6 +72,8 @@ import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.deeplink.AppDeepLink
import com.nuvio.app.core.deeplink.AppDeepLinkRepository
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.sync.AppForegroundMonitor
import com.nuvio.app.core.sync.ProfileSettingsSync
import com.nuvio.app.core.sync.SyncManager
@ -81,10 +83,12 @@ import com.nuvio.app.core.ui.NuvioPosterActionSheet
import com.nuvio.app.core.ui.PlatformBackHandler
import com.nuvio.app.core.ui.configurePlatformImageLoader
import com.nuvio.app.core.ui.NuvioToastHost
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.ui.NuvioFloatingPrompt
import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.NuvioTheme
import com.nuvio.app.features.auth.AuthScreen
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CatalogRepository
import com.nuvio.app.features.catalog.CatalogScreen
import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL
@ -426,6 +430,10 @@ private fun MainAppContent(
var pickerMembership by remember { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
var pickerPending by remember { mutableStateOf(false) }
var pickerError by remember { mutableStateOf<String?>(null) }
val addonsUiState by remember {
AddonRepository.initialize()
AddonRepository.uiState
}.collectAsStateWithLifecycle()
val libraryUiState by remember {
LibraryRepository.ensureLoaded()
LibraryRepository.uiState
@ -444,13 +452,112 @@ private fun MainAppContent(
WatchedRepository.ensureLoaded()
WatchedRepository.uiState
}.collectAsStateWithLifecycle()
val downloadsUiState by remember {
DownloadsRepository.ensureLoaded()
DownloadsRepository.uiState
}.collectAsStateWithLifecycle()
val networkStatusUiState by remember {
NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle()
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) }
var lastNetworkToastCondition by rememberSaveable { mutableStateOf(NetworkCondition.Unknown.name) }
val addonProbeTargets = remember(addonsUiState.addons) {
addonsUiState.addons
.mapNotNull { it.manifest?.transportUrl }
.distinct()
.sorted()
}
LaunchedEffect(Unit) {
NetworkStatusRepository.ensureStarted()
EpisodeReleaseNotificationsRepository.refreshAsync()
kotlinx.coroutines.delay(5_000)
initialHomeReady = true
}
LaunchedEffect(addonProbeTargets) {
NetworkStatusRepository.updateAddonProbeTargets(addonProbeTargets)
}
LaunchedEffect(Unit) {
AppForegroundMonitor.events().collect {
NetworkStatusRepository.requestRefresh(force = true)
}
}
LaunchedEffect(networkStatusUiState.condition) {
val condition = networkStatusUiState.condition
if (!networkToastBaselineReady) {
networkToastBaselineReady = true
lastNetworkToastCondition = condition.name
return@LaunchedEffect
}
val previousConditionName = lastNetworkToastCondition
if (previousConditionName == condition.name) return@LaunchedEffect
when (condition) {
NetworkCondition.NoInternet -> {
NuvioToastController.show("No internet connection")
}
NetworkCondition.ServersUnreachable -> {
NuvioToastController.show("Cannot reach servers")
}
NetworkCondition.Online -> {
if (
previousConditionName == NetworkCondition.NoInternet.name ||
previousConditionName == NetworkCondition.ServersUnreachable.name
) {
NuvioToastController.show("Back online")
}
}
NetworkCondition.Unknown,
NetworkCondition.Checking,
-> Unit
}
lastNetworkToastCondition = condition.name
}
LaunchedEffect(
initialHomeReady,
offlineLaunchRouteHandled,
networkStatusUiState.condition,
downloadsUiState.completedItems,
) {
if (!initialHomeReady || offlineLaunchRouteHandled) return@LaunchedEffect
when (networkStatusUiState.condition) {
NetworkCondition.Unknown,
NetworkCondition.Checking,
-> return@LaunchedEffect
NetworkCondition.Online -> {
offlineLaunchRouteHandled = true
}
NetworkCondition.NoInternet,
NetworkCondition.ServersUnreachable,
-> {
offlineLaunchRouteHandled = true
val hasPlayableDownload = downloadsUiState.completedItems.any { it.isPlayable }
if (hasPlayableDownload) {
selectedTab = AppScreenTab.Settings
navController.navigate(DownloadsSettingsRoute) {
launchSingleTop = true
}
}
}
}
}
LaunchedEffect(authState, profileState.activeProfile?.profileIndex) {
val authenticatedState = authState as? AuthState.Authenticated ?: return@LaunchedEffect
if (authenticatedState.isAnonymous) return@LaunchedEffect

View file

@ -0,0 +1,144 @@
package com.nuvio.app.core.network
import com.nuvio.app.features.addons.httpRequestRaw
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
enum class NetworkCondition {
Unknown,
Checking,
Online,
NoInternet,
ServersUnreachable,
}
data class NetworkStatusUiState(
val condition: NetworkCondition = NetworkCondition.Unknown,
) {
val isOnline: Boolean
get() = condition == NetworkCondition.Online
val isOfflineLike: Boolean
get() = condition == NetworkCondition.NoInternet || condition == NetworkCondition.ServersUnreachable
}
fun NetworkCondition.titleForEmptyState(): String =
when (this) {
NetworkCondition.ServersUnreachable -> "Cannot reach servers"
NetworkCondition.NoInternet -> "No internet connection"
else -> "Connection issue"
}
fun NetworkCondition.messageForEmptyState(): String =
when (this) {
NetworkCondition.ServersUnreachable -> "Your device is online, but Nuvio could not reach required servers."
NetworkCondition.NoInternet -> "Check your Wi-Fi or mobile data connection and try again."
else -> "Please check your connection and try again."
}
object NetworkStatusRepository {
private const val REQUEST_TIMEOUT_MS = 4_500L
private const val PUBLIC_PROBE_PRIMARY = "https://www.gstatic.com/generate_204"
private const val PUBLIC_PROBE_FALLBACK = "https://cloudflare.com/cdn-cgi/trace"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(NetworkStatusUiState())
val uiState: StateFlow<NetworkStatusUiState> = _uiState.asStateFlow()
private var started = false
private var probeInFlight = false
private var pendingProbeAfterCurrent = false
private var addonProbeTargets: List<String> = emptyList()
fun ensureStarted() {
if (started) return
started = true
requestRefresh(force = true)
}
fun updateAddonProbeTargets(urls: List<String>) {
val normalized = urls
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
if (normalized == addonProbeTargets) return
addonProbeTargets = normalized
requestRefresh(force = true)
}
fun requestRefresh(force: Boolean = false) {
ensureStarted()
if (probeInFlight) {
if (force) pendingProbeAfterCurrent = true
return
}
scope.launch {
do {
pendingProbeAfterCurrent = false
probeInFlight = true
runProbe()
probeInFlight = false
} while (pendingProbeAfterCurrent)
}
}
private suspend fun runProbe() {
if (_uiState.value.condition == NetworkCondition.Unknown) {
_uiState.value = NetworkStatusUiState(condition = NetworkCondition.Checking)
}
val internetReachable = probePublicInternet()
if (!internetReachable) {
_uiState.value = NetworkStatusUiState(condition = NetworkCondition.NoInternet)
return
}
val supabaseReachable = probeReachable(
url = "${SupabaseConfig.URL.trimEnd('/')}/rest/v1/",
headers = mapOf("apikey" to SupabaseConfig.ANON_KEY),
)
if (!supabaseReachable) {
_uiState.value = NetworkStatusUiState(condition = NetworkCondition.ServersUnreachable)
return
}
val addonTarget = addonProbeTargets.firstOrNull()
if (addonTarget != null) {
val addonReachable = probeReachable(url = addonTarget)
if (!addonReachable) {
_uiState.value = NetworkStatusUiState(condition = NetworkCondition.ServersUnreachable)
return
}
}
_uiState.value = NetworkStatusUiState(condition = NetworkCondition.Online)
}
private suspend fun probePublicInternet(): Boolean =
probeReachable(PUBLIC_PROBE_PRIMARY) || probeReachable(PUBLIC_PROBE_FALLBACK)
private suspend fun probeReachable(
url: String,
headers: Map<String, String> = emptyMap(),
): Boolean {
val response = withTimeoutOrNull(REQUEST_TIMEOUT_MS) {
runCatching {
httpRequestRaw(
method = "GET",
url = url,
headers = headers,
body = "",
)
}.getOrNull()
} ?: return false
return response.status in 100..599
}
}

View file

@ -0,0 +1,40 @@
package com.nuvio.app.core.ui
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.messageForEmptyState
import com.nuvio.app.core.network.titleForEmptyState
@Composable
fun NuvioNetworkOfflineCard(
condition: NetworkCondition,
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null,
) {
NuvioSurfaceCard(modifier = modifier) {
Text(
text = condition.titleForEmptyState(),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = condition.messageForEmptyState(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (onRetry != null) {
Spacer(modifier = Modifier.height(16.dp))
NuvioPrimaryButton(
text = "Retry",
onClick = onRetry,
)
}
}
}

View file

@ -27,6 +27,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
@ -40,6 +41,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import coil3.compose.AsyncImage
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.core.ui.NuvioBackButton
@ -66,8 +70,10 @@ fun CatalogScreen(
modifier: Modifier = Modifier,
) {
val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState()
var headerHeightPx by remember { mutableIntStateOf(0) }
var observedOfflineState by remember { mutableStateOf(false) }
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination) {
CatalogRepository.load(
@ -93,6 +99,33 @@ fun CatalogScreen(
}
}
LaunchedEffect(networkStatusUiState.condition, manifestUrl, type, catalogId, genre, supportsPagination) {
when (networkStatusUiState.condition) {
NetworkCondition.NoInternet,
NetworkCondition.ServersUnreachable,
-> {
observedOfflineState = true
}
NetworkCondition.Online -> {
if (!observedOfflineState) return@LaunchedEffect
observedOfflineState = false
CatalogRepository.load(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
force = true,
)
}
NetworkCondition.Unknown,
NetworkCondition.Checking,
-> Unit
}
}
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
@ -118,7 +151,21 @@ fun CatalogScreen(
items(columns * 3) { CatalogSkeletonTile() }
} else if (uiState.items.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
CatalogEmptyState(errorMessage = uiState.errorMessage)
CatalogEmptyState(
errorMessage = uiState.errorMessage,
networkCondition = networkStatusUiState.condition,
onRetry = {
NetworkStatusRepository.requestRefresh(force = true)
CatalogRepository.load(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
force = true,
)
},
)
}
} else {
items(
@ -251,7 +298,19 @@ private fun CatalogSkeletonTile() {
}
@Composable
private fun CatalogEmptyState(errorMessage: String?) {
private fun CatalogEmptyState(
errorMessage: String?,
networkCondition: NetworkCondition,
onRetry: (() -> Unit)? = null,
) {
if (networkCondition == NetworkCondition.NoInternet || networkCondition == NetworkCondition.ServersUnreachable) {
NuvioNetworkOfflineCard(
condition = networkCondition,
onRetry = onRetry,
)
return
}
Column(
modifier = Modifier
.fillMaxWidth()

View file

@ -57,6 +57,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.build.TrailerPlaybackMode
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.ui.NuvioBackButton
import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding
@ -141,7 +143,9 @@ fun MetaDetailsScreen(
PlayerSettingsRepository.ensureLoaded()
PlayerSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
var autoLoadAttempted by remember(type, id) { mutableStateOf(false) }
var observedOfflineState by remember(type, id) { mutableStateOf(false) }
var selectedEpisodeForActions by remember(type, id) { mutableStateOf<MetaVideo?>(null) }
val commentsEnabled by remember {
TraktCommentsSettings.ensureLoaded()
@ -194,6 +198,28 @@ fun MetaDetailsScreen(
}
}
LaunchedEffect(networkStatusUiState.condition, displayedMeta, uiState.isLoading, type, id) {
when (networkStatusUiState.condition) {
NetworkCondition.NoInternet,
NetworkCondition.ServersUnreachable,
-> {
observedOfflineState = true
}
NetworkCondition.Online -> {
if (!observedOfflineState) return@LaunchedEffect
observedOfflineState = false
if (displayedMeta == null && !uiState.isLoading) {
MetaDetailsRepository.load(type, id)
}
}
NetworkCondition.Unknown,
NetworkCondition.Checking,
-> Unit
}
}
Box(
modifier = modifier
.fillMaxSize()
@ -221,13 +247,20 @@ fun MetaDetailsScreen(
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = uiState.errorMessage.orEmpty(),
text = when (networkStatusUiState.condition) {
NetworkCondition.NoInternet -> "Check your Wi-Fi or mobile data connection and try again."
NetworkCondition.ServersUnreachable -> "Your device is online, but Nuvio could not reach required servers."
else -> uiState.errorMessage.orEmpty()
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { MetaDetailsRepository.load(type, id) },
onClick = {
NetworkStatusRepository.requestRefresh(force = true)
MetaDetailsRepository.load(type, id)
},
) {
Text("Retry")
}

View file

@ -14,7 +14,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.sortedPlayableEpisodes
@ -81,10 +84,33 @@ fun HomeScreen(
val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
val isTraktAuthenticated by remember {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.isAuthenticated
}.collectAsStateWithLifecycle()
var observedOfflineState by remember { mutableStateOf(false) }
LaunchedEffect(networkStatusUiState.condition) {
when (networkStatusUiState.condition) {
NetworkCondition.NoInternet,
NetworkCondition.ServersUnreachable,
-> {
observedOfflineState = true
}
NetworkCondition.Online -> {
if (observedOfflineState) {
observedOfflineState = false
HomeRepository.refresh(addonsUiState.addons, force = true)
}
}
NetworkCondition.Unknown,
NetworkCondition.Checking,
-> Unit
}
}
val effectiveWatchProgressEntries = remember(watchProgressUiState.entries, isTraktAuthenticated) {
if (!isTraktAuthenticated) {
@ -420,12 +446,23 @@ fun HomeScreen(
homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() &&
(!continueWatchingPreferences.isVisible || continueWatchingItems.isEmpty()) -> {
item {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = "No home rows available",
message = homeUiState.errorMessage
?: "Installed addons do not currently expose board-compatible catalogs without required extras.",
)
if (networkStatusUiState.isOfflineLike) {
NuvioNetworkOfflineCard(
condition = networkStatusUiState.condition,
modifier = Modifier.padding(horizontal = 16.dp),
onRetry = {
NetworkStatusRepository.requestRefresh(force = true)
HomeRepository.refresh(addonsUiState.addons, force = true)
},
)
} else {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = "No home rows available",
message = homeUiState.errorMessage
?: "Installed addons do not currently expose board-compatible catalogs without required extras.",
)
}
}
}

View file

@ -13,11 +13,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioViewAllPillSize
@ -25,6 +30,8 @@ import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.features.home.components.HomeEmptyStateCard
import com.nuvio.app.features.home.components.HomePosterCard
import com.nuvio.app.features.home.components.HomeSkeletonRow
import com.nuvio.app.features.profiles.ProfileRepository
import kotlinx.coroutines.launch
@Composable
fun LibraryScreen(
@ -36,9 +43,36 @@ fun LibraryScreen(
LibraryRepository.ensureLoaded()
LibraryRepository.uiState
}.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
var pendingRemovalItem by remember { mutableStateOf<LibraryItem?>(null) }
var observedOfflineState by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
LaunchedEffect(networkStatusUiState.condition, isTraktSource) {
when (networkStatusUiState.condition) {
NetworkCondition.NoInternet,
NetworkCondition.ServersUnreachable,
-> {
observedOfflineState = true
}
NetworkCondition.Online -> {
if (!observedOfflineState) return@LaunchedEffect
observedOfflineState = false
if (isTraktSource) {
coroutineScope.launch {
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
}
}
}
NetworkCondition.Unknown,
NetworkCondition.Checking,
-> Unit
}
}
NuvioScreen(
modifier = modifier,
horizontalPadding = 0.dp,
@ -66,25 +100,53 @@ fun LibraryScreen(
!uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> {
item {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = if (isTraktSource) "Couldn't load Trakt library" else "Couldn't load library",
message = uiState.errorMessage.orEmpty(),
)
if (networkStatusUiState.isOfflineLike) {
NuvioNetworkOfflineCard(
condition = networkStatusUiState.condition,
modifier = Modifier.padding(horizontal = 16.dp),
onRetry = {
NetworkStatusRepository.requestRefresh(force = true)
if (isTraktSource) {
coroutineScope.launch {
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
}
}
},
)
} else {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = if (isTraktSource) "Couldn't load Trakt library" else "Couldn't load library",
message = uiState.errorMessage.orEmpty(),
)
}
}
}
uiState.sections.isEmpty() -> {
item {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = if (isTraktSource) "Your Trakt library is empty" else "Your library is empty",
message = if (isTraktSource) {
"Connect Trakt and save titles to your watchlist or personal lists."
} else {
"Saved titles will appear here after you tap Save on a details screen."
},
)
if (networkStatusUiState.isOfflineLike && isTraktSource) {
NuvioNetworkOfflineCard(
condition = networkStatusUiState.condition,
modifier = Modifier.padding(horizontal = 16.dp),
onRetry = {
NetworkStatusRepository.requestRefresh(force = true)
coroutineScope.launch {
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
}
},
)
} else {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = if (isTraktSource) "Your Trakt library is empty" else "Your library is empty",
message = if (isTraktSource) {
"Connect Trakt and save titles to your watchlist or personal lists."
} else {
"Saved titles will appear here after you tap Save on a details screen."
},
)
}
}
}

View file

@ -47,7 +47,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge
import com.nuvio.app.core.ui.NuvioBottomSheetActionRow
import com.nuvio.app.core.ui.NuvioBottomSheetDivider
@ -64,9 +66,11 @@ import kotlinx.coroutines.launch
internal fun LazyListScope.discoverContent(
state: DiscoverUiState,
columns: Int,
networkCondition: NetworkCondition,
onTypeSelected: (String) -> Unit,
onCatalogSelected: (String) -> Unit,
onGenreSelected: (String?) -> Unit,
onRetry: (() -> Unit)? = null,
watchedKeys: Set<String> = emptySet(),
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
@ -112,6 +116,8 @@ internal fun LazyListScope.discoverContent(
DiscoverEmptyStateCard(
reason = state.emptyStateReason,
errorMessage = state.errorMessage,
networkCondition = networkCondition,
onRetry = onRetry,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
@ -454,8 +460,19 @@ private fun CatalogLoadingFooter(modifier: Modifier = Modifier) {
private fun DiscoverEmptyStateCard(
reason: DiscoverEmptyStateReason?,
errorMessage: String?,
networkCondition: NetworkCondition,
onRetry: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
if (networkCondition == NetworkCondition.NoInternet || networkCondition == NetworkCondition.ServersUnreachable) {
NuvioNetworkOfflineCard(
condition = networkCondition,
modifier = modifier,
onRetry = onRetry,
)
return
}
val title: String
val message: String

View file

@ -38,8 +38,11 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.ui.NuvioInputField
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.home.MetaPreview
@ -70,8 +73,10 @@ fun SearchScreen(
val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle()
val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle()
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") }
var lastRequestedQuery by rememberSaveable { mutableStateOf<String?>(null) }
var observedOfflineState by remember { mutableStateOf(false) }
val listState = rememberLazyListState()
val headerTitle by remember(query, listState) {
derivedStateOf {
@ -148,6 +153,35 @@ fun SearchScreen(
SearchHistoryRepository.recordSearch(normalizedQuery)
}
LaunchedEffect(networkStatusUiState.condition, query, addonRefreshKey) {
when (networkStatusUiState.condition) {
NetworkCondition.NoInternet,
NetworkCondition.ServersUnreachable,
-> {
observedOfflineState = true
}
NetworkCondition.Online -> {
if (!observedOfflineState) return@LaunchedEffect
observedOfflineState = false
val normalizedQuery = query.trim()
if (normalizedQuery.isBlank()) {
SearchRepository.refreshDiscover(addonsUiState.addons)
} else {
SearchRepository.search(
query = normalizedQuery,
addons = addonsUiState.addons,
)
}
}
NetworkCondition.Unknown,
NetworkCondition.Checking,
-> Unit
}
}
BoxWithConstraints(
modifier = modifier.fillMaxSize(),
) {
@ -211,9 +245,14 @@ fun SearchScreen(
discoverContent(
state = discoverUiState,
columns = discoverColumns,
networkCondition = networkStatusUiState.condition,
onTypeSelected = SearchRepository::selectDiscoverType,
onCatalogSelected = SearchRepository::selectDiscoverCatalog,
onGenreSelected = SearchRepository::selectDiscoverGenre,
onRetry = {
NetworkStatusRepository.requestRefresh(force = true)
SearchRepository.refreshDiscover(addonsUiState.addons)
},
watchedKeys = watchedUiState.watchedKeys,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
@ -231,6 +270,17 @@ fun SearchScreen(
SearchEmptyStateCard(
reason = uiState.emptyStateReason,
errorMessage = uiState.errorMessage,
networkCondition = networkStatusUiState.condition,
onRetry = {
val normalizedQuery = query.trim()
if (normalizedQuery.isNotBlank()) {
NetworkStatusRepository.requestRefresh(force = true)
SearchRepository.search(
query = normalizedQuery,
addons = addonsUiState.addons,
)
}
},
)
}
}
@ -268,8 +318,19 @@ private fun discoverColumnCountForWidth(screenWidth: Dp): Int =
private fun SearchEmptyStateCard(
reason: SearchEmptyStateReason?,
errorMessage: String?,
networkCondition: NetworkCondition,
onRetry: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
if (networkCondition == NetworkCondition.NoInternet || networkCondition == NetworkCondition.ServersUnreachable) {
NuvioNetworkOfflineCard(
condition = networkCondition,
modifier = modifier,
onRetry = onRetry,
)
return
}
val title: String
val message: String