From 3afc4f022b8deed988fae74fd55bdff85c6e8935 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 11 Oct 2025 14:38:59 -0400 Subject: [PATCH] Better error handling during setup --- app/src/main/AndroidManifest.xml | 8 +- .../damontecres/dolphin/MainActivity.kt | 47 +++-- .../dolphin/data/ServerRepository.kt | 72 +++----- .../damontecres/dolphin/ui/UiConstants.kt | 2 +- .../dolphin/ui/cards/BannerCard.kt | 4 +- .../damontecres/dolphin/ui/cards/ItemCard.kt | 4 +- .../dolphin/ui/components/CircularProgress.kt | 8 +- .../ui/components/CollectionFolderGrid.kt | 4 +- .../dolphin/ui/components/RecommendedMovie.kt | 4 +- .../ui/components/RecommendedTvShow.kt | 4 +- .../dolphin/ui/detail/EpisodeDetails.kt | 4 +- .../dolphin/ui/detail/SeriesDetails.kt | 8 +- .../dolphin/ui/detail/VideoDetails.kt | 4 +- .../dolphin/ui/detail/movie/MovieDetails.kt | 4 +- .../ui/detail/series/SeriesOverview.kt | 4 +- .../damontecres/dolphin/ui/main/HomePage.kt | 8 +- .../damontecres/dolphin/ui/main/SearchPage.kt | 6 +- .../dolphin/ui/nav/ApplicationContent.kt | 6 +- .../dolphin/ui/nav/NavigationManager.kt | 3 + .../dolphin/ui/setup/ServerList.kt | 48 +++-- .../dolphin/ui/setup/SwitchServerContent.kt | 167 ++++++++++++++---- .../dolphin/ui/setup/SwitchUserContent.kt | 49 ++++- .../dolphin/ui/setup/SwitchUserViewModel.kt | 106 ++++++++--- .../damontecres/dolphin/util/LoadingState.kt | 6 +- 24 files changed, 382 insertions(+), 198 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6931dcad..741efb5b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + @@ -30,7 +29,8 @@ android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/Theme.Dolphin" - android:name=".DolphinApplication"> + android:name=".DolphinApplication" + android:usesCleartextTraffic="true"> @@ -43,4 +43,4 @@ - \ No newline at end of file + diff --git a/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt index 3121618a..bd9210d0 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt @@ -32,8 +32,6 @@ import com.github.damontecres.dolphin.ui.CoilConfig import com.github.damontecres.dolphin.ui.nav.ApplicationContent import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.NavigationManager -import com.github.damontecres.dolphin.ui.setup.SwitchServerContent -import com.github.damontecres.dolphin.ui.setup.SwitchUserContent import com.github.damontecres.dolphin.ui.theme.DolphinTheme import com.github.damontecres.dolphin.util.profile.createDeviceProfile import dagger.hilt.android.AndroidEntryPoint @@ -78,10 +76,14 @@ class MainActivity : AppCompatActivity() { var isRestoringSession by remember { mutableStateOf(true) } LaunchedEffect(Unit) { if (appPreferences.currentServerId.isNotBlank()) { - serverRepository.restoreSession( - appPreferences.currentServerId, - appPreferences.currentUserId, - ) + try { + serverRepository.restoreSession( + appPreferences.currentServerId, + appPreferences.currentUserId, + ) + } catch (ex: Exception) { + Timber.e(ex, "Exception restoring session") + } Timber.d("MainActivity session restored") } isRestoringSession = false @@ -100,19 +102,8 @@ class MainActivity : AppCompatActivity() { remember(appPreferences) { createDeviceProfile(this@MainActivity, preferences, false) } - val backStack = rememberNavBackStack(Destination.Home()) - navigationManager.backStack = backStack - if (server != null && user != null) { - ApplicationContent( - user = user, - server = server, - navigationManager = navigationManager, - preferences = preferences, - deviceProfile = deviceProfile, - modifier = Modifier.fillMaxSize(), - ) - } else if (isRestoringSession) { + if (isRestoringSession) { Box( modifier = Modifier.size(200.dp), contentAlignment = Alignment.Center, @@ -122,13 +113,21 @@ class MainActivity : AppCompatActivity() { modifier = Modifier.align(Alignment.Center), ) } - } else if (server != null) { - // Have server but no user, go to user selection - SwitchUserContent( - modifier = Modifier.fillMaxSize(), - ) } else { - SwitchServerContent( + val initialDestination = + if (server != null && user != null) { + Destination.Home() + } else { + Destination.ServerList + } + val backStack = rememberNavBackStack(initialDestination) + navigationManager.backStack = backStack + ApplicationContent( + user = user, + server = server, + navigationManager = navigationManager, + preferences = preferences, + deviceProfile = deviceProfile, modifier = Modifier.fillMaxSize(), ) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt index ebafab9a..fd24aab9 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.model.api.AuthenticationResult import org.jellyfin.sdk.model.api.UserDto @@ -66,49 +65,36 @@ class ServerRepository server: JellyfinServer, user: JellyfinUser, ) = withContext(Dispatchers.IO) { - try { - if (server.id != user.serverId) { - throw IllegalStateException() - } - Timber.v("Changing user to ${user.name} on ${server.url}") - apiClient.update(baseUrl = server.url, accessToken = user.accessToken) - val userDto = - apiClient.userApi - .getCurrentUser() - .content + if (server.id != user.serverId) { + throw IllegalStateException("User is not part of the server") + } + Timber.v("Changing user to ${user.name} on ${server.url}") + apiClient.update(baseUrl = server.url, accessToken = user.accessToken) + val userDto = + apiClient.userApi + .getCurrentUser() + .content - val updatedServer = server.copy(name = userDto.serverName) - val updatedUser = - user.copy( - id = userDto.id.toString(), - name = userDto.name, - ) - serverDao.addServer(updatedServer) - serverDao.addUser(updatedUser) - userPreferencesDataStore.updateData { - it - .toBuilder() - .apply { - currentServerId = updatedServer.id - currentUserId = updatedUser.id - }.build() - } - withContext(Dispatchers.Main) { - _currentUserDto = userDto - _currentServer = updatedServer - _currentUser = updatedUser - } - } catch (e: InvalidStatusException) { - // TODO - Timber.e(e) - withContext(Dispatchers.Main) { - // Unauthorized - _currentServer = null - _currentUser = null - } - if (e.status == 401) { - return@withContext - } + val updatedServer = server.copy(name = userDto.serverName) + val updatedUser = + user.copy( + id = userDto.id.toString(), + name = userDto.name, + ) + serverDao.addServer(updatedServer) + serverDao.addUser(updatedUser) + userPreferencesDataStore.updateData { + it + .toBuilder() + .apply { + currentServerId = updatedServer.id + currentUserId = updatedUser.id + }.build() + } + withContext(Dispatchers.Main) { + _currentUserDto = userDto + _currentServer = updatedServer + _currentUser = updatedUser } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt index 3539ea82..c75cfeef 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt @@ -48,7 +48,7 @@ val DefaultButtonPadding = bottom = 10.dp / 2, ) -object CardDefaults { +object Cards { val height2x3 = 180.dp val playedPercentHeight = 6.dp } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/BannerCard.kt index 85ab0cc7..22d2ccb9 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/BannerCard.kt @@ -32,7 +32,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.dolphin.ui.AppColors -import com.github.damontecres.dolphin.ui.CardDefaults +import com.github.damontecres.dolphin.ui.Cards import com.github.damontecres.dolphin.ui.isNotNullOrBlank /** @@ -131,7 +131,7 @@ fun BannerCard( .background( MaterialTheme.colorScheme.tertiary, ).clip(RectangleShape) - .height(CardDefaults.playedPercentHeight) + .height(Cards.playedPercentHeight) .fillMaxWidth((playPercent / 100).toFloat()), ) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt index 51bbf671..a7cbe5ce 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt @@ -48,7 +48,7 @@ import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.data.model.BaseItem -import com.github.damontecres.dolphin.ui.CardDefaults +import com.github.damontecres.dolphin.ui.Cards import com.github.damontecres.dolphin.ui.FontAwesome import com.github.damontecres.dolphin.ui.enableMarquee import com.github.damontecres.dolphin.ui.ifElse @@ -282,7 +282,7 @@ fun ItemCardImage( .background( MaterialTheme.colorScheme.tertiary, ).clip(RectangleShape) - .height(CardDefaults.playedPercentHeight) + .height(Cards.playedPercentHeight) .fillMaxWidth((percent / 100.0).toFloat()), ) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt index f854abe7..ec8b6b03 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt @@ -9,14 +9,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme -import com.github.damontecres.dolphin.ui.ifElse @Composable -fun CircularProgress( - modifier: Modifier = Modifier, - fillMaxSize: Boolean = true, -) { - Box(modifier = modifier.ifElse(fillMaxSize, Modifier.fillMaxSize())) { +fun CircularProgress(modifier: Modifier = Modifier) { + Box(modifier = modifier) { CircularProgressIndicator( color = MaterialTheme.colorScheme.border, modifier = Modifier.align(Alignment.Center), diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt index 7244a132..56318a95 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt @@ -182,7 +182,9 @@ fun CollectionFolderGrid( when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) - LoadingState.Loading -> LoadingPage() + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() LoadingState.Success -> { pager?.let { pager -> CollectionFolderGridContent( diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/RecommendedMovie.kt index c0f73274..9c301bfe 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/RecommendedMovie.kt @@ -143,7 +143,9 @@ fun RecommendedMovie( when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) - LoadingState.Loading -> LoadingPage() + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() LoadingState.Success -> HomePageContent( diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/RecommendedTvShow.kt index 80c1d6ba..e5815536 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/RecommendedTvShow.kt @@ -155,7 +155,9 @@ fun RecommendedTvShow( when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) - LoadingState.Loading -> LoadingPage() + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() LoadingState.Success -> HomePageContent( diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt index a8a3d96f..d0d7d8cb 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt @@ -64,7 +64,9 @@ fun EpisodeDetails( val loading by viewModel.loading.observeAsState(LoadingState.Loading) when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) - LoadingState.Loading -> LoadingPage() + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() LoadingState.Success -> { item?.let { item -> EpisodeDetailsContent( diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt index 1e0ba7b5..83079193 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt @@ -49,7 +49,7 @@ import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.Person import com.github.damontecres.dolphin.preferences.UserPreferences -import com.github.damontecres.dolphin.ui.CardDefaults +import com.github.damontecres.dolphin.ui.Cards import com.github.damontecres.dolphin.ui.cards.ItemRow import com.github.damontecres.dolphin.ui.cards.PersonRow import com.github.damontecres.dolphin.ui.cards.SeasonCard @@ -95,7 +95,9 @@ fun SeriesDetails( when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) - LoadingState.Loading -> LoadingPage() + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() LoadingState.Success -> { item?.let { item -> val played = item.data.userData?.played ?: false @@ -241,7 +243,7 @@ fun SeriesDetailsContent( onClick = onClick, onLongClick = onLongClick, modifier = mod, - imageHeight = CardDefaults.height2x3, + imageHeight = Cards.height2x3, imageWidth = Dp.Unspecified, ) }, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/VideoDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/VideoDetails.kt index ac56a876..90a41b3b 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/VideoDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/VideoDetails.kt @@ -48,7 +48,9 @@ fun VideoDetails( val loading by viewModel.loading.observeAsState(LoadingState.Loading) when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) - LoadingState.Loading -> LoadingPage() + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() LoadingState.Success -> { item?.let { item -> val dto = item.data diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetails.kt index 981d833a..44ad8f07 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetails.kt @@ -131,7 +131,9 @@ fun MovieDetails( when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) - LoadingState.Loading -> LoadingPage() + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() LoadingState.Success -> { item?.let { movie -> MovieDetailsContent( diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt index 908533be..4710e7fc 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt @@ -111,7 +111,9 @@ fun SeriesOverview( when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) - LoadingState.Loading -> LoadingPage() + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() LoadingState.Success -> { series?.let { series -> diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt index a7493af9..36a487ae 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt @@ -39,7 +39,7 @@ import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.preferences.UserPreferences -import com.github.damontecres.dolphin.ui.CardDefaults +import com.github.damontecres.dolphin.ui.Cards import com.github.damontecres.dolphin.ui.cards.BannerCard import com.github.damontecres.dolphin.ui.cards.ItemRow import com.github.damontecres.dolphin.ui.components.DotSeparatedRow @@ -76,7 +76,9 @@ fun HomePage( when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) - LoadingState.Loading -> LoadingPage() + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() LoadingState.Success -> { val homeRows by viewModel.homeRows.observeAsState(listOf()) @@ -195,7 +197,7 @@ fun HomePageContent( Modifier.focusRequester(positionFocusRequester), ), interactionSource = null, - cardHeight = CardDefaults.height2x3, + cardHeight = Cards.height2x3, ) }, ) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/main/SearchPage.kt index 1b258aa2..051c394f 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/main/SearchPage.kt @@ -25,7 +25,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.preferences.UserPreferences -import com.github.damontecres.dolphin.ui.CardDefaults +import com.github.damontecres.dolphin.ui.Cards import com.github.damontecres.dolphin.ui.DefaultItemFields import com.github.damontecres.dolphin.ui.cards.EpisodeCard import com.github.damontecres.dolphin.ui.cards.ItemRow @@ -177,7 +177,7 @@ fun SearchPage( item = item, onClick = onClick, onLongClick = onLongClick, - imageHeight = CardDefaults.height2x3, + imageHeight = Cards.height2x3, modifier = mod, ) }, @@ -199,7 +199,7 @@ fun SearchPage( item = item, onClick = onClick, onLongClick = onLongClick, - imageHeight = CardDefaults.height2x3, + imageHeight = Cards.height2x3, modifier = mod, ) }, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt index dcd484c6..8e3f0927 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt @@ -21,8 +21,8 @@ import timber.log.Timber */ @Composable fun ApplicationContent( - server: JellyfinServer, - user: JellyfinUser, + server: JellyfinServer?, + user: JellyfinUser?, navigationManager: NavigationManager, preferences: UserPreferences, deviceProfile: DeviceProfile, @@ -39,7 +39,7 @@ fun ApplicationContent( ), entryProvider = { key -> key as Destination - val contentKey = "${key}_${server.id}_${user.id}" + val contentKey = "${key}_${server?.id}_${user?.id}" Timber.d("Navigate: %s", key) NavEntry(key, contentKey = contentKey) { if (key.fullScreen) { diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavigationManager.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavigationManager.kt index 87d22d5d..b54e6666 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavigationManager.kt @@ -42,6 +42,9 @@ class NavigationManager while (backStack.size > 1) { backStack.removeLastOrNull() } + if (backStack[0] !is Destination.Home) { + backStack[0] = Destination.Home() + } } /** diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/ServerList.kt index 51795849..66346cbd 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/ServerList.kt @@ -1,5 +1,6 @@ package com.github.damontecres.dolphin.ui.setup +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -17,6 +18,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.tv.material3.Icon +import androidx.tv.material3.IconButtonDefaults import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -47,6 +49,8 @@ fun ServerList( onSwitchServer: (JellyfinServer) -> Unit, onAddServer: () -> Unit, onRemoveServer: (JellyfinServer) -> Unit, + allowAdd: Boolean, + allowDelete: Boolean, modifier: Modifier = Modifier, ) { var showDeleteDialog by remember { mutableStateOf(null) } @@ -63,7 +67,9 @@ fun ServerList( when (status) { ServerConnectionStatus.Success -> {} ServerConnectionStatus.Pending -> { - CircularProgress() + CircularProgress( + Modifier.size(IconButtonDefaults.MediumIconSize), + ) } is ServerConnectionStatus.Error -> { Icon( @@ -76,32 +82,36 @@ fun ServerList( }, onClick = { onSwitchServer.invoke(server) }, onLongClick = { - showDeleteDialog = server + if (allowDelete) { + showDeleteDialog = server + } }, modifier = Modifier, ) } - item { - HorizontalDivider() - ListItem( - enabled = true, - selected = false, - headlineContent = { Text(text = "Add Server") }, - leadingContent = { - Icon( - imageVector = Icons.Default.Add, - tint = Color.Green.copy(alpha = .8f), - contentDescription = null, - ) - }, - onClick = { onAddServer.invoke() }, - modifier = Modifier, - ) + if (allowAdd) { + item { + HorizontalDivider() + ListItem( + enabled = true, + selected = false, + headlineContent = { Text(text = "Add Server") }, + leadingContent = { + Icon( + imageVector = Icons.Default.Add, + tint = Color.Green.copy(alpha = .8f), + contentDescription = null, + ) + }, + onClick = { onAddServer.invoke() }, + modifier = Modifier, + ) + } } } showDeleteDialog?.let { server -> DialogPopup( - showDialog = true, + showDialog = allowDelete, title = server.name ?: server.url, dialogItems = listOf( diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchServerContent.kt index 5eb1f57b..d9bf2779 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchServerContent.kt @@ -4,8 +4,10 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -30,10 +32,11 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.dolphin.ui.components.BasicDialog +import com.github.damontecres.dolphin.ui.components.CircularProgress import com.github.damontecres.dolphin.ui.components.EditTextBox import com.github.damontecres.dolphin.ui.isNotNullOrBlank -import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.tryRequestFocus +import com.github.damontecres.dolphin.util.LoadingState @Composable fun SwitchServerContent( @@ -44,64 +47,131 @@ fun SwitchServerContent( val servers by viewModel.servers.observeAsState(listOf()) val serverStatus by viewModel.serverStatus.observeAsState(mapOf()) + val discoveredServers by viewModel.discoveredServers.observeAsState(listOf()) + var showAddServer by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + viewModel.discoverServers() + } + Box( modifier = modifier, ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), + Row( modifier = Modifier - .fillMaxWidth(.5f) .align(Alignment.Center) - .padding(16.dp) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), - shape = RoundedCornerShape(16.dp), - ), + .padding(32.dp), ) { - Text( - text = "Select Server", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - ServerList( - servers = servers, - connectionStatus = serverStatus, - onSwitchServer = { - viewModel.addServer(it.url) { - viewModel.navigationManager.navigateTo(Destination.UserList) - } - }, - onAddServer = { - showAddServer = true - }, - onRemoveServer = { - viewModel.removeServer(it) - }, + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .fillMaxWidth() + .weight(1f) + .padding(16.dp) .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), shape = RoundedCornerShape(16.dp), ), - ) + ) { + Text( + text = "Select Server", + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + ServerList( + servers = servers, + connectionStatus = serverStatus, + onSwitchServer = { + viewModel.addServer(it.url) + }, + onAddServer = { + showAddServer = true + }, + onRemoveServer = { + viewModel.removeServer(it) + }, + allowAdd = true, + allowDelete = true, + modifier = + Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + shape = RoundedCornerShape(16.dp), + ), + ) + } + // Discover + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .padding(16.dp) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + shape = RoundedCornerShape(16.dp), + ), + ) { + Text( + text = "Discovered Servers", + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + if (discoveredServers.isEmpty()) { + Text( + text = "Searching...", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } else { + ServerList( + servers = discoveredServers, + connectionStatus = + discoveredServers + .map { it.id } + .associateWith { ServerConnectionStatus.Success }, + onSwitchServer = { + viewModel.addServer(it.url) + }, + onAddServer = {}, + onRemoveServer = {}, + allowAdd = false, + allowDelete = false, + modifier = + Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + shape = RoundedCornerShape(16.dp), + ), + ) + } + } } if (showAddServer) { + LaunchedEffect(Unit) { + viewModel.clearAddServerState() + } + val state by viewModel.addServerState.observeAsState(LoadingState.Pending) var url by remember { mutableStateOf("") } val submit = { - showAddServer = false - viewModel.addServer(url) { - viewModel.navigationManager.navigateTo(Destination.UserList) - } + viewModel.addServer(url) } BasicDialog( - onDismissRequest = { showAddServer = false }, + onDismissRequest = { + showAddServer = false + viewModel.clearAddServerState() + }, properties = DialogProperties(usePlatformDefaultWidth = false), + elevation = 10.dp, ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } @@ -118,7 +188,10 @@ fun SwitchServerContent( ) EditTextBox( value = url, - onValueChange = { url = it }, + onValueChange = { + url = it + viewModel.clearAddServerState() + }, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.None, @@ -134,12 +207,28 @@ fun SwitchServerContent( .focusRequester(focusRequester) .fillMaxWidth(), ) + when (val st = state) { + is LoadingState.Error -> { + Text( + text = + st.message ?: st.exception?.localizedMessage + ?: "An error occurred", + color = MaterialTheme.colorScheme.error, + ) + } + + else -> {} + } Button( onClick = { submit.invoke() }, - enabled = url.isNotNullOrBlank(), + enabled = url.isNotNullOrBlank() && state == LoadingState.Pending, modifier = Modifier, ) { - Text(text = "Submit") + if (state == LoadingState.Loading) { + CircularProgress(Modifier.size(32.dp)) + } else { + Text(text = "Submit") + } } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserContent.kt index 10795af7..04a6b124 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserContent.kt @@ -1,5 +1,6 @@ package com.github.damontecres.dolphin.ui.setup +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement @@ -22,6 +23,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -35,12 +37,15 @@ import com.github.damontecres.dolphin.ui.components.EditTextBox import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.tryRequestFocus +import com.github.damontecres.dolphin.util.LoadingState @Composable fun SwitchUserContent( modifier: Modifier = Modifier, viewModel: SwitchUserViewModel = hiltViewModel(), ) { + val context = LocalContext.current + val currentServer = viewModel.serverRepository.currentServer val currentUser = viewModel.serverRepository.currentUser val users by viewModel.users.observeAsState(listOf()) @@ -50,6 +55,20 @@ fun SwitchUserContent( val quickConnect by viewModel.quickConnectState.observeAsState(null) var showAddUser by remember { mutableStateOf(false) } + val userState by viewModel.switchUserState.observeAsState(LoadingState.Pending) + LaunchedEffect(userState) { + if (!showAddUser) { + when (val s = userState) { + is LoadingState.Error -> { + val msg = s.message ?: s.exception?.localizedMessage + Toast.makeText(context, "Error: $msg", Toast.LENGTH_LONG).show() + } + + else -> {} + } + } + } + currentServer?.let { server -> Box( modifier = modifier, @@ -81,9 +100,7 @@ fun SwitchUserContent( users = users, currentUser = currentUser, onSwitchUser = { user -> - viewModel.switchUser(server, user) { - viewModel.navigationManager.goToHome() - } + viewModel.switchUser(server, user) }, onAddUser = { showAddUser = true }, onRemoveUser = { user -> @@ -106,6 +123,7 @@ fun SwitchUserContent( if (showAddUser) { var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) } LaunchedEffect(Unit) { + viewModel.clearSwitchUserState() if (useQuickConnect) { viewModel.initiateQuickConnect(server) { viewModel.navigationManager.goToHome() @@ -151,10 +169,7 @@ fun SwitchUserContent( var username by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } val onSubmit = { - viewModel.login(server, username, password) { - showAddUser = false - viewModel.navigationManager.goToHome() - } + viewModel.login(server, username, password) } val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } @@ -173,7 +188,10 @@ fun SwitchUserContent( ) EditTextBox( value = username, - onValueChange = { username = it }, + onValueChange = { + username = it + viewModel.clearSwitchUserState() + }, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.None, @@ -193,7 +211,10 @@ fun SwitchUserContent( ) EditTextBox( value = password, - onValueChange = { password = it }, + onValueChange = { + password = it + viewModel.clearSwitchUserState() + }, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.None, @@ -215,6 +236,16 @@ fun SwitchUserContent( Text("Login") } } + when (val s = userState) { + is LoadingState.Error -> { + Text( + text = s.message ?: s.exception?.localizedMessage ?: "Error", + color = MaterialTheme.colorScheme.error, + ) + } + + else -> {} + } } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserViewModel.kt index c0815838..09ead631 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserViewModel.kt @@ -7,8 +7,10 @@ import com.github.damontecres.dolphin.data.JellyfinServer import com.github.damontecres.dolphin.data.JellyfinServerDao import com.github.damontecres.dolphin.data.JellyfinUser import com.github.damontecres.dolphin.data.ServerRepository +import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.util.ExceptionHandler +import com.github.damontecres.dolphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -16,6 +18,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.HttpClientOptions import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.authenticateUserByName import org.jellyfin.sdk.api.client.extensions.quickConnectApi @@ -25,6 +28,7 @@ import org.jellyfin.sdk.model.api.QuickConnectDto import org.jellyfin.sdk.model.api.QuickConnectResult import timber.log.Timber import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds @HiltViewModel class SwitchUserViewModel @@ -44,6 +48,19 @@ class SwitchUserViewModel private var quickConnectJob: Job? = null + val discoveredServers = MutableLiveData>(listOf()) + + val addServerState = MutableLiveData(LoadingState.Pending) + val switchUserState = MutableLiveData(LoadingState.Pending) + + fun clearAddServerState() { + addServerState.value = LoadingState.Pending + } + + fun clearSwitchUserState() { + switchUserState.value = LoadingState.Pending + } + init { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { val allServers = @@ -57,8 +74,15 @@ class SwitchUserViewModel allServers.forEach { server -> try { jellyfin - .createApi(server.url) - .systemApi + .createApi( + server.url, + httpClientOptions = + HttpClientOptions( + requestTimeout = 6.seconds, + connectTimeout = 6.seconds, + socketTimeout = 6.seconds, + ), + ).systemApi .getPublicSystemInfo() val quickConnect by jellyfin @@ -80,13 +104,15 @@ class SwitchUserViewModel } } catch (ex: Exception) { Timber.w(ex, "Error checking quick connect for server ${server.url}") - serverStatus.value = - serverStatus.value!!.toMutableMap().apply { - put( - server.id, - ServerConnectionStatus.Error(ex.localizedMessage), - ) - } + withContext(Dispatchers.Main) { + serverStatus.value = + serverStatus.value!!.toMutableMap().apply { + put( + server.id, + ServerConnectionStatus.Error(ex.localizedMessage), + ) + } + } } } } @@ -113,11 +139,14 @@ class SwitchUserViewModel fun switchUser( server: JellyfinServer, user: JellyfinUser, - callback: () -> Unit, ) { viewModelScope.launch(ExceptionHandler()) { - serverRepository.changeUser(server, user) - callback.invoke() + try { + serverRepository.changeUser(server, user) + navigationManager.goToHome() + } catch (ex: Exception) { + switchUserState.value = LoadingState.Error(exception = ex) + } } } @@ -125,7 +154,6 @@ class SwitchUserViewModel server: JellyfinServer, username: String, password: String, - onAuthenticated: () -> Unit, ) { quickConnectJob?.cancel() viewModelScope.launch(ExceptionHandler()) { @@ -137,12 +165,10 @@ class SwitchUserViewModel ) serverRepository.changeUser(server.url, authenticationResult) withContext(Dispatchers.Main) { - onAuthenticated.invoke() - } - } catch (err: InvalidStatusException) { - if (err.status == 401) { - // TODO set state to notify user + navigationManager.goToHome() } + } catch (ex: Exception) { + switchUserState.value = LoadingState.Error(ex) } } } @@ -185,14 +211,15 @@ class SwitchUserViewModel onAuthenticated.invoke() } } - } catch (err: InvalidStatusException) { - if (err.status == 401) { + } catch (ex: Exception) { + if (ex is InvalidStatusException && ex.status == 401) { quickConnectState.value = null serverQuickConnect.value = serverQuickConnect.value!!.toMutableMap().apply { put(server.id, false) } } + switchUserState.value = LoadingState.Error(ex) } } } @@ -202,18 +229,16 @@ class SwitchUserViewModel quickConnectState.value = null } - fun addServer( - serverUrl: String, - callback: () -> Unit, - ) { - viewModelScope.launch(ExceptionHandler()) { + fun addServer(serverUrl: String) { + addServerState.value = LoadingState.Loading + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { try { val serverInfo by jellyfin .createApi(serverUrl) .systemApi .getPublicSystemInfo() val id = serverInfo.id - if (id != null) { + if (id != null && serverInfo.startupWizardCompleted == true) { serverRepository.addAndChangeServer( JellyfinServer( id = id, @@ -233,13 +258,22 @@ class SwitchUserViewModel put(id, quickConnect) } } - callback.invoke() + withContext(Dispatchers.Main) { + addServerState.value = LoadingState.Success + navigationManager.navigateTo(Destination.UserList) + } } else { - // TODO + withContext(Dispatchers.Main) { + addServerState.value = + LoadingState.Error("Server returned invalid response") + } } } catch (ex: Exception) { Timber.w(ex, "Error creating API for $serverUrl") - // TODO notify user + withContext(Dispatchers.Main) { + addServerState.value = + LoadingState.Error(exception = ex) + } } } } @@ -255,4 +289,18 @@ class SwitchUserViewModel serverRepository.removeServer(server) } } + + fun discoverServers() { + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + jellyfin.discovery.discoverLocalServers().collect { server -> + val newServerList = + discoveredServers.value!! + .toMutableList() + .apply { add(JellyfinServer(server.id, server.name, server.address)) } + withContext(Dispatchers.Main) { + discoveredServers.value = newServerList + } + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/dolphin/util/LoadingState.kt index 3007ba6c..fea44fd0 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/LoadingState.kt @@ -4,6 +4,8 @@ package com.github.damontecres.dolphin.util * Generic state for loading something from the API */ sealed interface LoadingState { + object Pending : LoadingState + object Loading : LoadingState object Success : LoadingState @@ -11,5 +13,7 @@ sealed interface LoadingState { data class Error( val message: String? = null, val exception: Throwable? = null, - ) : LoadingState + ) : LoadingState { + constructor(exception: Throwable) : this(null, exception) + } }