Better error handling during setup

This commit is contained in:
Damontecres 2025-10-11 14:38:59 -04:00
parent 59b3ef2644
commit 3afc4f022b
No known key found for this signature in database
24 changed files with 382 additions and 198 deletions

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
@ -30,7 +29,8 @@
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Dolphin" android:theme="@style/Theme.Dolphin"
android:name=".DolphinApplication"> android:name=".DolphinApplication"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true">
@ -43,4 +43,4 @@
</activity> </activity>
</application> </application>
</manifest> </manifest>

View file

@ -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.ApplicationContent
import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager 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.ui.theme.DolphinTheme
import com.github.damontecres.dolphin.util.profile.createDeviceProfile import com.github.damontecres.dolphin.util.profile.createDeviceProfile
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -78,10 +76,14 @@ class MainActivity : AppCompatActivity() {
var isRestoringSession by remember { mutableStateOf(true) } var isRestoringSession by remember { mutableStateOf(true) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (appPreferences.currentServerId.isNotBlank()) { if (appPreferences.currentServerId.isNotBlank()) {
serverRepository.restoreSession( try {
appPreferences.currentServerId, serverRepository.restoreSession(
appPreferences.currentUserId, appPreferences.currentServerId,
) appPreferences.currentUserId,
)
} catch (ex: Exception) {
Timber.e(ex, "Exception restoring session")
}
Timber.d("MainActivity session restored") Timber.d("MainActivity session restored")
} }
isRestoringSession = false isRestoringSession = false
@ -100,19 +102,8 @@ class MainActivity : AppCompatActivity() {
remember(appPreferences) { remember(appPreferences) {
createDeviceProfile(this@MainActivity, preferences, false) createDeviceProfile(this@MainActivity, preferences, false)
} }
val backStack = rememberNavBackStack(Destination.Home())
navigationManager.backStack = backStack
if (server != null && user != null) { if (isRestoringSession) {
ApplicationContent(
user = user,
server = server,
navigationManager = navigationManager,
preferences = preferences,
deviceProfile = deviceProfile,
modifier = Modifier.fillMaxSize(),
)
} else if (isRestoringSession) {
Box( Box(
modifier = Modifier.size(200.dp), modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@ -122,13 +113,21 @@ class MainActivity : AppCompatActivity() {
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
) )
} }
} else if (server != null) {
// Have server but no user, go to user selection
SwitchUserContent(
modifier = Modifier.fillMaxSize(),
)
} else { } 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(), modifier = Modifier.fillMaxSize(),
) )
} }

View file

@ -9,7 +9,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient 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.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.AuthenticationResult import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.UserDto
@ -66,49 +65,36 @@ class ServerRepository
server: JellyfinServer, server: JellyfinServer,
user: JellyfinUser, user: JellyfinUser,
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
try { if (server.id != user.serverId) {
if (server.id != user.serverId) { throw IllegalStateException("User is not part of the server")
throw IllegalStateException() }
} Timber.v("Changing user to ${user.name} on ${server.url}")
Timber.v("Changing user to ${user.name} on ${server.url}") apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
apiClient.update(baseUrl = server.url, accessToken = user.accessToken) val userDto =
val userDto = apiClient.userApi
apiClient.userApi .getCurrentUser()
.getCurrentUser() .content
.content
val updatedServer = server.copy(name = userDto.serverName) val updatedServer = server.copy(name = userDto.serverName)
val updatedUser = val updatedUser =
user.copy( user.copy(
id = userDto.id.toString(), id = userDto.id.toString(),
name = userDto.name, name = userDto.name,
) )
serverDao.addServer(updatedServer) serverDao.addServer(updatedServer)
serverDao.addUser(updatedUser) serverDao.addUser(updatedUser)
userPreferencesDataStore.updateData { userPreferencesDataStore.updateData {
it it
.toBuilder() .toBuilder()
.apply { .apply {
currentServerId = updatedServer.id currentServerId = updatedServer.id
currentUserId = updatedUser.id currentUserId = updatedUser.id
}.build() }.build()
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_currentUserDto = userDto _currentUserDto = userDto
_currentServer = updatedServer _currentServer = updatedServer
_currentUser = updatedUser _currentUser = updatedUser
}
} catch (e: InvalidStatusException) {
// TODO
Timber.e(e)
withContext(Dispatchers.Main) {
// Unauthorized
_currentServer = null
_currentUser = null
}
if (e.status == 401) {
return@withContext
}
} }
} }

View file

@ -48,7 +48,7 @@ val DefaultButtonPadding =
bottom = 10.dp / 2, bottom = 10.dp / 2,
) )
object CardDefaults { object Cards {
val height2x3 = 180.dp val height2x3 = 180.dp
val playedPercentHeight = 6.dp val playedPercentHeight = 6.dp
} }

View file

@ -32,7 +32,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.ui.AppColors 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 import com.github.damontecres.dolphin.ui.isNotNullOrBlank
/** /**
@ -131,7 +131,7 @@ fun BannerCard(
.background( .background(
MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape) ).clip(RectangleShape)
.height(CardDefaults.playedPercentHeight) .height(Cards.playedPercentHeight)
.fillMaxWidth((playPercent / 100).toFloat()), .fillMaxWidth((playPercent / 100).toFloat()),
) )
} }

View file

@ -48,7 +48,7 @@ import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.data.model.BaseItem 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.FontAwesome
import com.github.damontecres.dolphin.ui.enableMarquee import com.github.damontecres.dolphin.ui.enableMarquee
import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.ifElse
@ -282,7 +282,7 @@ fun ItemCardImage(
.background( .background(
MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape) ).clip(RectangleShape)
.height(CardDefaults.playedPercentHeight) .height(Cards.playedPercentHeight)
.fillMaxWidth((percent / 100.0).toFloat()), .fillMaxWidth((percent / 100.0).toFloat()),
) )
} }

View file

@ -9,14 +9,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import com.github.damontecres.dolphin.ui.ifElse
@Composable @Composable
fun CircularProgress( fun CircularProgress(modifier: Modifier = Modifier) {
modifier: Modifier = Modifier, Box(modifier = modifier) {
fillMaxSize: Boolean = true,
) {
Box(modifier = modifier.ifElse(fillMaxSize, Modifier.fillMaxSize())) {
CircularProgressIndicator( CircularProgressIndicator(
color = MaterialTheme.colorScheme.border, color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),

View file

@ -182,7 +182,9 @@ fun CollectionFolderGrid(
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage() LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> { LoadingState.Success -> {
pager?.let { pager -> pager?.let { pager ->
CollectionFolderGridContent( CollectionFolderGridContent(

View file

@ -143,7 +143,9 @@ fun RecommendedMovie(
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage() LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> LoadingState.Success ->
HomePageContent( HomePageContent(

View file

@ -155,7 +155,9 @@ fun RecommendedTvShow(
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage() LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> LoadingState.Success ->
HomePageContent( HomePageContent(

View file

@ -64,7 +64,9 @@ fun EpisodeDetails(
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage() LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> { LoadingState.Success -> {
item?.let { item -> item?.let { item ->
EpisodeDetailsContent( EpisodeDetailsContent(

View file

@ -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.BaseItem
import com.github.damontecres.dolphin.data.model.Person import com.github.damontecres.dolphin.data.model.Person
import com.github.damontecres.dolphin.preferences.UserPreferences 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.ItemRow
import com.github.damontecres.dolphin.ui.cards.PersonRow import com.github.damontecres.dolphin.ui.cards.PersonRow
import com.github.damontecres.dolphin.ui.cards.SeasonCard import com.github.damontecres.dolphin.ui.cards.SeasonCard
@ -95,7 +95,9 @@ fun SeriesDetails(
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage() LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> { LoadingState.Success -> {
item?.let { item -> item?.let { item ->
val played = item.data.userData?.played ?: false val played = item.data.userData?.played ?: false
@ -241,7 +243,7 @@ fun SeriesDetailsContent(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = mod, modifier = mod,
imageHeight = CardDefaults.height2x3, imageHeight = Cards.height2x3,
imageWidth = Dp.Unspecified, imageWidth = Dp.Unspecified,
) )
}, },

View file

@ -48,7 +48,9 @@ fun VideoDetails(
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage() LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> { LoadingState.Success -> {
item?.let { item -> item?.let { item ->
val dto = item.data val dto = item.data

View file

@ -131,7 +131,9 @@ fun MovieDetails(
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage() LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> { LoadingState.Success -> {
item?.let { movie -> item?.let { movie ->
MovieDetailsContent( MovieDetailsContent(

View file

@ -111,7 +111,9 @@ fun SeriesOverview(
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage() LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> { LoadingState.Success -> {
series?.let { series -> series?.let { series ->

View file

@ -39,7 +39,7 @@ import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.preferences.UserPreferences 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.BannerCard
import com.github.damontecres.dolphin.ui.cards.ItemRow import com.github.damontecres.dolphin.ui.cards.ItemRow
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
@ -76,7 +76,9 @@ fun HomePage(
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage() LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> { LoadingState.Success -> {
val homeRows by viewModel.homeRows.observeAsState(listOf()) val homeRows by viewModel.homeRows.observeAsState(listOf())
@ -195,7 +197,7 @@ fun HomePageContent(
Modifier.focusRequester(positionFocusRequester), Modifier.focusRequester(positionFocusRequester),
), ),
interactionSource = null, interactionSource = null,
cardHeight = CardDefaults.height2x3, cardHeight = Cards.height2x3,
) )
}, },
) )

View file

@ -25,7 +25,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.preferences.UserPreferences 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.DefaultItemFields
import com.github.damontecres.dolphin.ui.cards.EpisodeCard import com.github.damontecres.dolphin.ui.cards.EpisodeCard
import com.github.damontecres.dolphin.ui.cards.ItemRow import com.github.damontecres.dolphin.ui.cards.ItemRow
@ -177,7 +177,7 @@ fun SearchPage(
item = item, item = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
imageHeight = CardDefaults.height2x3, imageHeight = Cards.height2x3,
modifier = mod, modifier = mod,
) )
}, },
@ -199,7 +199,7 @@ fun SearchPage(
item = item, item = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
imageHeight = CardDefaults.height2x3, imageHeight = Cards.height2x3,
modifier = mod, modifier = mod,
) )
}, },

View file

@ -21,8 +21,8 @@ import timber.log.Timber
*/ */
@Composable @Composable
fun ApplicationContent( fun ApplicationContent(
server: JellyfinServer, server: JellyfinServer?,
user: JellyfinUser, user: JellyfinUser?,
navigationManager: NavigationManager, navigationManager: NavigationManager,
preferences: UserPreferences, preferences: UserPreferences,
deviceProfile: DeviceProfile, deviceProfile: DeviceProfile,
@ -39,7 +39,7 @@ fun ApplicationContent(
), ),
entryProvider = { key -> entryProvider = { key ->
key as Destination key as Destination
val contentKey = "${key}_${server.id}_${user.id}" val contentKey = "${key}_${server?.id}_${user?.id}"
Timber.d("Navigate: %s", key) Timber.d("Navigate: %s", key)
NavEntry(key, contentKey = contentKey) { NavEntry(key, contentKey = contentKey) {
if (key.fullScreen) { if (key.fullScreen) {

View file

@ -42,6 +42,9 @@ class NavigationManager
while (backStack.size > 1) { while (backStack.size > 1) {
backStack.removeLastOrNull() backStack.removeLastOrNull()
} }
if (backStack[0] !is Destination.Home) {
backStack[0] = Destination.Home()
}
} }
/** /**

View file

@ -1,5 +1,6 @@
package com.github.damontecres.dolphin.ui.setup package com.github.damontecres.dolphin.ui.setup
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons 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.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.IconButtonDefaults
import androidx.tv.material3.ListItem import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
@ -47,6 +49,8 @@ fun ServerList(
onSwitchServer: (JellyfinServer) -> Unit, onSwitchServer: (JellyfinServer) -> Unit,
onAddServer: () -> Unit, onAddServer: () -> Unit,
onRemoveServer: (JellyfinServer) -> Unit, onRemoveServer: (JellyfinServer) -> Unit,
allowAdd: Boolean,
allowDelete: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) } var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) }
@ -63,7 +67,9 @@ fun ServerList(
when (status) { when (status) {
ServerConnectionStatus.Success -> {} ServerConnectionStatus.Success -> {}
ServerConnectionStatus.Pending -> { ServerConnectionStatus.Pending -> {
CircularProgress() CircularProgress(
Modifier.size(IconButtonDefaults.MediumIconSize),
)
} }
is ServerConnectionStatus.Error -> { is ServerConnectionStatus.Error -> {
Icon( Icon(
@ -76,32 +82,36 @@ fun ServerList(
}, },
onClick = { onSwitchServer.invoke(server) }, onClick = { onSwitchServer.invoke(server) },
onLongClick = { onLongClick = {
showDeleteDialog = server if (allowDelete) {
showDeleteDialog = server
}
}, },
modifier = Modifier, modifier = Modifier,
) )
} }
item { if (allowAdd) {
HorizontalDivider() item {
ListItem( HorizontalDivider()
enabled = true, ListItem(
selected = false, enabled = true,
headlineContent = { Text(text = "Add Server") }, selected = false,
leadingContent = { headlineContent = { Text(text = "Add Server") },
Icon( leadingContent = {
imageVector = Icons.Default.Add, Icon(
tint = Color.Green.copy(alpha = .8f), imageVector = Icons.Default.Add,
contentDescription = null, tint = Color.Green.copy(alpha = .8f),
) contentDescription = null,
}, )
onClick = { onAddServer.invoke() }, },
modifier = Modifier, onClick = { onAddServer.invoke() },
) modifier = Modifier,
)
}
} }
} }
showDeleteDialog?.let { server -> showDeleteDialog?.let { server ->
DialogPopup( DialogPopup(
showDialog = true, showDialog = allowDelete,
title = server.name ?: server.url, title = server.name ?: server.url,
dialogItems = dialogItems =
listOf( listOf(

View file

@ -4,8 +4,10 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth 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.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
@ -30,10 +32,11 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.dolphin.ui.components.BasicDialog 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.components.EditTextBox
import com.github.damontecres.dolphin.ui.isNotNullOrBlank 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.ui.tryRequestFocus
import com.github.damontecres.dolphin.util.LoadingState
@Composable @Composable
fun SwitchServerContent( fun SwitchServerContent(
@ -44,64 +47,131 @@ fun SwitchServerContent(
val servers by viewModel.servers.observeAsState(listOf()) val servers by viewModel.servers.observeAsState(listOf())
val serverStatus by viewModel.serverStatus.observeAsState(mapOf()) val serverStatus by viewModel.serverStatus.observeAsState(mapOf())
val discoveredServers by viewModel.discoveredServers.observeAsState(listOf())
var showAddServer by remember { mutableStateOf(false) } var showAddServer by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.discoverServers()
}
Box( Box(
modifier = modifier, modifier = modifier,
) { ) {
Column( Row(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier =
Modifier Modifier
.fillMaxWidth(.5f)
.align(Alignment.Center) .align(Alignment.Center)
.padding(16.dp) .padding(32.dp),
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
shape = RoundedCornerShape(16.dp),
),
) { ) {
Text( Column(
text = "Select Server", horizontalAlignment = Alignment.CenterHorizontally,
style = MaterialTheme.typography.displaySmall, verticalArrangement = Arrangement.spacedBy(8.dp),
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)
},
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f)
.padding(16.dp)
.background( .background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
shape = RoundedCornerShape(16.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) { if (showAddServer) {
LaunchedEffect(Unit) {
viewModel.clearAddServerState()
}
val state by viewModel.addServerState.observeAsState(LoadingState.Pending)
var url by remember { mutableStateOf("") } var url by remember { mutableStateOf("") }
val submit = { val submit = {
showAddServer = false viewModel.addServer(url)
viewModel.addServer(url) {
viewModel.navigationManager.navigateTo(Destination.UserList)
}
} }
BasicDialog( BasicDialog(
onDismissRequest = { showAddServer = false }, onDismissRequest = {
showAddServer = false
viewModel.clearAddServerState()
},
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false),
elevation = 10.dp,
) { ) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
@ -118,7 +188,10 @@ fun SwitchServerContent(
) )
EditTextBox( EditTextBox(
value = url, value = url,
onValueChange = { url = it }, onValueChange = {
url = it
viewModel.clearAddServerState()
},
keyboardOptions = keyboardOptions =
KeyboardOptions( KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
@ -134,12 +207,28 @@ fun SwitchServerContent(
.focusRequester(focusRequester) .focusRequester(focusRequester)
.fillMaxWidth(), .fillMaxWidth(),
) )
when (val st = state) {
is LoadingState.Error -> {
Text(
text =
st.message ?: st.exception?.localizedMessage
?: "An error occurred",
color = MaterialTheme.colorScheme.error,
)
}
else -> {}
}
Button( Button(
onClick = { submit.invoke() }, onClick = { submit.invoke() },
enabled = url.isNotNullOrBlank(), enabled = url.isNotNullOrBlank() && state == LoadingState.Pending,
modifier = Modifier, modifier = Modifier,
) { ) {
Text(text = "Submit") if (state == LoadingState.Loading) {
CircularProgress(Modifier.size(32.dp))
} else {
Text(text = "Submit")
}
} }
} }
} }

View file

@ -1,5 +1,6 @@
package com.github.damontecres.dolphin.ui.setup package com.github.damontecres.dolphin.ui.setup
import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -22,6 +23,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp 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.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.dolphin.util.LoadingState
@Composable @Composable
fun SwitchUserContent( fun SwitchUserContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SwitchUserViewModel = hiltViewModel(), viewModel: SwitchUserViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current
val currentServer = viewModel.serverRepository.currentServer val currentServer = viewModel.serverRepository.currentServer
val currentUser = viewModel.serverRepository.currentUser val currentUser = viewModel.serverRepository.currentUser
val users by viewModel.users.observeAsState(listOf()) val users by viewModel.users.observeAsState(listOf())
@ -50,6 +55,20 @@ fun SwitchUserContent(
val quickConnect by viewModel.quickConnectState.observeAsState(null) val quickConnect by viewModel.quickConnectState.observeAsState(null)
var showAddUser by remember { mutableStateOf(false) } 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 -> currentServer?.let { server ->
Box( Box(
modifier = modifier, modifier = modifier,
@ -81,9 +100,7 @@ fun SwitchUserContent(
users = users, users = users,
currentUser = currentUser, currentUser = currentUser,
onSwitchUser = { user -> onSwitchUser = { user ->
viewModel.switchUser(server, user) { viewModel.switchUser(server, user)
viewModel.navigationManager.goToHome()
}
}, },
onAddUser = { showAddUser = true }, onAddUser = { showAddUser = true },
onRemoveUser = { user -> onRemoveUser = { user ->
@ -106,6 +123,7 @@ fun SwitchUserContent(
if (showAddUser) { if (showAddUser) {
var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) } var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.clearSwitchUserState()
if (useQuickConnect) { if (useQuickConnect) {
viewModel.initiateQuickConnect(server) { viewModel.initiateQuickConnect(server) {
viewModel.navigationManager.goToHome() viewModel.navigationManager.goToHome()
@ -151,10 +169,7 @@ fun SwitchUserContent(
var username by remember { mutableStateOf("") } var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
val onSubmit = { val onSubmit = {
viewModel.login(server, username, password) { viewModel.login(server, username, password)
showAddUser = false
viewModel.navigationManager.goToHome()
}
} }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
@ -173,7 +188,10 @@ fun SwitchUserContent(
) )
EditTextBox( EditTextBox(
value = username, value = username,
onValueChange = { username = it }, onValueChange = {
username = it
viewModel.clearSwitchUserState()
},
keyboardOptions = keyboardOptions =
KeyboardOptions( KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
@ -193,7 +211,10 @@ fun SwitchUserContent(
) )
EditTextBox( EditTextBox(
value = password, value = password,
onValueChange = { password = it }, onValueChange = {
password = it
viewModel.clearSwitchUserState()
},
keyboardOptions = keyboardOptions =
KeyboardOptions( KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
@ -215,6 +236,16 @@ fun SwitchUserContent(
Text("Login") Text("Login")
} }
} }
when (val s = userState) {
is LoadingState.Error -> {
Text(
text = s.message ?: s.exception?.localizedMessage ?: "Error",
color = MaterialTheme.colorScheme.error,
)
}
else -> {}
}
} }
} }
} }

View file

@ -7,8 +7,10 @@ import com.github.damontecres.dolphin.data.JellyfinServer
import com.github.damontecres.dolphin.data.JellyfinServerDao import com.github.damontecres.dolphin.data.JellyfinServerDao
import com.github.damontecres.dolphin.data.JellyfinUser import com.github.damontecres.dolphin.data.JellyfinUser
import com.github.damontecres.dolphin.data.ServerRepository 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.ui.nav.NavigationManager
import com.github.damontecres.dolphin.util.ExceptionHandler import com.github.damontecres.dolphin.util.ExceptionHandler
import com.github.damontecres.dolphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -16,6 +18,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.Jellyfin 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.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.quickConnectApi 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 org.jellyfin.sdk.model.api.QuickConnectResult
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel @HiltViewModel
class SwitchUserViewModel class SwitchUserViewModel
@ -44,6 +48,19 @@ class SwitchUserViewModel
private var quickConnectJob: Job? = null private var quickConnectJob: Job? = null
val discoveredServers = MutableLiveData<List<JellyfinServer>>(listOf())
val addServerState = MutableLiveData<LoadingState>(LoadingState.Pending)
val switchUserState = MutableLiveData<LoadingState>(LoadingState.Pending)
fun clearAddServerState() {
addServerState.value = LoadingState.Pending
}
fun clearSwitchUserState() {
switchUserState.value = LoadingState.Pending
}
init { init {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
val allServers = val allServers =
@ -57,8 +74,15 @@ class SwitchUserViewModel
allServers.forEach { server -> allServers.forEach { server ->
try { try {
jellyfin jellyfin
.createApi(server.url) .createApi(
.systemApi server.url,
httpClientOptions =
HttpClientOptions(
requestTimeout = 6.seconds,
connectTimeout = 6.seconds,
socketTimeout = 6.seconds,
),
).systemApi
.getPublicSystemInfo() .getPublicSystemInfo()
val quickConnect by val quickConnect by
jellyfin jellyfin
@ -80,13 +104,15 @@ class SwitchUserViewModel
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.w(ex, "Error checking quick connect for server ${server.url}") Timber.w(ex, "Error checking quick connect for server ${server.url}")
serverStatus.value = withContext(Dispatchers.Main) {
serverStatus.value!!.toMutableMap().apply { serverStatus.value =
put( serverStatus.value!!.toMutableMap().apply {
server.id, put(
ServerConnectionStatus.Error(ex.localizedMessage), server.id,
) ServerConnectionStatus.Error(ex.localizedMessage),
} )
}
}
} }
} }
} }
@ -113,11 +139,14 @@ class SwitchUserViewModel
fun switchUser( fun switchUser(
server: JellyfinServer, server: JellyfinServer,
user: JellyfinUser, user: JellyfinUser,
callback: () -> Unit,
) { ) {
viewModelScope.launch(ExceptionHandler()) { viewModelScope.launch(ExceptionHandler()) {
serverRepository.changeUser(server, user) try {
callback.invoke() serverRepository.changeUser(server, user)
navigationManager.goToHome()
} catch (ex: Exception) {
switchUserState.value = LoadingState.Error(exception = ex)
}
} }
} }
@ -125,7 +154,6 @@ class SwitchUserViewModel
server: JellyfinServer, server: JellyfinServer,
username: String, username: String,
password: String, password: String,
onAuthenticated: () -> Unit,
) { ) {
quickConnectJob?.cancel() quickConnectJob?.cancel()
viewModelScope.launch(ExceptionHandler()) { viewModelScope.launch(ExceptionHandler()) {
@ -137,12 +165,10 @@ class SwitchUserViewModel
) )
serverRepository.changeUser(server.url, authenticationResult) serverRepository.changeUser(server.url, authenticationResult)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
onAuthenticated.invoke() navigationManager.goToHome()
}
} catch (err: InvalidStatusException) {
if (err.status == 401) {
// TODO set state to notify user
} }
} catch (ex: Exception) {
switchUserState.value = LoadingState.Error(ex)
} }
} }
} }
@ -185,14 +211,15 @@ class SwitchUserViewModel
onAuthenticated.invoke() onAuthenticated.invoke()
} }
} }
} catch (err: InvalidStatusException) { } catch (ex: Exception) {
if (err.status == 401) { if (ex is InvalidStatusException && ex.status == 401) {
quickConnectState.value = null quickConnectState.value = null
serverQuickConnect.value = serverQuickConnect.value =
serverQuickConnect.value!!.toMutableMap().apply { serverQuickConnect.value!!.toMutableMap().apply {
put(server.id, false) put(server.id, false)
} }
} }
switchUserState.value = LoadingState.Error(ex)
} }
} }
} }
@ -202,18 +229,16 @@ class SwitchUserViewModel
quickConnectState.value = null quickConnectState.value = null
} }
fun addServer( fun addServer(serverUrl: String) {
serverUrl: String, addServerState.value = LoadingState.Loading
callback: () -> Unit, viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
) {
viewModelScope.launch(ExceptionHandler()) {
try { try {
val serverInfo by jellyfin val serverInfo by jellyfin
.createApi(serverUrl) .createApi(serverUrl)
.systemApi .systemApi
.getPublicSystemInfo() .getPublicSystemInfo()
val id = serverInfo.id val id = serverInfo.id
if (id != null) { if (id != null && serverInfo.startupWizardCompleted == true) {
serverRepository.addAndChangeServer( serverRepository.addAndChangeServer(
JellyfinServer( JellyfinServer(
id = id, id = id,
@ -233,13 +258,22 @@ class SwitchUserViewModel
put(id, quickConnect) put(id, quickConnect)
} }
} }
callback.invoke() withContext(Dispatchers.Main) {
addServerState.value = LoadingState.Success
navigationManager.navigateTo(Destination.UserList)
}
} else { } else {
// TODO withContext(Dispatchers.Main) {
addServerState.value =
LoadingState.Error("Server returned invalid response")
}
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.w(ex, "Error creating API for $serverUrl") 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) 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
}
}
}
}
} }

View file

@ -4,6 +4,8 @@ package com.github.damontecres.dolphin.util
* Generic state for loading something from the API * Generic state for loading something from the API
*/ */
sealed interface LoadingState { sealed interface LoadingState {
object Pending : LoadingState
object Loading : LoadingState object Loading : LoadingState
object Success : LoadingState object Success : LoadingState
@ -11,5 +13,7 @@ sealed interface LoadingState {
data class Error( data class Error(
val message: String? = null, val message: String? = null,
val exception: Throwable? = null, val exception: Throwable? = null,
) : LoadingState ) : LoadingState {
constructor(exception: Throwable) : this(null, exception)
}
} }