mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-30 08:09:21 +00:00
feat: offline state management
This commit is contained in:
parent
8c6e444e5f
commit
d9a6f0dfd9
9 changed files with 584 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue