Fixes a few UI issues (#27)

Fixes:
- Rounded corners on dialogs during playback
- Follow up to #23 to show errors on Main thread
- Show login errors higher up to ensure they aren't blocked by the
keyboard
- Faster tracks switching by not recomposing the player surface
- Refocus on controller buttons after switching tracks
- Show playback errors when they occur
This commit is contained in:
damontecres 2025-10-17 18:26:03 -04:00 committed by GitHub
parent 6194eba8c5
commit e0772ed9b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 118 additions and 64 deletions

View file

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -306,6 +307,7 @@ fun LeftPlaybackButtons(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var showMoreOptions by remember { mutableStateOf(false) } var showMoreOptions by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
Row( Row(
modifier = modifier.focusGroup(), modifier = modifier.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(buttonSpacing), horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
@ -319,6 +321,7 @@ fun LeftPlaybackButtons(
}, },
enabled = true, enabled = true,
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
modifier = Modifier.focusRequester(focusRequester),
) )
} }
if (showMoreOptions) { if (showMoreOptions) {
@ -329,7 +332,10 @@ fun LeftPlaybackButtons(
} }
BottomDialog( BottomDialog(
choices = options, choices = options,
onDismissRequest = { showMoreOptions = false }, onDismissRequest = {
showMoreOptions = false
focusRequester.tryRequestFocus()
},
onSelectChoice = { index, choice -> onSelectChoice = { index, choice ->
val action = moreButtonOptions.options[choice] ?: PlaybackAction.ShowDebug val action = moreButtonOptions.options[choice] ?: PlaybackAction.ShowDebug
onPlaybackActionClick.invoke(action) onPlaybackActionClick.invoke(action)
@ -359,6 +365,10 @@ fun RightPlaybackButtons(
var showAudioDialog by remember { mutableStateOf(false) } var showAudioDialog by remember { mutableStateOf(false) }
var showSpeedDialog by remember { mutableStateOf(false) } var showSpeedDialog by remember { mutableStateOf(false) }
var showScaleDialog by remember { mutableStateOf(false) } var showScaleDialog by remember { mutableStateOf(false) }
val captionFocusRequester = remember { FocusRequester() }
val settingsFocusRequester = remember { FocusRequester() }
Row( Row(
modifier = modifier.focusGroup(), modifier = modifier.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(buttonSpacing), horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
@ -372,6 +382,7 @@ fun RightPlaybackButtons(
showCaptionDialog = true showCaptionDialog = true
}, },
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
modifier = Modifier.focusRequester(captionFocusRequester),
) )
// Playback speed, etc // Playback speed, etc
PlaybackButton( PlaybackButton(
@ -382,8 +393,10 @@ fun RightPlaybackButtons(
}, },
enabled = true, enabled = true,
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
modifier = Modifier.focusRequester(settingsFocusRequester),
) )
} }
val scope = rememberCoroutineScope()
if (showCaptionDialog) { if (showCaptionDialog) {
val options = subtitleStreams.map { it.displayName } val options = subtitleStreams.map { it.displayName }
Timber.v("subtitleIndex=$subtitleIndex, options=$options") Timber.v("subtitleIndex=$subtitleIndex, options=$options")
@ -395,6 +408,11 @@ fun RightPlaybackButtons(
onDismissRequest = { onDismissRequest = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
showCaptionDialog = false showCaptionDialog = false
scope.launch {
// TODO this is hacky, but playback changes force refocus and this is a workaround
delay(250L)
captionFocusRequester.tryRequestFocus()
}
}, },
onSelectChoice = { index, _ -> onSelectChoice = { index, _ ->
val send = val send =
@ -434,6 +452,10 @@ fun RightPlaybackButtons(
onDismissRequest = { onDismissRequest = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
showAudioDialog = false showAudioDialog = false
scope.launch {
delay(250L)
settingsFocusRequester.tryRequestFocus()
}
}, },
onSelectChoice = { index, _ -> onSelectChoice = { index, _ ->
onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(audioStreams[index].index)) onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(audioStreams[index].index))
@ -448,6 +470,10 @@ fun RightPlaybackButtons(
onDismissRequest = { onDismissRequest = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
showSpeedDialog = false showSpeedDialog = false
scope.launch {
delay(250L)
settingsFocusRequester.tryRequestFocus()
}
}, },
onSelectChoice = { _, value -> onSelectChoice = { _, value ->
onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.toFloat())) onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.toFloat()))
@ -462,6 +488,10 @@ fun RightPlaybackButtons(
onDismissRequest = { onDismissRequest = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
showScaleDialog = false showScaleDialog = false
scope.launch {
delay(250L)
settingsFocusRequester.tryRequestFocus()
}
}, },
onSelectChoice = { index, _ -> onSelectChoice = { index, _ ->
onPlaybackActionClick.invoke(PlaybackAction.Scale(playbackScaleOptions.keys.toList()[index])) onPlaybackActionClick.invoke(PlaybackAction.Scale(playbackScaleOptions.keys.toList()[index]))
@ -601,7 +631,7 @@ private fun BottomDialog(
Modifier Modifier
.wrapContentSize() .wrapContentSize()
.padding(8.dp) .padding(8.dp)
.background(Color.DarkGray), .background(Color.DarkGray, shape = RoundedCornerShape(16.dp)),
) { ) {
Column( Column(
modifier = modifier =

View file

@ -38,7 +38,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -61,9 +60,11 @@ import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
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.seasonEpisode import com.github.damontecres.wholphin.util.seasonEpisode
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
@ -84,60 +85,62 @@ fun PlaybackPage(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PlaybackViewModel = hiltViewModel(), viewModel: PlaybackViewModel = hiltViewModel(),
) { ) {
val prefs = preferences.appPreferences.playbackPreferences
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(destination.itemId) { LaunchedEffect(destination.itemId) {
viewModel.init(destination, deviceProfile, preferences) viewModel.init(destination, deviceProfile, preferences)
} }
val player = viewModel.player
val stream by viewModel.stream.observeAsState(null)
val title by viewModel.title.observeAsState(null) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val subtitle by viewModel.subtitle.observeAsState(null) when (val st = loading) {
val duration by viewModel.duration.observeAsState(null) is LoadingState.Error -> ErrorMessage(st, modifier)
val audioStreams by viewModel.audioStreams.observeAsState(listOf()) LoadingState.Pending,
val subtitleStreams by viewModel.subtitleStreams.observeAsState(listOf()) LoadingState.Loading,
val trickplay by viewModel.trickplay.observeAsState(null) -> LoadingPage(modifier)
val chapters by viewModel.chapters.observeAsState(listOf())
val currentPlayback by viewModel.currentPlayback.observeAsState(null)
val currentItemPlayback by viewModel.currentItemPlayback.observeAsState(
ItemPlayback(
userId = -1,
itemId = UUID.randomUUID(),
),
)
val currentSegment by viewModel.currentSegment.observeAsState(null)
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
var cues by remember { mutableStateOf<List<Cue>>(listOf()) } LoadingState.Success -> {
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } val prefs = preferences.appPreferences.playbackPreferences
val scope = rememberCoroutineScope()
val nextUp by viewModel.nextUp.observeAsState(null) val player = viewModel.player
val playlist by viewModel.playlist.observeAsState(Playlist(listOf())) val title by viewModel.title.observeAsState(null)
val subtitle by viewModel.subtitle.observeAsState(null)
val duration by viewModel.duration.observeAsState(null)
val audioStreams by viewModel.audioStreams.observeAsState(listOf())
val subtitleStreams by viewModel.subtitleStreams.observeAsState(listOf())
val trickplay by viewModel.trickplay.observeAsState(null)
val chapters by viewModel.chapters.observeAsState(listOf())
val currentPlayback by viewModel.currentPlayback.observeAsState(null)
val currentItemPlayback by viewModel.currentItemPlayback.observeAsState(
ItemPlayback(
userId = -1,
itemId = UUID.randomUUID(),
),
)
val currentSegment by viewModel.currentSegment.observeAsState(null)
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
// TODO move to viewmodel? var cues by remember { mutableStateOf<List<Cue>>(listOf()) }
val cueListener = var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
remember {
object : Player.Listener { val nextUp by viewModel.nextUp.observeAsState(null)
override fun onCues(cueGroup: CueGroup) { val playlist by viewModel.playlist.observeAsState(Playlist(listOf()))
cues = cueGroup.cues
// TODO move to viewmodel?
val cueListener =
remember {
object : Player.Listener {
override fun onCues(cueGroup: CueGroup) {
cues = cueGroup.cues
}
}
} }
OneTimeLaunchedEffect {
player.addListener(cueListener)
} }
} DisposableEffect(Unit) {
onDispose { player.removeListener(cueListener) }
OneTimeLaunchedEffect { }
player.addListener(cueListener) AmbientPlayerListener(player)
}
DisposableEffect(Unit) {
onDispose { player.removeListener(cueListener) }
}
AmbientPlayerListener(player)
if (stream == null) {
LoadingPage()
} else {
stream?.let {
var contentScale by remember { mutableStateOf(ContentScale.Fit) } var contentScale by remember { mutableStateOf(ContentScale.Fit) }
var playbackSpeed by remember { mutableFloatStateOf(1.0f) } var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
@ -146,6 +149,7 @@ fun PlaybackPage(
val scaledModifier = val scaledModifier =
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val controllerFocusRequester = remember { FocusRequester() }
val playPauseState = rememberPlayPauseButtonState(player) val playPauseState = rememberPlayPauseButtonState(player)
val seekBarState = rememberSeekBarState(player, scope) val seekBarState = rememberSeekBarState(player, scope)

View file

@ -10,6 +10,7 @@ import androidx.lifecycle.viewModelScope
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Format import androidx.media3.common.Format
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks import androidx.media3.common.Tracks
@ -32,6 +33,8 @@ import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.EqualityMutableLiveData import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
import com.github.damontecres.wholphin.util.TrackSupport import com.github.damontecres.wholphin.util.TrackSupport
import com.github.damontecres.wholphin.util.checkForSupport import com.github.damontecres.wholphin.util.checkForSupport
@ -105,7 +108,7 @@ class PlaybackViewModel
playWhenReady = true playWhenReady = true
} }
val stream = MutableLiveData<StreamDecision?>(null) val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val title = MutableLiveData<String?>(null) val title = MutableLiveData<String?>(null)
val subtitle = MutableLiveData<String?>(null) val subtitle = MutableLiveData<String?>(null)
@ -152,7 +155,12 @@ class PlaybackViewModel
val itemId = destination.itemId val itemId = destination.itemId
this.itemId = itemId this.itemId = itemId
val item = destination.item val item = destination.item
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { viewModelScope.launch(
LoadingExceptionHandler(
loading,
"Error preparing for plaback on ${destination.itemId}",
) + Dispatchers.IO,
) {
val queriedItem = item?.data ?: api.userLibraryApi.getItem(itemId).content val queriedItem = item?.data ?: api.userLibraryApi.getItem(itemId).content
val base = val base =
if (queriedItem.type == BaseItemKind.PLAYLIST) { if (queriedItem.type == BaseItemKind.PLAYLIST) {
@ -493,7 +501,7 @@ class PlaybackViewModel
this@PlaybackViewModel.activityListener = activityListener this@PlaybackViewModel.activityListener = activityListener
duration.value = source.runTimeTicks?.ticks duration.value = source.runTimeTicks?.ticks
stream.value = decision loading.value = LoadingState.Success
currentPlayback.value = playback currentPlayback.value = playback
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
player.setMediaItem( player.setMediaItem(
@ -708,6 +716,13 @@ class PlaybackViewModel
tracks = checkForSupport(tracks), tracks = checkForSupport(tracks),
) )
} }
override fun onPlayerError(error: PlaybackException) {
Timber.e(error, "Playback error")
viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) {
loading.value = LoadingState.Error("Error during playback", error)
}
}
} }
data class CurrentPlayback( data class CurrentPlayback(

View file

@ -178,6 +178,16 @@ fun SwitchUserContent(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
when (val s = userState) {
is LoadingState.Error -> {
Text(
text = s.message ?: s.exception?.localizedMessage ?: "Error",
color = MaterialTheme.colorScheme.error,
)
}
else -> {}
}
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.focusGroup(), modifier = Modifier.focusGroup(),
@ -236,16 +246,6 @@ fun SwitchUserContent(
Text("Login") Text("Login")
} }
} }
when (val s = userState) {
is LoadingState.Error -> {
Text(
text = s.message ?: s.exception?.localizedMessage ?: "Error",
color = MaterialTheme.colorScheme.error,
)
}
else -> {}
}
} }
} }
} }

View file

@ -154,7 +154,7 @@ class SwitchUserViewModel
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error switching user") Timber.e(ex, "Error switching user")
switchUserState.value = LoadingState.Error(exception = ex) setError(ex)
} }
} }
} }
@ -178,7 +178,7 @@ class SwitchUserViewModel
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error logging in user") Timber.e(ex, "Error logging in user")
switchUserState.value = LoadingState.Error(ex) setError(ex)
} }
} }
} }
@ -230,7 +230,7 @@ class SwitchUserViewModel
put(server.id, false) put(server.id, false)
} }
} }
switchUserState.value = LoadingState.Error(ex) setError(ex)
} }
} }
} }
@ -328,4 +328,9 @@ class SwitchUserViewModel
} }
} }
} }
private suspend fun setError(ex: Exception) =
withContext(Dispatchers.Main) {
switchUserState.value = LoadingState.Error(ex)
}
} }