mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-28 03:43:02 +00:00
meta hero layout changes
This commit is contained in:
parent
3a54098907
commit
b8f7ebdc4b
3 changed files with 417 additions and 359 deletions
|
|
@ -9,6 +9,7 @@ import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
|
@ -18,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
|
@ -46,6 +48,7 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.nuvio.app.core.ui.NuvioBackButton
|
import com.nuvio.app.core.ui.NuvioBackButton
|
||||||
|
|
@ -306,303 +309,313 @@ fun MetaDetailsScreen(
|
||||||
label = "detail_floating_header_progress",
|
label = "detail_floating_header_progress",
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
val isTablet = maxWidth >= 720.dp
|
||||||
modifier = Modifier
|
val contentHorizontalPadding = if (isTablet) 32.dp else 18.dp
|
||||||
.fillMaxSize()
|
val contentMaxWidth = detailTabletContentMaxWidth(maxWidth, isTablet)
|
||||||
.verticalScroll(scrollState),
|
|
||||||
) {
|
|
||||||
DetailHero(
|
|
||||||
meta = meta,
|
|
||||||
scrollOffset = scrollState.value,
|
|
||||||
onHeightChanged = { heroHeightPx = it },
|
|
||||||
)
|
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 18.dp),
|
.verticalScroll(scrollState),
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
|
||||||
) {
|
) {
|
||||||
DetailActionButtons(
|
DetailHero(
|
||||||
playLabel = playButtonLabel,
|
|
||||||
saveLabel = if (isSaved) "Saved" else "Save",
|
|
||||||
isSaved = isSaved,
|
|
||||||
onPlayClick = {
|
|
||||||
when {
|
|
||||||
(meta.type == "series" || hasEpisodes) && seriesAction != null -> {
|
|
||||||
onPlay?.invoke(
|
|
||||||
meta.type,
|
|
||||||
seriesAction.videoId,
|
|
||||||
meta.id,
|
|
||||||
meta.type,
|
|
||||||
meta.name,
|
|
||||||
meta.logo,
|
|
||||||
meta.poster,
|
|
||||||
meta.background,
|
|
||||||
seriesAction.seasonNumber,
|
|
||||||
seriesAction.episodeNumber,
|
|
||||||
seriesAction.episodeTitle,
|
|
||||||
seriesAction.episodeThumbnail,
|
|
||||||
seriesPauseDescription,
|
|
||||||
seriesAction.resumePositionMs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
onPlay?.invoke(
|
|
||||||
meta.type,
|
|
||||||
meta.id,
|
|
||||||
meta.id,
|
|
||||||
meta.type,
|
|
||||||
meta.name,
|
|
||||||
meta.logo,
|
|
||||||
meta.poster,
|
|
||||||
meta.background,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
meta.description,
|
|
||||||
movieProgress?.lastPositionMs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSaveClick = toggleSaved,
|
|
||||||
)
|
|
||||||
|
|
||||||
DetailMetaInfo(meta = meta)
|
|
||||||
|
|
||||||
if (hasEpisodes && hasProductionSection) {
|
|
||||||
DetailProductionSection(meta = meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
DetailCastSection(cast = meta.cast)
|
|
||||||
|
|
||||||
if (hasTrailersSection) {
|
|
||||||
DetailTrailersSection(
|
|
||||||
trailers = meta.trailers,
|
|
||||||
onTrailerClick = resolveTrailer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasEpisodes && hasProductionSection) {
|
|
||||||
DetailProductionSection(meta = meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
DetailSeriesContent(
|
|
||||||
meta = meta,
|
meta = meta,
|
||||||
progressByVideoId = watchProgressUiState.byVideoId,
|
isTablet = isTablet,
|
||||||
watchedKeys = watchedUiState.watchedKeys,
|
contentMaxWidth = contentMaxWidth,
|
||||||
onEpisodeClick = { video ->
|
scrollOffset = scrollState.value,
|
||||||
val season = video.season
|
onHeightChanged = { heroHeightPx = it },
|
||||||
val episode = video.episode
|
|
||||||
val playbackVideoId = buildPlaybackVideoId(
|
|
||||||
parentMetaId = meta.id,
|
|
||||||
seasonNumber = season,
|
|
||||||
episodeNumber = episode,
|
|
||||||
fallbackVideoId = video.id,
|
|
||||||
)
|
|
||||||
val savedProgress = watchProgressUiState.byVideoId[playbackVideoId]
|
|
||||||
?.takeUnless { it.isCompleted }
|
|
||||||
onPlay?.invoke(
|
|
||||||
meta.type,
|
|
||||||
playbackVideoId,
|
|
||||||
meta.id,
|
|
||||||
meta.type,
|
|
||||||
meta.name,
|
|
||||||
meta.logo,
|
|
||||||
meta.poster,
|
|
||||||
meta.background,
|
|
||||||
season,
|
|
||||||
episode,
|
|
||||||
video.title,
|
|
||||||
video.thumbnail,
|
|
||||||
video.overview,
|
|
||||||
savedProgress?.lastPositionMs,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onEpisodeLongPress = { video ->
|
|
||||||
selectedEpisodeForActions = video
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (hasEpisodes && hasAdditionalInfoSection) {
|
Column(
|
||||||
DetailAdditionalInfoSection(meta = meta)
|
modifier = Modifier
|
||||||
}
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = contentHorizontalPadding)
|
||||||
|
.widthIn(max = if (isTablet) contentMaxWidth else Dp.Unspecified),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
DetailActionButtons(
|
||||||
|
playLabel = playButtonLabel,
|
||||||
|
saveLabel = if (isSaved) "Saved" else "Save",
|
||||||
|
isSaved = isSaved,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onPlayClick = {
|
||||||
|
when {
|
||||||
|
(meta.type == "series" || hasEpisodes) && seriesAction != null -> {
|
||||||
|
onPlay?.invoke(
|
||||||
|
meta.type,
|
||||||
|
seriesAction.videoId,
|
||||||
|
meta.id,
|
||||||
|
meta.type,
|
||||||
|
meta.name,
|
||||||
|
meta.logo,
|
||||||
|
meta.poster,
|
||||||
|
meta.background,
|
||||||
|
seriesAction.seasonNumber,
|
||||||
|
seriesAction.episodeNumber,
|
||||||
|
seriesAction.episodeTitle,
|
||||||
|
seriesAction.episodeThumbnail,
|
||||||
|
seriesPauseDescription,
|
||||||
|
seriesAction.resumePositionMs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasEpisodes && hasAdditionalInfoSection) {
|
else -> {
|
||||||
DetailAdditionalInfoSection(meta = meta)
|
onPlay?.invoke(
|
||||||
}
|
meta.type,
|
||||||
|
meta.id,
|
||||||
if (!hasEpisodes && hasCollectionSection) {
|
meta.id,
|
||||||
DetailPosterRailSection(
|
meta.type,
|
||||||
title = meta.collectionName.orEmpty(),
|
meta.name,
|
||||||
items = meta.collectionItems,
|
meta.logo,
|
||||||
watchedKeys = watchedUiState.watchedKeys,
|
meta.poster,
|
||||||
onPosterClick = onOpenMeta,
|
meta.background,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
meta.description,
|
||||||
|
movieProgress?.lastPositionMs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSaveClick = toggleSaved,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
if (hasMoreLikeThisSection) {
|
DetailMetaInfo(meta = meta)
|
||||||
DetailPosterRailSection(
|
|
||||||
title = "More Like This",
|
|
||||||
items = meta.moreLikeThis,
|
|
||||||
watchedKeys = watchedUiState.watchedKeys,
|
|
||||||
onPosterClick = onOpenMeta,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding))
|
if (hasEpisodes && hasProductionSection) {
|
||||||
}
|
DetailProductionSection(meta = meta)
|
||||||
}
|
|
||||||
|
|
||||||
if (headerProgress <= 0.05f) {
|
|
||||||
NuvioBackButton(
|
|
||||||
onClick = onBack,
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = 12.dp,
|
|
||||||
top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp,
|
|
||||||
),
|
|
||||||
containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.5f),
|
|
||||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DetailFloatingHeader(
|
|
||||||
meta = meta,
|
|
||||||
isSaved = isSaved,
|
|
||||||
progress = headerProgress,
|
|
||||||
onBack = onBack,
|
|
||||||
onToggleSaved = toggleSaved,
|
|
||||||
)
|
|
||||||
|
|
||||||
selectedEpisodeForActions?.let { selectedEpisode ->
|
|
||||||
val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys) {
|
|
||||||
WatchingState.isEpisodeWatched(
|
|
||||||
watchedKeys = watchedUiState.watchedKeys,
|
|
||||||
metaType = meta.type,
|
|
||||||
metaId = meta.id,
|
|
||||||
episode = selectedEpisode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val previousEpisodes = remember(meta, selectedEpisode, todayIsoDate) {
|
|
||||||
meta.previousReleasedEpisodesBefore(
|
|
||||||
target = selectedEpisode,
|
|
||||||
todayIsoDate = todayIsoDate,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val seasonEpisodes = remember(meta, selectedEpisode, todayIsoDate) {
|
|
||||||
meta.releasedEpisodesForSeason(
|
|
||||||
seasonNumber = selectedEpisode.season,
|
|
||||||
todayIsoDate = todayIsoDate,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys) {
|
|
||||||
WatchingState.areEpisodesWatched(
|
|
||||||
watchedKeys = watchedUiState.watchedKeys,
|
|
||||||
metaType = meta.type,
|
|
||||||
metaId = meta.id,
|
|
||||||
episodes = previousEpisodes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys) {
|
|
||||||
WatchingState.areEpisodesWatched(
|
|
||||||
watchedKeys = watchedUiState.watchedKeys,
|
|
||||||
metaType = meta.type,
|
|
||||||
metaId = meta.id,
|
|
||||||
episodes = seasonEpisodes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
EpisodeWatchedActionSheet(
|
|
||||||
episode = selectedEpisode,
|
|
||||||
seasonLabel = selectedEpisode.season?.let { "Season $it" } ?: "Specials",
|
|
||||||
isEpisodeWatched = isSelectedEpisodeWatched,
|
|
||||||
canMarkPreviousEpisodes = previousEpisodes.isNotEmpty(),
|
|
||||||
arePreviousEpisodesWatched = arePreviousEpisodesWatched,
|
|
||||||
isSeasonWatched = isSeasonWatched,
|
|
||||||
onDismiss = { selectedEpisodeForActions = null },
|
|
||||||
onToggleWatched = {
|
|
||||||
WatchingActions.toggleEpisodeWatched(
|
|
||||||
meta = meta,
|
|
||||||
episode = selectedEpisode,
|
|
||||||
isCurrentlyWatched = isSelectedEpisodeWatched,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onTogglePreviousWatched = {
|
|
||||||
WatchingActions.togglePreviousEpisodesWatched(
|
|
||||||
meta = meta,
|
|
||||||
episodes = previousEpisodes,
|
|
||||||
areCurrentlyWatched = arePreviousEpisodesWatched,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onToggleSeasonWatched = {
|
|
||||||
WatchingActions.toggleSeasonWatched(
|
|
||||||
meta = meta,
|
|
||||||
episodes = seasonEpisodes,
|
|
||||||
areCurrentlyWatched = isSeasonWatched,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
TrailerPlayerPopup(
|
|
||||||
visible = selectedTrailer != null,
|
|
||||||
trailerTitle = selectedTrailer?.displayName ?: selectedTrailer?.name.orEmpty(),
|
|
||||||
trailerType = selectedTrailer?.type.orEmpty(),
|
|
||||||
contentTitle = meta.name,
|
|
||||||
playbackSource = trailerPlaybackSource,
|
|
||||||
isLoading = trailerLoading,
|
|
||||||
errorMessage = trailerErrorMessage,
|
|
||||||
onDismiss = {
|
|
||||||
trailerRequestToken += 1
|
|
||||||
trailerLoading = false
|
|
||||||
trailerPlaybackSource = null
|
|
||||||
trailerErrorMessage = null
|
|
||||||
selectedTrailer = null
|
|
||||||
},
|
|
||||||
onRetry = selectedTrailer?.let { trailer ->
|
|
||||||
{ resolveTrailer(trailer) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
TraktListPickerDialog(
|
|
||||||
visible = showLibraryListPicker,
|
|
||||||
title = meta.name,
|
|
||||||
tabs = pickerTabs,
|
|
||||||
membership = pickerMembership,
|
|
||||||
isPending = pickerPending,
|
|
||||||
errorMessage = pickerError,
|
|
||||||
onToggle = { listKey ->
|
|
||||||
pickerMembership = pickerMembership.toMutableMap().apply {
|
|
||||||
this[listKey] = !(this[listKey] == true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDismiss = {
|
|
||||||
if (!pickerPending) {
|
|
||||||
showLibraryListPicker = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSave = {
|
|
||||||
detailsScope.launch {
|
|
||||||
pickerPending = true
|
|
||||||
pickerError = null
|
|
||||||
runCatching {
|
|
||||||
LibraryRepository.applyMembershipChanges(
|
|
||||||
item = meta.toLibraryItem(savedAtEpochMs = 0L),
|
|
||||||
desiredMembership = pickerMembership,
|
|
||||||
)
|
|
||||||
}.onSuccess {
|
|
||||||
showLibraryListPicker = false
|
|
||||||
}.onFailure { error ->
|
|
||||||
pickerError = error.message ?: "Failed to update Trakt lists"
|
|
||||||
}
|
}
|
||||||
pickerPending = false
|
|
||||||
|
DetailCastSection(cast = meta.cast)
|
||||||
|
|
||||||
|
if (hasTrailersSection) {
|
||||||
|
DetailTrailersSection(
|
||||||
|
trailers = meta.trailers,
|
||||||
|
onTrailerClick = resolveTrailer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasEpisodes && hasProductionSection) {
|
||||||
|
DetailProductionSection(meta = meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
DetailSeriesContent(
|
||||||
|
meta = meta,
|
||||||
|
progressByVideoId = watchProgressUiState.byVideoId,
|
||||||
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
|
onEpisodeClick = { video ->
|
||||||
|
val season = video.season
|
||||||
|
val episode = video.episode
|
||||||
|
val playbackVideoId = buildPlaybackVideoId(
|
||||||
|
parentMetaId = meta.id,
|
||||||
|
seasonNumber = season,
|
||||||
|
episodeNumber = episode,
|
||||||
|
fallbackVideoId = video.id,
|
||||||
|
)
|
||||||
|
val savedProgress = watchProgressUiState.byVideoId[playbackVideoId]
|
||||||
|
?.takeUnless { it.isCompleted }
|
||||||
|
onPlay?.invoke(
|
||||||
|
meta.type,
|
||||||
|
playbackVideoId,
|
||||||
|
meta.id,
|
||||||
|
meta.type,
|
||||||
|
meta.name,
|
||||||
|
meta.logo,
|
||||||
|
meta.poster,
|
||||||
|
meta.background,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
video.title,
|
||||||
|
video.thumbnail,
|
||||||
|
video.overview,
|
||||||
|
savedProgress?.lastPositionMs,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onEpisodeLongPress = { video ->
|
||||||
|
selectedEpisodeForActions = video
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasEpisodes && hasAdditionalInfoSection) {
|
||||||
|
DetailAdditionalInfoSection(meta = meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasEpisodes && hasAdditionalInfoSection) {
|
||||||
|
DetailAdditionalInfoSection(meta = meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasEpisodes && hasCollectionSection) {
|
||||||
|
DetailPosterRailSection(
|
||||||
|
title = meta.collectionName.orEmpty(),
|
||||||
|
items = meta.collectionItems,
|
||||||
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
|
onPosterClick = onOpenMeta,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasMoreLikeThisSection) {
|
||||||
|
DetailPosterRailSection(
|
||||||
|
title = "More Like This",
|
||||||
|
items = meta.moreLikeThis,
|
||||||
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
|
onPosterClick = onOpenMeta,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding))
|
||||||
}
|
}
|
||||||
Unit
|
}
|
||||||
},
|
|
||||||
)
|
if (headerProgress <= 0.05f) {
|
||||||
|
NuvioBackButton(
|
||||||
|
onClick = onBack,
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = 12.dp,
|
||||||
|
top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp,
|
||||||
|
),
|
||||||
|
containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.5f),
|
||||||
|
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DetailFloatingHeader(
|
||||||
|
meta = meta,
|
||||||
|
isSaved = isSaved,
|
||||||
|
progress = headerProgress,
|
||||||
|
onBack = onBack,
|
||||||
|
onToggleSaved = toggleSaved,
|
||||||
|
)
|
||||||
|
|
||||||
|
selectedEpisodeForActions?.let { selectedEpisode ->
|
||||||
|
val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys) {
|
||||||
|
WatchingState.isEpisodeWatched(
|
||||||
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
|
metaType = meta.type,
|
||||||
|
metaId = meta.id,
|
||||||
|
episode = selectedEpisode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val previousEpisodes = remember(meta, selectedEpisode, todayIsoDate) {
|
||||||
|
meta.previousReleasedEpisodesBefore(
|
||||||
|
target = selectedEpisode,
|
||||||
|
todayIsoDate = todayIsoDate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val seasonEpisodes = remember(meta, selectedEpisode, todayIsoDate) {
|
||||||
|
meta.releasedEpisodesForSeason(
|
||||||
|
seasonNumber = selectedEpisode.season,
|
||||||
|
todayIsoDate = todayIsoDate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys) {
|
||||||
|
WatchingState.areEpisodesWatched(
|
||||||
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
|
metaType = meta.type,
|
||||||
|
metaId = meta.id,
|
||||||
|
episodes = previousEpisodes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys) {
|
||||||
|
WatchingState.areEpisodesWatched(
|
||||||
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
|
metaType = meta.type,
|
||||||
|
metaId = meta.id,
|
||||||
|
episodes = seasonEpisodes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
EpisodeWatchedActionSheet(
|
||||||
|
episode = selectedEpisode,
|
||||||
|
seasonLabel = selectedEpisode.season?.let { "Season $it" } ?: "Specials",
|
||||||
|
isEpisodeWatched = isSelectedEpisodeWatched,
|
||||||
|
canMarkPreviousEpisodes = previousEpisodes.isNotEmpty(),
|
||||||
|
arePreviousEpisodesWatched = arePreviousEpisodesWatched,
|
||||||
|
isSeasonWatched = isSeasonWatched,
|
||||||
|
onDismiss = { selectedEpisodeForActions = null },
|
||||||
|
onToggleWatched = {
|
||||||
|
WatchingActions.toggleEpisodeWatched(
|
||||||
|
meta = meta,
|
||||||
|
episode = selectedEpisode,
|
||||||
|
isCurrentlyWatched = isSelectedEpisodeWatched,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onTogglePreviousWatched = {
|
||||||
|
WatchingActions.togglePreviousEpisodesWatched(
|
||||||
|
meta = meta,
|
||||||
|
episodes = previousEpisodes,
|
||||||
|
areCurrentlyWatched = arePreviousEpisodesWatched,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onToggleSeasonWatched = {
|
||||||
|
WatchingActions.toggleSeasonWatched(
|
||||||
|
meta = meta,
|
||||||
|
episodes = seasonEpisodes,
|
||||||
|
areCurrentlyWatched = isSeasonWatched,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TrailerPlayerPopup(
|
||||||
|
visible = selectedTrailer != null,
|
||||||
|
trailerTitle = selectedTrailer?.displayName ?: selectedTrailer?.name.orEmpty(),
|
||||||
|
trailerType = selectedTrailer?.type.orEmpty(),
|
||||||
|
contentTitle = meta.name,
|
||||||
|
playbackSource = trailerPlaybackSource,
|
||||||
|
isLoading = trailerLoading,
|
||||||
|
errorMessage = trailerErrorMessage,
|
||||||
|
onDismiss = {
|
||||||
|
trailerRequestToken += 1
|
||||||
|
trailerLoading = false
|
||||||
|
trailerPlaybackSource = null
|
||||||
|
trailerErrorMessage = null
|
||||||
|
selectedTrailer = null
|
||||||
|
},
|
||||||
|
onRetry = selectedTrailer?.let { trailer ->
|
||||||
|
{ resolveTrailer(trailer) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
TraktListPickerDialog(
|
||||||
|
visible = showLibraryListPicker,
|
||||||
|
title = meta.name,
|
||||||
|
tabs = pickerTabs,
|
||||||
|
membership = pickerMembership,
|
||||||
|
isPending = pickerPending,
|
||||||
|
errorMessage = pickerError,
|
||||||
|
onToggle = { listKey ->
|
||||||
|
pickerMembership = pickerMembership.toMutableMap().apply {
|
||||||
|
this[listKey] = !(this[listKey] == true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
if (!pickerPending) {
|
||||||
|
showLibraryListPicker = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSave = {
|
||||||
|
detailsScope.launch {
|
||||||
|
pickerPending = true
|
||||||
|
pickerError = null
|
||||||
|
runCatching {
|
||||||
|
LibraryRepository.applyMembershipChanges(
|
||||||
|
item = meta.toLibraryItem(savedAtEpochMs = 0L),
|
||||||
|
desiredMembership = pickerMembership,
|
||||||
|
)
|
||||||
|
}.onSuccess {
|
||||||
|
showLibraryListPicker = false
|
||||||
|
}.onFailure { error ->
|
||||||
|
pickerError = error.message ?: "Failed to update Trakt lists"
|
||||||
|
}
|
||||||
|
pickerPending = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -621,6 +634,13 @@ fun MetaDetailsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp =
|
||||||
|
if (!isTablet) {
|
||||||
|
maxWidth
|
||||||
|
} else {
|
||||||
|
(maxWidth * 0.6f).coerceIn(520.dp, 680.dp)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
private fun TraktListPickerDialog(
|
private fun TraktListPickerDialog(
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -30,6 +31,7 @@ fun DetailActionButtons(
|
||||||
playLabel: String = "Play",
|
playLabel: String = "Play",
|
||||||
saveLabel: String = "Save",
|
saveLabel: String = "Save",
|
||||||
isSaved: Boolean = false,
|
isSaved: Boolean = false,
|
||||||
|
isTablet: Boolean = false,
|
||||||
onPlayClick: () -> Unit = {},
|
onPlayClick: () -> Unit = {},
|
||||||
onSaveClick: () -> Unit = {},
|
onSaveClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
|
@ -38,12 +40,22 @@ fun DetailActionButtons(
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = if (isTablet) {
|
||||||
|
Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally)
|
||||||
|
} else {
|
||||||
|
Arrangement.spacedBy(12.dp)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onPlayClick,
|
onClick = onPlayClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.then(
|
||||||
|
if (isTablet) {
|
||||||
|
Modifier.width(220.dp)
|
||||||
|
} else {
|
||||||
|
Modifier.weight(1f)
|
||||||
|
}
|
||||||
|
)
|
||||||
.height(50.dp),
|
.height(50.dp),
|
||||||
shape = RoundedCornerShape(40.dp),
|
shape = RoundedCornerShape(40.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
|
@ -68,7 +80,13 @@ fun DetailActionButtons(
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = onSaveClick,
|
onClick = onSaveClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.then(
|
||||||
|
if (isTablet) {
|
||||||
|
Modifier.width(220.dp)
|
||||||
|
} else {
|
||||||
|
Modifier.weight(1f)
|
||||||
|
}
|
||||||
|
)
|
||||||
.height(50.dp),
|
.height(50.dp),
|
||||||
shape = RoundedCornerShape(40.dp),
|
shape = RoundedCornerShape(40.dp),
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@ package com.nuvio.app.features.details.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -19,6 +20,7 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
|
@ -27,97 +29,115 @@ import com.nuvio.app.features.details.MetaDetails
|
||||||
@Composable
|
@Composable
|
||||||
fun DetailHero(
|
fun DetailHero(
|
||||||
meta: MetaDetails,
|
meta: MetaDetails,
|
||||||
|
isTablet: Boolean = false,
|
||||||
scrollOffset: Int = 0,
|
scrollOffset: Int = 0,
|
||||||
|
contentMaxWidth: Dp = 560.dp,
|
||||||
onHeightChanged: (Int) -> Unit = {},
|
onHeightChanged: (Int) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(
|
BoxWithConstraints(
|
||||||
modifier = modifier
|
modifier = modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
|
||||||
.aspectRatio(0.75f)
|
|
||||||
.onSizeChanged { onHeightChanged(it.height) }
|
|
||||||
.graphicsLayer {
|
|
||||||
clip = true
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
|
val heroHeight = detailHeroHeight(maxWidth, isTablet)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize(),
|
.fillMaxWidth()
|
||||||
contentAlignment = Alignment.BottomCenter,
|
.height(heroHeight)
|
||||||
|
.onSizeChanged { onHeightChanged(it.height) }
|
||||||
|
.graphicsLayer {
|
||||||
|
clip = true
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
val imageUrl = meta.background ?: meta.poster
|
|
||||||
if (imageUrl != null) {
|
|
||||||
AsyncImage(
|
|
||||||
model = imageUrl,
|
|
||||||
contentDescription = meta.name,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.graphicsLayer {
|
|
||||||
translationY = scrollOffset * 0.5f
|
|
||||||
scaleX = 1.08f
|
|
||||||
scaleY = 1.08f
|
|
||||||
},
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.surface),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxSize(),
|
||||||
.height(260.dp)
|
contentAlignment = Alignment.BottomCenter,
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.Transparent,
|
|
||||||
MaterialTheme.colorScheme.background.copy(alpha = 0.7f),
|
|
||||||
MaterialTheme.colorScheme.background,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 18.dp)
|
|
||||||
.padding(bottom = 8.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
) {
|
||||||
if (meta.logo != null) {
|
val imageUrl = meta.background ?: meta.poster
|
||||||
|
if (imageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = meta.logo,
|
model = imageUrl,
|
||||||
contentDescription = "${meta.name} logo",
|
contentDescription = meta.name,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(0.6f)
|
.fillMaxSize()
|
||||||
.height(80.dp),
|
.graphicsLayer {
|
||||||
contentScale = ContentScale.Fit,
|
translationY = scrollOffset * 0.5f
|
||||||
|
scaleX = 1.08f
|
||||||
|
scaleY = 1.08f
|
||||||
|
},
|
||||||
|
alignment = if (isTablet) Alignment.TopCenter else Alignment.Center,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Box(
|
||||||
text = meta.name,
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.displayLarge,
|
.fillMaxSize()
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meta.genres.isNotEmpty()) {
|
Box(
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
modifier = Modifier
|
||||||
Text(
|
.fillMaxWidth()
|
||||||
text = meta.genres.take(3).joinToString(" \u2022 "),
|
.height(260.dp)
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
.align(Alignment.BottomCenter)
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
.background(
|
||||||
textAlign = TextAlign.Center,
|
Brush.verticalGradient(
|
||||||
)
|
colors = listOf(
|
||||||
|
Color.Transparent,
|
||||||
|
MaterialTheme.colorScheme.background.copy(alpha = 0.7f),
|
||||||
|
MaterialTheme.colorScheme.background,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = if (isTablet) 32.dp else 18.dp)
|
||||||
|
.padding(bottom = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
if (meta.logo != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = meta.logo,
|
||||||
|
contentDescription = "${meta.name} logo",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(if (isTablet) 0.56f else 0.6f)
|
||||||
|
.widthIn(max = contentMaxWidth)
|
||||||
|
.height(if (isTablet) 72.dp else 80.dp),
|
||||||
|
alignment = Alignment.Center,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = meta.name,
|
||||||
|
style = if (isTablet) MaterialTheme.typography.displaySmall else MaterialTheme.typography.displayLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.genres.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = meta.genres.take(3).joinToString(" \u2022 "),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun detailHeroHeight(maxWidth: Dp, isTablet: Boolean): Dp =
|
||||||
|
if (!isTablet) {
|
||||||
|
(maxWidth * 1.33f).coerceIn(420.dp, 760.dp)
|
||||||
|
} else {
|
||||||
|
(maxWidth * 0.42f).coerceIn(300.dp, 420.dp)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue