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.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,
)
}
}

View file

@ -141,6 +141,7 @@ fun CollectionFolderLiveTv(
when (selectedTabIndex) {
0 -> {
TvGuideGrid(
preferences = preferences,
onRowPosition = {
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.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()
}

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.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()

View file

@ -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<Pair<Int, TvChannel>?>(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(

View file

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

View file

@ -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