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,15 +85,22 @@ 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 loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val st = loading) {
is LoadingState.Error -> ErrorMessage(st, modifier)
LoadingState.Pending,
LoadingState.Loading,
-> LoadingPage(modifier)
LoadingState.Success -> {
val prefs = preferences.appPreferences.playbackPreferences
val scope = rememberCoroutineScope()
val player = viewModel.player
val title by viewModel.title.observeAsState(null) val title by viewModel.title.observeAsState(null)
val subtitle by viewModel.subtitle.observeAsState(null) val subtitle by viewModel.subtitle.observeAsState(null)
val duration by viewModel.duration.observeAsState(null) val duration by viewModel.duration.observeAsState(null)
@ -133,11 +141,6 @@ fun PlaybackPage(
onDispose { player.removeListener(cueListener) } onDispose { player.removeListener(cueListener) }
} }
AmbientPlayerListener(player) 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)
}
} }