mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
feat(connectivity): add offline resilience with server reachability checks
This commit is contained in:
@@ -13,11 +13,11 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
@@ -46,6 +46,7 @@ fun AppScreen(
|
||||
val nextUp by viewModel.nextUp.collectAsStateWithLifecycle()
|
||||
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
|
||||
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
|
||||
val isCheckingConnection by viewModel.isCheckingConnection.collectAsStateWithLifecycle()
|
||||
val isCheckingForUpdates by updateViewModel.isCheckingForUpdates.collectAsStateWithLifecycle()
|
||||
val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle()
|
||||
val navigationManager = LocalNavigationManager.current
|
||||
@@ -133,6 +134,8 @@ fun AppScreen(
|
||||
DownloadsScreen(
|
||||
selectedTab = selectedTab,
|
||||
isOnline = isOnline,
|
||||
isCheckingConnection = isCheckingConnection,
|
||||
onCheckConnection = viewModel::checkConnection,
|
||||
onTabSelected = onTabSelected,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
@@ -10,12 +10,15 @@ import androidx.compose.ui.Modifier
|
||||
import hu.bbara.purefin.ui.screen.AppBottomBar
|
||||
import hu.bbara.purefin.ui.screen.download.components.DownloadsContent
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarTextButton
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DownloadsScreen(
|
||||
selectedTab: Int,
|
||||
isOnline: Boolean,
|
||||
isCheckingConnection: Boolean,
|
||||
onCheckConnection: () -> Unit,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -23,7 +26,19 @@ fun DownloadsScreen(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
topBar = { DefaultTopBar() },
|
||||
topBar = {
|
||||
DefaultTopBar(
|
||||
rightActions = {
|
||||
if (!isOnline) {
|
||||
DefaultTopBarTextButton(
|
||||
text = if (isCheckingConnection) "Checking…" else "Check connection",
|
||||
onClick = onCheckConnection,
|
||||
enabled = !isCheckingConnection
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(
|
||||
selectedTab = selectedTab,
|
||||
|
||||
@@ -5,14 +5,17 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
@@ -5,18 +5,23 @@ import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonColors
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
@@ -114,6 +119,37 @@ fun DefaultTopBarIconButton(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DefaultTopBarTextButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
containerColor = scheme.surface,
|
||||
contentColor = scheme.onSurface,
|
||||
disabledContainerColor = scheme.surface,
|
||||
disabledContentColor = scheme.onSurface
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 18.dp),
|
||||
modifier = modifier
|
||||
.height(50.dp)
|
||||
.clip(CircleShape),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun DefaultTopBarSearchButton(
|
||||
|
||||
@@ -9,12 +9,13 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -25,12 +26,13 @@ import javax.inject.Singleton
|
||||
class CompositeLocalMediaRepository @Inject constructor(
|
||||
@Offline private val offlineRepository: LocalMediaRepository,
|
||||
@Online private val onlineRepository: LocalMediaRepository,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
) : LocalMediaRepository {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
// TODO move this into the domain layer and there you can use NetworkMonitor. Data should be free of android stuff.
|
||||
private val activeRepository: Flow<LocalMediaRepository> = flowOf(onlineRepository)
|
||||
private val activeRepository: Flow<LocalMediaRepository> = networkMonitor.isOnline
|
||||
.map { isOnline -> if (isOnline) onlineRepository else offlineRepository }
|
||||
|
||||
override val movies: StateFlow<Map<UUID, Movie>> = activeRepository
|
||||
.flatMapLatest { it.movies }
|
||||
@@ -45,40 +47,111 @@ class CompositeLocalMediaRepository @Inject constructor(
|
||||
.stateIn(scope, Eagerly, emptyMap())
|
||||
|
||||
override suspend fun getMovie(id: UUID): Flow<Movie?> {
|
||||
return activeRepository
|
||||
.flatMapLatest { it.getMovie(id) }
|
||||
return getFromActiveRepository(
|
||||
onlineRead = { onlineRepository.getMovie(id) },
|
||||
offlineRead = { offlineRepository.getMovie(id) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getSeries(id: UUID): Flow<Series?> {
|
||||
return activeRepository
|
||||
.flatMapLatest { it.getSeries(id) }
|
||||
return getFromActiveRepository(
|
||||
onlineRead = { onlineRepository.getSeries(id) },
|
||||
offlineRead = { offlineRepository.getSeries(id) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getEpisode(id: UUID): Flow<Episode?> {
|
||||
return activeRepository
|
||||
.flatMapLatest { it.getEpisode(id) }
|
||||
return getFromActiveRepository(
|
||||
onlineRead = { onlineRepository.getEpisode(id) },
|
||||
offlineRead = { offlineRepository.getEpisode(id) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun loadSeasons(seriesId: UUID) {
|
||||
activeRepository.first().loadSeasons(seriesId)
|
||||
runOnlineOrOfflineNoOp(
|
||||
onlineAction = { onlineRepository.loadSeasons(seriesId) },
|
||||
offlineAction = { offlineRepository.loadSeasons(seriesId) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
|
||||
activeRepository.first().loadSeasonEpisodes(seriesId, seasonId)
|
||||
runOnlineOrOfflineNoOp(
|
||||
onlineAction = { onlineRepository.loadSeasonEpisodes(seriesId, seasonId) },
|
||||
offlineAction = { offlineRepository.loadSeasonEpisodes(seriesId, seasonId) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
||||
onlineRepository.updateWatchProgress(mediaId, positionMs, durationMs)
|
||||
if (networkMonitor.isOnline.first()) {
|
||||
runOnlineAction { onlineRepository.updateWatchProgress(mediaId, positionMs, durationMs) }
|
||||
}
|
||||
offlineRepository.updateWatchProgress(mediaId, positionMs, durationMs)
|
||||
}
|
||||
|
||||
override suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) {
|
||||
onlineRepository.updateWatchProgressPercent(mediaId, progressPercent)
|
||||
if (networkMonitor.isOnline.first()) {
|
||||
runOnlineAction { onlineRepository.updateWatchProgressPercent(mediaId, progressPercent) }
|
||||
}
|
||||
offlineRepository.updateWatchProgressPercent(mediaId, progressPercent)
|
||||
}
|
||||
|
||||
override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) {
|
||||
val repository = onlineRepository
|
||||
repository.markAsWatched(mediaId, watched)
|
||||
runOnlineOrOfflineNoOp(
|
||||
onlineAction = { onlineRepository.markAsWatched(mediaId, watched) },
|
||||
offlineAction = { offlineRepository.markAsWatched(mediaId, watched) },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun <T> getFromActiveRepository(
|
||||
onlineRead: suspend () -> Flow<T>,
|
||||
offlineRead: suspend () -> Flow<T>,
|
||||
): Flow<T> {
|
||||
if (!networkMonitor.isOnline.first()) {
|
||||
return offlineRead()
|
||||
}
|
||||
return try {
|
||||
onlineRead()
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
if (!networkMonitor.checkConnection()) {
|
||||
offlineRead()
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runOnlineOrOfflineNoOp(
|
||||
onlineAction: suspend () -> Unit,
|
||||
offlineAction: suspend () -> Unit,
|
||||
) {
|
||||
if (!networkMonitor.isOnline.first()) {
|
||||
offlineAction()
|
||||
return
|
||||
}
|
||||
try {
|
||||
onlineAction()
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
if (!networkMonitor.checkConnection()) {
|
||||
offlineAction()
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runOnlineAction(action: suspend () -> Unit) {
|
||||
try {
|
||||
action()
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
if (networkMonitor.checkConnection()) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface NetworkMonitor {
|
||||
val isOnline: Flow<Boolean>
|
||||
suspend fun checkConnection(): Boolean
|
||||
}
|
||||
|
||||
@@ -43,17 +43,20 @@ class AppViewModel @Inject constructor(
|
||||
private val navigationManager: NavigationManager,
|
||||
private val homeRefreshCoordinator: HomeRefreshCoordinator,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
networkMonitor: NetworkMonitor,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||
|
||||
private val _isCheckingConnection = MutableStateFlow(false)
|
||||
val isCheckingConnection: StateFlow<Boolean> = _isCheckingConnection.asStateFlow()
|
||||
|
||||
val isOnline: StateFlow<Boolean> = networkMonitor.isOnline
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
false
|
||||
true
|
||||
)
|
||||
|
||||
val serverUrl: StateFlow<String> = userSessionRepository.serverUrl
|
||||
@@ -253,18 +256,34 @@ class AppViewModel @Inject constructor(
|
||||
|
||||
fun onResumed() {
|
||||
viewModelScope.launch {
|
||||
networkMonitor.checkConnection()
|
||||
homeRefreshCoordinator.onResumed()
|
||||
}
|
||||
}
|
||||
|
||||
fun onRefresh() {
|
||||
viewModelScope.launch {
|
||||
networkMonitor.checkConnection()
|
||||
homeRefreshCoordinator.onRefresh { isRefreshing ->
|
||||
_isRefreshing.value = isRefreshing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkConnection() {
|
||||
viewModelScope.launch {
|
||||
if (_isCheckingConnection.value) return@launch
|
||||
_isCheckingConnection.value = true
|
||||
val isConnected = networkMonitor.checkConnection()
|
||||
_isCheckingConnection.value = false
|
||||
if (isConnected) {
|
||||
homeRefreshCoordinator.onRefresh { isRefreshing ->
|
||||
_isRefreshing.value = isRefreshing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openSearch() {
|
||||
navigationManager.navigate(Route.HomeSearchRoute)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import hu.bbara.purefin.data.offline.cache.toSeries
|
||||
import hu.bbara.purefin.model.Library
|
||||
import hu.bbara.purefin.model.LibraryKind
|
||||
import hu.bbara.purefin.model.Media
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -51,6 +52,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
) : HomeRepository {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var cacheLoadJob: Job? = null
|
||||
private var refreshJob: Job? = null
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
@@ -72,9 +74,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = latestLibraryContentState.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
ensureReady()
|
||||
}
|
||||
ensureReady()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
@@ -83,17 +83,23 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
return
|
||||
}
|
||||
Timber.tag(TAG).d("Initializing home repository")
|
||||
scope.launch { loadHomeCache() }
|
||||
scope.launch { refreshHomeData() }
|
||||
val loadJob = scope.launch { loadHomeCache() }
|
||||
cacheLoadJob = loadJob
|
||||
scope.launch {
|
||||
loadJob.join()
|
||||
refreshHomeData()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun refreshHomeData() {
|
||||
cacheLoadJob?.join()
|
||||
val job = synchronized(this) {
|
||||
refreshJob?.takeIf { it.isActive } ?: scope.launch {
|
||||
runCatching {
|
||||
val snapshot = snapshotHomeContent()
|
||||
try {
|
||||
Timber.tag(TAG).d("Refreshing home data")
|
||||
if (!networkMonitor.isOnline.first()) {
|
||||
return@runCatching
|
||||
return@launch
|
||||
}
|
||||
loadLibraries()
|
||||
loadSuggestions()
|
||||
@@ -102,8 +108,15 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
loadLatestLibraryContent()
|
||||
Timber.tag(TAG).d("Home refresh successful")
|
||||
persistHomeCache()
|
||||
}.onFailure { error ->
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: HomeRefreshFailedException) {
|
||||
restoreHomeContent(snapshot)
|
||||
Timber.tag(TAG).w(error.cause, "Home refresh failed; keeping cached content")
|
||||
} catch (error: Exception) {
|
||||
restoreHomeContent(snapshot)
|
||||
Timber.tag(TAG).w(error, "Home refresh failed; keeping cached content")
|
||||
networkMonitor.checkConnection()
|
||||
}
|
||||
}.also { refreshJob = it }
|
||||
}
|
||||
@@ -200,8 +213,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
private suspend fun loadLibraries() {
|
||||
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
||||
.getOrElse { error ->
|
||||
Timber.tag(TAG).w(error, "Unable to load libraries")
|
||||
return
|
||||
handleRefreshFailure(error, "Unable to load libraries")
|
||||
}
|
||||
val filteredLibraries = librariesItem.filter {
|
||||
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
|
||||
@@ -222,8 +234,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
private suspend fun loadLibrary(library: Library): Library {
|
||||
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
|
||||
.getOrElse { error ->
|
||||
Timber.tag(TAG).w(error, "Unable to load library ${library.id}")
|
||||
return library
|
||||
handleRefreshFailure(error, "Unable to load library ${library.id}")
|
||||
}
|
||||
return when (library.type) {
|
||||
LibraryKind.MOVIES -> library.copy(
|
||||
@@ -240,8 +251,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
private suspend fun loadSuggestions() {
|
||||
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
|
||||
.getOrElse { error ->
|
||||
Timber.tag(TAG).w(error, "Unable to load suggestions")
|
||||
return
|
||||
handleRefreshFailure(error, "Unable to load suggestions")
|
||||
}
|
||||
suggestionsState.value = suggestionsItems.mapNotNull { item ->
|
||||
when (item.type) {
|
||||
@@ -261,8 +271,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
private suspend fun loadContinueWatching() {
|
||||
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
|
||||
.getOrElse { error ->
|
||||
Timber.tag(TAG).w(error, "Unable to load continue watching")
|
||||
return
|
||||
handleRefreshFailure(error, "Unable to load continue watching")
|
||||
}
|
||||
continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
|
||||
when (item.type) {
|
||||
@@ -282,8 +291,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
private suspend fun loadNextUp() {
|
||||
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
|
||||
.getOrElse { error ->
|
||||
Timber.tag(TAG).w(error, "Unable to load next up")
|
||||
return
|
||||
handleRefreshFailure(error, "Unable to load next up")
|
||||
}
|
||||
nextUpState.value = nextUpItems.map { item ->
|
||||
Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
|
||||
@@ -297,8 +305,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
private suspend fun loadLatestLibraryContent() {
|
||||
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
||||
.getOrElse { error ->
|
||||
Timber.tag(TAG).w(error, "Unable to load latest library content")
|
||||
return
|
||||
handleRefreshFailure(error, "Unable to load latest library content")
|
||||
}
|
||||
val filteredLibraries = librariesItem.filter {
|
||||
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
|
||||
@@ -306,8 +313,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
val latestLibraryContents = filteredLibraries.associate { library ->
|
||||
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
|
||||
.getOrElse { error ->
|
||||
Timber.tag(TAG).w(error, "Unable to load latest items for library ${library.id}")
|
||||
emptyList()
|
||||
handleRefreshFailure(error, "Unable to load latest items for library ${library.id}")
|
||||
}
|
||||
library.id to when (library.collectionType) {
|
||||
CollectionType.MOVIES -> latestFromLibrary.map {
|
||||
@@ -341,6 +347,31 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
return userSessionRepository.serverUrl.first()
|
||||
}
|
||||
|
||||
private fun snapshotHomeContent(): HomeContentSnapshot = HomeContentSnapshot(
|
||||
libraries = librariesState.value,
|
||||
suggestions = suggestionsState.value,
|
||||
continueWatching = continueWatchingState.value,
|
||||
nextUp = nextUpState.value,
|
||||
latestLibraryContent = latestLibraryContentState.value,
|
||||
)
|
||||
|
||||
private fun restoreHomeContent(snapshot: HomeContentSnapshot) {
|
||||
librariesState.value = snapshot.libraries
|
||||
suggestionsState.value = snapshot.suggestions
|
||||
continueWatchingState.value = snapshot.continueWatching
|
||||
nextUpState.value = snapshot.nextUp
|
||||
latestLibraryContentState.value = snapshot.latestLibraryContent
|
||||
}
|
||||
|
||||
private suspend fun handleRefreshFailure(error: Throwable, message: String): Nothing {
|
||||
if (error is CancellationException) {
|
||||
throw error
|
||||
}
|
||||
Timber.tag(TAG).w(error, message)
|
||||
networkMonitor.checkConnection()
|
||||
throw HomeRefreshFailedException(error)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "InMemoryAppContentRepo"
|
||||
}
|
||||
@@ -351,3 +382,13 @@ private data class ReferencedHomeMediaIds(
|
||||
val seriesIds: Set<UUID>,
|
||||
val episodeIds: Set<UUID>,
|
||||
)
|
||||
|
||||
private data class HomeContentSnapshot(
|
||||
val libraries: List<Library>,
|
||||
val suggestions: List<Media>,
|
||||
val continueWatching: List<Media>,
|
||||
val nextUp: List<Media>,
|
||||
val latestLibraryContent: Map<UUID, List<Media>>,
|
||||
)
|
||||
|
||||
private class HomeRefreshFailedException(cause: Throwable) : RuntimeException(cause)
|
||||
@@ -7,28 +7,48 @@ import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import hu.bbara.purefin.core.data.NetworkMonitor
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ConnectivityNetworkMonitor @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
) : NetworkMonitor {
|
||||
|
||||
override val isOnline: Flow<Boolean> = callbackFlow {
|
||||
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val isAndroidConnected = MutableStateFlow(connectivityManager.isCurrentlyConnected())
|
||||
private val isServerReachable = MutableStateFlow(true)
|
||||
|
||||
override val isOnline: Flow<Boolean> = combine(
|
||||
isAndroidConnected,
|
||||
isServerReachable,
|
||||
) { androidConnected, serverReachable ->
|
||||
androidConnected && serverReachable
|
||||
}.distinctUntilChanged()
|
||||
|
||||
init {
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(true)
|
||||
isAndroidConnected.value = true
|
||||
scope.launch { updateServerReachability() }
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
trySend(connectivityManager.isCurrentlyConnected())
|
||||
isAndroidConnected.value = connectivityManager.isCurrentlyConnected()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,14 +56,42 @@ class ConnectivityNetworkMonitor @Inject constructor(
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
trySend(connectivityManager.isCurrentlyConnected())
|
||||
connectivityManager.registerNetworkCallback(request, callback)
|
||||
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
override suspend fun checkConnection(): Boolean {
|
||||
val androidConnected = connectivityManager.isCurrentlyConnected()
|
||||
isAndroidConnected.value = androidConnected
|
||||
if (!androidConnected) {
|
||||
isServerReachable.value = false
|
||||
return false
|
||||
}
|
||||
return updateServerReachability()
|
||||
}
|
||||
|
||||
private suspend fun updateServerReachability(): Boolean {
|
||||
val reachable = try {
|
||||
withTimeoutOrNull(SERVER_CHECK_TIMEOUT_MS) {
|
||||
jellyfinApiClient.probeServer()
|
||||
} ?: false
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
Timber.tag(TAG).w(error, "Jellyfin server reachability check failed")
|
||||
false
|
||||
}
|
||||
isServerReachable.value = reachable
|
||||
return reachable
|
||||
}
|
||||
|
||||
private fun ConnectivityManager.isCurrentlyConnected(): Boolean {
|
||||
val network = activeNetwork ?: return false
|
||||
val caps = getNetworkCapabilities(network) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "ConnectivityNetworkMonitor"
|
||||
private const val SERVER_CHECK_TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,18 +67,31 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
||||
return getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val onlinePlayableMedia = runCatching {
|
||||
getOnlinePlayableMedia(mediaId, downloadedMediaItem)
|
||||
?: getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
|
||||
}.getOrElse { error ->
|
||||
if (error is CancellationException) throw error
|
||||
networkMonitor.checkConnection()
|
||||
Timber.tag(TAG).w(error, "Unable to load online metadata for downloaded media $mediaId")
|
||||
getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
|
||||
null
|
||||
}
|
||||
if (onlinePlayableMedia != null) return onlinePlayableMedia
|
||||
|
||||
networkMonitor.checkConnection()
|
||||
return getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
|
||||
}
|
||||
|
||||
private suspend fun getStreamingPlayableMedia(mediaId: UUID): PlayableMedia? {
|
||||
return getOnlinePlayableMedia(mediaId, downloadedMediaItem = null)
|
||||
if (!networkMonitor.isOnline.first()) return null
|
||||
|
||||
return runCatching {
|
||||
getOnlinePlayableMedia(mediaId, downloadedMediaItem = null)
|
||||
}.getOrElse { error ->
|
||||
if (error is CancellationException) throw error
|
||||
networkMonitor.checkConnection()
|
||||
Timber.tag(TAG).w(error, "Unable to load streaming media $mediaId")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getOnlinePlayableMedia(
|
||||
|
||||
@@ -26,6 +26,7 @@ import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
|
||||
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
@@ -227,6 +228,16 @@ class JellyfinApiClient @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun probeServer(): Boolean = withContext(Dispatchers.IO) {
|
||||
logRequest("probeServer") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest false
|
||||
}
|
||||
api.systemApi.getPingSystem()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
logRequest("getLibraryContent") {
|
||||
if (!ensureConfigured()) {
|
||||
|
||||
Reference in New Issue
Block a user