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
<img width="1280" height="720" alt="server_error Large"
src="https://github.com/user-attachments/assets/1227e7e7-a364-48d9-9a77-cb3f78d134a1"
/>

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-07-25 22:52:02 -04:00 • committed by GitHub
parent 4171d0b861
commit e7b0be2660
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 200 additions and 34 deletions

View file

@ -15,6 +15,8 @@ import org.acra.ReportField
import org.acra.config.dialog import org.acra.config.dialog
import org.acra.data.StringFormat import org.acra.data.StringFormat
import org.acra.ktx.initAcra import org.acra.ktx.initAcra
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.model.ServerVersion
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -120,5 +122,7 @@ class WholphinApplication :
companion object { companion object {
lateinit var instance: WholphinApplication lateinit var instance: WholphinApplication
private set private set
val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion
} }
} }

View file

@ -97,6 +97,7 @@ fun rememberIdColor(
@Composable @Composable
fun ServerIconCard( fun ServerIconCard(
server: JellyfinServer, server: JellyfinServer,
serverVersionSupported: ServerVersionSupported,
connectionStatus: ServerConnectionStatus, connectionStatus: ServerConnectionStatus,
isCurrentServer: Boolean, isCurrentServer: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
@ -166,28 +167,8 @@ fun ServerIconCard(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
// Show connection status indicator or server name/letter // Show connection status indicator or server name/letter
when (connectionStatus) { when {
is ServerConnectionStatus.Success -> { connectionStatus is ServerConnectionStatus.Error || serverVersionSupported != ServerVersionSupported.SUPPORTED -> {
// 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 // Show warning icon with server letter below
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@ -195,7 +176,7 @@ fun ServerIconCard(
) { ) {
Icon( Icon(
imageVector = Icons.Default.Warning, imageVector = Icons.Default.Warning,
contentDescription = connectionStatus.message, contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message,
tint = MaterialTheme.colorScheme.errorContainer, tint = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.size(cardSize * 0.3f), 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),
)
}
} }
} }
} }

View file

@ -1,5 +1,10 @@
package com.github.damontecres.wholphin.ui.setup 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType 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.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.dimAndBlur
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank 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.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
@ -85,8 +95,10 @@ private fun SwitchServerContentInternal(
viewModel: SwitchServerViewModel, viewModel: SwitchServerViewModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val resources = LocalResources.current
var showAddServer by remember { mutableStateOf(false) } var showAddServer by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) } var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) }
var focusedIndex by rememberInt(0)
Box( Box(
modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null), modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null),
) { ) {
@ -125,8 +137,13 @@ private fun SwitchServerContentInternal(
) { ) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val firstServerFocus = remember { FocusRequester() } val firstServerFocus = remember { FocusRequester() }
if (state.servers.isNotEmpty()) { LaunchedEffect(state.loading) {
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } val state = state
if (state.loading == LoadingState.Success && state.servers.isNotEmpty()) {
firstServerFocus.tryRequestFocus()
} else if (state.loading == LoadingState.Pending) {
firstServerFocus.tryRequestFocus()
}
} }
LazyRow( LazyRow(
horizontalArrangement = Arrangement.spacedBy(24.dp), horizontalArrangement = Arrangement.spacedBy(24.dp),
@ -141,6 +158,7 @@ private fun SwitchServerContentInternal(
ServerIconCard( ServerIconCard(
server = server.server, server = server.server,
connectionStatus = server.status, connectionStatus = server.status,
serverVersionSupported = server.versionSupported,
isCurrentServer = false, // TODO: Determine current server if needed isCurrentServer = false, // TODO: Determine current server if needed
onClick = { onClick = {
when (server.status) { when (server.status) {
@ -162,10 +180,13 @@ private fun SwitchServerContentInternal(
}, },
allowDelete = true, allowDelete = true,
modifier = modifier =
Modifier.ifElse( Modifier
index == 0, .onFocusChanged {
Modifier.focusRequester(firstServerFocus), if (it.isFocused) focusedIndex = index
), }.ifElse(
index == 0,
Modifier.focusRequester(firstServerFocus),
),
) )
} }
// Add Server card - always rightmost // Add Server card - always rightmost
@ -173,10 +194,13 @@ private fun SwitchServerContentInternal(
AddServerCard( AddServerCard(
onClick = { showAddServer = true }, onClick = { showAddServer = true },
modifier = modifier =
Modifier.ifElse( Modifier
state.servers.isEmpty(), .onFocusChanged {
Modifier.focusRequester(firstServerFocus), 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 // Delete server dialog
showDeleteDialog?.let { server -> showDeleteDialog?.let { server ->
DialogPopup( 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
}
}

View file

@ -5,6 +5,7 @@ import android.widget.Toast
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer 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.api.client.extensions.systemApi
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.discovery.RecommendedServerIssue import org.jellyfin.sdk.discovery.RecommendedServerIssue
import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
@ -73,11 +75,30 @@ class SwitchServerViewModel
server: JellyfinServer, server: JellyfinServer,
result: ServerConnectionStatus, 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 { _state.update {
val servers = val servers =
it.servers.toMutableList().apply { it.servers.toMutableList().apply {
val index = indexOfFirst { it.server.id == server.id } 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) it.copy(servers = servers)
} }
@ -245,4 +266,11 @@ data class ServerState(
val server: JellyfinServer, val server: JellyfinServer,
val status: ServerConnectionStatus = ServerConnectionStatus.Pending, val status: ServerConnectionStatus = ServerConnectionStatus.Pending,
val quickConnect: Boolean = false, val quickConnect: Boolean = false,
val versionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN,
) )
enum class ServerVersionSupported {
SUPPORTED,
NOT_SUPPORTED,
UNKNOWN,
}

View file

@ -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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -169,6 +171,37 @@ fun SwitchUserContent(
modifier = Modifier.fillMaxWidth(), 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),
)
}
}
} }
} }
} }

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.setup
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer 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.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.quickConnectApi import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.api.QuickConnectDto import org.jellyfin.sdk.model.api.QuickConnectDto
import org.jellyfin.sdk.model.api.QuickConnectResult import org.jellyfin.sdk.model.api.QuickConnectResult
import timber.log.Timber import timber.log.Timber
@ -78,10 +81,13 @@ class SwitchUserViewModel
_state.update { SwitchUserState() } _state.update { SwitchUserState() }
try { try {
val serverUsers = getUsers() val serverUsers = getUsers()
val (serverVersion, supported) = checkServerVersion()
_state.update { _state.update {
it.copy( it.copy(
loading = LoadingState.Success, loading = LoadingState.Success,
users = serverUsers, users = serverUsers,
serverVersion = serverVersion,
serverVersionSupported = supported,
) )
} }
} catch (ex: Exception) { } catch (ex: Exception) {
@ -272,6 +278,19 @@ class SwitchUserViewModel
knownUsers + publicUsers knownUsers + publicUsers
} }
private suspend fun checkServerVersion(): Pair<String?, ServerVersionSupported> {
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( private fun setError(
msg: String? = null, msg: String? = null,
ex: Exception? = null, ex: Exception? = null,
@ -300,4 +319,6 @@ data class SwitchUserState(
// LoadingState for while adding/switching users // LoadingState for while adding/switching users
val switchUserState: LoadingState = LoadingState.Pending, val switchUserState: LoadingState = LoadingState.Pending,
val loginAttempts: Int = 0, val loginAttempts: Int = 0,
val serverVersion: String? = null,
val serverVersionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN,
) )

View file

@ -898,4 +898,5 @@
<string name="user_views">User views</string> <string name="user_views">User views</string>
<string name="books">Books</string> <string name="books">Books</string>
<string name="audio_books">Audio books</string> <string name="audio_books">Audio books</string>
<string name="server_version_not_supported">Server version not supported</string>
</resources> </resources>