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"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
@ -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">
<activity
android:name=".MainActivity"
android:exported="true">

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.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()) {
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(),
)
}

View file

@ -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,9 +65,8 @@ class ServerRepository
server: JellyfinServer,
user: JellyfinUser,
) = withContext(Dispatchers.IO) {
try {
if (server.id != user.serverId) {
throw IllegalStateException()
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)
@ -98,18 +96,6 @@ class ServerRepository
_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
}
}
}
/**

View file

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

View file

@ -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()),
)
}

View file

@ -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()),
)
}

View file

@ -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),

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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(

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.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,
)
},

View file

@ -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

View file

@ -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(

View file

@ -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 ->

View file

@ -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,
)
},
)

View file

@ -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,
)
},

View file

@ -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) {

View file

@ -42,6 +42,9 @@ class NavigationManager
while (backStack.size > 1) {
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
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<JellyfinServer?>(null) }
@ -63,7 +67,9 @@ fun ServerList(
when (status) {
ServerConnectionStatus.Success -> {}
ServerConnectionStatus.Pending -> {
CircularProgress()
CircularProgress(
Modifier.size(IconButtonDefaults.MediumIconSize),
)
}
is ServerConnectionStatus.Error -> {
Icon(
@ -76,11 +82,14 @@ fun ServerList(
},
onClick = { onSwitchServer.invoke(server) },
onLongClick = {
if (allowDelete) {
showDeleteDialog = server
}
},
modifier = Modifier,
)
}
if (allowAdd) {
item {
HorizontalDivider()
ListItem(
@ -99,9 +108,10 @@ fun ServerList(
)
}
}
}
showDeleteDialog?.let { server ->
DialogPopup(
showDialog = true,
showDialog = allowDelete,
title = server.name ?: server.url,
dialogItems =
listOf(

View file

@ -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,18 +47,30 @@ 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,
) {
Row(
modifier =
Modifier
.align(Alignment.Center)
.padding(32.dp),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.fillMaxWidth(.5f)
.align(Alignment.Center)
.fillMaxWidth()
.weight(1f)
.padding(16.dp)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
@ -71,9 +86,7 @@ fun SwitchServerContent(
servers = servers,
connectionStatus = serverStatus,
onSwitchServer = {
viewModel.addServer(it.url) {
viewModel.navigationManager.navigateTo(Destination.UserList)
}
viewModel.addServer(it.url)
},
onAddServer = {
showAddServer = true
@ -81,6 +94,8 @@ fun SwitchServerContent(
onRemoveServer = {
viewModel.removeServer(it)
},
allowAdd = true,
allowDelete = true,
modifier =
Modifier
.fillMaxWidth()
@ -90,18 +105,73 @@ fun SwitchServerContent(
),
)
}
// 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,15 +207,31 @@ 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,
) {
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
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 -> {}
}
}
}
}

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.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<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 {
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,6 +104,7 @@ class SwitchUserViewModel
}
} catch (ex: Exception) {
Timber.w(ex, "Error checking quick connect for server ${server.url}")
withContext(Dispatchers.Main) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
@ -90,6 +115,7 @@ class SwitchUserViewModel
}
}
}
}
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
serverRepository.currentServer?.let {
val quickConnect =
@ -113,11 +139,14 @@ class SwitchUserViewModel
fun switchUser(
server: JellyfinServer,
user: JellyfinUser,
callback: () -> Unit,
) {
viewModelScope.launch(ExceptionHandler()) {
try {
serverRepository.changeUser(server, user)
callback.invoke()
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
}
}
}
}
}

View file

@ -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)
}
}