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