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 -> { is SetupDestination.UserList -> {
SwitchUserContent( SwitchUserContent(
currentServer = key.server, server = key.server,
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
) )
} }

View file

@ -73,7 +73,7 @@ class ServerRepository
suspend fun changeUser( suspend fun changeUser(
server: JellyfinServer, server: JellyfinServer,
user: JellyfinUser, user: JellyfinUser,
): CurrentUser? = ): CurrentUser =
withContext(ioDispatcher) { withContext(ioDispatcher) {
if (server.id != user.serverId) { if (server.id != user.serverId) {
throw IllegalStateException("User is not part of the server") throw IllegalStateException("User is not part of the server")
@ -104,15 +104,16 @@ class ServerRepository
currentUserId = updatedUser.id.toServerString() currentUserId = updatedUser.id.toServerString()
}.build() }.build()
} }
val currentUser = CurrentUser(updatedServer, updatedUser)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_current.value = CurrentUser(updatedServer, updatedUser) _current.value = currentUser
_currentUserDto.value = userDto _currentUserDto.value = userDto
} }
getServerSharedPreferences(context).edit(true) { getServerSharedPreferences(context).edit(true) {
putString(SERVER_URL_KEY, updatedServer.url) putString(SERVER_URL_KEY, updatedServer.url)
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Border import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.IconButtonDefaults
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text 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.data.model.JellyfinServer
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.CircularProgress 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 org.jellyfin.sdk.model.api.PublicSystemInfo
import java.util.UUID import java.util.UUID
@ -62,120 +49,6 @@ sealed interface ServerConnectionStatus {
) : 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 * 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.material.icons.filled.Delete
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -43,11 +43,14 @@ 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
import com.github.damontecres.wholphin.R 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.BasicDialog
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EditTextBox 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.components.TextButton
import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.dimAndBlur
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
@ -60,16 +63,30 @@ fun SwitchServerContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SwitchServerViewModel = hiltViewModel(), viewModel: SwitchServerViewModel = hiltViewModel(),
) { ) {
val servers by viewModel.servers.observeAsState(listOf()) val state by viewModel.state.collectAsState()
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) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init() 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( Box(
modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null), modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null),
) { ) {
@ -108,7 +125,7 @@ fun SwitchServerContent(
) { ) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val firstServerFocus = remember { FocusRequester() } val firstServerFocus = remember { FocusRequester() }
if (servers.isNotEmpty()) { if (state.servers.isNotEmpty()) {
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
} }
LazyRow( LazyRow(
@ -120,16 +137,15 @@ fun SwitchServerContent(
.focusRestorer(firstServerFocus) .focusRestorer(firstServerFocus)
.focusRequester(focusRequester), .focusRequester(focusRequester),
) { ) {
itemsIndexed(servers) { index, server -> itemsIndexed(state.servers) { index, server ->
val status = serverStatus[server.id] ?: ServerConnectionStatus.Pending
ServerIconCard( ServerIconCard(
server = server, server = server.server,
connectionStatus = status, connectionStatus = server.status,
isCurrentServer = false, // TODO: Determine current server if needed isCurrentServer = false, // TODO: Determine current server if needed
onClick = { onClick = {
when (status) { when (server.status) {
is ServerConnectionStatus.Success -> { is ServerConnectionStatus.Success -> {
viewModel.switchServer(server) viewModel.switchServer(server.server)
} }
ServerConnectionStatus.Pending -> { ServerConnectionStatus.Pending -> {
@ -137,21 +153,30 @@ fun SwitchServerContent(
} }
is ServerConnectionStatus.Error -> { is ServerConnectionStatus.Error -> {
viewModel.testServer(server) viewModel.testServer(server.server)
} }
} }
}, },
onLongClick = { onLongClick = {
showDeleteDialog = server showDeleteDialog = server.server
}, },
allowDelete = true, allowDelete = true,
modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstServerFocus)), modifier =
Modifier.ifElse(
index == 0,
Modifier.focusRequester(firstServerFocus),
),
) )
} }
// Add Server card - always rightmost // Add Server card - always rightmost
item { item {
AddServerCard( AddServerCard(
onClick = { showAddServer = true }, 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) // Filter out duplicates within the discovered servers list (same URL appearing multiple times)
val filteredDiscoveredServers = val filteredDiscoveredServers =
remember(discoveredServers) { remember(state.discoveredServers) {
val seenUrls = mutableSetOf<String>() val seenUrls = mutableSetOf<String>()
discoveredServers.filter { server -> state.discoveredServers.filter { server ->
val normalizedUrl = server.url.lowercase().trim() val normalizedUrl = server.url.lowercase().trim()
if (normalizedUrl in seenUrls) { if (normalizedUrl in seenUrls) {
false // Duplicate, filter it out false // Duplicate, filter it out
@ -258,7 +281,7 @@ fun SwitchServerContent(
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
if (filteredDiscoveredServers.isEmpty() && discoveredServers.isEmpty()) { if (filteredDiscoveredServers.isEmpty() && state.discoveredServers.isEmpty()) {
Text( Text(
text = stringResource(R.string.searching), text = stringResource(R.string.searching),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@ -294,7 +317,9 @@ fun SwitchServerContent(
selected = false, selected = false,
headlineContent = { headlineContent = {
Text( Text(
text = server.name?.ifBlank { null } ?: server.url, text =
server.name?.ifBlank { null }
?: server.url,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
) )
}, },
@ -323,7 +348,7 @@ fun SwitchServerContent(
} }
} else { } else {
// Show enter server address form // Show enter server address form
val state by viewModel.addServerState.observeAsState(LoadingState.Pending) val addServerState = state.addServerState
var url by remember { mutableStateOf("") } var url by remember { mutableStateOf("") }
val submit = { val submit = {
viewModel.addServer(url) viewModel.addServer(url)
@ -357,7 +382,7 @@ fun SwitchServerContent(
.focusRequester(textBoxFocusRequester) .focusRequester(textBoxFocusRequester)
.fillMaxWidth(), .fillMaxWidth(),
) )
when (val st = state) { when (val st = addServerState) {
is LoadingState.Error -> { is LoadingState.Error -> {
Text( Text(
text = text =
@ -371,10 +396,10 @@ fun SwitchServerContent(
} }
TextButton( TextButton(
onClick = { submit.invoke() }, onClick = { submit.invoke() },
enabled = url.isNotNullOrBlank() && state == LoadingState.Pending, enabled = url.isNotNullOrBlank() && addServerState == LoadingState.Pending,
modifier = Modifier, modifier = Modifier,
) { ) {
if (state == LoadingState.Loading) { if (addServerState == LoadingState.Loading) {
CircularProgress(Modifier.size(32.dp)) CircularProgress(Modifier.size(32.dp))
} else { } else {
Text(text = stringResource(R.string.submit)) 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.content.Context
import android.widget.Toast import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R 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.data.model.JellyfinServer
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager 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.launchIO
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay 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.Jellyfin
import org.jellyfin.sdk.api.client.HttpClientOptions 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.api.client.extensions.systemApi
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.discovery.RecommendedServerIssue import org.jellyfin.sdk.discovery.RecommendedServerIssue
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -45,52 +39,54 @@ class SwitchServerViewModel
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val serverDao: JellyfinServerDao, val serverDao: JellyfinServerDao,
val navigationManager: SetupNavigationManager, val navigationManager: SetupNavigationManager,
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
val servers = MutableLiveData<List<JellyfinServer>>(listOf()) private val _state = MutableStateFlow(SwitchServerState())
val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf()) val state: StateFlow<SwitchServerState> = _state
val serverQuickConnect = MutableLiveData<Map<UUID, Boolean>>(mapOf())
val discoveredServers = MutableLiveData<List<JellyfinServer>>(listOf())
val addServerState = MutableLiveData<LoadingState>(LoadingState.Pending)
fun clearAddServerState() { fun clearAddServerState() {
addServerState.value = LoadingState.Pending _state.update { it.copy(addServerState = LoadingState.Pending) }
} }
fun init() { fun init() {
viewModelScope.launchIO { viewModelScope.launchIO {
withContext(Dispatchers.Main) { _state.update { SwitchServerState() }
serverStatus.value = mapOf()
serverQuickConnect.value = mapOf()
}
val allServers = val allServers =
serverDao serverDao
.getServers() .getServers()
.map { it.server } .map { it.server }
.sortedWith(compareBy<JellyfinServer> { it.name }.thenBy { it.url }) .sortedWith(compareBy<JellyfinServer> { it.name }.thenBy { it.url })
withContext(Dispatchers.Main) { .map { ServerState(it) }
servers.value = allServers _state.update {
it.copy(
loading = LoadingState.Success,
servers = allServers,
)
} }
allServers.forEach { server -> allServers.forEach { server ->
internalTestServer(server) internalTestServer(server.server)
} }
} }
} }
fun testServer(server: JellyfinServer) { private fun updateServerState(
serverStatus.value = server: JellyfinServer,
serverStatus.value!!.toMutableMap().apply { result: ServerConnectionStatus,
put( ) {
server.id, _state.update {
ServerConnectionStatus.Pending, 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 { viewModelScope.launchIO {
delay(1000) delay(500)
val result = internalTestServer(server) val result = internalTestServer(server)
if (result is ServerConnectionStatus.Success) { if (result is ServerConnectionStatus.Success) {
showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT) 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 { try {
val systemInfo = val systemInfo =
jellyfin jellyfin
@ -115,43 +112,18 @@ class SwitchServerViewModel
).systemApi ).systemApi
.getPublicSystemInfo() .getPublicSystemInfo()
.content .content
val result = ServerConnectionStatus.Success(systemInfo) ServerConnectionStatus.Success(systemInfo)
withContext(Dispatchers.Main) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
result,
)
}
}
result
} catch (ex: Exception) { } catch (ex: Exception) {
val status = ServerConnectionStatus.Error(ex.localizedMessage)
Timber.w(ex, "Error checking server ${server.url}") Timber.w(ex, "Error checking server ${server.url}")
withContext(Dispatchers.Main) { ServerConnectionStatus.Error(ex.localizedMessage)
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
status,
)
} }
} updateServerState(server, result)
status return result
} }
fun switchServer(server: JellyfinServer) { fun switchServer(server: JellyfinServer) {
viewModelScope.launchIO { viewModelScope.launchIO {
withContext(Dispatchers.Main) { updateServerState(server, ServerConnectionStatus.Pending)
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
ServerConnectionStatus.Pending,
)
}
}
val result = internalTestServer(server) val result = internalTestServer(server)
if (result is ServerConnectionStatus.Success) { if (result is ServerConnectionStatus.Success) {
val updatedServer = val updatedServer =
@ -160,9 +132,7 @@ class SwitchServerViewModel
version = result.systemInfo.version, version = result.systemInfo.version,
) )
serverRepository.addAndChangeServer(updatedServer) serverRepository.addAndChangeServer(updatedServer)
withContext(Dispatchers.Main) {
navigationManager.navigateTo(SetupDestination.UserList(updatedServer)) navigationManager.navigateTo(SetupDestination.UserList(updatedServer))
}
} else if (result is ServerConnectionStatus.Error) { } else if (result is ServerConnectionStatus.Error) {
showToast(context, "Error connecting: $${result.message}") showToast(context, "Error connecting: $${result.message}")
} }
@ -170,7 +140,7 @@ class SwitchServerViewModel
} }
fun addServer(inputUrl: String) { fun addServer(inputUrl: String) {
addServerState.value = LoadingState.Loading _state.update { it.copy(addServerState = LoadingState.Loading) }
viewModelScope.launchIO { viewModelScope.launchIO {
try { try {
val scores = val scores =
@ -192,27 +162,10 @@ class SwitchServerViewModel
version = serverInfo.version, version = serverInfo.version,
) )
serverRepository.addAndChangeServer(server) serverRepository.addAndChangeServer(server)
val quickConnect = _state.update { it.copy(addServerState = LoadingState.Success) }
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
navigationManager.navigateTo(SetupDestination.UserList(server)) navigationManager.navigateTo(SetupDestination.UserList(server))
}
} else { } else {
withContext(Dispatchers.Main) { _state.update { it.copy(addServerState = LoadingState.Error("Server returned invalid response")) }
addServerState.value =
LoadingState.Error("Server returned invalid response")
}
} }
} else { } else {
Timber.w("Error connecting with %s: %s", inputUrl, scores) Timber.w("Error connecting with %s: %s", inputUrl, scores)
@ -240,14 +193,11 @@ class SwitchServerViewModel
"${it.address} - $issues" "${it.address} - $issues"
} }
val message = "Error, tried addresses:\n$errors" val message = "Error, tried addresses:\n$errors"
addServerState.setValueOnMain(LoadingState.Error(message)) _state.update { it.copy(addServerState = LoadingState.Error(message)) }
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.w(ex, "Error creating API for $inputUrl") Timber.w(ex, "Error creating API for $inputUrl")
withContext(Dispatchers.Main) { _state.update { it.copy(addServerState = LoadingState.Error(exception = ex)) }
addServerState.value =
LoadingState.Error(exception = ex)
}
} }
} }
} }
@ -262,23 +212,37 @@ class SwitchServerViewModel
fun discoverServers() { fun discoverServers() {
viewModelScope.launchIO { viewModelScope.launchIO {
jellyfin.discovery.discoverLocalServers().collect { server -> jellyfin.discovery.discoverLocalServers().collect { server ->
val newServerList = val jellyfinServer =
discoveredServers.value!!
.toMutableList()
.apply {
add(
JellyfinServer( JellyfinServer(
server.id.toUUID(), server.id.toUUID(),
server.name, server.name,
server.address, server.address,
null, 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.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.BasicDialog
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.EditTextBox 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.components.TextButton
import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.dimAndBlur
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
@Composable @Composable
fun SwitchUserContent( fun SwitchUserContent(
currentServer: JellyfinServer, server: JellyfinServer,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SwitchUserViewModel = viewModel: SwitchUserViewModel =
hiltViewModel<SwitchUserViewModel, SwitchUserViewModel.Factory>( hiltViewModel<SwitchUserViewModel, SwitchUserViewModel.Factory>(
creationCallback = { it.create(currentServer) }, creationCallback = { it.create(server) },
), ),
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init() viewModel.init()
} }
// val currentServer by viewModel.serverRepository.currentServer.observeAsState() val state by viewModel.state.collectAsState()
val currentUser by viewModel.serverRepository.currentUser.observeAsState() 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 showAddUser by remember { mutableStateOf(false) }
var username by remember(server) { mutableStateOf("") }
val userState by viewModel.switchUserState.observeAsState(LoadingState.Pending) fun showAddUserDialog(user: JellyfinUser?) {
val loginAttempts by viewModel.loginAttempts.observeAsState(0) username = user?.name ?: ""
LaunchedEffect(userState) { showAddUser = true
}
LaunchedEffect(state.switchUserState) {
if (!showAddUser) { if (!showAddUser) {
when (val s = userState) { when (val s = state.switchUserState) {
is LoadingState.Error -> { is LoadingState.Error -> {
val msg = s.message ?: s.exception?.localizedMessage val msg = s.message ?: s.exception?.localizedMessage
Toast.makeText(context, "Error: $msg", Toast.LENGTH_LONG).show() Toast.makeText(context, "Error: $msg", Toast.LENGTH_LONG).show()
@ -86,7 +93,18 @@ fun SwitchUserContent(
} }
var switchUserWithPin by remember { mutableStateOf<JellyfinUser?>(null) } 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( Box(
modifier = modifier.dimAndBlur(showAddUser || switchUserWithPin != null), modifier = modifier.dimAndBlur(showAddUser || switchUserWithPin != null),
) { ) {
@ -115,16 +133,26 @@ fun SwitchUserContent(
) )
} }
UserList( UserList(
users = users, users = state.users,
currentUser = currentUser, currentUser = currentUser,
onSwitchUser = { user -> onSwitchUser = { user ->
if (user.hasPin) { if (user.accessToken == null) {
showAddUserDialog(user)
} else if (user.hasPin) {
switchUserWithPin = user switchUserWithPin = user
} else { } 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 -> onRemoveUser = { user ->
viewModel.removeUser(user) viewModel.removeUser(user)
}, },
@ -137,9 +165,11 @@ fun SwitchUserContent(
) )
} }
} }
}
}
if (showAddUser) { if (showAddUser) {
var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) } var useQuickConnect by remember { mutableStateOf(state.quickConnectEnabled) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.clearSwitchUserState() viewModel.clearSwitchUserState()
viewModel.resetAttempts() viewModel.resetAttempts()
@ -166,7 +196,7 @@ fun SwitchUserContent(
.fillMaxWidth(.4f), .fillMaxWidth(.4f),
) { ) {
if (useQuickConnect) { if (useQuickConnect) {
if (quickConnect == null && userState !is LoadingState.Error) { if (state.quickConnectStatus == null && state.switchUserState !is LoadingState.Error) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@ -184,7 +214,7 @@ fun SwitchUserContent(
modifier = Modifier, modifier = Modifier,
) )
} }
} else if (quickConnect != null) { } else if (state.quickConnectStatus != null) {
Text( Text(
text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}", text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@ -193,13 +223,13 @@ fun SwitchUserContent(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Text( Text(
text = quickConnect?.code ?: "Failed to get code", text = state.quickConnectStatus?.code ?: "Failed to get code",
style = MaterialTheme.typography.displayMedium, style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
) )
} }
UserStateError(userState) UserStateError(state.switchUserState)
TextButton( TextButton(
stringRes = R.string.username_or_password, stringRes = R.string.username_or_password,
onClick = { onClick = {
@ -210,9 +240,6 @@ fun SwitchUserContent(
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
) )
} else { } else {
// val username = rememberTextFieldState()
// val password = rememberTextFieldState()
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
val onSubmit = { val onSubmit = {
viewModel.login( viewModel.login(
@ -223,7 +250,13 @@ fun SwitchUserContent(
} }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) {
if (username.isBlank()) {
focusRequester.tryRequestFocus()
} else {
passwordFocusRequester.tryRequestFocus()
}
}
Text( Text(
text = "Enter username/password to login to ${server.name ?: server.url}", text = "Enter username/password to login to ${server.name ?: server.url}",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@ -231,13 +264,13 @@ fun SwitchUserContent(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
UserStateError(userState) UserStateError(state.switchUserState)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
) { ) {
Text( Text(
text = "Username", text = stringResource(R.string.username),
modifier = Modifier.padding(end = 8.dp), modifier = Modifier.padding(end = 8.dp),
) )
EditTextBox( EditTextBox(
@ -259,7 +292,7 @@ fun SwitchUserContent(
// onKeyboardAction = { // onKeyboardAction = {
// passwordFocusRequester.tryRequestFocus() // passwordFocusRequester.tryRequestFocus()
// }, // },
isInputValid = { userState !is LoadingState.Error }, isInputValid = { state.switchUserState !is LoadingState.Error },
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(focusRequester),
) )
} }
@ -268,7 +301,7 @@ fun SwitchUserContent(
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
) { ) {
Text( Text(
text = "Password", text = stringResource(R.string.password),
modifier = Modifier.padding(end = 8.dp), modifier = Modifier.padding(end = 8.dp),
) )
LaunchedEffect(password) { LaunchedEffect(password) {
@ -288,7 +321,7 @@ fun SwitchUserContent(
KeyboardActions( KeyboardActions(
onGo = { onSubmit.invoke() }, onGo = { onSubmit.invoke() },
), ),
isInputValid = { userState !is LoadingState.Error }, isInputValid = { state.switchUserState !is LoadingState.Error },
modifier = Modifier.focusRequester(passwordFocusRequester), modifier = Modifier.focusRequester(passwordFocusRequester),
) )
} }
@ -299,7 +332,7 @@ fun SwitchUserContent(
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
) )
} }
if (loginAttempts > 2) { if (state.loginAttempts > 2) {
Text( Text(
text = "Trouble logging in?", text = "Trouble logging in?",
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
@ -319,15 +352,23 @@ fun SwitchUserContent(
PinEntryDialog( PinEntryDialog(
onDismissRequest = { switchUserWithPin = null }, onDismissRequest = { switchUserWithPin = null },
onClickServerAuth = { onClickServerAuth = {
showAddUser = true showAddUserDialog(user)
switchUserWithPin = null switchUserWithPin = null
}, },
onTextChange = { 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 @Composable

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.ui.setup package com.github.damontecres.wholphin.ui.setup
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.JellyfinServerDao 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.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager 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.launchIO
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.delay 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 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.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.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.quickConnectApi 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.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.QuickConnectDto import org.jellyfin.sdk.model.api.QuickConnectDto
import org.jellyfin.sdk.model.api.QuickConnectResult import org.jellyfin.sdk.model.api.QuickConnectResult
@ -54,44 +56,47 @@ class SwitchUserViewModel
fun create(server: JellyfinServer): SwitchUserViewModel fun create(server: JellyfinServer): SwitchUserViewModel
} }
val serverQuickConnect = MutableLiveData<Boolean>(false) private val _state = MutableStateFlow(SwitchUserState())
val state: StateFlow<SwitchUserState> = _state
val users = MutableLiveData<List<JellyfinUserAndImage>>(listOf())
val quickConnectState = MutableLiveData<QuickConnectResult?>(null)
private var quickConnectJob: Job? = null private var quickConnectJob: Job? = null
val switchUserState = MutableLiveData<LoadingState>(LoadingState.Pending)
val loginAttempts = MutableLiveData(0)
fun clearSwitchUserState() { fun clearSwitchUserState() {
switchUserState.value = LoadingState.Pending _state.update { it.copy(switchUserState = LoadingState.Pending) }
} }
fun resetAttempts() { fun resetAttempts() {
loginAttempts.value = 0 _state.update { it.copy(loginAttempts = 0) }
}
init {
init()
} }
fun init() { fun init() {
viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) { viewModelScope.launchDefault {
serverRepository.switchServerOrUser() serverRepository.switchServerOrUser()
} }
quickConnectJob?.cancel() quickConnectJob?.cancel()
viewModelScope.launchIO { viewModelScope.launchIO {
users.setValueOnMain(listOf()) _state.update { SwitchUserState() }
try {
val serverUsers = getUsers() val serverUsers = getUsers()
withContext(Dispatchers.Main) { _state.update {
users.setValueOnMain(serverUsers) 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 { viewModelScope.launchIO {
try { try {
val quickConnect by
jellyfin jellyfin
.createApi( .createApi(
server.url, server.url,
@ -101,38 +106,33 @@ class SwitchUserViewModel
connectTimeout = 6.seconds, connectTimeout = 6.seconds,
socketTimeout = 6.seconds, socketTimeout = 6.seconds,
), ),
).systemApi ).quickConnectApi
.getPublicSystemInfo()
val quickConnect by
jellyfin
.createApi(server.url)
.quickConnectApi
.getQuickConnectEnabled() .getQuickConnectEnabled()
withContext(Dispatchers.Main) { _state.update { it.copy(quickConnectEnabled = quickConnect) }
serverQuickConnect.value = quickConnect } catch (_: CancellationException) {
} // no-op, user may have canceled
} 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}")
withContext(Dispatchers.Main) { _state.update { it.copy(quickConnectEnabled = false) }
serverQuickConnect.value = false
}
} }
} }
} }
fun switchUser(user: JellyfinUser) { fun trySwitchUser(user: JellyfinUser): Deferred<String?> =
viewModelScope.launchIO { viewModelScope.async(Dispatchers.IO) {
try { try {
val current = serverRepository.changeUser(server, user) val current = serverRepository.changeUser(server, user)
if (current != null) {
withContext(Dispatchers.Main) {
setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) 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) { } catch (ex: Exception) {
Timber.e(ex, "Error switching user") Timber.e(ex, "Error switching user")
setError("Error switching user", ex) ex.localizedMessage
}
} }
} }
@ -150,18 +150,11 @@ class SwitchUserViewModel
password = password, password = password,
) )
val current = serverRepository.changeUser(server.url, authenticationResult) val current = serverRepository.changeUser(server.url, authenticationResult)
if (current != null) {
withContext(Dispatchers.Main) {
setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) setupNavigationManager.navigateTo(SetupDestination.AppContent(current))
}
}
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error logging in user") Timber.e(ex, "Error logging in user")
if (ex is InvalidStatusException && ex.status == 401) { if (ex is InvalidStatusException && ex.status == 401) {
withContext(Dispatchers.Main) { _state.update { it.copy(switchUserState = LoadingState.Error("Invalid username or password")) }
switchUserState.value =
LoadingState.Error("Invalid username or password")
}
} else { } else {
setError("Error during login", ex) setError("Error during login", ex)
} }
@ -175,44 +168,37 @@ class SwitchUserViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
try { try {
val api = jellyfin.createApi(server.url) val api = jellyfin.createApi(server.url)
var state = var quickConnectStatus =
api api
.quickConnectApi .quickConnectApi
.initiateQuickConnect() .initiateQuickConnect()
.content .content
_state.update { it.copy(quickConnectStatus = quickConnectStatus) }
withContext(Dispatchers.Main) { while (!quickConnectStatus.authenticated) {
quickConnectState.value = state
}
while (!state.authenticated) {
delay(5_000L) delay(5_000L)
state = quickConnectStatus =
api.quickConnectApi api.quickConnectApi
.getQuickConnectState( .getQuickConnectState(
secret = state.secret, secret = quickConnectStatus.secret,
).content ).content
withContext(Dispatchers.Main) { _state.update { it.copy(quickConnectStatus = quickConnectStatus) }
quickConnectState.value = state
}
} }
val authenticationResult by api.userApi.authenticateWithQuickConnect( val authenticationResult by api.userApi.authenticateWithQuickConnect(
QuickConnectDto(secret = state.secret), QuickConnectDto(secret = quickConnectStatus.secret),
) )
val current = serverRepository.changeUser(server.url, authenticationResult) val current = serverRepository.changeUser(server.url, authenticationResult)
if (current != null) { setupNavigationManager.navigateTo(SetupDestination.AppContent(current))
withContext(Dispatchers.Main) { } catch (_: CancellationException) {
setupNavigationManager.navigateTo( // no-op, user may have canceled
SetupDestination.AppContent(current),
)
}
}
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error during quick connect") Timber.e(ex, "Error during quick connect")
if (ex is InvalidStatusException && ex.status == 401) { if (ex is InvalidStatusException && ex.status == 401) {
withContext(Dispatchers.Main) { _state.update {
quickConnectState.value = null it.copy(
serverQuickConnect.value = false quickConnectEnabled = false,
quickConnectStatus = null,
)
} }
} }
setError("Error with Quick Connect", ex) setError("Error with Quick Connect", ex)
@ -222,39 +208,86 @@ class SwitchUserViewModel
fun cancelQuickConnect() { fun cancelQuickConnect() {
quickConnectJob?.cancel() quickConnectJob?.cancel()
quickConnectState.value = null _state.update {
it.copy(
quickConnectStatus = null,
)
}
} }
fun removeUser(user: JellyfinUser) { fun removeUser(user: JellyfinUser) {
viewModelScope.launchIO { viewModelScope.launchIO {
serverRepository.removeUser(user) serverRepository.removeUser(user)
val serverUsers = getUsers() val serverUsers = getUsers()
withContext(Dispatchers.Main) { _state.update { it.copy(users = serverUsers) }
users.value = serverUsers
}
} }
} }
private suspend fun getUsers(): List<JellyfinUserAndImage> { private suspend fun getUsers(): List<JellyfinUserAndImage> =
withContext(Dispatchers.IO) {
val api = jellyfin.createApi(server.url) val api = jellyfin.createApi(server.url)
return serverDao val knownUsers =
serverDao
.getServer(server.id) .getServer(server.id)
?.users ?.users
?.sortedBy { it.name }
?.map { JellyfinUserAndImage(it, api.imageApi.getUserImageUrl(it.id)) }
.orEmpty() .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, msg: String? = null,
ex: Exception? = null, ex: Exception? = null,
) = withContext(Dispatchers.Main) { ) {
loginAttempts.value = (loginAttempts.value ?: 0) + 1 _state.update {
switchUserState.value = LoadingState.Error(msg, ex) it.copy(
loginAttempts = it.loginAttempts + 1,
switchUserState = LoadingState.Error(msg, ex),
)
}
} }
} }
data class JellyfinUserAndImage( data class JellyfinUserAndImage(
val user: JellyfinUser, val user: JellyfinUser,
val imageUrl: String?, 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.components.DialogPopup
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import java.util.UUID import java.util.UUID
@ -75,7 +76,7 @@ fun UserList(
onSwitchServer: () -> Unit, onSwitchServer: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var showDeleteDialog by remember { mutableStateOf<JellyfinUser?>(null) } var showDeleteDialog by remember { mutableStateOf<JellyfinUserAndImage?>(null) }
Column( Column(
modifier = modifier, modifier = modifier,
@ -106,7 +107,7 @@ fun UserList(
user = user, user = user,
isCurrentUser = user.user.id == currentUser?.id, isCurrentUser = user.user.id == currentUser?.id,
onClick = { onSwitchUser.invoke(user.user) }, onClick = { onSwitchUser.invoke(user.user) },
onLongClick = { showDeleteDialog = user.user }, onLongClick = { showDeleteDialog = user },
modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier, modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier,
) )
} }
@ -152,28 +153,33 @@ fun UserList(
showDeleteDialog?.let { user -> showDeleteDialog?.let { user ->
DialogPopup( DialogPopup(
showDialog = true, showDialog = true,
title = user.name ?: user.id.toString(), title = user.user.name ?: user.user.id.toServerString(),
dialogItems = dialogItems =
listOf( buildList {
add(
DialogItem( DialogItem(
stringResource(R.string.switch_user), stringResource(R.string.switch_user),
R.string.fa_arrow_left_arrow_right, R.string.fa_arrow_left_arrow_right,
) { ) {
onSwitchUser.invoke(user) onSwitchUser.invoke(user.user)
}, },
)
if (user.known) {
add(
DialogItem( DialogItem(
stringResource(R.string.delete), stringResource(R.string.delete),
Icons.Default.Delete, Icons.Default.Delete,
Color.Red.copy(alpha = .8f), Color.Red.copy(alpha = .8f),
) { ) {
onRemoveUser.invoke(user) onRemoveUser.invoke(user.user)
},
)
}
}, },
),
onDismissRequest = { showDeleteDialog = null }, onDismissRequest = { showDeleteDialog = null },
dismissOnClick = true, dismissOnClick = true,
waitToLoad = true, waitToLoad = true,
properties = DialogProperties(), 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.input.key.Key
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
@ -184,11 +185,15 @@ class BasicUiTests {
composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed()
composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost")
composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter() composeTestRule
.onNodeWithText("Submit")
.assertIsEnabled()
.requestFocus()
.performClickEnter()
TestModule.testDispatcher.scheduler.advanceUntilIdle() TestModule.testDispatcher.scheduler.advanceUntilIdle()
switchServerViewModel.addServerState.value.let { switchServerViewModel.state.value.addServerState.let {
if (it is LoadingState.Error) throw it.exception ?: Exception(it.message) 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.onNodeWithText("Enter Server IP or URL").assertIsDisplayed()
composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost")
composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter() composeTestRule
.onNodeWithText("Submit")
.assertIsEnabled()
.requestFocus()
.performClickEnter()
TestModule.testDispatcher.scheduler.advanceUntilIdle() 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() composeTestRule.onNodeWithText("Server returned invalid response").assertIsDisplayed()
} }