Show season extras row on series overview (#1286)

## Description
Adds a row for season level extras on the series overview page below the
people rows. The extras will update when switching between season tabs.

Like elsewhere in the app, an single extra of a type is show inline and
multiple extras of the same type open a grid when clicked.

### Related issues
Closes #504

### Testing
Emulator

## Screenshots
<img width="1280" height="720" alt="season_extras Large"
src="https://github.com/user-attachments/assets/bc62bef4-66c8-4412-9eca-ec0105d0d0fa"
/>


## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-04-21 17:34:38 -04:00 committed by GitHub
parent 8f98526558
commit c9cab7a5c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 41 additions and 3 deletions

View file

@ -98,13 +98,15 @@ fun SeriesOverview(
val episodeRowFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() }
val castCrewRowFocusRequester = remember { FocusRequester() } val castCrewRowFocusRequester = remember { FocusRequester() }
val guestStarRowFocusRequester = remember { FocusRequester() } val guestStarRowFocusRequester = remember { FocusRequester() }
val extrasRowFocusRequester = remember { FocusRequester() }
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val series by viewModel.item.observeAsState(null) val series by viewModel.item.observeAsState(null)
val seasons by viewModel.seasons.observeAsState(listOf()) val seasons by viewModel.seasons.observeAsState(emptyList())
val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading) val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading)
val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(listOf()) val seasonExtras by viewModel.extras.observeAsState(emptyList())
val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(emptyList())
val episodeList = (episodes as? EpisodeList.Success)?.episodes val episodeList = (episodes as? EpisodeList.Success)?.episodes
val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0)) val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0))
@ -164,6 +166,7 @@ fun SeriesOverview(
EPISODE_ROW -> episodeRowFocusRequester EPISODE_ROW -> episodeRowFocusRequester
CAST_AND_CREW_ROW -> castCrewRowFocusRequester CAST_AND_CREW_ROW -> castCrewRowFocusRequester
GUEST_STAR_ROW -> guestStarRowFocusRequester GUEST_STAR_ROW -> guestStarRowFocusRequester
EXTRAS_ROW -> extrasRowFocusRequester
else -> episodeRowFocusRequester else -> episodeRowFocusRequester
}, },
"series_overview", "series_overview",
@ -280,11 +283,13 @@ fun SeriesOverview(
episodes = episodes, episodes = episodes,
chosenStreams = chosenStreams, chosenStreams = chosenStreams,
peopleInEpisode = peopleInEpisode, peopleInEpisode = peopleInEpisode,
seasonExtras = seasonExtras,
position = position, position = position,
firstItemFocusRequester = firstItemFocusRequester, firstItemFocusRequester = firstItemFocusRequester,
episodeRowFocusRequester = episodeRowFocusRequester, episodeRowFocusRequester = episodeRowFocusRequester,
castCrewRowFocusRequester = castCrewRowFocusRequester, castCrewRowFocusRequester = castCrewRowFocusRequester,
guestStarRowFocusRequester = guestStarRowFocusRequester, guestStarRowFocusRequester = guestStarRowFocusRequester,
extrasRowFocusRequester = extrasRowFocusRequester,
onChangeSeason = { index -> onChangeSeason = { index ->
if (index != position.seasonTabIndex) { if (index != position.seasonTabIndex) {
seasons.getOrNull(index)?.let { season -> seasons.getOrNull(index)?.let { season ->
@ -364,6 +369,10 @@ fun SeriesOverview(
), ),
) )
}, },
onClickExtra = { _, extra ->
rowFocused = EXTRAS_ROW
viewModel.navigateTo(extra.destination)
},
canDelete = { viewModel.canDelete(it, preferences.appPreferences) }, canDelete = { viewModel.canDelete(it, preferences.appPreferences) },
deleteOnClick = { showDeleteDialog = it }, deleteOnClick = { showDeleteDialog = it },
modifier = modifier, modifier = modifier,
@ -435,3 +444,4 @@ fun SeriesOverview(
private const val EPISODE_ROW = 0 private const val EPISODE_ROW = 0
private const val CAST_AND_CREW_ROW = EPISODE_ROW + 1 private const val CAST_AND_CREW_ROW = EPISODE_ROW + 1
private const val GUEST_STAR_ROW = CAST_AND_CREW_ROW + 1 private const val GUEST_STAR_ROW = CAST_AND_CREW_ROW + 1
private const val EXTRAS_ROW = GUEST_STAR_ROW + 1

View file

@ -34,7 +34,6 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
@ -47,11 +46,13 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.ExtrasRow
import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.HeaderUtils
@ -74,6 +75,7 @@ fun SeriesOverviewContent(
series: BaseItem, series: BaseItem,
seasons: List<BaseItem?>, seasons: List<BaseItem?>,
episodes: EpisodeList, episodes: EpisodeList,
seasonExtras: List<ExtrasItem>,
chosenStreams: ChosenStreams?, chosenStreams: ChosenStreams?,
peopleInEpisode: List<Person>, peopleInEpisode: List<Person>,
position: SeriesOverviewPosition, position: SeriesOverviewPosition,
@ -81,6 +83,7 @@ fun SeriesOverviewContent(
episodeRowFocusRequester: FocusRequester, episodeRowFocusRequester: FocusRequester,
castCrewRowFocusRequester: FocusRequester, castCrewRowFocusRequester: FocusRequester,
guestStarRowFocusRequester: FocusRequester, guestStarRowFocusRequester: FocusRequester,
extrasRowFocusRequester: FocusRequester,
onChangeSeason: (Int) -> Unit, onChangeSeason: (Int) -> Unit,
onFocusEpisode: (Int) -> Unit, onFocusEpisode: (Int) -> Unit,
onClick: (BaseItem) -> Unit, onClick: (BaseItem) -> Unit,
@ -93,6 +96,7 @@ fun SeriesOverviewContent(
personOnClick: (Person) -> Unit, personOnClick: (Person) -> Unit,
canDelete: (BaseItem) -> Boolean, canDelete: (BaseItem) -> Boolean,
deleteOnClick: (BaseItem) -> Unit, deleteOnClick: (BaseItem) -> Unit,
onClickExtra: (Int, ExtrasItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -365,6 +369,21 @@ fun SeriesOverviewContent(
} }
} }
} }
AnimatedVisibility(
visible = seasonExtras.isNotEmpty(),
enter = fadeIn(),
exit = fadeOut(),
) {
ExtrasRow(
extras = seasonExtras,
onClickItem = onClickExtra,
onLongClickItem = { _, _ -> },
modifier =
Modifier
.fillMaxWidth()
.focusRequester(extrasRowFocusRequester),
)
}
} }
} }
} }

View file

@ -184,6 +184,10 @@ class SeriesViewModel
it.copy(seasonTabIndex = index.coerceAtLeast(0)) it.copy(seasonTabIndex = index.coerceAtLeast(0))
} }
} }
viewModelScope.launchIO {
val extras = extrasService.getExtras(seasonEpisodeIds.seasonId)
this@SeriesViewModel.extras.setValueOnMain(extras)
}
} }
val remoteTrailers = trailerService.getRemoteTrailers(item) val remoteTrailers = trailerService.getRemoteTrailers(item)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -388,6 +392,7 @@ class SeriesViewModel
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) { if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
this@SeriesViewModel.peopleInEpisode.value = PeopleInItem() this@SeriesViewModel.peopleInEpisode.value = PeopleInItem()
this@SeriesViewModel.episodes.value = EpisodeList.Loading this@SeriesViewModel.episodes.value = EpisodeList.Loading
this@SeriesViewModel.extras.value = emptyList()
} }
viewModelScope.launchIO(ExceptionHandler(true)) { viewModelScope.launchIO(ExceptionHandler(true)) {
val episodes = val episodes =
@ -401,6 +406,10 @@ class SeriesViewModel
this@SeriesViewModel.episodes.value = episodes this@SeriesViewModel.episodes.value = episodes
} }
} }
viewModelScope.launchIO {
val extras = extrasService.getExtras(seasonId)
this@SeriesViewModel.extras.setValueOnMain(extras)
}
} }
fun setWatched( fun setWatched(