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.
This commit is contained in:
damontecres 2025-11-28 09:41:09 -05:00 committed by GitHub
parent 70f569208c
commit 10c08c5515
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 760 additions and 117 deletions

View file

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

View file

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

View file

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

View file

@ -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<EpisodeViewModel, EpisodeViewModel.Factory>(
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<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(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]),
)
}
}
}
}
}

View file

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

View file

@ -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>(LoadingState.Pending)
val item = MutableLiveData<BaseItem?>(null)
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
init {
init()
}
private fun fetchAndSetItem(): Deferred<BaseItem> =
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)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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