From 10c08c5515b94b3a4b5247324e77e98da42fbba6 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Fri, 28 Nov 2025 09:41:09 -0500 Subject: [PATCH] Display episode even if season metadata is missing (#329) Adds a simple fallback page to show an episode in cases when it doesn't have an associated season due to missing metadata,see #280. The missing metadata is probably a server bug, but it's good to have a fallback anyway. --- .../wholphin/ui/components/MovieComponents.kt | 37 ++ .../ui/components/SeriesComponents.kt | 77 ++++ .../wholphin/ui/detail/DetailUtils.kt | 7 +- .../ui/detail/episode/EpisodeDetails.kt | 334 ++++++++++++++++++ .../ui/detail/episode/EpisodeDetailsHeader.kt | 102 ++++++ .../ui/detail/episode/EpisodeViewModel.kt | 173 +++++++++ .../wholphin/ui/detail/movie/MovieDetails.kt | 2 +- .../ui/detail/movie/MovieDetailsHeader.kt | 37 +- .../ui/detail/series/FocusedEpisodeHeader.kt | 41 +-- .../ui/detail/series/SeriesOverview.kt | 2 +- .../ui/detail/series/SeriesOverviewContent.kt | 14 +- .../damontecres/wholphin/ui/main/HomePage.kt | 43 +-- .../wholphin/ui/nav/DestinationContent.kt | 8 + 13 files changed, 760 insertions(+), 117 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt new file mode 100644 index 00000000..5247f801 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt @@ -0,0 +1,37 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.tv.material3.MaterialTheme +import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.timeRemaining +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.extensions.ticks + +@Composable +fun MovieQuickDetails( + dto: BaseItemDto?, + modifier: Modifier = Modifier, +) { + val details = + remember(dto) { + buildList { + dto?.productionYear?.let { add(it.toString()) } + dto?.runTimeTicks?.ticks?.roundMinutes?.let { + add(it.toString()) + } + dto?.timeRemaining?.roundMinutes?.let { + add("$it left") + } + dto?.officialRating?.let(::add) + } + } + + DotSeparatedRow( + texts = details, + rating = dto?.communityRating, + textStyle = MaterialTheme.typography.titleSmall, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt new file mode 100644 index 00000000..1f96967b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt @@ -0,0 +1,77 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.ui.formatDateTime +import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.seasonEpisode +import com.github.damontecres.wholphin.ui.timeRemaining +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.extensions.ticks + +@Composable +fun SeriesName( + seriesName: String?, + modifier: Modifier = Modifier, +) { + Text( + text = seriesName ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) +} + +@Composable +fun EpisodeName( + episodeName: String?, + modifier: Modifier = Modifier, +) { + Text( + text = episodeName ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) +} + +@Composable +fun EpisodeName( + episode: BaseItemDto?, + modifier: Modifier = Modifier, +) = EpisodeName(episode?.episodeTitle ?: episode?.name, modifier) + +@Composable +fun EpisodeQuickDetails( + dto: BaseItemDto?, + modifier: Modifier = Modifier, +) { + val details = + remember(dto) { + buildList { + dto?.seasonEpisode?.let(::add) + dto?.premiereDate?.let { add(formatDateTime(it)) } + val duration = dto?.runTimeTicks?.ticks + duration + ?.roundMinutes + ?.toString() + ?.let(::add) + dto?.timeRemaining?.roundMinutes?.let { add("$it left") } + dto?.officialRating?.let(::add) + } + } + DotSeparatedRow( + texts = details, + rating = dto?.communityRating, + textStyle = MaterialTheme.typography.titleSmall, + modifier = modifier, + ) +} 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 d62d0f73..23daa573 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 @@ -47,7 +47,7 @@ data class MoreDialogActions( fun buildMoreDialogItems( context: Context, item: BaseItem, - series: BaseItem?, + seriesId: UUID?, sourceId: UUID?, watched: Boolean, favorite: Boolean, @@ -144,7 +144,7 @@ fun buildMoreDialogItems( actions.onClickFavorite.invoke(item.id, !favorite) }, ) - series?.let { + seriesId?.let { add( DialogItem( context.getString(R.string.go_to_series), @@ -152,9 +152,8 @@ fun buildMoreDialogItems( ) { actions.navigateTo( Destination.MediaItem( - series.id, + seriesId, BaseItemKind.SERIES, - series, ), ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt new file mode 100644 index 00000000..7425449d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -0,0 +1,334 @@ +package com.github.damontecres.wholphin.ui.detail.episode + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.LifecycleStartEffect +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ChosenStreams +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.chooseSource +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.Optional +import com.github.damontecres.wholphin.ui.components.chooseStream +import com.github.damontecres.wholphin.ui.components.chooseVersionParams +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.detail.MoreDialogActions +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.extensions.ticks +import org.jellyfin.sdk.model.serializer.toUUID +import java.util.UUID +import kotlin.time.Duration + +@Composable +fun EpisodeDetails( + preferences: UserPreferences, + destination: Destination.MediaItem, + modifier: Modifier = Modifier, + viewModel: EpisodeViewModel = + hiltViewModel( + creationCallback = { it.create(destination.itemId) }, + ), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + LifecycleResumeEffect(Unit) { + viewModel.init() + onPauseOrDispose { } + } + val item by viewModel.item.observeAsState() + val loading by viewModel.loading.observeAsState(LoadingState.Loading) + val chosenStreams by viewModel.chosenStreams.observeAsState(null) + + var overviewDialog by remember { mutableStateOf(null) } + var moreDialog by remember { mutableStateOf(null) } + var chooseVersion by remember { mutableStateOf(null) } + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + + val moreActions = + MoreDialogActions( + navigateTo = viewModel::navigateTo, + onClickWatch = { itemId, watched -> + viewModel.setWatched(itemId, watched) + }, + onClickFavorite = { itemId, favorite -> + viewModel.setFavorite(itemId, favorite) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(itemId) + }, + ) + + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state) + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() + + LoadingState.Success -> { + item?.let { ep -> + LifecycleStartEffect(destination.itemId) { + viewModel.maybePlayThemeSong( + destination.itemId, + preferences.appPreferences.interfacePreferences.playThemeSongs, + ) + onStopOrDispose { + viewModel.release() + } + } + EpisodeDetailsContent( + preferences = preferences, + ep = ep, + chosenStreams = chosenStreams, + playOnClick = { + viewModel.navigateTo( + Destination.Playback( + ep.id, + it.inWholeMilliseconds, + ep, + ), + ) + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo( + title = ep.name ?: context.getString(R.string.unknown), + overview = ep.data.overview, + files = ep.data.mediaSources.orEmpty(), + ) + }, + moreOnClick = { + moreDialog = + DialogParams( + fromLongClick = false, + title = ep.name + " (${ep.data.productionYear ?: ""})", + items = + buildMoreDialogItems( + context = context, + item = ep, + watched = ep.data.userData?.played ?: false, + favorite = ep.data.userData?.isFavorite ?: false, + seriesId = ep.data.seriesId, + sourceId = chosenStreams?.sourceId, + actions = moreActions, + onChooseVersion = { + chooseVersion = + chooseVersionParams( + context, + ep.data.mediaSources!!, + ) { idx -> + val source = ep.data.mediaSources!![idx] + viewModel.savePlayVersion( + ep, + source.id!!.toUUID(), + ) + } + moreDialog = null + }, + onChooseTracks = { type -> + chooseSource( + ep.data, + chosenStreams?.itemPlayback, + )?.let { source -> + chooseVersion = + chooseStream( + context = context, + streams = source.mediaStreams.orEmpty(), + type = type, + onClick = { trackIndex -> + viewModel.saveTrackSelection( + ep, + chosenStreams?.itemPlayback, + trackIndex, + type, + ) + }, + ) + } + }, + ), + ) + }, + watchOnClick = { + viewModel.setWatched(ep.id, !ep.played) + }, + favoriteOnClick = { + viewModel.setFavorite(ep.id, !ep.favorite) + }, + modifier = modifier, + ) + } + } + } + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = + viewModel.serverRepository.currentUserDto.value + ?.policy + ?.isAdministrator == true, + onDismissRequest = { overviewDialog = null }, + ) + } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } + chooseVersion?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { chooseVersion = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } + showPlaylistDialog.compose { itemId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() + }, + elevation = 3.dp, + ) + } +} + +private const val HEADER_ROW = 0 + +@Composable +fun EpisodeDetailsContent( + preferences: UserPreferences, + ep: BaseItem, + chosenStreams: ChosenStreams?, + playOnClick: (Duration) -> Unit, + overviewOnClick: () -> Unit, + watchOnClick: () -> Unit, + favoriteOnClick: () -> Unit, + moreOnClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var position by rememberInt(0) + val focusRequesters = remember { List(1) { FocusRequester() } } + val dto = ep.data + val backdropImageUrl = ep.backdropImageUrl + val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO + + val bringIntoViewRequester = remember { BringIntoViewRequester() } + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position)?.tryRequestFocus() + } + Box(modifier = modifier) { + DetailsBackdropImage(backdropImageUrl) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), + modifier = Modifier.fillMaxSize(), + ) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester), + ) { + EpisodeDetailsHeader( + preferences = preferences, + ep = ep, + chosenStreams = chosenStreams, + bringIntoViewRequester = bringIntoViewRequester, + overviewOnClick = overviewOnClick, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 32.dp, bottom = 16.dp), + ) + ExpandablePlayButtons( + resumePosition = resumePosition, + watched = dto.userData?.played ?: false, + favorite = dto.userData?.isFavorite ?: false, + playOnClick = { + position = HEADER_ROW + playOnClick.invoke(it) + }, + moreOnClick = moreOnClick, + watchOnClick = watchOnClick, + favoriteOnClick = favoriteOnClick, + buttonOnFocusChanged = { + if (it.isFocused) { + position = HEADER_ROW + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + modifier = + Modifier + .padding(bottom = 16.dp) + .focusRequester(focusRequesters[HEADER_ROW]), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt new file mode 100644 index 00000000..126f9f30 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt @@ -0,0 +1,102 @@ +package com.github.damontecres.wholphin.ui.detail.episode + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ChosenStreams +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.components.EpisodeName +import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails +import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.SeriesName +import com.github.damontecres.wholphin.ui.components.VideoStreamDetails +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import org.jellyfin.sdk.model.api.PersonKind + +@Composable +fun EpisodeDetailsHeader( + preferences: UserPreferences, + ep: BaseItem, + chosenStreams: ChosenStreams?, + bringIntoViewRequester: BringIntoViewRequester, + overviewOnClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val dto = ep.data + val context = LocalContext.current + val scope = rememberCoroutineScope() + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + SeriesName(dto.seriesName, Modifier.fillMaxWidth(.75f)) + EpisodeName(dto, Modifier.fillMaxWidth(.75f)) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(.60f), + ) { + val padding = 8.dp + EpisodeQuickDetails(dto) + + VideoStreamDetails( + preferences = preferences, + dto = dto, + itemPlayback = chosenStreams?.itemPlayback, + modifier = Modifier.padding(bottom = padding), + ) + dto.taglines?.firstOrNull()?.let { tagline -> + Text( + text = tagline, + style = MaterialTheme.typography.bodyLarge, + fontStyle = FontStyle.Italic, + modifier = Modifier, + ) + } + + // Description + dto.overview?.let { overview -> + val interactionSource = remember { MutableInteractionSource() } + val focused = interactionSource.collectIsFocusedAsState().value + LaunchedEffect(focused) { + if (focused) bringIntoViewRequester.bringIntoView() + } + OverviewText( + overview = overview, + maxLines = 3, + onClick = overviewOnClick, + textBoxHeight = Dp.Unspecified, + interactionSource = interactionSource, + ) + } + ep.data.people + ?.filter { it.type == PersonKind.DIRECTOR && it.name.isNotNullOrBlank() } + ?.joinToString(", ") { it.name!! } + ?.let { + Text( + text = stringResource(R.string.directed_by, it), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt new file mode 100644 index 00000000..b07e4b5c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -0,0 +1,173 @@ +package com.github.damontecres.wholphin.ui.detail.episode + +import android.content.Context +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ChosenStreams +import com.github.damontecres.wholphin.data.ItemPlaybackRepository +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.preferences.ThemeSongVolume +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ThemeSongPlayer +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.MediaStreamType +import java.util.UUID + +@HiltViewModel(assistedFactory = EpisodeViewModel.Factory::class) +class EpisodeViewModel + @AssistedInject + constructor( + private val api: ApiClient, + @param:ApplicationContext private val context: Context, + private val navigationManager: NavigationManager, + val serverRepository: ServerRepository, + val itemPlaybackRepository: ItemPlaybackRepository, + private val themeSongPlayer: ThemeSongPlayer, + private val favoriteWatchManager: FavoriteWatchManager, + @Assisted val itemId: UUID, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(itemId: UUID): EpisodeViewModel + } + + val loading = MutableLiveData(LoadingState.Pending) + val item = MutableLiveData(null) + val chosenStreams = MutableLiveData(null) + + init { + init() + } + + private fun fetchAndSetItem(): Deferred = + viewModelScope.async( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val item = + api.userLibraryApi.getItem(itemId).content.let { + BaseItem.from(it, api) + } + this@EpisodeViewModel.item.setValueOnMain(item) + item + } + + fun init(): Job = + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val item = fetchAndSetItem().await() + val result = itemPlaybackRepository.getSelectedTracks(item.id, item) + withContext(Dispatchers.Main) { + this@EpisodeViewModel.item.value = item + chosenStreams.value = result + loading.value = LoadingState.Success + } + } + + fun setWatched( + itemId: UUID, + played: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + fetchAndSetItem() + } + + fun setFavorite( + itemId: UUID, + favorite: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + fetchAndSetItem() + } + + fun savePlayVersion( + item: BaseItem, + sourceId: UUID, + ) { + viewModelScope.launchIO { + val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) + val chosen = + result?.let { + itemPlaybackRepository.getChosenItemFromPlayback(item, result) + } + withContext(Dispatchers.Main) { + chosenStreams.value = chosen + } + } + } + + fun saveTrackSelection( + item: BaseItem, + itemPlayback: ItemPlayback?, + trackIndex: Int, + type: MediaStreamType, + ) { + viewModelScope.launchIO { + val result = + itemPlaybackRepository.saveTrackSelection( + item = item, + itemPlayback = itemPlayback, + trackIndex = trackIndex, + type = type, + ) + val chosen = + result?.let { + itemPlaybackRepository.getChosenItemFromPlayback(item, result) + } + withContext(Dispatchers.Main) { + chosenStreams.value = chosen + } + } + } + + fun maybePlayThemeSong( + seriesId: UUID, + playThemeSongs: ThemeSongVolume, + ) { + viewModelScope.launchIO { + themeSongPlayer.playThemeFor(seriesId, playThemeSongs) + addCloseable { + themeSongPlayer.stop() + } + } + } + + fun release() { + themeSongPlayer.stop() + } + + fun navigateTo(destination: Destination) { + release() + navigationManager.navigateTo(destination) + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index aa90c35d..6ba7ae76 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -196,7 +196,7 @@ fun MovieDetails( item = movie, watched = movie.data.userData?.played ?: false, favorite = movie.data.userData?.isFavorite ?: false, - series = null, + seriesId = null, sourceId = chosenStreams?.sourceId, actions = moreActions, onChooseVersion = { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index 1ec2c81e..a2d68ca9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -24,14 +24,11 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.DotSeparatedRow +import com.github.damontecres.wholphin.ui.components.MovieQuickDetails import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.VideoStreamDetails import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.roundMinutes -import com.github.damontecres.wholphin.ui.timeRemaining import org.jellyfin.sdk.model.api.PersonKind -import org.jellyfin.sdk.model.extensions.ticks @Composable fun MovieDetailsHeader( @@ -64,25 +61,7 @@ fun MovieDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { val padding = 8.dp - val details = - remember(dto) { - buildList { - dto.productionYear?.let { add(it.toString()) } - val duration = dto.runTimeTicks?.ticks - duration - ?.roundMinutes - ?.toString() - ?.let(::add) - dto.timeRemaining?.roundMinutes?.let { add("$it left") } - dto.officialRating?.let(::add) - } - } - DotSeparatedRow( - texts = details, - rating = dto.communityRating, - textStyle = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = padding), - ) + MovieQuickDetails(dto, Modifier.padding(bottom = padding)) VideoStreamDetails( preferences = preferences, @@ -114,9 +93,15 @@ fun MovieDetailsHeader( interactionSource = interactionSource, ) } - movie.data.people - ?.filter { it.type == PersonKind.DIRECTOR && it.name.isNotNullOrBlank() } - ?.joinToString(", ") { it.name!! } + + val directorName = + remember(movie.data.people) { + movie.data.people + ?.filter { it.type == PersonKind.DIRECTOR && it.name.isNotNullOrBlank() } + ?.joinToString(", ") { it.name!! } + } + + directorName ?.let { Text( text = stringResource(R.string.directed_by, it), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index 9eb9330a..2582c925 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -3,24 +3,16 @@ package com.github.damontecres.wholphin.ui.detail.series import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.DotSeparatedRow +import com.github.damontecres.wholphin.ui.components.EpisodeName +import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.VideoStreamDetails -import com.github.damontecres.wholphin.ui.formatDateTime -import com.github.damontecres.wholphin.ui.roundMinutes -import com.github.damontecres.wholphin.ui.seasonEpisode -import com.github.damontecres.wholphin.ui.timeRemaining -import org.jellyfin.sdk.model.extensions.ticks @Composable fun FocusedEpisodeHeader( @@ -36,32 +28,9 @@ fun FocusedEpisodeHeader( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { - Text( - text = dto?.episodeTitle ?: dto?.name ?: "", - style = MaterialTheme.typography.headlineSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier, - ) - val details = - remember(dto) { - buildList { - dto?.seasonEpisode?.let(::add) - dto?.premiereDate?.let { add(formatDateTime(it)) } - val duration = dto?.runTimeTicks?.ticks - duration - ?.roundMinutes - ?.toString() - ?.let(::add) - dto?.timeRemaining?.roundMinutes?.let { add("$it left") } - dto?.officialRating?.let(::add) - } - } - DotSeparatedRow( - texts = details, - rating = dto?.communityRating, - textStyle = MaterialTheme.typography.titleSmall, - ) + EpisodeName(dto, modifier = Modifier) + + EpisodeQuickDetails(dto) if (dto != null) { VideoStreamDetails( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index ad5befee..5fe6ee64 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -195,7 +195,7 @@ fun SeriesOverview( item = ep, watched = ep.data.userData?.played ?: false, favorite = ep.data.userData?.isFavorite ?: false, - series = series, + seriesId = series.id, sourceId = chosenStreams?.sourceId, actions = MoreDialogActions( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index 5d75a081..198e8c48 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -34,10 +34,7 @@ import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem @@ -48,6 +45,7 @@ import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.SeriesName import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.ifElse @@ -133,15 +131,7 @@ fun SeriesOverviewContent( ) } item { - series.name?.let { - Text( - text = it, - style = MaterialTheme.typography.headlineMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier, - ) - } + SeriesName(series.name, Modifier) } item { // Episode header diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 249b8f13..d2575ef6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -57,9 +57,10 @@ import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup -import com.github.damontecres.wholphin.ui.components.DotSeparatedRow +import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.MovieQuickDetails import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumnSaver @@ -67,19 +68,14 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome -import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.roundMinutes -import com.github.damontecres.wholphin.ui.seasonEpisode -import com.github.damontecres.wholphin.ui.timeRemaining import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.delay import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaType -import org.jellyfin.sdk.model.extensions.ticks import java.util.UUID import kotlin.time.Duration.Companion.milliseconds @@ -457,30 +453,6 @@ fun HomePageHeader( val title = if (isEpisode) dto.seriesName ?: item.name else item.name val subtitle = if (isEpisode) dto.name else null val overview = dto.overview - val details = - buildList { - if (isEpisode) { - val se = dto.seasonEpisode - if (se != null) { - add(se) - } else if (dto.parentIndexNumber != null) { - // Maybe a daily episode, so just show season, the date is added below - add("S${dto.parentIndexNumber}") - } - } - if (isEpisode) { - dto.premiereDate?.let { add(formatDateTime(it)) } - } else { - dto.productionYear?.let { add(it.toString()) } - } - dto.runTimeTicks?.ticks?.roundMinutes?.let { - add(it.toString()) - } - dto.timeRemaining?.roundMinutes?.let { - add("$it left") - } - dto.officialRating?.let(::add) - } title?.let { Text( text = title, @@ -499,13 +471,10 @@ fun HomePageHeader( overflow = TextOverflow.Ellipsis, ) } - if (details.isNotEmpty()) { - DotSeparatedRow( - texts = details, - rating = item.data.communityRating, - textStyle = MaterialTheme.typography.bodyLarge, - modifier = Modifier, - ) + if (isEpisode) { + EpisodeQuickDetails(dto, Modifier) + } else { + MovieQuickDetails(dto, Modifier) } val overviewModifier = Modifier 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 b3bc2fce..de85ddee 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 @@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.ui.detail.DebugPage import com.github.damontecres.wholphin.ui.detail.FavoritesPage import com.github.damontecres.wholphin.ui.detail.PersonPage import com.github.damontecres.wholphin.ui.detail.PlaylistDetails +import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview @@ -101,6 +102,13 @@ fun DestinationContent( modifier, ) + BaseItemKind.EPISODE -> + EpisodeDetails( + preferences, + destination, + modifier, + ) + BaseItemKind.BOX_SET -> CollectionFolderBoxSet( preferences = preferences,