Show warning for future unsupported server version (#2115)

## Description
Now that Jellyfin 12 has been out for over six weeks, it's time to start
considering dropping support for Jellyfin 10 & 11 as part of #1640.

This PR adds a warning message on the switch server, switch user, &
preferences pages if the current server version is <12.

### Related issues
See https://github.com/damontecres/Wholphin/discussions/1640 for further
discussion!

### Testing
Emulator w/ faked data

## Screenshots
N/A, reuses the same error message space

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-10-25 20:52:20 -04:00 • committed by GitHub
parent 9218940a69
commit 6cde8ce8a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 164 additions and 67 deletions

View file

@ -122,5 +122,7 @@ class WholphinApplication :
private set
val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion
val newMinimumServerVersion: ServerVersion = ServerVersion(12, 0, 0)
}
}

View file

@ -9,6 +9,8 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported
import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSegmentType
@ -319,3 +321,55 @@ fun Resources.formatDuration(duration: Duration): String =
else -> getString(R.string.duration_seconds, seconds)
}
}
fun Resources.serverSupportedMessage(
serverVersionSupported: ServerVersionSupported,
serverVersion: ServerVersion?,
): String? {
val errorMsg =
when (serverVersionSupported) {
ServerVersionSupported.SUPPORTED -> {
null
}
ServerVersionSupported.NOT_SUPPORTED,
ServerVersionSupported.UNKNOWN,
-> {
getString(R.string.server_version_not_supported)
}
ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> {
getString(R.string.server_version_not_supported_future)
}
}
return errorMsg?.let {
val msg =
if (serverVersion != null) {
"$it: $serverVersion"
} else {
it + ": " + getString(R.string.unknown)
}
msg + "\n" +
when (serverVersionSupported) {
ServerVersionSupported.SUPPORTED -> {
""
}
ServerVersionSupported.NOT_SUPPORTED,
ServerVersionSupported.UNKNOWN,
-> {
getString(
R.string.server_version_required,
WholphinApplication.minimumServerVersion.toString(),
)
}
ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> {
getString(
R.string.server_version_required,
WholphinApplication.newMinimumServerVersion.toString(),
)
}
}
}
}

View file

@ -36,10 +36,12 @@ 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.style.TextAlign
import androidx.compose.ui.unit.dp
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
@ -78,6 +80,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.ui.serverSupportedMessage
import com.github.damontecres.wholphin.ui.setup.ReleaseNotes
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog
@ -102,6 +105,7 @@ fun PreferencesContent(
onFocus: (Int, Int) -> Unit = { _, _ -> },
) {
val context = LocalContext.current
val resources = LocalResources.current
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
val firstFocusRequester = remember { FocusRequester() }
@ -114,6 +118,11 @@ fun PreferencesContent(
var showPinFlow by remember { mutableStateOf(false) }
var showVersionDialog by remember { mutableStateOf(false) }
val players by viewModel.externalPlayers.collectAsState()
val serverSupported by viewModel.serverSupported.collectAsState()
val serverVersionMessage =
remember(resources, serverSupported) {
resources.serverSupportedMessage(serverSupported.second, serverSupported.first)
}
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
val seerrConnection by viewModel.seerrConnection.collectAsState()
@ -230,6 +239,19 @@ fun PreferencesContent(
contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxSize(),
) {
serverVersionMessage?.let { message ->
item {
ListItem(
selected = false,
enabled = true,
onClick = {},
headlineContent = {
PreferenceTitle(message, color = MaterialTheme.colorScheme.error)
},
modifier = Modifier,
)
}
}
if (showUpdate) {
item {
val interactionSource = remember { MutableInteractionSource() }
@ -265,6 +287,7 @@ fun PreferencesContent(
)
}
}
prefList.forEachIndexed { groupIndex, group ->
item {
Text(

View file

@ -12,6 +12,7 @@ import androidx.datastore.core.DataStore
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.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
@ -25,6 +26,7 @@ import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.ServerReportService
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
@ -33,6 +35,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
@ -76,6 +79,23 @@ class PreferencesViewModel
val externalPlayers = MutableStateFlow<List<ExternalPlayerApp>>(emptyList())
val serverSupported =
serverRepository.currentServerFlow
.map {
val serverVersion = it?.serverVersion
serverVersion to
when {
serverVersion == null -> ServerVersionSupported.NOT_SUPPORTED
serverVersion < WholphinApplication.minimumServerVersion -> ServerVersionSupported.NOT_SUPPORTED
serverVersion < WholphinApplication.newMinimumServerVersion -> ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE
else -> ServerVersionSupported.SUPPORTED
}
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
null to ServerVersionSupported.SUPPORTED,
)
init {
viewModelScope.launchIO {
val fakeIntent =

View file

@ -32,6 +32,7 @@ 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.AppColors
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.CircularProgress
import org.jellyfin.sdk.model.api.PublicSystemInfo
@ -177,7 +178,12 @@ fun ServerIconCard(
Icon(
imageVector = Icons.Default.Warning,
contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message,
tint = MaterialTheme.colorScheme.errorContainer,
tint =
if (serverVersionSupported == ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE) {
AppColors.GoldenYellow
} else {
MaterialTheme.colorScheme.errorContainer
},
modifier = Modifier.size(cardSize * 0.3f),
)
Text(

View file

@ -11,6 +11,7 @@ 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.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
@ -69,8 +70,10 @@ 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.serverSupportedMessage
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import org.jellyfin.sdk.model.ServerVersion
import timber.log.Timber
@Composable
@ -235,20 +238,7 @@ private fun SwitchServerContentInternal(
.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),
)
SwitchErrorMessage(message ?: "")
}
// Delete server dialog
@ -491,21 +481,38 @@ 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
}
if (server == null || server.status == ServerConnectionStatus.Pending) {
null
} else if (server.status is ServerConnectionStatus.Success) {
resources.serverSupportedMessage(
server.versionSupported,
server.status.systemInfo.version
?.let { ServerVersion.fromString(it) },
)
} else if (server.status is ServerConnectionStatus.Error) {
server.status.message
} else {
resources.serverSupportedMessage(
server.versionSupported,
null,
)
}
@Composable
fun BoxScope.SwitchErrorMessage(message: String?) {
message?.let {
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
.align(Alignment.BottomCenter),
)
}
}

View file

@ -97,6 +97,8 @@ class SwitchServerViewModel
result.systemInfo.version?.let { ServerVersion.fromString(it) }
if (serverVersion == null || serverVersion < WholphinApplication.minimumServerVersion) {
ServerVersionSupported.NOT_SUPPORTED
} else if (serverVersion < WholphinApplication.newMinimumServerVersion) {
ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE
} else {
ServerVersionSupported.SUPPORTED
}
@ -307,4 +309,5 @@ enum class ServerVersionSupported {
SUPPORTED,
NOT_SUPPORTED,
UNKNOWN,
NOT_SUPPORTED_IN_FUTURE,
}

View file

@ -31,7 +31,6 @@ 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
@ -50,6 +49,7 @@ import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.dimAndBlur
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.serverSupportedMessage
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
@ -183,37 +183,15 @@ 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),
val resources = LocalResources.current
val message =
remember(resources, state.serverVersionSupported) {
resources.serverSupportedMessage(
state.serverVersionSupported,
state.serverVersion,
)
}
}
SwitchErrorMessage(message)
}
}
}

View file

@ -300,16 +300,18 @@ class SwitchUserViewModel
knownUsers + publicUsers
}
private suspend fun checkServerVersion(): Pair<String?, ServerVersionSupported> {
private suspend fun checkServerVersion(): Pair<ServerVersion?, 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
serverVersion to ServerVersionSupported.UNKNOWN
} else if (serverVersion < WholphinApplication.minimumServerVersion) {
systemInfo.version to ServerVersionSupported.NOT_SUPPORTED
serverVersion to ServerVersionSupported.NOT_SUPPORTED
} else if (serverVersion < WholphinApplication.newMinimumServerVersion) {
serverVersion to ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE
} else {
systemInfo.version to ServerVersionSupported.SUPPORTED
serverVersion to ServerVersionSupported.SUPPORTED
}
}
@ -341,7 +343,7 @@ data class SwitchUserState(
// LoadingState for while adding/switching users
val switchUserState: LoadingState = LoadingState.Pending,
val loginAttempts: Int = 0,
val serverVersion: String? = null,
val serverVersion: ServerVersion? = null,
val serverVersionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN,
)

View file

@ -932,6 +932,8 @@
<string name="books">Books</string>
<string name="audio_books">Audio books</string>
<string name="server_version_not_supported">Server version not supported</string>
<string name="server_version_not_supported_future">Server version will not be supported soon</string>
<string name="server_version_required">Required server version: %1$s</string>
<string name="appears_on">Appears on</string>
<string name="dim_after_delay">Dim screen after delay</string>
<string name="dim_percentage">Dim percentage</string>