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:
YogiBear12 2025-12-18 03:56:19 +11:00 committed by GitHub
parent bbfbc13532
commit 801ff5e67b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 886 additions and 225 deletions

View file

@ -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
*/

View file

@ -68,6 +68,7 @@ val SlimItemFields =
object Cards {
val height2x3 = 172.dp
val playedPercentHeight = 6.dp
val serverUserCircle = height2x3 * .75f
}
object AspectRatios {

View file

@ -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),
)
}
}

View file

@ -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),
) {
Row(
modifier =
Modifier
.align(Alignment.Center)
.padding(32.dp),
modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier =
Modifier
.fillMaxWidth()
.weight(1f)
.padding(16.dp)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
shape = RoundedCornerShape(16.dp),
),
// Center the content like the Select User screen
.align(Alignment.Center)
.padding(16.dp),
) {
// Match SwitchUser header height (title + subtitle) to align icons vertically across screens
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.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),
modifier =
Modifier
.fillMaxWidth()
.weight(1f)
.padding(16.dp)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
shape = RoundedCornerShape(16.dp),
),
// Horizontal scrollable list of server icons - centered
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
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)
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)
}
}
},
onTestServer = {
viewModel.testServer(it)
onLongClick = {
showDeleteDialog = server
},
onAddServer = {},
onRemoveServer = {},
allowAdd = false,
allowDelete = false,
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()
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
shape = RoundedCornerShape(16.dp),
),
.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
},
DialogItem(
stringResource(R.string.delete),
Icons.Default.Delete,
Color.Red.copy(alpha = .8f),
) {
viewModel.removeServer(server)
showDeleteDialog = null
},
),
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 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
}
}
}
if (showAddServer) {
LaunchedEffect(Unit) {
viewModel.clearAddServerState()
val firstDiscoveredServerFocusRequester = remember { FocusRequester() }
// Default focus to first discovered server if available
LaunchedEffect(filteredDiscoveredServers.isNotEmpty(), showEnterAddress) {
if (!showEnterAddress && filteredDiscoveredServers.isNotEmpty()) {
firstDiscoveredServerFocusRequester.tryRequestFocus()
}
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)
}
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,6 +249,90 @@ fun SwitchServerContent(
.padding(16.dp)
.fillMaxWidth(.4f),
) {
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 = 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),
)
}
}
}
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),
)
@ -213,10 +350,9 @@ fun SwitchServerContent(
KeyboardActions(
onGo = { submit.invoke() },
),
// onKeyboardAction = { submit.invoke() },
modifier =
Modifier
.focusRequester(focusRequester)
.focusRequester(textBoxFocusRequester)
.fillMaxWidth(),
)
when (val st = state) {
@ -246,4 +382,5 @@ fun SwitchServerContent(
}
}
}
}
}

View file

@ -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,16 +91,16 @@ 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),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(R.string.select_user),
@ -115,6 +112,7 @@ fun SwitchUserContent(
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(

View file

@ -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?,
)

View file

@ -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,61 +72,78 @@ 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,
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,
)
}
// Add User card - always rightmost
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,
)
},
AddUserCard(
onClick = { onAddUser.invoke() },
modifier = Modifier,
)
}
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 ->
DialogPopup(
showDialog = true,
@ -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),
)
}
}

View file

@ -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>