Show public users when switching users (#1358)

## Description
Show the public users (ie "Hide this user from login screens" is
unchecked) if the server has any. Previously, all users had to be
explicitly added by username or quick connect.

This PR also refactors the server & user pages to use modern state flows
and deletes some unused code.

### Related issues
Closes #600

### Testing
Emulator

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-05-10 08:50:08 -04:00 committed by GitHub
parent b23d30f94b
commit dbd17597b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 579 additions and 627 deletions

View file

@ -85,7 +85,7 @@ fun MainContent(
is SetupDestination.UserList -> {
SwitchUserContent(
currentServer = key.server,
server = key.server,
Modifier.fillMaxSize(),
)
}

View file

@ -73,7 +73,7 @@ class ServerRepository
suspend fun changeUser(
server: JellyfinServer,
user: JellyfinUser,
): CurrentUser? =
): CurrentUser =
withContext(ioDispatcher) {
if (server.id != user.serverId) {
throw IllegalStateException("User is not part of the server")
@ -104,15 +104,16 @@ class ServerRepository
currentUserId = updatedUser.id.toServerString()
}.build()
}
val currentUser = CurrentUser(updatedServer, updatedUser)
withContext(Dispatchers.Main) {
_current.value = CurrentUser(updatedServer, updatedUser)
_current.value = currentUser
_currentUserDto.value = userDto
}
getServerSharedPreferences(context).edit(true) {
putString(SERVER_URL_KEY, updatedServer.url)
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
}
return@withContext _current.value
return@withContext currentUser
}
/**

View file

@ -9,19 +9,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -31,12 +24,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.IconButtonDefaults
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
@ -44,9 +34,6 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.PublicSystemInfo
import java.util.UUID
@ -62,120 +49,6 @@ sealed interface ServerConnectionStatus {
) : ServerConnectionStatus
}
/**
* Display a list of servers plus option to add a new one
*/
@Composable
fun ServerList(
servers: List<JellyfinServer>,
connectionStatus: Map<UUID, ServerConnectionStatus>,
onSwitchServer: (JellyfinServer) -> Unit,
onTestServer: (JellyfinServer) -> Unit,
onAddServer: () -> Unit,
onRemoveServer: (JellyfinServer) -> Unit,
allowAdd: Boolean,
allowDelete: Boolean,
modifier: Modifier = Modifier,
) {
var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) }
LazyColumn(modifier = modifier) {
items(servers) { server ->
val status = connectionStatus[server.id] ?: ServerConnectionStatus.Pending
ListItem(
enabled = true,
selected = false,
headlineContent = { Text(text = server.name?.ifBlank { null } ?: server.url) },
supportingContent = { if (server.name.isNotNullOrBlank()) Text(text = server.url) },
leadingContent = {
when (status) {
is ServerConnectionStatus.Success -> {}
ServerConnectionStatus.Pending -> {
CircularProgress(
Modifier.size(IconButtonDefaults.MediumIconSize),
)
}
is ServerConnectionStatus.Error -> {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = status.message,
tint = MaterialTheme.colorScheme.errorContainer,
)
}
}
},
onClick = {
when (status) {
is ServerConnectionStatus.Success -> {
onSwitchServer.invoke(server)
}
ServerConnectionStatus.Pending -> {}
is ServerConnectionStatus.Error -> {
onTestServer.invoke(server)
}
}
},
onLongClick = {
if (allowDelete) {
showDeleteDialog = server
}
},
modifier = Modifier,
)
}
if (allowAdd) {
item {
HorizontalDivider()
ListItem(
enabled = true,
selected = false,
headlineContent = { Text(text = stringResource(R.string.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 = allowDelete,
title = server.name ?: server.url,
dialogItems =
listOf(
DialogItem(
stringResource(R.string.switch_servers),
R.string.fa_arrow_left_arrow_right,
) {
onSwitchServer.invoke(server)
},
DialogItem(
stringResource(R.string.delete),
Icons.Default.Delete,
Color.Red.copy(alpha = .8f),
) {
onRemoveServer.invoke(server)
},
),
onDismissRequest = { showDeleteDialog = null },
dismissOnClick = true,
waitToLoad = true,
properties = DialogProperties(),
elevation = 5.dp,
)
}
}
/**
* Generate a consistent color for a UUID
*/

View file

@ -20,8 +20,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -43,11 +43,14 @@ import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.dimAndBlur
import com.github.damontecres.wholphin.ui.ifElse
@ -60,16 +63,30 @@ fun SwitchServerContent(
modifier: Modifier = Modifier,
viewModel: SwitchServerViewModel = hiltViewModel(),
) {
val servers by viewModel.servers.observeAsState(listOf())
val serverStatus by viewModel.serverStatus.observeAsState(mapOf())
var showAddServer by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf<com.github.damontecres.wholphin.data.model.JellyfinServer?>(null) }
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) {
viewModel.init()
}
when (val st = state.loading) {
is LoadingState.Error -> ErrorMessage(st, modifier)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage(modifier)
LoadingState.Success -> SwitchServerContentInternal(state, viewModel, modifier)
}
}
@Composable
private fun SwitchServerContentInternal(
state: SwitchServerState,
viewModel: SwitchServerViewModel,
modifier: Modifier = Modifier,
) {
var showAddServer by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) }
Box(
modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null),
) {
@ -108,7 +125,7 @@ fun SwitchServerContent(
) {
val focusRequester = remember { FocusRequester() }
val firstServerFocus = remember { FocusRequester() }
if (servers.isNotEmpty()) {
if (state.servers.isNotEmpty()) {
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
}
LazyRow(
@ -120,16 +137,15 @@ fun SwitchServerContent(
.focusRestorer(firstServerFocus)
.focusRequester(focusRequester),
) {
itemsIndexed(servers) { index, server ->
val status = serverStatus[server.id] ?: ServerConnectionStatus.Pending
itemsIndexed(state.servers) { index, server ->
ServerIconCard(
server = server,
connectionStatus = status,
server = server.server,
connectionStatus = server.status,
isCurrentServer = false, // TODO: Determine current server if needed
onClick = {
when (status) {
when (server.status) {
is ServerConnectionStatus.Success -> {
viewModel.switchServer(server)
viewModel.switchServer(server.server)
}
ServerConnectionStatus.Pending -> {
@ -137,21 +153,30 @@ fun SwitchServerContent(
}
is ServerConnectionStatus.Error -> {
viewModel.testServer(server)
viewModel.testServer(server.server)
}
}
},
onLongClick = {
showDeleteDialog = server
showDeleteDialog = server.server
},
allowDelete = true,
modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstServerFocus)),
modifier =
Modifier.ifElse(
index == 0,
Modifier.focusRequester(firstServerFocus),
),
)
}
// Add Server card - always rightmost
item {
AddServerCard(
onClick = { showAddServer = true },
modifier =
Modifier.ifElse(
state.servers.isEmpty(),
Modifier.focusRequester(firstServerFocus),
),
)
}
}
@ -207,13 +232,11 @@ fun SwitchServerContent(
}
}
val discoveredServers by viewModel.discoveredServers.observeAsState(listOf())
// Filter out duplicates within the discovered servers list (same URL appearing multiple times)
val filteredDiscoveredServers =
remember(discoveredServers) {
remember(state.discoveredServers) {
val seenUrls = mutableSetOf<String>()
discoveredServers.filter { server ->
state.discoveredServers.filter { server ->
val normalizedUrl = server.url.lowercase().trim()
if (normalizedUrl in seenUrls) {
false // Duplicate, filter it out
@ -258,7 +281,7 @@ fun SwitchServerContent(
color = MaterialTheme.colorScheme.onSurface,
)
if (filteredDiscoveredServers.isEmpty() && discoveredServers.isEmpty()) {
if (filteredDiscoveredServers.isEmpty() && state.discoveredServers.isEmpty()) {
Text(
text = stringResource(R.string.searching),
style = MaterialTheme.typography.bodyMedium,
@ -294,7 +317,9 @@ fun SwitchServerContent(
selected = false,
headlineContent = {
Text(
text = server.name?.ifBlank { null } ?: server.url,
text =
server.name?.ifBlank { null }
?: server.url,
style = MaterialTheme.typography.bodyLarge,
)
},
@ -323,7 +348,7 @@ fun SwitchServerContent(
}
} else {
// Show enter server address form
val state by viewModel.addServerState.observeAsState(LoadingState.Pending)
val addServerState = state.addServerState
var url by remember { mutableStateOf("") }
val submit = {
viewModel.addServer(url)
@ -357,7 +382,7 @@ fun SwitchServerContent(
.focusRequester(textBoxFocusRequester)
.fillMaxWidth(),
)
when (val st = state) {
when (val st = addServerState) {
is LoadingState.Error -> {
Text(
text =
@ -371,10 +396,10 @@ fun SwitchServerContent(
}
TextButton(
onClick = { submit.invoke() },
enabled = url.isNotNullOrBlank() && state == LoadingState.Pending,
enabled = url.isNotNullOrBlank() && addServerState == LoadingState.Pending,
modifier = Modifier,
) {
if (state == LoadingState.Loading) {
if (addServerState == LoadingState.Loading) {
CircularProgress(Modifier.size(32.dp))
} else {
Text(text = stringResource(R.string.submit))

View file

@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.ui.setup
import android.content.Context
import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
@ -11,28 +10,23 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.HttpClientOptions
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.discovery.RecommendedServerIssue
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@ -45,52 +39,54 @@ class SwitchServerViewModel
val serverRepository: ServerRepository,
val serverDao: JellyfinServerDao,
val navigationManager: SetupNavigationManager,
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
) : ViewModel() {
val servers = MutableLiveData<List<JellyfinServer>>(listOf())
val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf())
val serverQuickConnect = MutableLiveData<Map<UUID, Boolean>>(mapOf())
val discoveredServers = MutableLiveData<List<JellyfinServer>>(listOf())
val addServerState = MutableLiveData<LoadingState>(LoadingState.Pending)
private val _state = MutableStateFlow(SwitchServerState())
val state: StateFlow<SwitchServerState> = _state
fun clearAddServerState() {
addServerState.value = LoadingState.Pending
_state.update { it.copy(addServerState = LoadingState.Pending) }
}
fun init() {
viewModelScope.launchIO {
withContext(Dispatchers.Main) {
serverStatus.value = mapOf()
serverQuickConnect.value = mapOf()
}
_state.update { SwitchServerState() }
val allServers =
serverDao
.getServers()
.map { it.server }
.sortedWith(compareBy<JellyfinServer> { it.name }.thenBy { it.url })
withContext(Dispatchers.Main) {
servers.value = allServers
.map { ServerState(it) }
_state.update {
it.copy(
loading = LoadingState.Success,
servers = allServers,
)
}
allServers.forEach { server ->
internalTestServer(server)
internalTestServer(server.server)
}
}
}
fun testServer(server: JellyfinServer) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
ServerConnectionStatus.Pending,
)
private fun updateServerState(
server: JellyfinServer,
result: ServerConnectionStatus,
) {
_state.update {
val servers =
it.servers.toMutableList().apply {
val index = indexOfFirst { it.server.id == server.id }
set(index, get(index).copy(server = server, status = result))
}
it.copy(servers = servers)
}
}
fun testServer(server: JellyfinServer) {
updateServerState(server, ServerConnectionStatus.Pending)
viewModelScope.launchIO {
delay(1000)
delay(500)
val result = internalTestServer(server)
if (result is ServerConnectionStatus.Success) {
showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT)
@ -100,7 +96,8 @@ class SwitchServerViewModel
}
}
private suspend fun internalTestServer(server: JellyfinServer): ServerConnectionStatus =
private suspend fun internalTestServer(server: JellyfinServer): ServerConnectionStatus {
val result =
try {
val systemInfo =
jellyfin
@ -115,43 +112,18 @@ class SwitchServerViewModel
).systemApi
.getPublicSystemInfo()
.content
val result = ServerConnectionStatus.Success(systemInfo)
withContext(Dispatchers.Main) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
result,
)
}
}
result
ServerConnectionStatus.Success(systemInfo)
} catch (ex: Exception) {
val status = ServerConnectionStatus.Error(ex.localizedMessage)
Timber.w(ex, "Error checking server ${server.url}")
withContext(Dispatchers.Main) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
status,
)
ServerConnectionStatus.Error(ex.localizedMessage)
}
}
status
updateServerState(server, result)
return result
}
fun switchServer(server: JellyfinServer) {
viewModelScope.launchIO {
withContext(Dispatchers.Main) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
ServerConnectionStatus.Pending,
)
}
}
updateServerState(server, ServerConnectionStatus.Pending)
val result = internalTestServer(server)
if (result is ServerConnectionStatus.Success) {
val updatedServer =
@ -160,9 +132,7 @@ class SwitchServerViewModel
version = result.systemInfo.version,
)
serverRepository.addAndChangeServer(updatedServer)
withContext(Dispatchers.Main) {
navigationManager.navigateTo(SetupDestination.UserList(updatedServer))
}
} else if (result is ServerConnectionStatus.Error) {
showToast(context, "Error connecting: $${result.message}")
}
@ -170,7 +140,7 @@ class SwitchServerViewModel
}
fun addServer(inputUrl: String) {
addServerState.value = LoadingState.Loading
_state.update { it.copy(addServerState = LoadingState.Loading) }
viewModelScope.launchIO {
try {
val scores =
@ -192,27 +162,10 @@ class SwitchServerViewModel
version = serverInfo.version,
)
serverRepository.addAndChangeServer(server)
val quickConnect =
jellyfin
.createApi(serverUrl)
.quickConnectApi
.getQuickConnectEnabled()
.content
withContext(Dispatchers.Main) {
serverQuickConnect.value =
serverQuickConnect.value!!.toMutableMap().apply {
put(id, quickConnect)
}
}
withContext(Dispatchers.Main) {
addServerState.value = LoadingState.Success
_state.update { it.copy(addServerState = LoadingState.Success) }
navigationManager.navigateTo(SetupDestination.UserList(server))
}
} else {
withContext(Dispatchers.Main) {
addServerState.value =
LoadingState.Error("Server returned invalid response")
}
_state.update { it.copy(addServerState = LoadingState.Error("Server returned invalid response")) }
}
} else {
Timber.w("Error connecting with %s: %s", inputUrl, scores)
@ -240,14 +193,11 @@ class SwitchServerViewModel
"${it.address} - $issues"
}
val message = "Error, tried addresses:\n$errors"
addServerState.setValueOnMain(LoadingState.Error(message))
_state.update { it.copy(addServerState = LoadingState.Error(message)) }
}
} catch (ex: Exception) {
Timber.w(ex, "Error creating API for $inputUrl")
withContext(Dispatchers.Main) {
addServerState.value =
LoadingState.Error(exception = ex)
}
_state.update { it.copy(addServerState = LoadingState.Error(exception = ex)) }
}
}
}
@ -262,23 +212,37 @@ class SwitchServerViewModel
fun discoverServers() {
viewModelScope.launchIO {
jellyfin.discovery.discoverLocalServers().collect { server ->
val newServerList =
discoveredServers.value!!
.toMutableList()
.apply {
add(
val jellyfinServer =
JellyfinServer(
server.id.toUUID(),
server.name,
server.address,
null,
),
)
_state.update {
it.copy(
discoveredServers =
it.discoveredServers
.toMutableList()
.apply {
add(jellyfinServer)
},
)
}
withContext(Dispatchers.Main) {
discoveredServers.value = newServerList
}
}
}
}
}
data class SwitchServerState(
val loading: LoadingState = LoadingState.Pending,
val servers: List<ServerState> = emptyList(),
val discoveredServers: List<JellyfinServer> = emptyList(),
val addServerState: LoadingState = LoadingState.Pending,
)
data class ServerState(
val server: JellyfinServer,
val status: ServerConnectionStatus = ServerConnectionStatus.Pending,
val quickConnect: Boolean = false,
)

View file

@ -14,10 +14,12 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -41,40 +43,45 @@ import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.dimAndBlur
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
@Composable
fun SwitchUserContent(
currentServer: JellyfinServer,
server: JellyfinServer,
modifier: Modifier = Modifier,
viewModel: SwitchUserViewModel =
hiltViewModel<SwitchUserViewModel, SwitchUserViewModel.Factory>(
creationCallback = { it.create(currentServer) },
creationCallback = { it.create(server) },
),
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
viewModel.init()
}
// val currentServer by viewModel.serverRepository.currentServer.observeAsState()
val state by viewModel.state.collectAsState()
val currentUser by viewModel.serverRepository.currentUser.observeAsState()
val users by viewModel.users.observeAsState(listOf())
val quickConnectEnabled by viewModel.serverQuickConnect.observeAsState(false)
val quickConnect by viewModel.quickConnectState.observeAsState(null)
var showAddUser by remember { mutableStateOf(false) }
var username by remember(server) { mutableStateOf("") }
val userState by viewModel.switchUserState.observeAsState(LoadingState.Pending)
val loginAttempts by viewModel.loginAttempts.observeAsState(0)
LaunchedEffect(userState) {
fun showAddUserDialog(user: JellyfinUser?) {
username = user?.name ?: ""
showAddUser = true
}
LaunchedEffect(state.switchUserState) {
if (!showAddUser) {
when (val s = userState) {
when (val s = state.switchUserState) {
is LoadingState.Error -> {
val msg = s.message ?: s.exception?.localizedMessage
Toast.makeText(context, "Error: $msg", Toast.LENGTH_LONG).show()
@ -86,7 +93,18 @@ fun SwitchUserContent(
}
var switchUserWithPin by remember { mutableStateOf<JellyfinUser?>(null) }
currentServer?.let { server ->
when (val st = state.loading) {
is LoadingState.Error -> {
ErrorMessage(st, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage(modifier)
}
LoadingState.Success -> {
Box(
modifier = modifier.dimAndBlur(showAddUser || switchUserWithPin != null),
) {
@ -115,16 +133,26 @@ fun SwitchUserContent(
)
}
UserList(
users = users,
users = state.users,
currentUser = currentUser,
onSwitchUser = { user ->
if (user.hasPin) {
if (user.accessToken == null) {
showAddUserDialog(user)
} else if (user.hasPin) {
switchUserWithPin = user
} else {
viewModel.switchUser(user)
val result = viewModel.trySwitchUser(user)
scope.launch {
result.await()?.let {
Toast.makeText(context, it, Toast.LENGTH_LONG).show()
showAddUserDialog(user)
}
}
}
},
onAddUser = { showAddUser = true },
onAddUser = {
showAddUserDialog(null)
},
onRemoveUser = { user ->
viewModel.removeUser(user)
},
@ -137,9 +165,11 @@ fun SwitchUserContent(
)
}
}
}
}
if (showAddUser) {
var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) }
var useQuickConnect by remember { mutableStateOf(state.quickConnectEnabled) }
LaunchedEffect(Unit) {
viewModel.clearSwitchUserState()
viewModel.resetAttempts()
@ -166,7 +196,7 @@ fun SwitchUserContent(
.fillMaxWidth(.4f),
) {
if (useQuickConnect) {
if (quickConnect == null && userState !is LoadingState.Error) {
if (state.quickConnectStatus == null && state.switchUserState !is LoadingState.Error) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
@ -184,7 +214,7 @@ fun SwitchUserContent(
modifier = Modifier,
)
}
} else if (quickConnect != null) {
} else if (state.quickConnectStatus != null) {
Text(
text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}",
style = MaterialTheme.typography.titleMedium,
@ -193,13 +223,13 @@ fun SwitchUserContent(
modifier = Modifier.fillMaxWidth(),
)
Text(
text = quickConnect?.code ?: "Failed to get code",
text = state.quickConnectStatus?.code ?: "Failed to get code",
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
UserStateError(userState)
UserStateError(state.switchUserState)
TextButton(
stringRes = R.string.username_or_password,
onClick = {
@ -210,9 +240,6 @@ fun SwitchUserContent(
modifier = Modifier.align(Alignment.CenterHorizontally),
)
} else {
// val username = rememberTextFieldState()
// val password = rememberTextFieldState()
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val onSubmit = {
viewModel.login(
@ -223,7 +250,13 @@ fun SwitchUserContent(
}
val focusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
LaunchedEffect(Unit) {
if (username.isBlank()) {
focusRequester.tryRequestFocus()
} else {
passwordFocusRequester.tryRequestFocus()
}
}
Text(
text = "Enter username/password to login to ${server.name ?: server.url}",
style = MaterialTheme.typography.titleMedium,
@ -231,13 +264,13 @@ fun SwitchUserContent(
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
UserStateError(userState)
UserStateError(state.switchUserState)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(
text = "Username",
text = stringResource(R.string.username),
modifier = Modifier.padding(end = 8.dp),
)
EditTextBox(
@ -259,7 +292,7 @@ fun SwitchUserContent(
// onKeyboardAction = {
// passwordFocusRequester.tryRequestFocus()
// },
isInputValid = { userState !is LoadingState.Error },
isInputValid = { state.switchUserState !is LoadingState.Error },
modifier = Modifier.focusRequester(focusRequester),
)
}
@ -268,7 +301,7 @@ fun SwitchUserContent(
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(
text = "Password",
text = stringResource(R.string.password),
modifier = Modifier.padding(end = 8.dp),
)
LaunchedEffect(password) {
@ -288,7 +321,7 @@ fun SwitchUserContent(
KeyboardActions(
onGo = { onSubmit.invoke() },
),
isInputValid = { userState !is LoadingState.Error },
isInputValid = { state.switchUserState !is LoadingState.Error },
modifier = Modifier.focusRequester(passwordFocusRequester),
)
}
@ -299,7 +332,7 @@ fun SwitchUserContent(
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
if (loginAttempts > 2) {
if (state.loginAttempts > 2) {
Text(
text = "Trouble logging in?",
modifier = Modifier.align(Alignment.CenterHorizontally),
@ -319,15 +352,23 @@ fun SwitchUserContent(
PinEntryDialog(
onDismissRequest = { switchUserWithPin = null },
onClickServerAuth = {
showAddUser = true
showAddUserDialog(user)
switchUserWithPin = null
},
onTextChange = {
if (it == user.pin) viewModel.switchUser(user)
if (it == user.pin) {
val result = viewModel.trySwitchUser(user)
scope.launch {
result.await()?.let {
Toast.makeText(context, it, Toast.LENGTH_LONG).show()
showAddUserDialog(user)
switchUserWithPin = null
}
}
}
},
)
}
}
}
@Composable

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.ui.setup
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.JellyfinServerDao
@ -11,18 +10,22 @@ import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.HttpClientOptions
@ -30,7 +33,6 @@ import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.QuickConnectDto
import org.jellyfin.sdk.model.api.QuickConnectResult
@ -54,44 +56,47 @@ class SwitchUserViewModel
fun create(server: JellyfinServer): SwitchUserViewModel
}
val serverQuickConnect = MutableLiveData<Boolean>(false)
val users = MutableLiveData<List<JellyfinUserAndImage>>(listOf())
val quickConnectState = MutableLiveData<QuickConnectResult?>(null)
private val _state = MutableStateFlow(SwitchUserState())
val state: StateFlow<SwitchUserState> = _state
private var quickConnectJob: Job? = null
val switchUserState = MutableLiveData<LoadingState>(LoadingState.Pending)
val loginAttempts = MutableLiveData(0)
fun clearSwitchUserState() {
switchUserState.value = LoadingState.Pending
_state.update { it.copy(switchUserState = LoadingState.Pending) }
}
fun resetAttempts() {
loginAttempts.value = 0
}
init {
init()
_state.update { it.copy(loginAttempts = 0) }
}
fun init() {
viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) {
viewModelScope.launchDefault {
serverRepository.switchServerOrUser()
}
quickConnectJob?.cancel()
viewModelScope.launchIO {
users.setValueOnMain(listOf())
_state.update { SwitchUserState() }
try {
val serverUsers = getUsers()
withContext(Dispatchers.Main) {
users.setValueOnMain(serverUsers)
_state.update {
it.copy(
loading = LoadingState.Success,
users = serverUsers,
)
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching users for server $server")
_state.update {
it.copy(
loading = LoadingState.Error(ex),
)
}
}
}
viewModelScope.launchIO {
try {
val quickConnect by
jellyfin
.createApi(
server.url,
@ -101,38 +106,33 @@ class SwitchUserViewModel
connectTimeout = 6.seconds,
socketTimeout = 6.seconds,
),
).systemApi
.getPublicSystemInfo()
val quickConnect by
jellyfin
.createApi(server.url)
.quickConnectApi
).quickConnectApi
.getQuickConnectEnabled()
withContext(Dispatchers.Main) {
serverQuickConnect.value = quickConnect
}
_state.update { it.copy(quickConnectEnabled = quickConnect) }
} catch (_: CancellationException) {
// no-op, user may have canceled
} catch (ex: Exception) {
Timber.w(ex, "Error checking quick connect for server ${server.url}")
withContext(Dispatchers.Main) {
serverQuickConnect.value = false
}
_state.update { it.copy(quickConnectEnabled = false) }
}
}
}
fun switchUser(user: JellyfinUser) {
viewModelScope.launchIO {
fun trySwitchUser(user: JellyfinUser): Deferred<String?> =
viewModelScope.async(Dispatchers.IO) {
try {
val current = serverRepository.changeUser(server, user)
if (current != null) {
withContext(Dispatchers.Main) {
setupNavigationManager.navigateTo(SetupDestination.AppContent(current))
}
null
} catch (ex: InvalidStatusException) {
if (ex.status == 401) {
"Credentials expired, please login in again"
} else {
ex.localizedMessage
}
} catch (ex: Exception) {
Timber.e(ex, "Error switching user")
setError("Error switching user", ex)
}
ex.localizedMessage
}
}
@ -150,18 +150,11 @@ class SwitchUserViewModel
password = password,
)
val current = serverRepository.changeUser(server.url, authenticationResult)
if (current != null) {
withContext(Dispatchers.Main) {
setupNavigationManager.navigateTo(SetupDestination.AppContent(current))
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error logging in user")
if (ex is InvalidStatusException && ex.status == 401) {
withContext(Dispatchers.Main) {
switchUserState.value =
LoadingState.Error("Invalid username or password")
}
_state.update { it.copy(switchUserState = LoadingState.Error("Invalid username or password")) }
} else {
setError("Error during login", ex)
}
@ -175,44 +168,37 @@ class SwitchUserViewModel
viewModelScope.launchIO {
try {
val api = jellyfin.createApi(server.url)
var state =
var quickConnectStatus =
api
.quickConnectApi
.initiateQuickConnect()
.content
_state.update { it.copy(quickConnectStatus = quickConnectStatus) }
withContext(Dispatchers.Main) {
quickConnectState.value = state
}
while (!state.authenticated) {
while (!quickConnectStatus.authenticated) {
delay(5_000L)
state =
quickConnectStatus =
api.quickConnectApi
.getQuickConnectState(
secret = state.secret,
secret = quickConnectStatus.secret,
).content
withContext(Dispatchers.Main) {
quickConnectState.value = state
}
_state.update { it.copy(quickConnectStatus = quickConnectStatus) }
}
val authenticationResult by api.userApi.authenticateWithQuickConnect(
QuickConnectDto(secret = state.secret),
QuickConnectDto(secret = quickConnectStatus.secret),
)
val current = serverRepository.changeUser(server.url, authenticationResult)
if (current != null) {
withContext(Dispatchers.Main) {
setupNavigationManager.navigateTo(
SetupDestination.AppContent(current),
)
}
}
setupNavigationManager.navigateTo(SetupDestination.AppContent(current))
} catch (_: CancellationException) {
// no-op, user may have canceled
} catch (ex: Exception) {
Timber.e(ex, "Error during quick connect")
if (ex is InvalidStatusException && ex.status == 401) {
withContext(Dispatchers.Main) {
quickConnectState.value = null
serverQuickConnect.value = false
_state.update {
it.copy(
quickConnectEnabled = false,
quickConnectStatus = null,
)
}
}
setError("Error with Quick Connect", ex)
@ -222,39 +208,86 @@ class SwitchUserViewModel
fun cancelQuickConnect() {
quickConnectJob?.cancel()
quickConnectState.value = null
_state.update {
it.copy(
quickConnectStatus = null,
)
}
}
fun removeUser(user: JellyfinUser) {
viewModelScope.launchIO {
serverRepository.removeUser(user)
val serverUsers = getUsers()
withContext(Dispatchers.Main) {
users.value = serverUsers
}
_state.update { it.copy(users = serverUsers) }
}
}
private suspend fun getUsers(): List<JellyfinUserAndImage> {
private suspend fun getUsers(): List<JellyfinUserAndImage> =
withContext(Dispatchers.IO) {
val api = jellyfin.createApi(server.url)
return serverDao
val knownUsers =
serverDao
.getServer(server.id)
?.users
?.sortedBy { it.name }
?.map { JellyfinUserAndImage(it, api.imageApi.getUserImageUrl(it.id)) }
.orEmpty()
.map {
JellyfinUserAndImage(
user = it,
imageUrl = api.imageApi.getUserImageUrl(it.id),
known = true,
)
}
val knownUserIds = knownUsers.map { it.user.id }
val publicUsers =
api.userApi
.getPublicUsers()
.content
.map {
JellyfinUser(
id = it.id,
name = it.name,
serverId = server.id,
accessToken = null,
)
}.filter { it.id !in knownUserIds }
.map {
JellyfinUserAndImage(
user = it,
imageUrl = api.imageApi.getUserImageUrl(it.id),
known = false,
)
}
private suspend fun setError(
knownUsers + publicUsers
}
private fun setError(
msg: String? = null,
ex: Exception? = null,
) = withContext(Dispatchers.Main) {
loginAttempts.value = (loginAttempts.value ?: 0) + 1
switchUserState.value = LoadingState.Error(msg, ex)
) {
_state.update {
it.copy(
loginAttempts = it.loginAttempts + 1,
switchUserState = LoadingState.Error(msg, ex),
)
}
}
}
data class JellyfinUserAndImage(
val user: JellyfinUser,
val imageUrl: String?,
val known: Boolean,
)
data class SwitchUserState(
// LoadingState for fetching available users
val loading: LoadingState = LoadingState.Pending,
val quickConnectEnabled: Boolean = false,
val quickConnectStatus: QuickConnectResult? = null,
val users: List<JellyfinUserAndImage> = emptyList(),
// LoadingState for while adding/switching users
val switchUserState: LoadingState = LoadingState.Pending,
val loginAttempts: Int = 0,
)

View file

@ -58,6 +58,7 @@ import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
import java.util.UUID
@ -75,7 +76,7 @@ fun UserList(
onSwitchServer: () -> Unit,
modifier: Modifier = Modifier,
) {
var showDeleteDialog by remember { mutableStateOf<JellyfinUser?>(null) }
var showDeleteDialog by remember { mutableStateOf<JellyfinUserAndImage?>(null) }
Column(
modifier = modifier,
@ -106,7 +107,7 @@ fun UserList(
user = user,
isCurrentUser = user.user.id == currentUser?.id,
onClick = { onSwitchUser.invoke(user.user) },
onLongClick = { showDeleteDialog = user.user },
onLongClick = { showDeleteDialog = user },
modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier,
)
}
@ -152,28 +153,33 @@ fun UserList(
showDeleteDialog?.let { user ->
DialogPopup(
showDialog = true,
title = user.name ?: user.id.toString(),
title = user.user.name ?: user.user.id.toServerString(),
dialogItems =
listOf(
buildList {
add(
DialogItem(
stringResource(R.string.switch_user),
R.string.fa_arrow_left_arrow_right,
) {
onSwitchUser.invoke(user)
onSwitchUser.invoke(user.user)
},
)
if (user.known) {
add(
DialogItem(
stringResource(R.string.delete),
Icons.Default.Delete,
Color.Red.copy(alpha = .8f),
) {
onRemoveUser.invoke(user)
onRemoveUser.invoke(user.user)
},
)
}
},
),
onDismissRequest = { showDeleteDialog = null },
dismissOnClick = true,
waitToLoad = true,
properties = DialogProperties(),
elevation = 5.dp,
)
}
}

View file

@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
@ -184,11 +185,15 @@ class BasicUiTests {
composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed()
composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost")
composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter()
composeTestRule
.onNodeWithText("Submit")
.assertIsEnabled()
.requestFocus()
.performClickEnter()
TestModule.testDispatcher.scheduler.advanceUntilIdle()
switchServerViewModel.addServerState.value.let {
switchServerViewModel.state.value.addServerState.let {
if (it is LoadingState.Error) throw it.exception ?: Exception(it.message)
}
@ -249,11 +254,15 @@ class BasicUiTests {
composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed()
composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost")
composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter()
composeTestRule
.onNodeWithText("Submit")
.assertIsEnabled()
.requestFocus()
.performClickEnter()
TestModule.testDispatcher.scheduler.advanceUntilIdle()
Assert.assertTrue(switchServerViewModel.addServerState.value is LoadingState.Error)
Assert.assertTrue(switchServerViewModel.state.value.addServerState is LoadingState.Error)
composeTestRule.onNodeWithText("Server returned invalid response").assertIsDisplayed()
}