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 -> {
|
is SetupDestination.UserList -> {
|
||||||
SwitchUserContent(
|
SwitchUserContent(
|
||||||
currentServer = key.server,
|
server = key.server,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue