Listen for user config changes and improved websocket error handling (#2012)

## Description
Add better error handling for server web socket messages that are
(technically) invalid.

For example, I talked to Niels and some third party plugins don't always
have the required fields. Since the server isn't as strict as the client
SDK, it's fine on the server but will generate an invalid message sent
to the client.

Also listens for server-side user configuration changes via web socket.

### Related issues
Fixes #1979

### Testing
Valid server messages are handled as normal and I did some manual
testing to push invalid JSON into the webSocketApi, but the code is
gnarly and not viable as an actual unit test

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-09-29 12:43:13 -04:00 • committed by GitHub
parent a577ae9f1a
commit cf74736579
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 152 additions and 78 deletions

View file

@ -18,6 +18,7 @@ import androidx.media3.common.Player
import androidx.media3.common.Tracks
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.exoplayer.DecoderCounters
import androidx.media3.exoplayer.DecoderReuseEvaluation
import androidx.media3.exoplayer.ExoPlayer
@ -81,6 +82,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -113,6 +115,7 @@ import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.PlaystateCommand
import org.jellyfin.sdk.model.api.PlaystateMessage
import org.jellyfin.sdk.model.api.PlaystateRequest
import org.jellyfin.sdk.model.api.TrickplayInfo
import org.jellyfin.sdk.model.api.VideoRange
import org.jellyfin.sdk.model.api.VideoRangeType
@ -187,6 +190,7 @@ class PlaybackViewModel
private var activityListener: TrackActivityPlaybackListener? = null
private var trackChangeListener: TracksChangedListener? = null
private val jobs = mutableListOf<Job>()
private var subscribeJob: Job? = null
private val isPlaylist = destination is Destination.PlaybackList
@ -271,7 +275,7 @@ class PlaybackViewModel
private fun configurePlayer() {
player.addListener(this)
(player as? ExoPlayer)?.addAnalyticsListener(this)
jobs.add(subscribe())
subscribeToWebSocket()
jobs.add(listenForTranscodeReason())
val sessionPlayer =
MediaSessionPlayer(
@ -1417,63 +1421,83 @@ class PlaybackViewModel
activityListener = null
}
fun subscribe(): Job =
api.webSocket
.subscribe<PlaystateMessage>()
.onEach { message ->
message.data?.let {
withContext(WholphinDispatchers.Main) {
when (it.command) {
PlaystateCommand.STOP -> {
release()
navigationManager.goBack()
}
PlaystateCommand.PAUSE -> {
player.pause()
}
PlaystateCommand.UNPAUSE -> {
player.play()
}
PlaystateCommand.NEXT_TRACK -> {
playNextUp()
}
PlaystateCommand.PREVIOUS_TRACK -> {
playPrevious()
}
PlaystateCommand.SEEK -> {
it.seekPositionTicks?.ticks?.let {
player.seekTo(
it.inWholeMilliseconds,
)
fun subscribeToWebSocket() {
subscribeJob?.cancel()
try {
subscribeJob =
viewModelScope.launch {
coroutineScope {
api.webSocket
.subscribe<PlaystateMessage>()
.onEach { message ->
message.data?.let { request ->
Timber.v("Received playstate request: %s", request)
withContext(WholphinDispatchers.Main) {
handlePlaystateRequest(request)
}
}
}
PlaystateCommand.REWIND -> {
player.seekBack(
preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
)
}
PlaystateCommand.FAST_FORWARD -> {
player.seekForward(
preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
)
}
PlaystateCommand.PLAY_PAUSE -> {
if (player.isPlaying) player.pause() else player.play()
}
}
}.catch { ex ->
Timber.e(ex, "Error in websocket subscription")
}.launchIn(this)
}
}
}.catch { ex ->
Timber.e(ex, "Error in websocket subscription")
}.launchIn(viewModelScope)
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error in playback websocket subscription")
if (viewModelScope.isActive) {
subscribeToWebSocket()
}
}
}
private suspend fun handlePlaystateRequest(request: PlaystateRequest) =
withContext(WholphinDispatchers.Main) {
when (request.command) {
PlaystateCommand.STOP -> {
release()
navigationManager.goBack()
}
PlaystateCommand.PAUSE -> {
player.pause()
}
PlaystateCommand.UNPAUSE -> {
player.play()
}
PlaystateCommand.NEXT_TRACK -> {
playNextUp()
}
PlaystateCommand.PREVIOUS_TRACK -> {
playPrevious()
}
PlaystateCommand.SEEK -> {
request.seekPositionTicks?.ticks?.let {
player.seekTo(it.inWholeMilliseconds)
}
}
PlaystateCommand.REWIND -> {
player.seekBack(
preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
)
}
PlaystateCommand.FAST_FORWARD -> {
player.seekForward(
preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
)
}
PlaystateCommand.PLAY_PAUSE -> {
Util.handlePlayPauseButtonAction(player)
}
}
}
/**
* Atomically update [currentMediaInfo]