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.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
}
}

View file

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

View file

@ -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<JellyfinServer?>(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
}
}

View file

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

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

View file

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

View file

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