mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
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:
parent
b23d30f94b
commit
dbd17597b2
9 changed files with 579 additions and 627 deletions
|
|
@ -85,7 +85,7 @@ fun MainContent(
|
|||
|
||||
is SetupDestination.UserList -> {
|
||||
SwitchUserContent(
|
||||
currentServer = key.server,
|
||||
server = key.server,
|
||||
Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue