Several minor fixes (#1505)

## Description
Collecting some minor fixes together:

- Adds error handling when submitting a discover/seerr request,
previously there was no visual feedback if the request failed
- Fix MpvPlayer reporting that it was buffering forever
- Fix discover results row on regular search page not being clickable
- Fix focus loss when returning to movie details after playback advanced
enough to switch the button from Play to Resume
- Fix some focus issues when returning to the TV guide page

### Related issues
Related to #1503 for MPV
Fixes #1500
Fixes #1508
Fixes #1501

### Testing
Emulator & nvidia shield

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-06-03 20:47:07 -04:00 • committed by GitHub
parent a1b372667b
commit a0869ca6e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 103 additions and 66 deletions

View file

@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -59,6 +60,7 @@ import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import kotlin.time.Duration import kotlin.time.Duration
@ -85,6 +87,14 @@ fun ExpandablePlayButtons(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }
val restartInteractionSource = remember { MutableInteractionSource() }
val restartButtonFocused by restartInteractionSource.collectIsFocusedAsState()
LaunchedEffect(resumePosition) {
// Reset focus if the restart button was focuses and it is going to disappear
if (restartButtonFocused && resumePosition == Duration.ZERO) {
firstFocus.tryRequestFocus()
}
}
LazyRow( LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(8.dp), contentPadding = PaddingValues(8.dp),
@ -93,19 +103,19 @@ fun ExpandablePlayButtons(
.focusGroup() .focusGroup()
.focusRestorer(firstFocus), .focusRestorer(firstFocus),
) { ) {
item("play") {
ExpandablePlayButton(
title = if (resumePosition > Duration.ZERO) R.string.resume else R.string.play,
resume = resumePosition,
icon = Icons.Default.PlayArrow,
onClick = playOnClick,
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged)
.focusRequester(firstFocus),
)
}
if (resumePosition > Duration.ZERO) { if (resumePosition > Duration.ZERO) {
item("play") {
ExpandablePlayButton(
title = R.string.resume,
resume = resumePosition,
icon = Icons.Default.PlayArrow,
onClick = playOnClick,
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged)
.focusRequester(firstFocus),
)
}
item("restart") { item("restart") {
ExpandablePlayButton( ExpandablePlayButton(
title = R.string.restart, title = R.string.restart,
@ -114,19 +124,7 @@ fun ExpandablePlayButtons(
onClick = playOnClick, onClick = playOnClick,
modifier = Modifier.onFocusChanged(buttonOnFocusChanged), modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
mirrorIcon = true, mirrorIcon = true,
) interactionSource = restartInteractionSource,
}
} else {
item("play") {
ExpandablePlayButton(
title = R.string.play,
resume = Duration.ZERO,
icon = Icons.Default.PlayArrow,
onClick = playOnClick,
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged)
.focusRequester(firstFocus),
) )
} }
} }

View file

@ -141,6 +141,7 @@ fun CollectionFolderLiveTv(
when (selectedTabIndex) { when (selectedTabIndex) {
0 -> { 0 -> {
TvGuideGrid( TvGuideGrid(
preferences = preferences,
onRowPosition = { onRowPosition = {
showHeader = it <= 0 showHeader = it <= 0
}, },

View file

@ -22,6 +22,7 @@ import com.github.damontecres.wholphin.services.SeerrUserConfig
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.successValue import com.github.damontecres.wholphin.util.successValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
@ -168,7 +169,7 @@ class DiscoverMovieViewModel
is4k: Boolean, is4k: Boolean,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val request = try {
seerrService.api.requestApi.requestPost( seerrService.api.requestApi.requestPost(
RequestPostRequest( RequestPostRequest(
is4k = is4k, is4k = is4k,
@ -176,6 +177,12 @@ class DiscoverMovieViewModel
mediaType = RequestPostRequest.MediaType.MOVIE, mediaType = RequestPostRequest.MediaType.MOVIE,
), ),
) )
} catch (ex: kotlinx.coroutines.CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error requesting %s", id)
showToast(context, "An error occurred")
}
fetchAndSetItem().await() fetchAndSetItem().await()
updateCanCancel() updateCanCancel()
} }
@ -185,7 +192,14 @@ class DiscoverMovieViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
state.value.movie.successValue?.mediaInfo?.requests?.firstOrNull()?.let { state.value.movie.successValue?.mediaInfo?.requests?.firstOrNull()?.let {
// TODO handle multiple requests? Or just delete self's request? // TODO handle multiple requests? Or just delete self's request?
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) try {
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
} catch (ex: kotlinx.coroutines.CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error requesting %s", id)
showToast(context, "An error occurred")
}
fetchAndSetItem().await() fetchAndSetItem().await()
updateCanCancel() updateCanCancel()
} }

View file

@ -20,6 +20,7 @@ import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.successValue import com.github.damontecres.wholphin.util.successValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
@ -209,27 +210,34 @@ class DiscoverSeriesViewModel
it.requestedBy?.id == it.requestedBy?.id ==
seerrServerRepository.currentUserId.first() seerrServerRepository.currentUserId.first()
} }
if (currentRequest != null) { try {
Timber.v("User has pending request, will update") if (currentRequest != null) {
seerrService.api.requestApi.requestRequestIdPut( Timber.v("User has pending request, will update")
requestId = currentRequest.id.toString(), seerrService.api.requestApi.requestRequestIdPut(
requestRequestIdPutRequest = requestId = currentRequest.id.toString(),
RequestRequestIdPutRequest( requestRequestIdPutRequest =
RequestRequestIdPutRequest(
is4k = is4k,
mediaType = RequestRequestIdPutRequest.MediaType.TV,
seasons = seasons.toList(),
),
)
} else {
Timber.v("New request for %s seasons", seasons.size)
seerrService.api.requestApi.requestPost(
RequestPostRequest(
is4k = is4k, is4k = is4k,
mediaType = RequestRequestIdPutRequest.MediaType.TV, mediaId = id,
mediaType = RequestPostRequest.MediaType.TV,
seasons = seasons.toList(), seasons = seasons.toList(),
), ),
) )
} else { }
Timber.v("New request for %s seasons", seasons.size) } catch (ex: CancellationException) {
seerrService.api.requestApi.requestPost( throw ex
RequestPostRequest( } catch (ex: Exception) {
is4k = is4k, Timber.e(ex, "Error requesting %s", id)
mediaId = id, showToast(context, "An error occurred")
mediaType = RequestPostRequest.MediaType.TV,
seasons = seasons.toList(),
),
)
} }
fetchAndSetItem().await()?.let { fetchAndSetItem().await()?.let {
@ -244,7 +252,15 @@ class DiscoverSeriesViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
state.value.tvSeries.successValue?.mediaInfo?.requests?.firstOrNull()?.let { state.value.tvSeries.successValue?.mediaInfo?.requests?.firstOrNull()?.let {
// TODO handle multiple requests? Or just delete self's request? // TODO handle multiple requests? Or just delete self's request?
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) try {
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error requesting %s", id)
showToast(context, "An error occurred")
}
fetchAndSetItem().await()?.let { fetchAndSetItem().await()?.let {
updateSeasonStatus(it) updateSeasonStatus(it)
updateCanCancel() updateCanCancel()

View file

@ -22,7 +22,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -30,7 +29,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -43,8 +41,8 @@ import androidx.tv.material3.Surface
import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.SurfaceDefaults
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.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.LiveTvPreferences import com.github.damontecres.wholphin.preferences.LiveTvPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
@ -57,6 +55,7 @@ import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.rememberPosition
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
@ -75,14 +74,14 @@ import kotlin.math.abs
@Composable @Composable
fun TvGuideGrid( fun TvGuideGrid(
preferences: UserPreferences,
onRowPosition: (Int) -> Unit, onRowPosition: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: LiveTvViewModel = hiltViewModel(), viewModel: LiveTvViewModel = hiltViewModel(),
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val preferences by viewModel.preferences.data val preferences by viewModel.preferences.data.collectAsState(preferences.appPreferences)
.collectAsState(AppPreferences.getDefaultInstance())
val tvPrefs = preferences.interfacePreferences.liveTvPreferences val tvPrefs = preferences.interfacePreferences.liveTvPreferences
var showViewOptions by remember { mutableStateOf(false) } var showViewOptions by remember { mutableStateOf(false) }
var showChannelDialog by remember { mutableStateOf<Pair<Int, TvChannel>?>(null) } var showChannelDialog by remember { mutableStateOf<Pair<Int, TvChannel>?>(null) }
@ -196,11 +195,11 @@ fun TvGuideGrid(
showItemDialog = index showItemDialog = index
} }
}, },
focusRequester = focusRequester,
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background),
.focusRequester(focusRequester),
) )
} }
} }
@ -308,6 +307,7 @@ fun TvGuideGridContent(
onClickChannel: (Int, TvChannel) -> Unit, onClickChannel: (Int, TvChannel) -> Unit,
onClickProgram: (Int, TvProgram) -> Unit, onClickProgram: (Int, TvProgram) -> Unit,
onFocus: (RowColumn) -> Unit, onFocus: (RowColumn) -> Unit,
focusRequester: FocusRequester,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -315,8 +315,10 @@ fun TvGuideGridContent(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val guideStart = remember(guideTimes) { guideTimes.first() } val guideStart = remember(guideTimes) { guideTimes.first() }
var focusedChannelIndex by rememberInt(-1)
var focusedItem by rememberPosition(RowColumn(0, 0)) var focusedItem by rememberPosition(RowColumn(0, 0))
var focusedProgramIndex by remember { mutableIntStateOf(0) } var focusedProgramIndex by rememberInt(0)
LaunchedEffect(onFocus, focusedProgramIndex) { LaunchedEffect(onFocus, focusedProgramIndex) {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val program = programs.programs.getOrNull(focusedProgramIndex) val program = programs.programs.getOrNull(focusedProgramIndex)
@ -358,16 +360,13 @@ fun TvGuideGridContent(
} }
} }
Box(modifier = modifier) { Box(
modifier = modifier,
) {
ProgramGuide( ProgramGuide(
state = state, state = state,
dimensions = tvGuideDimensions, dimensions = tvGuideDimensions,
modifier = modifier = Modifier.fillMaxSize(),
Modifier
.fillMaxSize()
.focusProperties {
onEnter = { programFocusRequester.tryRequestFocus() }
},
) { ) {
guideStartHour = 0f guideStartHour = 0f
currentTime( currentTime(
@ -465,8 +464,12 @@ fun TvGuideGridContent(
.ifElse( .ifElse(
programIndex == focusedProgramIndex, programIndex == focusedProgramIndex,
Modifier.focusRequester(programFocusRequester), Modifier.focusRequester(programFocusRequester),
).ifElse(
focusedChannelIndex < 0 && programIndex == focusedProgramIndex,
Modifier.focusRequester(focusRequester),
).onFocusChanged { ).onFocusChanged {
if (it.isFocused) { if (it.isFocused) {
focusedChannelIndex = -1
focusedProgramIndex = programIndex focusedProgramIndex = programIndex
scope.launch { scope.launch {
try { try {
@ -497,8 +500,12 @@ fun TvGuideGridContent(
onLongClick = {}, onLongClick = {},
modifier = modifier =
Modifier Modifier
.onFocusChanged { .ifElse(
focusedChannelIndex == channelIndex,
Modifier.focusRequester(focusRequester),
).onFocusChanged {
if (it.isFocused) { if (it.isFocused) {
focusedChannelIndex = channelIndex
scope.launch { scope.launch {
try { try {
state.animateToChannel( state.animateToChannel(

View file

@ -808,6 +808,7 @@ fun SearchPage(
position = position, position = position,
focusRequester = focusRequesters[SEERR_ROW], focusRequester = focusRequesters[SEERR_ROW],
onClickItem = onClickItem, onClickItem = onClickItem,
onClickDiscover = onClickDiscover,
onClickPosition = { position = it }, onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )

View file

@ -628,12 +628,11 @@ class MpvPlayer(
MPVProperty.PAUSED_FOR_CACHE -> { MPVProperty.PAUSED_FOR_CACHE -> {
Timber.v("paused-for-cache %s", value) Timber.v("paused-for-cache %s", value)
val newState = if (value) STATE_BUFFERING else STATE_READY
playbackState.update { playbackState.update {
it.copy( it.copy(state = newState)
state = if (value) STATE_BUFFERING else it.state,
)
} }
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(playbackState.load().state) } notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(newState) }
} }
} }
} }
@ -842,6 +841,7 @@ class MpvPlayer(
) )
} }
notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) } notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) }
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(STATE_READY) }
val url = val url =
media.mediaItem.localConfiguration media.mediaItem.localConfiguration
?.uri ?.uri