mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Redesign Select Server and Select User screens (#478)
Redesign Select Server and Select User screens with horizontal card layout, similar to traditional streaming services - Replace vertical lists with horizontal list of icons - Center align text in Quick Connect and Username/Password modals - Display Jellyfin profile images - Fallback to first letter of username if no image is available - Filter out duplicate server entries in the auto-discovery list --------- Co-authored-by: Damontecres <damontecres@gmail.com>
This commit is contained in:
parent
bbfbc13532
commit
801ff5e67b
8 changed files with 886 additions and 225 deletions
|
|
@ -145,6 +145,8 @@ class ImageUrlService
|
|||
imageIndex = imageIndex,
|
||||
)
|
||||
|
||||
fun getUserImageUrl(userId: UUID) = api.imageApi.getUserImageUrl(userId)
|
||||
|
||||
/**
|
||||
* Just a convenient way to get the image URL and remember it
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ val SlimItemFields =
|
|||
object Cards {
|
||||
val height2x3 = 172.dp
|
||||
val playedPercentHeight = 6.dp
|
||||
val serverUserCircle = height2x3 * .75f
|
||||
}
|
||||
|
||||
object AspectRatios {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
package com.github.damontecres.wholphin.ui.setup
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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
|
||||
|
|
@ -13,18 +22,26 @@ 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
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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
|
||||
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
|
||||
|
|
@ -157,3 +174,255 @@ fun ServerList(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a consistent color for a UUID
|
||||
*/
|
||||
@Composable
|
||||
fun rememberIdColor(id: UUID): Color =
|
||||
remember(id) {
|
||||
// Generate a color based on the server ID hash, fallback to URL hash
|
||||
val hash = id.hashCode()
|
||||
val hue = (hash % 360).toFloat()
|
||||
val saturation = 0.6f + ((hash / 360) % 40).toFloat() / 100f // 0.6-1.0
|
||||
val brightness = 0.4f + ((hash / 14400) % 30).toFloat() / 100f // 0.4-0.7 (darker colors)
|
||||
|
||||
// Convert HSV to RGB
|
||||
val c = brightness * saturation
|
||||
val x = c * (1 - kotlin.math.abs((hue / 60f) % 2f - 1))
|
||||
val m = brightness - c
|
||||
|
||||
val (r, g, b) =
|
||||
when {
|
||||
hue < 60 -> Triple(c, x, 0f)
|
||||
hue < 120 -> Triple(x, c, 0f)
|
||||
hue < 180 -> Triple(0f, c, x)
|
||||
hue < 240 -> Triple(0f, x, c)
|
||||
hue < 300 -> Triple(x, 0f, c)
|
||||
else -> Triple(c, 0f, x)
|
||||
}
|
||||
|
||||
Color(
|
||||
red = (r + m).coerceIn(0f, 1f),
|
||||
green = (g + m).coerceIn(0f, 1f),
|
||||
blue = (b + m).coerceIn(0f, 1f),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Server icon card component - displays a circular card with server name/letter
|
||||
*/
|
||||
@Composable
|
||||
fun ServerIconCard(
|
||||
server: JellyfinServer,
|
||||
connectionStatus: ServerConnectionStatus,
|
||||
isCurrentServer: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
allowDelete: Boolean = false,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
// Generate unique color for this server
|
||||
val serverColor = rememberIdColor(server.id)
|
||||
|
||||
// Card dimensions - circular card
|
||||
val cardSize = Cards.serverUserCircle
|
||||
|
||||
val displayText =
|
||||
remember(server) {
|
||||
(server.name ?: server.url.replace(Regex("^https?://"), ""))
|
||||
.firstOrNull()
|
||||
?.uppercase()
|
||||
?: "?"
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
// Circular card with colored background
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
onLongClick = if (allowDelete) onLongClick else null,
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier.size(cardSize),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
||||
colors =
|
||||
ClickableSurfaceDefaults.colors(
|
||||
containerColor =
|
||||
if (isCurrentServer) {
|
||||
serverColor.copy(alpha = 0.7f)
|
||||
} else {
|
||||
serverColor.copy(alpha = 0.5f)
|
||||
},
|
||||
focusedContainerColor =
|
||||
if (isCurrentServer) {
|
||||
serverColor.copy(alpha = 0.9f)
|
||||
} else {
|
||||
serverColor.copy(alpha = 0.7f)
|
||||
},
|
||||
),
|
||||
border =
|
||||
ClickableSurfaceDefaults.border(
|
||||
focusedBorder =
|
||||
Border(
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 3.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
shape = CircleShape,
|
||||
),
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Show connection status indicator or server name/letter
|
||||
when (connectionStatus) {
|
||||
is ServerConnectionStatus.Success -> {
|
||||
// Show server name/letter
|
||||
|
||||
Text(
|
||||
text = displayText,
|
||||
style =
|
||||
MaterialTheme.typography.displayLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
ServerConnectionStatus.Pending -> {
|
||||
CircularProgress(
|
||||
modifier = Modifier.size(cardSize * 0.4f),
|
||||
)
|
||||
}
|
||||
|
||||
is ServerConnectionStatus.Error -> {
|
||||
// Show warning icon with server letter below
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = connectionStatus.message,
|
||||
tint = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.size(cardSize * 0.3f),
|
||||
)
|
||||
Text(
|
||||
text = displayText,
|
||||
style =
|
||||
MaterialTheme.typography.titleLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Server name below the card
|
||||
Text(
|
||||
text = server.name?.ifBlank { null } ?: server.url,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.width(cardSize)
|
||||
.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Server card component - displays a + icon in a circle
|
||||
*/
|
||||
@Composable
|
||||
fun AddServerCard(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
// Use a neutral gray color for the add server card
|
||||
val addServerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
|
||||
// Card dimensions - circular card (same as server cards)
|
||||
val cardSize = Cards.height2x3 * 0.75f // ~120dp
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
// Circular card with colored background
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier.size(cardSize),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
||||
colors =
|
||||
ClickableSurfaceDefaults.colors(
|
||||
containerColor = addServerColor.copy(alpha = 0.4f),
|
||||
focusedContainerColor = addServerColor.copy(alpha = 0.6f),
|
||||
),
|
||||
border =
|
||||
ClickableSurfaceDefaults.border(
|
||||
focusedBorder =
|
||||
Border(
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 3.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
shape = CircleShape,
|
||||
),
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = stringResource(R.string.add_server),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(cardSize * 0.4f), // Size of the + icon
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// "Add Server" text below the card
|
||||
Text(
|
||||
text = stringResource(R.string.add_server),
|
||||
style =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.width(cardSize)
|
||||
.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,23 @@
|
|||
package com.github.damontecres.wholphin.ui.setup
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.getValue
|
||||
|
|
@ -22,6 +29,8 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
|
|
@ -29,19 +38,21 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.tv.material3.ListItem
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.R
|
||||
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.TextButton
|
||||
import com.github.damontecres.wholphin.ui.dimAndBlur
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||
|
||||
@Composable
|
||||
fun SwitchServerContent(
|
||||
|
|
@ -51,143 +62,185 @@ fun SwitchServerContent(
|
|||
val servers by viewModel.servers.observeAsState(listOf())
|
||||
val serverStatus by viewModel.serverStatus.observeAsState(mapOf())
|
||||
|
||||
val discoveredServers by viewModel.discoveredServers.observeAsState(listOf())
|
||||
|
||||
var showAddServer by remember { mutableStateOf(false) }
|
||||
var showDeleteDialog by remember { mutableStateOf<com.github.damontecres.wholphin.data.model.JellyfinServer?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.init()
|
||||
viewModel.discoverServers()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.dimAndBlur(showAddServer),
|
||||
modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null),
|
||||
) {
|
||||
Row(
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
// Center the content like the Select User screen
|
||||
.align(Alignment.Center)
|
||||
.padding(32.dp),
|
||||
.padding(16.dp),
|
||||
) {
|
||||
// Match SwitchUser header height (title + subtitle) to align icons vertically across screens
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(16.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.select_server),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
ServerList(
|
||||
servers = servers,
|
||||
connectionStatus = serverStatus,
|
||||
onSwitchServer = {
|
||||
viewModel.switchServer(it)
|
||||
},
|
||||
onTestServer = {
|
||||
viewModel.testServer(it)
|
||||
},
|
||||
onAddServer = {
|
||||
showAddServer = true
|
||||
},
|
||||
onRemoveServer = {
|
||||
viewModel.removeServer(it)
|
||||
},
|
||||
allowAdd = true,
|
||||
allowDelete = true,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
// Invisible subtitle placeholder to mirror the server name line on the Select User screen
|
||||
Text(
|
||||
text = "Server placeholder",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = Color.Transparent,
|
||||
)
|
||||
}
|
||||
// Discover
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
||||
// Horizontal scrollable list of server icons - centered
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val firstServerFocus = remember { FocusRequester() }
|
||||
if (servers.isNotEmpty()) {
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
}
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.wrapContentWidth()
|
||||
.focusRestorer(firstServerFocus)
|
||||
.focusRequester(focusRequester),
|
||||
) {
|
||||
itemsIndexed(servers) { index, server ->
|
||||
val status = serverStatus[server.id] ?: ServerConnectionStatus.Pending
|
||||
ServerIconCard(
|
||||
server = server,
|
||||
connectionStatus = status,
|
||||
isCurrentServer = false, // TODO: Determine current server if needed
|
||||
onClick = {
|
||||
when (status) {
|
||||
is ServerConnectionStatus.Success -> {
|
||||
viewModel.switchServer(server)
|
||||
}
|
||||
|
||||
ServerConnectionStatus.Pending -> {
|
||||
// Do nothing while pending
|
||||
}
|
||||
|
||||
is ServerConnectionStatus.Error -> {
|
||||
viewModel.testServer(server)
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
showDeleteDialog = server
|
||||
},
|
||||
allowDelete = true,
|
||||
modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstServerFocus)),
|
||||
)
|
||||
}
|
||||
// Add Server card - always rightmost
|
||||
item {
|
||||
AddServerCard(
|
||||
onClick = { showAddServer = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Non-focusable spacer to mirror the space occupied by the "Switch Servers" button
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(16.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.discovered_servers),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
if (discoveredServers.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.searching),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
} else {
|
||||
ServerList(
|
||||
servers = discoveredServers,
|
||||
connectionStatus =
|
||||
discoveredServers
|
||||
.map { it.id }
|
||||
.associateWith { ServerConnectionStatus.Success(PublicSystemInfo()) },
|
||||
onSwitchServer = {
|
||||
viewModel.addServer(it.url)
|
||||
.height(56.dp),
|
||||
// approximate TV button height
|
||||
)
|
||||
}
|
||||
|
||||
// Delete server dialog
|
||||
showDeleteDialog?.let { server ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = server.name ?: server.url,
|
||||
dialogItems =
|
||||
listOf(
|
||||
DialogItem(
|
||||
stringResource(R.string.switch_servers),
|
||||
R.string.fa_arrow_left_arrow_right,
|
||||
) {
|
||||
viewModel.switchServer(server)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
onTestServer = {
|
||||
viewModel.testServer(it)
|
||||
DialogItem(
|
||||
stringResource(R.string.delete),
|
||||
Icons.Default.Delete,
|
||||
Color.Red.copy(alpha = .8f),
|
||||
) {
|
||||
viewModel.removeServer(server)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
onAddServer = {},
|
||||
onRemoveServer = {},
|
||||
allowAdd = false,
|
||||
allowDelete = false,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
),
|
||||
onDismissRequest = { showDeleteDialog = null },
|
||||
dismissOnClick = true,
|
||||
waitToLoad = true,
|
||||
properties = DialogProperties(),
|
||||
elevation = 5.dp,
|
||||
)
|
||||
}
|
||||
|
||||
if (showAddServer) {
|
||||
var showEnterAddress by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.clearAddServerState()
|
||||
if (!showEnterAddress) {
|
||||
viewModel.discoverServers()
|
||||
}
|
||||
}
|
||||
val state by viewModel.addServerState.observeAsState(LoadingState.Pending)
|
||||
// val url = rememberTextFieldState()
|
||||
var url by remember { mutableStateOf("") }
|
||||
val submit = {
|
||||
// viewModel.addServer(url.text.toString())
|
||||
viewModel.addServer(url)
|
||||
|
||||
val discoveredServers by viewModel.discoveredServers.observeAsState(listOf())
|
||||
|
||||
// Filter out duplicates within the discovered servers list (same URL appearing multiple times)
|
||||
val filteredDiscoveredServers =
|
||||
remember(discoveredServers) {
|
||||
val seenUrls = mutableSetOf<String>()
|
||||
discoveredServers.filter { server ->
|
||||
val normalizedUrl = server.url.lowercase().trim()
|
||||
if (normalizedUrl in seenUrls) {
|
||||
false // Duplicate, filter it out
|
||||
} else {
|
||||
seenUrls.add(normalizedUrl)
|
||||
true // First occurrence, keep it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val firstDiscoveredServerFocusRequester = remember { FocusRequester() }
|
||||
|
||||
// Default focus to first discovered server if available
|
||||
LaunchedEffect(filteredDiscoveredServers.isNotEmpty(), showEnterAddress) {
|
||||
if (!showEnterAddress && filteredDiscoveredServers.isNotEmpty()) {
|
||||
firstDiscoveredServerFocusRequester.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
BasicDialog(
|
||||
onDismissRequest = {
|
||||
showAddServer = false
|
||||
showEnterAddress = false
|
||||
viewModel.clearAddServerState()
|
||||
},
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
elevation = 10.dp,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
|
@ -196,50 +249,134 @@ fun SwitchServerContent(
|
|||
.padding(16.dp)
|
||||
.fillMaxWidth(.4f),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.enter_server_url),
|
||||
)
|
||||
EditTextBox(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onGo = { submit.invoke() },
|
||||
),
|
||||
// onKeyboardAction = { submit.invoke() },
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
when (val st = state) {
|
||||
is LoadingState.Error -> {
|
||||
if (!showEnterAddress) {
|
||||
// Show discovered servers first
|
||||
Text(
|
||||
text = stringResource(R.string.discovered_servers),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
if (filteredDiscoveredServers.isEmpty() && discoveredServers.isEmpty()) {
|
||||
Text(
|
||||
text =
|
||||
st.message ?: st.exception?.localizedMessage
|
||||
?: "An error occurred",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
text = stringResource(R.string.searching),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
} else if (filteredDiscoveredServers.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_servers_found),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp),
|
||||
) {
|
||||
items(
|
||||
filteredDiscoveredServers.size,
|
||||
key = { filteredDiscoveredServers[it].url },
|
||||
) { index ->
|
||||
val server = filteredDiscoveredServers[index]
|
||||
val focusRequester =
|
||||
if (index == 0) {
|
||||
firstDiscoveredServerFocusRequester
|
||||
} else {
|
||||
remember { FocusRequester() }
|
||||
}
|
||||
|
||||
ListItem(
|
||||
enabled = true,
|
||||
selected = false,
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = server.name?.ifBlank { null } ?: server.url,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = server.url,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.addServer(server.url)
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
TextButton(
|
||||
onClick = { submit.invoke() },
|
||||
enabled = url.isNotNullOrBlank() && state == LoadingState.Pending,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
if (state == LoadingState.Loading) {
|
||||
CircularProgress(Modifier.size(32.dp))
|
||||
} else {
|
||||
Text(text = stringResource(R.string.submit))
|
||||
TextButton(
|
||||
onClick = {
|
||||
showEnterAddress = true
|
||||
},
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
) {
|
||||
Text(text = stringResource(R.string.enter_server_address))
|
||||
}
|
||||
} else {
|
||||
// Show enter server address form
|
||||
val state by viewModel.addServerState.observeAsState(LoadingState.Pending)
|
||||
var url by remember { mutableStateOf("") }
|
||||
val submit = {
|
||||
viewModel.addServer(url)
|
||||
}
|
||||
val textBoxFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
textBoxFocusRequester.tryRequestFocus()
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.enter_server_url),
|
||||
)
|
||||
EditTextBox(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onGo = { submit.invoke() },
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(textBoxFocusRequester)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
when (val st = state) {
|
||||
is LoadingState.Error -> {
|
||||
Text(
|
||||
text =
|
||||
st.message ?: st.exception?.localizedMessage
|
||||
?: "An error occurred",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
TextButton(
|
||||
onClick = { submit.invoke() },
|
||||
enabled = url.isNotNullOrBlank() && state == LoadingState.Pending,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
if (state == LoadingState.Loading) {
|
||||
CircularProgress(Modifier.size(32.dp))
|
||||
} else {
|
||||
Text(text = stringResource(R.string.submit))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.github.damontecres.wholphin.ui.setup
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -36,7 +34,6 @@ import androidx.compose.ui.window.DialogProperties
|
|||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
|
|
@ -94,27 +91,28 @@ fun SwitchUserContent(
|
|||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(.5f)
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.select_user),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = server.name ?: server.url,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.select_user),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = server.name ?: server.url,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
UserList(
|
||||
users = users,
|
||||
currentUser = currentUser,
|
||||
|
|
@ -134,13 +132,7 @@ fun SwitchUserContent(
|
|||
onSwitchServer = {
|
||||
viewModel.navigationManager.navigateTo(Destination.ServerList)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -170,7 +162,7 @@ fun SwitchUserContent(
|
|||
Modifier
|
||||
.focusGroup()
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(.66f),
|
||||
.fillMaxWidth(.4f),
|
||||
) {
|
||||
if (useQuickConnect) {
|
||||
if (quickConnect == null && userState !is LoadingState.Error) {
|
||||
|
|
@ -196,6 +188,8 @@ fun SwitchUserContent(
|
|||
text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
text = quickConnect?.code ?: "Failed to get code",
|
||||
|
|
@ -233,6 +227,8 @@ fun SwitchUserContent(
|
|||
text = "Enter username/password to login to ${server.name ?: server.url}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
UserStateError(userState)
|
||||
Row(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.JellyfinServerDao
|
|||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
|
|
@ -25,6 +26,7 @@ import org.jellyfin.sdk.Jellyfin
|
|||
import org.jellyfin.sdk.api.client.HttpClientOptions
|
||||
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||
import org.jellyfin.sdk.api.client.extensions.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
|
||||
|
|
@ -41,6 +43,7 @@ class SwitchUserViewModel
|
|||
val serverRepository: ServerRepository,
|
||||
val serverDao: JellyfinServerDao,
|
||||
val navigationManager: NavigationManager,
|
||||
val imageUrlService: ImageUrlService,
|
||||
@Assisted val server: JellyfinServer,
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
|
|
@ -50,7 +53,7 @@ class SwitchUserViewModel
|
|||
|
||||
val serverQuickConnect = MutableLiveData<Boolean>(false)
|
||||
|
||||
val users = MutableLiveData<List<JellyfinUser>>(listOf())
|
||||
val users = MutableLiveData<List<JellyfinUserAndImage>>(listOf())
|
||||
val quickConnectState = MutableLiveData<QuickConnectResult?>(null)
|
||||
|
||||
private var quickConnectJob: Job? = null
|
||||
|
|
@ -78,8 +81,7 @@ class SwitchUserViewModel
|
|||
quickConnectJob?.cancel()
|
||||
viewModelScope.launchIO {
|
||||
users.setValueOnMain(listOf())
|
||||
val serverUsers =
|
||||
serverDao.getServer(server.id)?.users?.sortedBy { it.name } ?: listOf()
|
||||
val serverUsers = getUsers()
|
||||
withContext(Dispatchers.Main) {
|
||||
users.setValueOnMain(serverUsers)
|
||||
}
|
||||
|
|
@ -215,14 +217,23 @@ class SwitchUserViewModel
|
|||
fun removeUser(user: JellyfinUser) {
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.removeUser(user)
|
||||
val serverUsers =
|
||||
serverDao.getServer(user.serverId)?.users?.sortedBy { it.name } ?: listOf()
|
||||
val serverUsers = getUsers()
|
||||
withContext(Dispatchers.Main) {
|
||||
users.value = serverUsers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getUsers(): List<JellyfinUserAndImage> {
|
||||
val api = jellyfin.createApi(server.url)
|
||||
return serverDao
|
||||
.getServer(server.id)
|
||||
?.users
|
||||
?.sortedBy { it.name }
|
||||
?.map { JellyfinUserAndImage(it, api.imageApi.getUserImageUrl(it.id)) }
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
private suspend fun setError(
|
||||
msg: String? = null,
|
||||
ex: Exception? = null,
|
||||
|
|
@ -231,3 +242,8 @@ class SwitchUserViewModel
|
|||
switchUserState.value = LoadingState.Error(msg, ex)
|
||||
}
|
||||
}
|
||||
|
||||
data class JellyfinUserAndImage(
|
||||
val user: JellyfinUser,
|
||||
val imageUrl: String?,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,37 +1,68 @@
|
|||
package com.github.damontecres.wholphin.ui.setup
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
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.Check
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.Button
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.ListItem
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
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.tryRequestFocus
|
||||
|
||||
/**
|
||||
* Display a list of users plus option to add a new one or switch servers
|
||||
* Redesigned to match streaming service style with horizontal scrollable user icons
|
||||
*/
|
||||
@Composable
|
||||
fun UserList(
|
||||
users: List<JellyfinUser>,
|
||||
users: List<JellyfinUserAndImage>,
|
||||
currentUser: JellyfinUser?,
|
||||
onSwitchUser: (JellyfinUser) -> Unit,
|
||||
onAddUser: () -> Unit,
|
||||
|
|
@ -41,59 +72,76 @@ fun UserList(
|
|||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf<JellyfinUser?>(null) }
|
||||
|
||||
LazyColumn(modifier = modifier) {
|
||||
items(users) { user ->
|
||||
ListItem(
|
||||
enabled = true,
|
||||
selected = user == currentUser,
|
||||
headlineContent = { Text(text = user.name ?: user.id.toString()) },
|
||||
leadingContent = {
|
||||
if (user.id == currentUser?.id) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "current user",
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { onSwitchUser.invoke(user) },
|
||||
onLongClick = {
|
||||
showDeleteDialog = user
|
||||
},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
HorizontalDivider()
|
||||
ListItem(
|
||||
enabled = true,
|
||||
selected = false,
|
||||
headlineContent = { Text(text = stringResource(R.string.add_user)) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
tint = Color.Green.copy(alpha = .8f),
|
||||
contentDescription = null,
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
// Horizontal scrollable list of user icons - centered
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val firstFocusRequester = remember { FocusRequester() }
|
||||
if (users.isNotEmpty()) {
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
}
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp), // Spacing to accommodate 20% scale
|
||||
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp), // Increased padding to accommodate 20% scale
|
||||
modifier =
|
||||
Modifier
|
||||
.wrapContentWidth()
|
||||
.focusRestorer(firstFocusRequester)
|
||||
.focusRequester(focusRequester),
|
||||
) {
|
||||
itemsIndexed(users) { index, user ->
|
||||
UserIconCard(
|
||||
user = user,
|
||||
isCurrentUser = user.user.id == currentUser?.id,
|
||||
onClick = { onSwitchUser.invoke(user.user) },
|
||||
onLongClick = { showDeleteDialog = user.user },
|
||||
modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier,
|
||||
)
|
||||
},
|
||||
onClick = { onAddUser.invoke() },
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
// Add User card - always rightmost
|
||||
item {
|
||||
AddUserCard(
|
||||
onClick = { onAddUser.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
HorizontalDivider()
|
||||
ListItem(
|
||||
enabled = true,
|
||||
selected = false,
|
||||
headlineContent = { Text(text = stringResource(R.string.switch_servers)) },
|
||||
leadingContent = {
|
||||
|
||||
// Switch servers button below user list - centered
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { onSwitchServer.invoke() },
|
||||
modifier = Modifier.width(200.dp), // Fixed width for consistency
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_arrow_left_arrow_right),
|
||||
fontFamily = FontAwesome,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
},
|
||||
onClick = { onSwitchServer.invoke() },
|
||||
modifier = Modifier,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.switch_servers),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
showDeleteDialog?.let { user ->
|
||||
|
|
@ -124,3 +172,193 @@ fun UserList(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserIconCard(
|
||||
user: JellyfinUserAndImage,
|
||||
isCurrentUser: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
// Generate unique color for this user
|
||||
val userColor = rememberIdColor(user.user.id)
|
||||
|
||||
// Track image loading errors
|
||||
var imageError by remember { mutableStateOf(false) }
|
||||
|
||||
// Card dimensions - circular card
|
||||
val cardSize = Cards.serverUserCircle
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
// Circular card with colored background
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier.size(cardSize),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
||||
colors =
|
||||
ClickableSurfaceDefaults.colors(
|
||||
containerColor =
|
||||
if (isCurrentUser) {
|
||||
userColor.copy(alpha = 0.7f)
|
||||
} else {
|
||||
userColor.copy(alpha = 0.5f)
|
||||
},
|
||||
focusedContainerColor =
|
||||
if (isCurrentUser) {
|
||||
userColor.copy(alpha = 0.9f)
|
||||
} else {
|
||||
userColor.copy(alpha = 0.7f)
|
||||
},
|
||||
),
|
||||
border =
|
||||
ClickableSurfaceDefaults.border(
|
||||
focusedBorder =
|
||||
Border(
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 3.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
shape = CircleShape,
|
||||
),
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (user.imageUrl.isNotNullOrBlank() && !imageError) {
|
||||
AsyncImage(
|
||||
model = user.imageUrl,
|
||||
contentDescription = user.user.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
onError = { imageError = true },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape),
|
||||
)
|
||||
} else {
|
||||
// Show big bold first letter of username
|
||||
val firstLetter =
|
||||
remember(user) {
|
||||
user.user.let {
|
||||
(it.name ?: it.id.toString()).firstOrNull()?.uppercase()
|
||||
} ?: "?"
|
||||
}
|
||||
Text(
|
||||
text = firstLetter,
|
||||
style =
|
||||
MaterialTheme.typography.displayLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Username below the card
|
||||
Text(
|
||||
text = user.user.name ?: user.user.id.toString(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.width(cardSize)
|
||||
.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add User card component - displays a + icon in a circle
|
||||
*/
|
||||
@Composable
|
||||
private fun AddUserCard(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
// Use a neutral gray color for the add user card
|
||||
val addUserColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
|
||||
// Card dimensions - circular card (same as user cards)
|
||||
val cardSize = Cards.height2x3 * 0.75f // ~120dp
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp), // Increased to accommodate 20% scale
|
||||
) {
|
||||
// Circular card with colored background
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier.size(cardSize),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
||||
colors =
|
||||
ClickableSurfaceDefaults.colors(
|
||||
containerColor = addUserColor.copy(alpha = 0.4f),
|
||||
focusedContainerColor = addUserColor.copy(alpha = 0.6f),
|
||||
),
|
||||
border =
|
||||
ClickableSurfaceDefaults.border(
|
||||
focusedBorder =
|
||||
Border(
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 3.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
shape = CircleShape,
|
||||
),
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = stringResource(R.string.add_user),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(cardSize * 0.4f), // Size of the + icon
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// "Add User" text below the card
|
||||
Text(
|
||||
text = stringResource(R.string.add_user),
|
||||
style =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.width(cardSize)
|
||||
.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
<string name="downloading">Downloading…</string>
|
||||
<string name="enabled">Enabled</string>
|
||||
<string name="enter_server_url">Enter Server IP or URL</string>
|
||||
<string name="enter_server_address">Enter server address</string>
|
||||
<string name="episodes">Episodes</string>
|
||||
<string name="error_loading_collection">Error loading collection %1$s</string>
|
||||
<string name="external_track">External</string>
|
||||
|
|
@ -69,6 +70,7 @@
|
|||
<string name="next_up">Next Up</string>
|
||||
<string name="no_data">No data</string>
|
||||
<string name="no_results">No results</string>
|
||||
<string name="no_servers_found">No servers found</string>
|
||||
<string name="no_scheduled_recordings">No scheduled recordings</string>
|
||||
<string name="no_update_available">No update available</string>
|
||||
<string name="none">None</string>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue