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 private set
val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion 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 androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication 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.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaSegmentType
@ -319,3 +321,55 @@ fun Resources.formatDuration(duration: Duration): String =
else -> getString(R.string.duration_seconds, seconds) 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.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.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation 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.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings 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.ReleaseNotes
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog
@ -102,6 +105,7 @@ fun PreferencesContent(
onFocus: (Int, Int) -> Unit = { _, _ -> }, onFocus: (Int, Int) -> Unit = { _, _ -> },
) { ) {
val context = LocalContext.current val context = LocalContext.current
val resources = LocalResources.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val firstFocusRequester = remember { FocusRequester() } val firstFocusRequester = remember { FocusRequester() }
@ -114,6 +118,11 @@ fun PreferencesContent(
var showPinFlow by remember { mutableStateOf(false) } var showPinFlow by remember { mutableStateOf(false) }
var showVersionDialog by remember { mutableStateOf(false) } var showVersionDialog by remember { mutableStateOf(false) }
val players by viewModel.externalPlayers.collectAsState() 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)) } var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
val seerrConnection by viewModel.seerrConnection.collectAsState() val seerrConnection by viewModel.seerrConnection.collectAsState()
@ -230,6 +239,19 @@ fun PreferencesContent(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
serverVersionMessage?.let { message ->
item {
ListItem(
selected = false,
enabled = true,
onClick = {},
headlineContent = {
PreferenceTitle(message, color = MaterialTheme.colorScheme.error)
},
modifier = Modifier,
)
}
}
if (showUpdate) { if (showUpdate) {
item { item {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
@ -265,6 +287,7 @@ fun PreferencesContent(
) )
} }
} }
prefList.forEachIndexed { groupIndex, group -> prefList.forEachIndexed { groupIndex, group ->
item { item {
Text( Text(

View file

@ -12,6 +12,7 @@ import androidx.datastore.core.DataStore
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.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences 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.ServerReportService
import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.launchIO 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.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState 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.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -76,6 +79,23 @@ class PreferencesViewModel
val externalPlayers = MutableStateFlow<List<ExternalPlayerApp>>(emptyList()) 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 { init {
viewModelScope.launchIO { viewModelScope.launchIO {
val fakeIntent = val fakeIntent =

View file

@ -32,6 +32,7 @@ import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer 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.Cards
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
import org.jellyfin.sdk.model.api.PublicSystemInfo import org.jellyfin.sdk.model.api.PublicSystemInfo
@ -177,7 +178,12 @@ fun ServerIconCard(
Icon( Icon(
imageVector = Icons.Default.Warning, imageVector = Icons.Default.Warning,
contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message, 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), modifier = Modifier.size(cardSize * 0.3f),
) )
Text( Text(

View file

@ -11,6 +11,7 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith 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.BoxScope
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer 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.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.rememberInt
import com.github.damontecres.wholphin.ui.serverSupportedMessage
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
import org.jellyfin.sdk.model.ServerVersion
import timber.log.Timber import timber.log.Timber
@Composable @Composable
@ -235,20 +238,7 @@ private fun SwitchServerContentInternal(
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter), .align(Alignment.BottomCenter),
) { message -> ) { message ->
SwitchErrorMessage(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
@ -491,21 +481,38 @@ fun serverErrorMessage(
server: ServerState?, server: ServerState?,
resources: Resources, resources: Resources,
): String? = ): String? =
when { if (server == null || server.status == ServerConnectionStatus.Pending) {
server?.status is ServerConnectionStatus.Error -> { null
server.status.message } else if (server.status is ServerConnectionStatus.Success) {
} resources.serverSupportedMessage(
server.versionSupported,
server?.status is ServerConnectionStatus.Success && server.status.systemInfo.version
server.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> { ?.let { ServerVersion.fromString(it) },
resources.getString(R.string.server_version_not_supported) + ": ${server.status.systemInfo.version}" )
} } else if (server.status is ServerConnectionStatus.Error) {
server.status.message
server?.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> { } else {
resources.getString(R.string.server_version_not_supported) resources.serverSupportedMessage(
} server.versionSupported,
null,
else -> { )
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) } result.systemInfo.version?.let { ServerVersion.fromString(it) }
if (serverVersion == null || serverVersion < WholphinApplication.minimumServerVersion) { if (serverVersion == null || serverVersion < WholphinApplication.minimumServerVersion) {
ServerVersionSupported.NOT_SUPPORTED ServerVersionSupported.NOT_SUPPORTED
} else if (serverVersion < WholphinApplication.newMinimumServerVersion) {
ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE
} else { } else {
ServerVersionSupported.SUPPORTED ServerVersionSupported.SUPPORTED
} }
@ -307,4 +309,5 @@ enum class ServerVersionSupported {
SUPPORTED, SUPPORTED,
NOT_SUPPORTED, NOT_SUPPORTED,
UNKNOWN, 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.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
@ -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.dimAndBlur
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination 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.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -183,37 +183,15 @@ fun SwitchUserContent(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
when (state.serverVersionSupported) { val resources = LocalResources.current
ServerVersionSupported.SUPPORTED -> {} val message =
remember(resources, state.serverVersionSupported) {
ServerVersionSupported.NOT_SUPPORTED, resources.serverSupportedMessage(
ServerVersionSupported.UNKNOWN, state.serverVersionSupported,
-> { state.serverVersion,
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),
) )
} }
} SwitchErrorMessage(message)
} }
} }
} }

View file

@ -300,16 +300,18 @@ class SwitchUserViewModel
knownUsers + publicUsers knownUsers + publicUsers
} }
private suspend fun checkServerVersion(): Pair<String?, ServerVersionSupported> { private suspend fun checkServerVersion(): Pair<ServerVersion?, ServerVersionSupported> {
val api = jellyfin.createApi(server.url) val api = jellyfin.createApi(server.url)
val systemInfo by api.systemApi.getPublicSystemInfo() val systemInfo by api.systemApi.getPublicSystemInfo()
val serverVersion = systemInfo.version?.let { ServerVersion.fromString(it) } val serverVersion = systemInfo.version?.let { ServerVersion.fromString(it) }
return if (serverVersion == null) { return if (serverVersion == null) {
systemInfo.version to ServerVersionSupported.UNKNOWN serverVersion to ServerVersionSupported.UNKNOWN
} else if (serverVersion < WholphinApplication.minimumServerVersion) { } 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 { } else {
systemInfo.version to ServerVersionSupported.SUPPORTED serverVersion to ServerVersionSupported.SUPPORTED
} }
} }
@ -341,7 +343,7 @@ 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 serverVersion: ServerVersion? = null,
val serverVersionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN, val serverVersionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN,
) )

View file

@ -932,6 +932,8 @@
<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> <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="appears_on">Appears on</string>
<string name="dim_after_delay">Dim screen after delay</string> <string name="dim_after_delay">Dim screen after delay</string>
<string name="dim_percentage">Dim percentage</string> <string name="dim_percentage">Dim percentage</string>