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

View file

@ -38,7 +38,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
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.skipBackOnResume
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.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.seasonEpisode
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.DeviceProfile
@ -84,15 +85,22 @@ fun PlaybackPage(
modifier: Modifier = Modifier,
viewModel: PlaybackViewModel = hiltViewModel(),
) {
val prefs = preferences.appPreferences.playbackPreferences
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(destination.itemId) {
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 subtitle by viewModel.subtitle.observeAsState(null)
val duration by viewModel.duration.observeAsState(null)
@ -133,11 +141,6 @@ fun PlaybackPage(
onDispose { player.removeListener(cueListener) }
}
AmbientPlayerListener(player)
if (stream == null) {
LoadingPage()
} else {
stream?.let {
var contentScale by remember { mutableStateOf(ContentScale.Fit) }
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
@ -146,6 +149,7 @@ fun PlaybackPage(
val scaledModifier =
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
val focusRequester = remember { FocusRequester() }
val controllerFocusRequester = remember { FocusRequester() }
val playPauseState = rememberPlayPauseButtonState(player)
val seekBarState = rememberSeekBarState(player, scope)

View file

@ -10,6 +10,7 @@ import androidx.lifecycle.viewModelScope
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
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.util.EqualityMutableLiveData
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.TrackSupport
import com.github.damontecres.wholphin.util.checkForSupport
@ -105,7 +108,7 @@ class PlaybackViewModel
playWhenReady = true
}
val stream = MutableLiveData<StreamDecision?>(null)
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val title = MutableLiveData<String?>(null)
val subtitle = MutableLiveData<String?>(null)
@ -152,7 +155,12 @@ class PlaybackViewModel
val itemId = destination.itemId
this.itemId = itemId
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 base =
if (queriedItem.type == BaseItemKind.PLAYLIST) {
@ -493,7 +501,7 @@ class PlaybackViewModel
this@PlaybackViewModel.activityListener = activityListener
duration.value = source.runTimeTicks?.ticks
stream.value = decision
loading.value = LoadingState.Success
currentPlayback.value = playback
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
player.setMediaItem(
@ -708,6 +716,13 @@ class PlaybackViewModel
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(

View file

@ -178,6 +178,16 @@ fun SwitchUserContent(
style = MaterialTheme.typography.titleMedium,
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(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.focusGroup(),
@ -236,16 +246,6 @@ fun SwitchUserContent(
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) {
Timber.e(ex, "Error switching user")
switchUserState.value = LoadingState.Error(exception = ex)
setError(ex)
}
}
}
@ -178,7 +178,7 @@ class SwitchUserViewModel
}
} catch (ex: Exception) {
Timber.e(ex, "Error logging in user")
switchUserState.value = LoadingState.Error(ex)
setError(ex)
}
}
}
@ -230,7 +230,7 @@ class SwitchUserViewModel
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)
}
}