From a0869ca6e480151f4804c5a4a9296704b94043fc Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 3 Jun 2026 20:47:07 -0400 Subject: [PATCH] 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 --- .../wholphin/ui/components/PlayButtons.kt | 48 ++++++++--------- .../ui/detail/CollectionFolderLiveTv.kt | 1 + .../detail/discover/DiscoverMovieViewModel.kt | 18 ++++++- .../discover/DiscoverSeriesViewModel.kt | 54 ++++++++++++------- .../wholphin/ui/detail/livetv/TvGuideGrid.kt | 39 ++++++++------ .../wholphin/ui/main/SearchPage.kt | 1 + .../wholphin/util/mpv/MpvPlayer.kt | 8 +-- 7 files changed, 103 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt index ca4c11b8..a0c427b9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf 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.theme.PreviewInteractionSource 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.SortOrder import kotlin.time.Duration @@ -85,6 +87,14 @@ fun ExpandablePlayButtons( modifier: Modifier = Modifier, ) { 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( horizontalArrangement = Arrangement.spacedBy(16.dp), contentPadding = PaddingValues(8.dp), @@ -93,19 +103,19 @@ fun ExpandablePlayButtons( .focusGroup() .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) { - item("play") { - ExpandablePlayButton( - title = R.string.resume, - resume = resumePosition, - icon = Icons.Default.PlayArrow, - onClick = playOnClick, - modifier = - Modifier - .onFocusChanged(buttonOnFocusChanged) - .focusRequester(firstFocus), - ) - } item("restart") { ExpandablePlayButton( title = R.string.restart, @@ -114,19 +124,7 @@ fun ExpandablePlayButtons( onClick = playOnClick, modifier = Modifier.onFocusChanged(buttonOnFocusChanged), mirrorIcon = true, - ) - } - } else { - item("play") { - ExpandablePlayButton( - title = R.string.play, - resume = Duration.ZERO, - icon = Icons.Default.PlayArrow, - onClick = playOnClick, - modifier = - Modifier - .onFocusChanged(buttonOnFocusChanged) - .focusRequester(firstFocus), + interactionSource = restartInteractionSource, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index f1e575b3..b6f5b19a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -141,6 +141,7 @@ fun CollectionFolderLiveTv( when (selectedTabIndex) { 0 -> { TvGuideGrid( + preferences = preferences, onRowPosition = { showHeader = it <= 0 }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt index f80457fc..60e6a816 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -22,6 +22,7 @@ import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO 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.successValue import dagger.assisted.Assisted @@ -168,7 +169,7 @@ class DiscoverMovieViewModel is4k: Boolean, ) { viewModelScope.launchIO { - val request = + try { seerrService.api.requestApi.requestPost( RequestPostRequest( is4k = is4k, @@ -176,6 +177,12 @@ class DiscoverMovieViewModel 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() updateCanCancel() } @@ -185,7 +192,14 @@ class DiscoverMovieViewModel viewModelScope.launchIO { state.value.movie.successValue?.mediaInfo?.requests?.firstOrNull()?.let { // 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() updateCanCancel() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt index fc40b0c4..4a13c65f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -20,6 +20,7 @@ import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO 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.successValue import dagger.assisted.Assisted @@ -209,27 +210,34 @@ class DiscoverSeriesViewModel it.requestedBy?.id == seerrServerRepository.currentUserId.first() } - if (currentRequest != null) { - Timber.v("User has pending request, will update") - seerrService.api.requestApi.requestRequestIdPut( - requestId = currentRequest.id.toString(), - requestRequestIdPutRequest = - RequestRequestIdPutRequest( + try { + if (currentRequest != null) { + Timber.v("User has pending request, will update") + seerrService.api.requestApi.requestRequestIdPut( + requestId = currentRequest.id.toString(), + 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, - mediaType = RequestRequestIdPutRequest.MediaType.TV, + mediaId = id, + mediaType = RequestPostRequest.MediaType.TV, seasons = seasons.toList(), ), - ) - } else { - Timber.v("New request for %s seasons", seasons.size) - seerrService.api.requestApi.requestPost( - RequestPostRequest( - is4k = is4k, - mediaId = id, - mediaType = RequestPostRequest.MediaType.TV, - seasons = seasons.toList(), - ), - ) + ) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error requesting %s", id) + showToast(context, "An error occurred") } fetchAndSetItem().await()?.let { @@ -244,7 +252,15 @@ class DiscoverSeriesViewModel viewModelScope.launchIO { state.value.tvSeries.successValue?.mediaInfo?.requests?.firstOrNull()?.let { // 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 { updateSeasonStatus(it) updateCanCancel() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt index 54e32aa9..55a8c515 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt @@ -22,7 +22,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -30,7 +29,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color @@ -43,8 +41,8 @@ import androidx.tv.material3.Surface import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.Text 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.UserPreferences import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.DialogItem 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.launchIO 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.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState @@ -75,14 +74,14 @@ import kotlin.math.abs @Composable fun TvGuideGrid( + preferences: UserPreferences, onRowPosition: (Int) -> Unit, modifier: Modifier = Modifier, viewModel: LiveTvViewModel = hiltViewModel(), ) { val scope = rememberCoroutineScope() val state by viewModel.state.collectAsState() - val preferences by viewModel.preferences.data - .collectAsState(AppPreferences.getDefaultInstance()) + val preferences by viewModel.preferences.data.collectAsState(preferences.appPreferences) val tvPrefs = preferences.interfacePreferences.liveTvPreferences var showViewOptions by remember { mutableStateOf(false) } var showChannelDialog by remember { mutableStateOf?>(null) } @@ -196,11 +195,11 @@ fun TvGuideGrid( showItemDialog = index } }, + focusRequester = focusRequester, modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .focusRequester(focusRequester), + .background(MaterialTheme.colorScheme.background), ) } } @@ -308,6 +307,7 @@ fun TvGuideGridContent( onClickChannel: (Int, TvChannel) -> Unit, onClickProgram: (Int, TvProgram) -> Unit, onFocus: (RowColumn) -> Unit, + focusRequester: FocusRequester, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -315,8 +315,10 @@ fun TvGuideGridContent( val scope = rememberCoroutineScope() val guideStart = remember(guideTimes) { guideTimes.first() } + var focusedChannelIndex by rememberInt(-1) var focusedItem by rememberPosition(RowColumn(0, 0)) - var focusedProgramIndex by remember { mutableIntStateOf(0) } + var focusedProgramIndex by rememberInt(0) + LaunchedEffect(onFocus, focusedProgramIndex) { withContext(Dispatchers.Default) { val program = programs.programs.getOrNull(focusedProgramIndex) @@ -358,16 +360,13 @@ fun TvGuideGridContent( } } - Box(modifier = modifier) { + Box( + modifier = modifier, + ) { ProgramGuide( state = state, dimensions = tvGuideDimensions, - modifier = - Modifier - .fillMaxSize() - .focusProperties { - onEnter = { programFocusRequester.tryRequestFocus() } - }, + modifier = Modifier.fillMaxSize(), ) { guideStartHour = 0f currentTime( @@ -465,8 +464,12 @@ fun TvGuideGridContent( .ifElse( programIndex == focusedProgramIndex, Modifier.focusRequester(programFocusRequester), + ).ifElse( + focusedChannelIndex < 0 && programIndex == focusedProgramIndex, + Modifier.focusRequester(focusRequester), ).onFocusChanged { if (it.isFocused) { + focusedChannelIndex = -1 focusedProgramIndex = programIndex scope.launch { try { @@ -497,8 +500,12 @@ fun TvGuideGridContent( onLongClick = {}, modifier = Modifier - .onFocusChanged { + .ifElse( + focusedChannelIndex == channelIndex, + Modifier.focusRequester(focusRequester), + ).onFocusChanged { if (it.isFocused) { + focusedChannelIndex = channelIndex scope.launch { try { state.animateToChannel( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt index 9eef0972..6b4df7e1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -808,6 +808,7 @@ fun SearchPage( position = position, focusRequester = focusRequesters[SEERR_ROW], onClickItem = onClickItem, + onClickDiscover = onClickDiscover, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt index 1766de82..3057866d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt @@ -628,12 +628,11 @@ class MpvPlayer( MPVProperty.PAUSED_FOR_CACHE -> { Timber.v("paused-for-cache %s", value) + val newState = if (value) STATE_BUFFERING else STATE_READY playbackState.update { - it.copy( - state = if (value) STATE_BUFFERING else it.state, - ) + it.copy(state = newState) } - 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_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(STATE_READY) } val url = media.mediaItem.localConfiguration ?.uri