diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index cf9a17c2..e9167521 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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>(emptyMap()) } var pickerPending by remember { mutableStateOf(false) } var pickerError by remember { mutableStateOf(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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt new file mode 100644 index 00000000..e9976f75 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt @@ -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 = _uiState.asStateFlow() + + private var started = false + private var probeInFlight = false + private var pendingProbeAfterCurrent = false + private var addonProbeTargets: List = emptyList() + + fun ensureStarted() { + if (started) return + started = true + requestRefresh(force = true) + } + + fun updateAddonProbeTargets(urls: List) { + 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 = 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 + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNetworkOfflineCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNetworkOfflineCard.kt new file mode 100644 index 00000000..958ccd0c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNetworkOfflineCard.kt @@ -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, + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index 3f401e25..b2d4b790 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 6b7de178..eae92a8d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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(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") } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 1f61a0a7..c0455b07 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -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.", + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 6bf4bfb0..1f86203f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -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(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." + }, + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index 936c56a6..e52090cb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -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 = 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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index eb26bc8b..90a834b6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -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(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