From e7b0be2660300eca03c47e922729bcebfdd3c6c9 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 25 Jul 2026 22:52:02 -0400 Subject: [PATCH] Display server error message on server list (#1741) ## Description On the switch server page, display error message at the bottom for the focused server. This includes unsupported server version. ### Related issues Jellyfin 10.11/12.0 support discussion: #1640 ### Testing Emulator ## Screenshots server_error Large ## AI or LLM usage None --- .../wholphin/WholphinApplication.kt | 4 + .../wholphin/ui/setup/ServerList.kt | 47 ++++----- .../wholphin/ui/setup/SwitchServerContent.kt | 98 +++++++++++++++++-- .../ui/setup/SwitchServerViewModel.kt | 30 +++++- .../wholphin/ui/setup/SwitchUserContent.kt | 33 +++++++ .../wholphin/ui/setup/SwitchUserViewModel.kt | 21 ++++ app/src/main/res/values/strings.xml | 1 + 7 files changed, 200 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index ceb7264e..bd25a2d4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -15,6 +15,8 @@ import org.acra.ReportField import org.acra.config.dialog import org.acra.data.StringFormat import org.acra.ktx.initAcra +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.model.ServerVersion import timber.log.Timber import javax.inject.Inject @@ -120,5 +122,7 @@ class WholphinApplication : companion object { lateinit var instance: WholphinApplication private set + + val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion } } 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 84e4cea6..81cc7c2a 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 @@ -97,6 +97,7 @@ fun rememberIdColor( @Composable fun ServerIconCard( server: JellyfinServer, + serverVersionSupported: ServerVersionSupported, connectionStatus: ServerConnectionStatus, isCurrentServer: Boolean, onClick: () -> Unit, @@ -166,28 +167,8 @@ fun ServerIconCard( 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 -> { + when { + connectionStatus is ServerConnectionStatus.Error || serverVersionSupported != ServerVersionSupported.SUPPORTED -> { // Show warning icon with server letter below Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -195,7 +176,7 @@ fun ServerIconCard( ) { Icon( imageVector = Icons.Default.Warning, - contentDescription = connectionStatus.message, + contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message, tint = MaterialTheme.colorScheme.errorContainer, modifier = Modifier.size(cardSize * 0.3f), ) @@ -210,6 +191,26 @@ fun ServerIconCard( ) } } + + 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, + ) + } + + connectionStatus == ServerConnectionStatus.Pending -> { + CircularProgress( + modifier = Modifier.size(cardSize * 0.4f), + ) + } } } } 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 d975fc29..71483c0a 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,5 +1,10 @@ package com.github.damontecres.wholphin.ui.setup +import android.content.res.Resources +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,12 +35,16 @@ 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.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType +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.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -55,6 +64,7 @@ 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.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState @@ -85,8 +95,10 @@ private fun SwitchServerContentInternal( viewModel: SwitchServerViewModel, modifier: Modifier = Modifier, ) { + val resources = LocalResources.current var showAddServer by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(null) } + var focusedIndex by rememberInt(0) Box( modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null), ) { @@ -125,8 +137,13 @@ private fun SwitchServerContentInternal( ) { val focusRequester = remember { FocusRequester() } val firstServerFocus = remember { FocusRequester() } - if (state.servers.isNotEmpty()) { - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(state.loading) { + val state = state + if (state.loading == LoadingState.Success && state.servers.isNotEmpty()) { + firstServerFocus.tryRequestFocus() + } else if (state.loading == LoadingState.Pending) { + firstServerFocus.tryRequestFocus() + } } LazyRow( horizontalArrangement = Arrangement.spacedBy(24.dp), @@ -141,6 +158,7 @@ private fun SwitchServerContentInternal( ServerIconCard( server = server.server, connectionStatus = server.status, + serverVersionSupported = server.versionSupported, isCurrentServer = false, // TODO: Determine current server if needed onClick = { when (server.status) { @@ -162,10 +180,13 @@ private fun SwitchServerContentInternal( }, allowDelete = true, modifier = - Modifier.ifElse( - index == 0, - Modifier.focusRequester(firstServerFocus), - ), + Modifier + .onFocusChanged { + if (it.isFocused) focusedIndex = index + }.ifElse( + index == 0, + Modifier.focusRequester(firstServerFocus), + ), ) } // Add Server card - always rightmost @@ -173,10 +194,13 @@ private fun SwitchServerContentInternal( AddServerCard( onClick = { showAddServer = true }, modifier = - Modifier.ifElse( - state.servers.isEmpty(), - Modifier.focusRequester(firstServerFocus), - ), + Modifier + .onFocusChanged { + if (it.isFocused) focusedIndex = -1 + }.ifElse( + state.servers.isEmpty(), + Modifier.focusRequester(firstServerFocus), + ), ) } } @@ -191,6 +215,37 @@ private fun SwitchServerContentInternal( ) } + val errorMessage = + remember(resources, focusedIndex, state.servers) { + val server = state.servers.getOrNull(focusedIndex) + serverErrorMessage(server, resources) + } + AnimatedContent( + targetState = errorMessage, + transitionSpec = { + slideInVertically { it / 2 } togetherWith slideOutVertically { it } + }, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) { message -> + + Text( + text = message ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 32.dp, end = 32.dp, bottom = 32.dp) + .align(Alignment.BottomCenter), + ) + } + // Delete server dialog showDeleteDialog?.let { server -> DialogPopup( @@ -411,3 +466,26 @@ private fun SwitchServerContentInternal( } } } + +fun serverErrorMessage( + server: ServerState?, + resources: Resources, +): String? = + when { + server?.status is ServerConnectionStatus.Error -> { + server.status.message + } + + server?.status is ServerConnectionStatus.Success && + server.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> { + resources.getString(R.string.server_version_not_supported) + ": ${server.status.systemInfo.version}" + } + + server?.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> { + resources.getString(R.string.server_version_not_supported) + } + + else -> { + null + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt index 9c28aff8..209320eb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt @@ -5,6 +5,7 @@ import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer @@ -24,6 +25,7 @@ import org.jellyfin.sdk.api.client.HttpClientOptions import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.discovery.RecommendedServerInfoScore import org.jellyfin.sdk.discovery.RecommendedServerIssue +import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber @@ -73,11 +75,30 @@ class SwitchServerViewModel server: JellyfinServer, result: ServerConnectionStatus, ) { + val supported = + if (result is ServerConnectionStatus.Success) { + val serverVersion = + result.systemInfo.version?.let { ServerVersion.fromString(it) } + if (serverVersion == null || serverVersion < WholphinApplication.minimumServerVersion) { + ServerVersionSupported.NOT_SUPPORTED + } else { + ServerVersionSupported.SUPPORTED + } + } else { + ServerVersionSupported.UNKNOWN + } _state.update { val servers = it.servers.toMutableList().apply { val index = indexOfFirst { it.server.id == server.id } - set(index, get(index).copy(server = server, status = result)) + set( + index, + get(index).copy( + server = server, + status = result, + versionSupported = supported, + ), + ) } it.copy(servers = servers) } @@ -245,4 +266,11 @@ data class ServerState( val server: JellyfinServer, val status: ServerConnectionStatus = ServerConnectionStatus.Pending, val quickConnect: Boolean = false, + val versionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN, ) + +enum class ServerVersionSupported { + SUPPORTED, + NOT_SUPPORTED, + UNKNOWN, +} 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 2d9158de..22b5a594 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 @@ -25,11 +25,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType 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.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -169,6 +171,37 @@ fun SwitchUserContent( modifier = Modifier.fillMaxWidth(), ) } + when (state.serverVersionSupported) { + ServerVersionSupported.SUPPORTED -> {} + + ServerVersionSupported.NOT_SUPPORTED, + ServerVersionSupported.UNKNOWN, + -> { + val resources = LocalResources.current + val message = + remember(resources) { + if (state.serverVersion.isNotNullOrBlank()) { + resources.getString(R.string.server_version_not_supported) + ": ${state.serverVersion}" + } else { + resources.getString(R.string.server_version_not_supported) + ": " + + resources.getString(R.string.unknown) + } + } + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 32.dp, end = 32.dp, bottom = 32.dp) + .align(Alignment.BottomCenter), + ) + } + } } } } 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 8b8c159a..a282eb15 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 @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.setup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer @@ -33,7 +34,9 @@ import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.authenticateUserByName import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.quickConnectApi +import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.api.client.extensions.userApi +import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.api.QuickConnectDto import org.jellyfin.sdk.model.api.QuickConnectResult import timber.log.Timber @@ -78,10 +81,13 @@ class SwitchUserViewModel _state.update { SwitchUserState() } try { val serverUsers = getUsers() + val (serverVersion, supported) = checkServerVersion() _state.update { it.copy( loading = LoadingState.Success, users = serverUsers, + serverVersion = serverVersion, + serverVersionSupported = supported, ) } } catch (ex: Exception) { @@ -272,6 +278,19 @@ class SwitchUserViewModel knownUsers + publicUsers } + private suspend fun checkServerVersion(): Pair { + val api = jellyfin.createApi(server.url) + val systemInfo by api.systemApi.getPublicSystemInfo() + val serverVersion = systemInfo.version?.let { ServerVersion.fromString(it) } + return if (serverVersion == null) { + systemInfo.version to ServerVersionSupported.UNKNOWN + } else if (serverVersion < WholphinApplication.minimumServerVersion) { + systemInfo.version to ServerVersionSupported.NOT_SUPPORTED + } else { + systemInfo.version to ServerVersionSupported.SUPPORTED + } + } + private fun setError( msg: String? = null, ex: Exception? = null, @@ -300,4 +319,6 @@ data class SwitchUserState( // LoadingState for while adding/switching users val switchUserState: LoadingState = LoadingState.Pending, val loginAttempts: Int = 0, + val serverVersion: String? = null, + val serverVersionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9db3f496..115efc22 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -898,4 +898,5 @@ User views Books Audio books + Server version not supported