From 801ff5e67bef468cf3837529596a6a1551d48ad5 Mon Sep 17 00:00:00 2001 From: YogiBear12 <139140546+YogiBear12@users.noreply.github.com> Date: Thu, 18 Dec 2025 03:56:19 +1100 Subject: [PATCH] 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 --- .../wholphin/services/ImageUrlService.kt | 2 + .../damontecres/wholphin/ui/UiConstants.kt | 1 + .../wholphin/ui/setup/ServerList.kt | 269 ++++++++++++ .../wholphin/ui/setup/SwitchServerContent.kt | 415 ++++++++++++------ .../wholphin/ui/setup/SwitchUserContent.kt | 52 +-- .../wholphin/ui/setup/SwitchUserViewModel.kt | 26 +- .../damontecres/wholphin/ui/setup/UserList.kt | 344 ++++++++++++--- app/src/main/res/values/strings.xml | 2 + 8 files changed, 886 insertions(+), 225 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index ee7a4348..821794e3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -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 */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index 5af35e03..9557de96 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -68,6 +68,7 @@ val SlimItemFields = object Cards { val height2x3 = 172.dp val playedPercentHeight = 6.dp + val serverUserCircle = height2x3 * .75f } object AspectRatios { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index 08957981..4ab990de 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt index 6fafeecc..4902ca06 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt @@ -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(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() + 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)) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index c734aed2..19c00f18 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index 56d7ab98..7da64ca7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -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(false) - val users = MutableLiveData>(listOf()) + val users = MutableLiveData>(listOf()) val quickConnectState = MutableLiveData(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 { + 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?, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt index c4fb035e..d8b1b096 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt @@ -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, + users: List, currentUser: JellyfinUser?, onSwitchUser: (JellyfinUser) -> Unit, onAddUser: () -> Unit, @@ -41,59 +72,76 @@ fun UserList( ) { var showDeleteDialog by remember { mutableStateOf(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), + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6647d22..d399631a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,6 +37,7 @@ Downloading… Enabled Enter Server IP or URL + Enter server address Episodes Error loading collection %1$s External @@ -69,6 +70,7 @@ Next Up No data No results + No servers found No scheduled recordings No update available None