From afc47f254b8a91bf1d9f8a7c787971b56bc6d7ac Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:54:00 -0400 Subject: [PATCH 1/4] Request individual seasons from Jellyseerr (#1060) ## Description Adds a dialog to select individual seasons for a TV show when requesting it via Discover/Jellyseerr If a request is already pending, it can be updated. If there's more than 7 seasons, a second submit button is added at the bottom of the dialog for easier access. Also, adds a button from Jellyfin series details to its Discover equivalent and added a request button for partially available series to request more seasons. ### Related issues Closes #961 ### Testing Emulator & Jellyseerr 2.7.3 ## Screenshots ![discover_seasons Large](https://github.com/user-attachments/assets/c7781bf4-7c1b-4e4e-a0b8-a572111a8497) ## AI or LLM usage None --- .../wholphin/data/model/DiscoverItem.kt | 2 + .../services/SeerrServerRepository.kt | 1 + .../wholphin/services/SeerrService.kt | 13 + .../detail/discover/DiscoverSeriesDetails.kt | 38 +-- .../discover/DiscoverSeriesViewModel.kt | 89 ++++- .../discover/ExpandableDiscoverButtons.kt | 18 +- .../ui/detail/discover/RequestSeasons.kt | 323 ++++++++++++++++++ .../ui/detail/series/SeriesDetails.kt | 31 ++ .../ui/detail/series/SeriesViewModel.kt | 18 + .../ui/setup/seerr/SwitchSeerrViewModel.kt | 1 + app/src/main/res/values/strings.xml | 1 + app/src/main/seerr/seerr-api.yml | 23 +- 12 files changed, 512 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt index a274caa1..3144b014 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.data.model +import androidx.compose.runtime.Stable import com.github.damontecres.wholphin.api.seerr.model.CreditCast import com.github.damontecres.wholphin.api.seerr.model.CreditCrew import com.github.damontecres.wholphin.api.seerr.model.MovieDetails @@ -74,6 +75,7 @@ enum class SeerrAvailability( /** * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well. */ +@Stable @Serializable data class DiscoverItem( val id: Int, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index 970e80c3..6db60574 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -57,6 +57,7 @@ class SeerrServerRepository connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server } val currentUser: Flow = connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } + val currentUserId: Flow = current.map { it?.config?.id } /** * Whether Seerr integration is currently active of not diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt index e38ba41b..14aa0b9a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner +import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import kotlinx.coroutines.flow.first @@ -125,4 +126,16 @@ class SeerrService } else { null } + + suspend fun getTvSeries(item: BaseItem): TvDetails? = + if (active.first()) { + item.data.providerIds + ?.get("Tmdb") + ?.toIntOrNull() + ?.let { tvId -> + api.tvApi.tvTvIdGet(tvId = tvId) + } + } else { + null + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index 3c036534..12acfd37 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -35,7 +35,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.api.seerr.model.Season import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem @@ -99,6 +98,7 @@ fun DiscoverSeriesDetails( var overviewDialog by remember { mutableStateOf(null) } var seasonDialog by remember { mutableStateOf(null) } var moreDialog by remember { mutableStateOf(null) } + var showRequestSeasonDialog by remember { mutableStateOf(false) } val requestStr = stringResource(R.string.request) val request4kStr = stringResource(R.string.request_4k) @@ -159,26 +159,7 @@ fun DiscoverSeriesDetails( trailers = trailers, requestOnClick = { item.id?.let { id -> - if (request4kEnabled) { - moreDialog = - DialogParams( - fromLongClick = false, - title = item.name ?: "", - items = - listOf( - DialogItem( - text = requestStr, - onClick = { viewModel.request(id, false) }, - ), - DialogItem( - text = request4kStr, - onClick = { viewModel.request(id, true) }, - ), - ), - ) - } else { - viewModel.request(id, false) - } + showRequestSeasonDialog = true } }, cancelOnClick = { @@ -218,6 +199,18 @@ fun DiscoverSeriesDetails( waitToLoad = params.fromLongClick, ) } + if (showRequestSeasonDialog) { + RequestSeasonsDialog( + title = item?.name ?: "", + seasons = seasons, + request4kEnabled = request4kEnabled, + onSubmit = { seasons, is4k -> + item?.id?.let { viewModel.request(it, seasons, is4k) } + showRequestSeasonDialog = false + }, + onDismissRequest = { showRequestSeasonDialog = false }, + ) + } } private const val HEADER_ROW = 0 @@ -234,7 +227,7 @@ fun DiscoverSeriesDetailsContent( series: TvDetails, rating: DiscoverRating?, canCancel: Boolean, - seasons: List, + seasons: List, similar: List, recommended: List, trailers: List, @@ -299,6 +292,7 @@ fun DiscoverSeriesDetailsContent( SeerrAvailability.from(series.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, requestOnClick = requestOnClick, + pendingOnClick = requestOnClick, cancelOnClick = cancelOnClick, moreOnClick = moreOnClick, goToOnClick = goToOnClick, 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 f7b11137..da6550ec 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 @@ -6,12 +6,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest -import com.github.damontecres.wholphin.api.seerr.model.Season +import com.github.damontecres.wholphin.api.seerr.model.RequestRequestIdPutRequest import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.RemoteTrailer +import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager @@ -33,6 +34,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update @@ -63,7 +65,7 @@ class DiscoverSeriesViewModel val tvSeries = MutableLiveData(null) val rating = MutableLiveData(null) - val seasons = MutableLiveData>(listOf()) + val seasons = MutableLiveData>(listOf()) val trailers = MutableLiveData>(listOf()) val people = MutableLiveData>(listOf()) val similar = MutableLiveData>() @@ -103,6 +105,7 @@ class DiscoverSeriesViewModel val discoveredItem = DiscoverItem(tv) backdropService.submit(discoveredItem) + updateSeasonStatus() updateCanCancel() withContext(Dispatchers.Main) { @@ -157,6 +160,43 @@ class DiscoverSeriesViewModel navigationManager.navigateTo(destination) } + private suspend fun updateSeasonStatus() { + tvSeries.value?.let { tv -> + val seasonStatus = mutableMapOf() + tv.seasons?.forEach { + it.seasonNumber?.let { + seasonStatus[it] = SeerrAvailability.UNKNOWN + } + } + val tvStatus = + SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN + tv.mediaInfo + ?.requests + ?.forEach { + it.seasons?.mapNotNull { season -> + season.seasonNumber?.let { + val current = seasonStatus[season.seasonNumber] + val new = + SeerrAvailability + .from(season.status) + ?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus + if (current == null || new.status > current.status) { + seasonStatus[season.seasonNumber] = new + } + } + } + } + Timber.v("seasonStatus=%s", seasonStatus) + val requestSeasons = + seasonStatus.mapNotNull { (seasonNumber, availability) -> + tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let { + RequestSeason(it, availability) + } + } + seasons.setValueOnMain(requestSeasons) + } + } + private suspend fun updateCanCancel() { val user = userConfig.firstOrNull() val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests) @@ -165,20 +205,43 @@ class DiscoverSeriesViewModel fun request( id: Int, + seasons: Set, is4k: Boolean, ) { viewModelScope.launchIO { - val request = - seerrService.api.requestApi.requestPost( - RequestPostRequest( - is4k = is4k, - mediaId = id, - mediaType = RequestPostRequest.MediaType.TV, - seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons - ), - ) - fetchAndSetItem().await() - updateCanCancel() + tvSeries.value?.let { tv -> + val currentRequest = + tv.mediaInfo?.requests?.firstOrNull { + 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( + 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, + mediaId = id, + mediaType = RequestPostRequest.MediaType.TV, + seasons = seasons.toList(), + ), + ) + } + + fetchAndSetItem().await() + updateSeasonStatus() + updateCanCancel() + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt index 4b73a0b2..2c3fc036 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt @@ -37,6 +37,7 @@ fun ExpandableDiscoverButtons( trailerOnClick: (Trailer) -> Unit, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, + pendingOnClick: () -> Unit = {}, ) { val firstFocus = remember { FocusRequester() } LazyRow( @@ -89,7 +90,7 @@ fun ExpandableDiscoverButtons( SeerrAvailability.PENDING, SeerrAvailability.PROCESSING, -> { - // TODO? + pendingOnClick.invoke() } SeerrAvailability.PARTIALLY_AVAILABLE, @@ -109,6 +110,21 @@ fun ExpandableDiscoverButtons( .onFocusChanged(buttonOnFocusChanged), ) } + if (availability == SeerrAvailability.PARTIALLY_AVAILABLE) { + item("request_partial") { + ExpandableFaButton( + title = R.string.request, + iconStringRes = R.string.fa_download, + onClick = { + requestOnClick.invoke() + }, + enabled = availability == SeerrAvailability.PARTIALLY_AVAILABLE, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged), + ) + } + } if (canCancel) { item("cancel") { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt new file mode 100644 index 00000000..4a56cb88 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt @@ -0,0 +1,323 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Switch +import androidx.tv.material3.Text +import androidx.tv.material3.contentColorFor +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.Season +import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.ui.cards.AvailableIndicator +import com.github.damontecres.wholphin.ui.cards.PartiallyAvailableIndicator +import com.github.damontecres.wholphin.ui.cards.PendingIndicator +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.theme.WholphinTheme + +data class RequestSeason( + val season: Season, + val availability: SeerrAvailability, +) + +@Composable +fun RequestSeasons( + title: String, + seasons: List, + onSubmit: (Set, Boolean) -> Unit, + request4kEnabled: Boolean, + modifier: Modifier, +) { + val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() } + val selected = + remember { + mutableStateSetOf( + *seasons + .mapNotNull { + if (it.availability > SeerrAvailability.UNKNOWN) { + it.season.seasonNumber + } else { + null + } + }.toTypedArray(), + ) + } + var is4k by remember { mutableStateOf(false) } + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + LazyColumn { + item { + val isSelected = selected.containsAll(allSeasonNumbers) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + ClickSwitch( + label = stringResource(R.string.select_all), + checked = isSelected, + onClick = { + if (isSelected) { + selected.removeAll(allSeasonNumbers) + } else { + selected.addAll(allSeasonNumbers) + } + }, + ) + Button( + onClick = { onSubmit.invoke(selected, is4k) }, + ) { + Text( + text = stringResource(R.string.submit), + ) + } + } + } + if (request4kEnabled) { + item { + ClickSwitch( + label = stringResource(R.string.request_4k), + checked = is4k, + onClick = { is4k = !is4k }, + ) + } + } + itemsIndexed(seasons) { index, season -> + val seasonNumber = season.season.seasonNumber + val isSelected = seasonNumber in selected + SeasonListItem( + season = season, + selected = isSelected, + onClick = { + if (isSelected) { + selected.remove(seasonNumber) + } else { + seasonNumber?.let { selected.add(it) } + } + }, + modifier = Modifier, + ) + } + if (seasons.size > 7) { + item { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) { + Button( + onClick = { onSubmit.invoke(selected, is4k) }, + ) { + Text( + text = stringResource(R.string.submit), + ) + } + } + } + } + } + } +} + +@Composable +fun SeasonListItem( + season: RequestSeason, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + selected = false, + headlineContent = { + Text( + text = + season.season.name + ?: (stringResource(R.string.tv_season) + " ${season.season.seasonNumber}"), + ) + }, + supportingContent = { + season.season.episodeCount?.let { + Text( + // TODO should use plurals string + text = "${season.season.episodeCount} " + stringResource(R.string.episodes), + ) + } + }, + leadingContent = { + when (season.availability) { + SeerrAvailability.UNKNOWN -> {} + + SeerrAvailability.DELETED -> {} + + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + -> { + PendingIndicator() + } + + SeerrAvailability.PARTIALLY_AVAILABLE -> { + PartiallyAvailableIndicator() + } + + SeerrAvailability.AVAILABLE -> { + AvailableIndicator() + } + } + }, + trailingContent = { + Row { + Switch( + checked = selected, + onCheckedChange = { + onClick.invoke() + }, + ) + } + }, + onClick = onClick, + modifier = modifier, + ) +} + +@Composable +private fun ClickSurface( + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + Surface( + colors = + ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurface, + focusedContainerColor = MaterialTheme.colorScheme.inverseSurface, + focusedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface), + pressedContainerColor = MaterialTheme.colorScheme.inverseSurface, + pressedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface), + ), + onClick = onClick, + content = content, + modifier = modifier, + ) +} + +@Composable +private fun ClickSwitch( + label: String, + checked: Boolean, + onClick: () -> Unit, +) { + ClickSurface( + onClick = onClick, + modifier = Modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(horizontal = 8.dp) + .height(54.dp), + ) { + Switch( + checked = checked, + onCheckedChange = {}, + modifier = Modifier.padding(end = 8.dp), + ) + Text( + text = label, + ) + } + } +} + +@Composable +fun RequestSeasonsDialog( + title: String, + seasons: List, + request4kEnabled: Boolean, + onSubmit: (Set, Boolean) -> Unit, + onDismissRequest: () -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + RequestSeasons( + title = title, + seasons = seasons, + request4kEnabled = request4kEnabled, + onSubmit = onSubmit, + modifier = Modifier.padding(16.dp), + ) + } +} + +@Preview( + device = "spec:parent=tv_1080p", + backgroundColor = 0xFF383535, + uiMode = UI_MODE_TYPE_TELEVISION, + heightDp = 800, +) +@Composable +fun RequestSeasonsPreview() { + val seasons = + List(10) { + RequestSeason( + season = + Season( + seasonNumber = it + 1, + episodeCount = 10 + it, + ), + availability = + if (it < 3) { + SeerrAvailability.AVAILABLE + } else { + SeerrAvailability.UNKNOWN + }, + ) + } + + WholphinTheme { + RequestSeasons( + title = "Series title", + seasons = seasons, + request4kEnabled = true, + onSubmit = { _, _ -> }, + modifier = Modifier.width(400.dp), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 81beb82f..4f8e4366 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -1,6 +1,9 @@ package com.github.damontecres.wholphin.ui.detail.series import android.content.Context +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -120,6 +123,7 @@ fun SeriesDetails( val people by viewModel.people.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf()) val discovered by viewModel.discovered.collectAsState() + val discoverSeries by viewModel.discoverSeries.collectAsState() val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var overviewDialog by remember { mutableStateOf(null) } @@ -230,6 +234,12 @@ fun SeriesDetails( onClickExtra = { _, extra -> viewModel.navigateTo(extra.destination) }, + discoverSeries = discoverSeries, + onClickDiscoverSeries = { + discoverSeries?.let { + viewModel.navigateTo(Destination.DiscoveredItem(it)) + } + }, discovered = discovered, onClickDiscover = { index, item -> viewModel.navigateTo(item.destination) @@ -350,6 +360,8 @@ fun SeriesDetailsContent( onClickExtra: (Int, ExtrasItem) -> Unit, moreActions: MoreDialogActions, onClickDiscover: (Int, DiscoverItem) -> Unit, + discoverSeries: DiscoverItem?, + onClickDiscoverSeries: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -487,6 +499,25 @@ fun SeriesDetailsContent( }, ) } + AnimatedVisibility( + visible = discoverSeries != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + ExpandableFaButton( + title = R.string.discover, + iconStringRes = R.string.fa_magnifying_glass_plus, + onClick = onClickDiscoverSeries, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } } } item { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 53924431..ee07a170 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -58,6 +58,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update @@ -123,6 +124,7 @@ class SeriesViewModel val peopleInEpisode = MutableLiveData(PeopleInItem()) val discovered = MutableStateFlow>(listOf()) + val discoverSeries = MutableStateFlow(null) val position = MutableStateFlow(SeriesOverviewPosition(0, 0)) @@ -232,6 +234,22 @@ class SeriesViewModel val results = seerrService.similar(item).orEmpty() discovered.update { results } } + viewModelScope.launchIO { + seerrService.active.collectLatest { active -> + val tv = + if (active) { + try { + seerrService.getTvSeries(item)?.let { DiscoverItem(it) } + } catch (ex: Exception) { + Timber.e(ex) + null + } + } else { + null + } + discoverSeries.update { tv } + } + } } mediaManagementService.deletedItemFlow .onEach { deletedItem -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt index 0f62f12f..02e97ff5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -98,6 +98,7 @@ class SwitchSeerrViewModel ) } } + serverConnectionStatus.update { LoadingState.Success } } else { val message = results diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7a2fb92..66276854 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -719,5 +719,6 @@ Creator Artist Search %s + Select all diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index d619d042..d6b719ec 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -1086,6 +1086,11 @@ components: type: array items: $ref: '#/components/schemas/Episode' + status: + type: integer + example: 0 + description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED + readOnly: true TvDetails: type: object properties: @@ -1268,6 +1273,10 @@ components: type: string type: type: string + seasons: + type: array + items: + $ref: '#/components/schemas/Season' required: - id - status @@ -6135,16 +6144,10 @@ paths: type: integer example: 123 seasons: - # oneOf: - # - type: array - # items: - # type: integer - # minimum: 0 - # - type: string - # enum: [all] - # TODO - type: string - enum: [all] + type: array + items: + type: integer + minimum: 0 nullable: true is4k: type: boolean From 7bb47bf329f58c91072184f762a1d6b8da059e5e Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:16:19 -0400 Subject: [PATCH 2/4] Photo/slideshow fixes & Seerr server removal fix (#1076) ## Description A few fixes - Better crossfade for photo slideshow - Fix "Go to" in context menu for photos - Fix certain home video/photo libraries not working for photos - Fix removing seerr server in some cases ### Related issues Closes #835 Fixes #1069 Addresses https://github.com/damontecres/Wholphin/issues/634#issuecomment-4018580518 ### Testing Emulator mostly ## Screenshots N/A ## AI or LLM usage None --- .../services/SeerrServerRepository.kt | 21 +- .../ui/components/CollectionFolderGrid.kt | 3 + .../ui/detail/CollectionFolderPhotoAlbum.kt | 2 +- .../wholphin/ui/detail/DetailUtils.kt | 3 +- .../wholphin/ui/nav/DestinationContent.kt | 13 +- .../ui/preferences/PreferencesContent.kt | 2 +- .../wholphin/ui/slideshow/SlideshowPage.kt | 333 +++++++++--------- .../ui/slideshow/SlideshowViewModel.kt | 5 +- 8 files changed, 198 insertions(+), 184 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index 6db60574..80eafb7a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -25,7 +25,7 @@ import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.supervisorScope @@ -71,10 +71,11 @@ class SeerrServerRepository } fun error( - serverUrl: String, + server: SeerrServer, + user: SeerrUser, exception: Exception, ) { - _connection.update { SeerrConnectionStatus.Error(serverUrl, exception) } + _connection.update { SeerrConnectionStatus.Error(server, user, exception) } seerrApi.update("", null) } @@ -175,8 +176,13 @@ class SeerrServerRepository } suspend fun removeServerForCurrentUser(): Boolean { - val current = current.firstOrNull() ?: return false - val rows = seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) + val user = + when (val conn = connection.first()) { + SeerrConnectionStatus.NotConfigured -> return false + is SeerrConnectionStatus.Error -> conn.user + is SeerrConnectionStatus.Success -> conn.current.user + } + val rows = seerrServerDao.deleteUser(user) clear() return rows > 0 } @@ -191,7 +197,8 @@ sealed interface SeerrConnectionStatus { data object NotConfigured : SeerrConnectionStatus data class Error( - val serverUrl: String, + val server: SeerrServer, + val user: SeerrUser, val ex: Exception, ) : SeerrConnectionStatus @@ -317,7 +324,7 @@ class UserSwitchListener "Error logging into %s", server.url, ) - seerrServerRepository.error(server.url, ex) + seerrServerRepository.error(server, seerrUser, ex) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 8415c7a6..a4e4ef22 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -771,6 +771,9 @@ fun CollectionFolderGrid( onClickDelete = { showDeleteDialog = PositionItem(position, item) }, + onClickGoTo = { + onClickItem.invoke(position, it) + }, ), ), onDismissRequest = { moreDialog.makeAbsent() }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt index 4ddc41c1..562af214 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt @@ -59,7 +59,7 @@ fun CollectionFolderPhotoAlbum( index = index, filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()), sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT, - recursive = true, + recursive = recursive, startSlideshow = false, ) } else { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index f34c0228..5fe13871 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -30,6 +30,7 @@ data class MoreDialogActions( val onClickAddPlaylist: (UUID) -> Unit, val onSendMediaInfo: (UUID) -> Unit, val onClickDelete: (BaseItem) -> Unit, + val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) }, ) enum class ClearChosenStreams { @@ -246,7 +247,7 @@ fun buildMoreDialogItemsForHome( context.getString(R.string.go_to), Icons.Default.ArrowForward, ) { - actions.navigateTo(item.destination()) + actions.onClickGoTo(item) }, ) if (item.type in supportedPlayableTypes) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 0888e721..c0e4ce26 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -209,7 +209,7 @@ fun DestinationContent( CollectionFolderPhotoAlbum( preferences = preferences, itemId = destination.itemId, - recursive = true, + recursive = false, modifier = modifier, ) } @@ -387,10 +387,19 @@ fun CollectionFolder( } CollectionType.HOMEVIDEOS, + CollectionType.PHOTOS, + -> { + CollectionFolderPhotoAlbum( + preferences = preferences, + itemId = destination.itemId, + recursive = recursiveOverride ?: false, + modifier = modifier, + ) + } + CollectionType.MUSICVIDEOS, CollectionType.MUSIC, CollectionType.BOOKS, - CollectionType.PHOTOS, -> { CollectionFolderGeneric( preferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 2dc4f3cf..c214d3cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -400,7 +400,7 @@ fun PreferencesContent( when (val conn = seerrConnection) { is SeerrConnectionStatus.Error -> { SeerrDialogMode.Error( - conn.serverUrl, + conn.server.url, conn.ex, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt index f35bc102..edb1e859 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -8,7 +8,6 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -53,13 +52,16 @@ import androidx.media3.ui.compose.modifiers.resizeWithContentScale import androidx.media3.ui.compose.state.rememberPresentationState import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import coil3.annotation.ExperimentalCoilApi import coil3.compose.SubcomposeAsyncImage +import coil3.compose.useExistingImageAsPlaceholder import coil3.request.ImageRequest -import coil3.request.crossfade +import coil3.request.transitionFactory import coil3.size.Size import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.VideoFilter import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.CrossFadeFactory import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.nav.Destination @@ -70,12 +72,14 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import kotlin.math.abs +import kotlin.time.Duration.Companion.milliseconds private const val TAG = "ImagePage" private const val DEBUG = false @SuppressLint("ConfigurationScreenWidthHeight") @OptIn(UnstableApi::class) +@kotlin.OptIn(ExperimentalCoilApi::class) @Composable fun SlideshowPage( slideshow: Destination.Slideshow, @@ -93,7 +97,7 @@ fun SlideshowPage( val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) val position by viewModel.position.observeAsState(0) val pager by viewModel.pager.observeAsState() - val imageState by viewModel.image.observeAsState() +// val imageState by viewModel.image.observeAsState() var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } val isZoomed = zoomFactor * 100 > 102 @@ -197,9 +201,6 @@ fun SlideshowPage( } } - LaunchedEffect(imageState) { - reset(true) - } val player = viewModel.player val presentationState = rememberPresentationState(player) LaunchedEffect(slideshowActive) { @@ -209,16 +210,6 @@ fun SlideshowPage( var longPressing by remember { mutableStateOf(false) } - val contentModifier = - Modifier - .clickable( - interactionSource = null, - indication = null, - onClick = { - showOverlay = !showOverlay - }, - ) - Box( modifier = modifier @@ -303,149 +294,144 @@ fun SlideshowPage( result }, ) { - when (loadingState) { + when (val st = loadingState) { ImageLoadingState.Error -> { ErrorMessage("Error loading image", null, modifier) } ImageLoadingState.Loading -> { - LoadingPage(modifier) + LoadingPage(modifier, false) } is ImageLoadingState.Success -> { - imageState?.let { imageState -> - if (imageState.image.data.mediaType == MediaType.VIDEO) { - LaunchedEffect(imageState.id) { - val mediaItem = - MediaItem - .Builder() - .setUri(imageState.url) - .build() - player.setMediaItem(mediaItem) - player.repeatMode = - if (slideshowState.enabled) { - Player.REPEAT_MODE_OFF - } else { - Player.REPEAT_MODE_ONE - } - player.prepare() - player.play() - viewModel.pulseSlideshow(Long.MAX_VALUE) - } - LifecycleStartEffect(Unit) { - onStopOrDispose { - player.stop() + val imageState = st.image + LaunchedEffect(imageState) { + reset(true) + } + if (imageState.image.data.mediaType == MediaType.VIDEO) { + LaunchedEffect(imageState.id) { + val mediaItem = + MediaItem + .Builder() + .setUri(imageState.url) + .build() + player.setMediaItem(mediaItem) + player.repeatMode = + if (slideshowState.enabled) { + Player.REPEAT_MODE_OFF + } else { + Player.REPEAT_MODE_ONE } + player.prepare() + player.play() + viewModel.pulseSlideshow(Long.MAX_VALUE) + } + LifecycleStartEffect(Unit) { + onStopOrDispose { + player.stop() } - val contentScale = ContentScale.Fit - val scaledModifier = - contentModifier.resizeWithContentScale( - contentScale, - presentationState.videoSizeDp, - ) - PlayerSurface( - player = player, - surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = - scaledModifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - }.rotate(rotateAnimation), + } + val contentScale = ContentScale.Fit + val scaledModifier = + Modifier.resizeWithContentScale( + contentScale, + presentationState.videoSizeDp, ) - if (presentationState.coverSurface) { - Box( - Modifier - .matchParentSize() - .background(Color.Black), - ) - } - } else { - val colorFilter = - remember(imageState.id, imageFilter) { - if (imageFilter.hasImageFilter()) { - ColorMatrixColorFilter(imageFilter.colorMatrix) - } else { - null - } - } - // If the image loading is large, show the thumbnail while waiting - // TODO - val showLoadingThumbnail = true - SubcomposeAsyncImage( - modifier = - contentModifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - - val xTransform = - (screenWidth - panXAnimation) / (screenWidth * 2) - val yTransform = - (screenHeight - panYAnimation) / (screenHeight * 2) - if (DEBUG) { - Timber.d( - "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", - ) - } - - transformOrigin = TransformOrigin(xTransform, yTransform) - }.rotate(rotateAnimation), - model = - ImageRequest - .Builder(LocalContext.current) - .data(imageState.url) - .size(Size.ORIGINAL) - .crossfade(!showLoadingThumbnail) - .build(), - contentDescription = null, - contentScale = ContentScale.Fit, - colorFilter = colorFilter, - error = { - Text( - modifier = - Modifier - .align(Alignment.Center), - text = "Error loading image", - color = MaterialTheme.colorScheme.onBackground, - ) - }, - loading = { - ImageLoadingPlaceholder( - thumbnailUrl = imageState.thumbnailUrl, - showThumbnail = showLoadingThumbnail, - colorFilter = colorFilter, - modifier = Modifier.fillMaxSize(), - ) - }, - // Ensure that if an image takes a long time to load, it won't be skipped - onLoading = { - viewModel.pulseSlideshow(Long.MAX_VALUE) - }, - onSuccess = { - viewModel.pulseSlideshow() - }, - onError = { - Timber.e( - it.result.throwable, - "Error loading image ${imageState.id}", - ) - Toast - .makeText( - context, - "Error loading image: ${it.result.throwable.localizedMessage}", - Toast.LENGTH_LONG, - ).show() - viewModel.pulseSlideshow() - }, + PlayerSurface( + player = player, + surfaceType = SURFACE_TYPE_SURFACE_VIEW, + modifier = + scaledModifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + }.rotate(rotateAnimation), + ) + if (presentationState.coverSurface) { + Box( + Modifier + .matchParentSize() + .background(Color.Black), ) } + } else { + val colorFilter = + remember(imageState.id, imageFilter) { + if (imageFilter.hasImageFilter()) { + ColorMatrixColorFilter(imageFilter.colorMatrix) + } else { + null + } + } + // If the image loading is large, show the thumbnail while waiting + // TODO + val showLoadingThumbnail = true + SubcomposeAsyncImage( + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + + val xTransform = + (screenWidth - panXAnimation) / (screenWidth * 2) + val yTransform = + (screenHeight - panYAnimation) / (screenHeight * 2) + if (DEBUG) { + Timber.d( + "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", + ) + } + + transformOrigin = TransformOrigin(xTransform, yTransform) + }.rotate(rotateAnimation), + model = + ImageRequest + .Builder(LocalContext.current) + .data(imageState.url) + .size(Size.ORIGINAL) + .transitionFactory(CrossFadeFactory(750.milliseconds)) + .useExistingImageAsPlaceholder(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = colorFilter, + error = { + Text( + modifier = + Modifier + .align(Alignment.Center), + text = "Error loading image", + color = MaterialTheme.colorScheme.onBackground, + ) + }, + // Ensure that if an image takes a long time to load, it won't be skipped + onLoading = { + viewModel.pulseSlideshow(Long.MAX_VALUE) + }, + onSuccess = { + viewModel.pulseSlideshow() + }, + onError = { + Timber.e( + it.result.throwable, + "Error loading image ${imageState.id}", + ) + Toast + .makeText( + context, + "Error loading image: ${it.result.throwable.localizedMessage}", + Toast.LENGTH_LONG, + ).show() + viewModel.pulseSlideshow() + }, + ) } } } @@ -455,30 +441,37 @@ fun SlideshowPage( exit = slideOutVertically { it }, modifier = Modifier.align(Alignment.BottomStart), ) { - imageState?.let { imageState -> - ImageOverlay( - modifier = - contentModifier - .fillMaxWidth() - .background(AppColors.TransparentBlack50), - onDismiss = { showOverlay = false }, - player = player, - slideshowControls = slideshowControls, - slideshowEnabled = slideshowState.enabled, - image = imageState, - position = position, - count = pager?.size ?: -1, - onClickItem = {}, - onLongClickItem = {}, - onZoom = ::zoom, - onRotate = { rotation += it }, - onReset = { reset(true) }, - onShowFilterDialogClick = { - showFilterDialog = true - showOverlay = false - viewModel.pauseSlideshow() - }, - ) + when (val st = loadingState) { + ImageLoadingState.Error -> {} + + ImageLoadingState.Loading -> {} + + is ImageLoadingState.Success -> { + val imageState = st.image + ImageOverlay( + modifier = + Modifier + .fillMaxWidth() + .background(AppColors.TransparentBlack50), + onDismiss = { showOverlay = false }, + player = player, + slideshowControls = slideshowControls, + slideshowEnabled = slideshowState.enabled, + image = imageState, + position = position, + count = pager?.size ?: -1, + onClickItem = {}, + onLongClickItem = {}, + onZoom = ::zoom, + onRotate = { rotation += it }, + onReset = { reset(true) }, + onShowFilterDialogClick = { + showFilterDialog = true + showOverlay = false + viewModel.pauseSlideshow() + }, + ) + } } } AnimatedVisibility(showFilterDialog) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt index 992659ca..3a4f3bce 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -141,7 +141,7 @@ class SlideshowViewModel parentId = slideshowSettings.parentId, includeItemTypes = includeItemTypes, fields = PhotoItemFields, - recursive = true, + recursive = slideshowSettings.recursive, sortBy = listOf(slideshowSettings.sortAndDirection.sort), sortOrder = listOf(slideshowSettings.sortAndDirection.direction), ), @@ -193,7 +193,8 @@ class SlideshowViewModel _pager.value?.let { pager -> viewModelScope.launchIO { try { - val image = pager.getBlocking(position) + val image = + if (position in pager.indices) pager.getBlocking(position) else null Timber.v("Got image for $position: ${image != null}") if (image != null) { this@SlideshowViewModel.position.setValueOnMain(position) From 0dca02b9197f677ea1295ef6c654fe1886f3fa0a Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:10:14 -0400 Subject: [PATCH 3/4] Fix race condition when updating preferences (#1078) ## Description Fixes a race condition saving preferences. It would be triggered if you have "Remember selected tabs" enabled and either: 1) changed settings while on a library grid page or 2) changed settings on a page navigated _from_ a library grid page and then returned to the grid ### Related issues I think this will fix #1075 ### Testing Emulator following the steps above ## Screenshots N/A ## AI or LLM usage None --- .../com/github/damontecres/wholphin/services/hilt/AppModule.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index 991a8e98..a44ab65b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -171,7 +171,7 @@ object AppModule { if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) { scope.launch(ExceptionHandler()) { appPreference.updateData { - preferences.appPreferences.updateInterfacePreferences { + it.updateInterfacePreferences { putRememberedTabs(key(itemId), tabIndex) } } From cca818fabbcec2cc49ef65ba834c0e50d8fc2640 Mon Sep 17 00:00:00 2001 From: Jannik Mahr <156718546+Jannik-M91@users.noreply.github.com> Date: Wed, 11 Mar 2026 04:44:44 +0100 Subject: [PATCH 4/4] Improve display of "DTS:HD" and "DTS:HD MA" audio tracks (#1064) ## Description The formatter for audio codecs did only match the profile of high definition DTS streams against "DTS:HD" or "DTS:X". At least for DTS:HD there are also cases where the profile contains "DTS-HD" with "-" instead of ":". Made the matcher more generic by allowing both "-"" or ":"" and also checking if the profile contains "MA" for Master Audio tracks. This will now correctly display the following high definition DTS types in the UI: - DTS:HD - DTS:HD MA - DTS:X **Note:** This is my first public pull request ever, let me know if I did it wrong :) ### Related issues Relates to (but does not fix): https://github.com/damontecres/Wholphin/issues/1057 ### Testing Tested on a Fire TV Cube Gen 3 with multiple files containing DTS HD audio tracks. --------- Co-authored-by: Damontecres --- .../wholphin/ui/util/StreamFormatting.kt | 13 +++++++------ app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-da/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-et/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-in/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-tr/strings.xml | 1 + app/src/main/res/values-uk/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 16 files changed, 22 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt index ca6e4027..15d35b3b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt @@ -92,12 +92,13 @@ object StreamFormatting { context.getString(R.string.dolby_atmos) } - profile?.contains("DTS:X", true) == true -> { - context.getString(R.string.dts_x) - } - - profile?.contains("DTS:HD", true) == true -> { - context.getString(R.string.dts_hd) + profile?.contains("DTS", true) == true -> { + when { + profile.contains("X", true) -> context.getString(R.string.dts_x) + profile.contains("MA", true) -> context.getString(R.string.dts_hd_ma) + profile.contains("HD", true) -> context.getString(R.string.dts_hd) + else -> context.getString(R.string.dts) + } } else -> { diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 8f32bc3a..e41bd2b5 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -401,6 +401,7 @@ Vyčistit výběr skladeb DTS:X DTS:HD + DTS:HD MA True HD DD DD+ diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 229bc4c8..7fdb5d82 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -397,6 +397,7 @@ Ryd valg af spor DTS:X DTS:HD + DTS:HD MA TrueHD DD DD+ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2a8884ba..628c465a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -365,6 +365,7 @@ In 4K anfragen DTS:X DTS:HD + DTS:HD MA TrueHD DD DD+ diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 93bf5c7c..e228ac4d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -401,6 +401,7 @@ Opciones de pista claras DTS:X DTS:HD + DTS:HD MA TrueHD DD DD+ diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 10b611eb..85a803a8 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -367,6 +367,7 @@ Vaid sundkorras kuvatud subtiitrid DTS:X DTS:HD + DTS:HD MA TrueHD DD DD+ diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 74e702c8..312caa8e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -382,6 +382,7 @@ Pas de bandes-annonces DTS:X DTS:HD + DTS:HD MA TrueHD DD DD+ diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 23e81de6..dc94ff96 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -369,6 +369,7 @@ Hapus pilihan trek DTS:X DTS:HD + DTS:HD MA TrueHD DD DD+ diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3142001e..596d898f 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -383,6 +383,7 @@ Solo sottotitoli forzati DTS:X DTS:HD + DTS:HD MA TrueHD DD DD+ diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index ed794d85..94da1f4d 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -405,6 +405,7 @@ Limpar escolhas de faixas DTS:X DTS:HD + DTS:HD MA TrueHD DD DD+ diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 6d8ac4e0..b4f44186 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -382,6 +382,7 @@ Apagar seleções de faixas DTS:X DTS:HD + DTS:HD MA TrueHD DD DD+ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 213ef136..4e4c1838 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -394,6 +394,7 @@ Parça seçimlerini temizle DTS:X DTS:HD + DTS:HD MA TrueHD DD DD+ diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 1a31690d..f7835121 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -432,6 +432,7 @@ Чіткий вибір доріжки DTS:X DTS:HD + DTS:HD MA TrueHD DD DD+ diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3134076d..b78e3322 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -351,6 +351,7 @@ 仅强制字幕 DTS:X DTS:HD + DTS:HD MA TrueHD DD DD+ diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 5bf09d0f..c96c987f 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -349,6 +349,7 @@ 無預告片 DTS:X DTS:HD + DTS:HD MA TrueHD DD DD+ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66276854..a28fbaae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -451,6 +451,7 @@ Clear track choices DTS:X DTS:HD + DTS:HD MA TrueHD DD DD+