diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt index b4b1a1aa8..996401f6b 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt @@ -4,6 +4,7 @@ actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = true actual val p2pEnabled: Boolean = true actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP + actual val heroTrailerPlaybackSupported: Boolean = true actual val inAppUpdaterEnabled: Boolean = true actual val imdbRatingLogoEnabled: Boolean = true } diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.android.kt new file mode 100644 index 000000000..27435ef34 --- /dev/null +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.android.kt @@ -0,0 +1,244 @@ +package com.nuvio.app.features.details.components + +import android.content.Context +import android.graphics.Matrix +import android.view.TextureView +import android.widget.FrameLayout +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.VideoSize +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.MergingMediaSource +import com.nuvio.app.features.player.PlatformPlaybackDataSourceFactory + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +actual fun HeroTrailerPlayerSurface( + sourceUrl: String, + sourceAudioUrl: String?, + playWhenReady: Boolean, + muted: Boolean, + modifier: Modifier, + onReady: () -> Unit, + onEnded: () -> Unit, + onError: () -> Unit, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val latestPlayWhenReady = rememberUpdatedState(playWhenReady) + val latestOnReady = rememberUpdatedState(onReady) + val latestOnEnded = rememberUpdatedState(onEnded) + val latestOnError = rememberUpdatedState(onError) + var playerContainer by remember { mutableStateOf(null) } + + val dataSourceFactory = remember(context) { + PlatformPlaybackDataSourceFactory.create( + context = context, + defaultRequestHeaders = emptyMap(), + defaultResponseHeaders = emptyMap(), + useYoutubeChunkedPlayback = true, + ) + } + val exoPlayer = remember(sourceUrl, sourceAudioUrl, dataSourceFactory) { + val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory) + ExoPlayer.Builder(context) + .setMediaSourceFactory(mediaSourceFactory) + .build() + .apply { + if (!sourceAudioUrl.isNullOrBlank()) { + setMediaSource( + MergingMediaSource( + mediaSourceFactory.createMediaSource(MediaItem.fromUri(sourceUrl)), + mediaSourceFactory.createMediaSource(MediaItem.fromUri(sourceAudioUrl)), + ), + ) + } else { + setMediaItem(MediaItem.fromUri(sourceUrl)) + } + repeatMode = Player.REPEAT_MODE_OFF + volume = if (muted) 0f else 1f + prepare() + } + } + + DisposableEffect(exoPlayer, lifecycleOwner) { + fun detachVideoSurface() { + playerContainer?.detachPlayer(exoPlayer) + playerContainer?.alpha = 0f + } + + val listener = object : Player.Listener { + private var readyReported = false + private var endedReported = false + + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_READY -> { + if (!readyReported) { + readyReported = true + latestOnReady.value() + } + } + Player.STATE_ENDED -> { + if (!endedReported) { + endedReported = true + latestOnEnded.value() + } + } + else -> Unit + } + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + playerContainer?.setVideoSize(videoSize) + } + + override fun onPlayerError(error: PlaybackException) { + detachVideoSurface() + latestOnError.value() + } + } + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> { + if (latestPlayWhenReady.value && exoPlayer.playbackState != Player.STATE_ENDED) { + playerContainer?.attachPlayer(exoPlayer) + playerContainer?.alpha = 1f + exoPlayer.play() + } + } + Lifecycle.Event.ON_STOP -> { + exoPlayer.pause() + detachVideoSurface() + } + else -> Unit + } + } + exoPlayer.addListener(listener) + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + exoPlayer.removeListener(listener) + detachVideoSurface() + exoPlayer.stop() + exoPlayer.release() + playerContainer = null + } + } + + LaunchedEffect(exoPlayer, playWhenReady) { + if (exoPlayer.playbackState == Player.STATE_ENDED) { + return@LaunchedEffect + } + exoPlayer.playWhenReady = playWhenReady + if (playWhenReady) { + playerContainer?.attachPlayer(exoPlayer) + playerContainer?.alpha = 1f + exoPlayer.play() + } else { + exoPlayer.pause() + } + } + + LaunchedEffect(exoPlayer, muted) { + exoPlayer.volume = if (muted) 0f else 1f + } + + AndroidView( + modifier = modifier, + factory = { viewContext -> + HeroTrailerTextureContainer(viewContext).apply { + layoutParams = android.view.ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) + attachPlayer(exoPlayer) + playerContainer = this + } + }, + update = { container -> + playerContainer = container + if (playWhenReady) { + container.attachPlayer(exoPlayer) + } + }, + ) +} + +private class HeroTrailerTextureContainer( + context: Context, +) : FrameLayout(context) { + private val textureView = TextureView(context) + private val textureTransform = Matrix() + private var videoAspectRatio = 16f / 9f + private var attachedPlayer: ExoPlayer? = null + + init { + clipChildren = true + clipToPadding = true + isFocusable = false + addView( + textureView, + LayoutParams(MATCH_PARENT, MATCH_PARENT), + ) + textureView.isFocusable = false + textureView.isClickable = false + } + + fun attachPlayer(player: ExoPlayer) { + if (attachedPlayer === player) return + attachedPlayer?.clearVideoTextureView(textureView) + attachedPlayer = player + player.setVideoTextureView(textureView) + } + + fun detachPlayer(player: ExoPlayer) { + if (attachedPlayer === player) { + player.clearVideoTextureView(textureView) + attachedPlayer = null + } + } + + fun setVideoSize(videoSize: VideoSize) { + if (videoSize.width <= 0 || videoSize.height <= 0) return + videoAspectRatio = videoSize.width * videoSize.pixelWidthHeightRatio / videoSize.height + updateTextureTransform() + } + + override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { + super.onSizeChanged(width, height, oldWidth, oldHeight) + updateTextureTransform() + } + + private fun updateTextureTransform() { + val viewWidth = width.toFloat() + val viewHeight = height.toFloat() + if (viewWidth <= 0f || viewHeight <= 0f || videoAspectRatio <= 0f) return + + val viewAspectRatio = viewWidth / viewHeight + textureTransform.reset() + if (viewAspectRatio > videoAspectRatio) { + val scaleY = viewAspectRatio / videoAspectRatio + textureTransform.setScale(1f, scaleY, viewWidth / 2f, viewHeight / 2f) + } else { + val scaleX = videoAspectRatio / viewAspectRatio + textureTransform.setScale(scaleX, 1f, viewWidth / 2f, viewHeight / 2f) + } + textureView.setTransform(textureTransform) + } +} diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt index 6493ef481..2b308a232 100644 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt @@ -4,6 +4,7 @@ actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = false actual val p2pEnabled: Boolean = true actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL + actual val heroTrailerPlaybackSupported: Boolean = false actual val inAppUpdaterEnabled: Boolean = false actual val imdbRatingLogoEnabled: Boolean = false } diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.android.kt new file mode 100644 index 000000000..85aeb5016 --- /dev/null +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.android.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.details.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier + +@Composable +actual fun HeroTrailerPlayerSurface( + sourceUrl: String, + sourceAudioUrl: String?, + playWhenReady: Boolean, + muted: Boolean, + modifier: Modifier, + onReady: () -> Unit, + onEnded: () -> Unit, + onError: () -> Unit, +) { + LaunchedEffect(sourceUrl) { + onError() + } +} diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index ceb6d614b..fd8226bfc 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -710,6 +710,8 @@ Reviews from Trakt Details Runtime, status, release, language, and related info. + Hero Trailer Playback + Play trailer previews in the metadata hero when a trailer is available. Episode Cards Choose how episodes are rendered on the metadata screen. Horizontal diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt index 3c82f754e..8e62eb8ed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt @@ -9,6 +9,7 @@ expect object AppFeaturePolicy { val pluginsEnabled: Boolean val p2pEnabled: Boolean val trailerPlaybackMode: TrailerPlaybackMode + val heroTrailerPlaybackSupported: Boolean val inAppUpdaterEnabled: Boolean val imdbRatingLogoEnabled: Boolean } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/HeroTrailerAudioState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/HeroTrailerAudioState.kt new file mode 100644 index 000000000..b0b3a4472 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/HeroTrailerAudioState.kt @@ -0,0 +1,14 @@ +package com.nuvio.app.features.details + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +object HeroTrailerAudioState { + private val _muted = MutableStateFlow(true) + val muted: StateFlow = _muted.asStateFlow() + + fun toggleMuted() { + _muted.value = !_muted.value + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/HeroTrailerSelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/HeroTrailerSelector.kt new file mode 100644 index 000000000..4edd89514 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/HeroTrailerSelector.kt @@ -0,0 +1,36 @@ +package com.nuvio.app.features.details + +internal fun selectHeroTrailer(trailers: List): MetaTrailer? = + trailers + .asSequence() + .filter { it.isPlayableYouTubeTrailerCandidate() } + .maxWithOrNull( + compareBy( + { it.heroTrailerPriority() }, + { it.publishedAt.orEmpty() }, + { it.size ?: 0 }, + { it.name }, + ), + ) + +internal fun MetaTrailer.youtubePlaybackUrl(): String = + key.takeIf { it.startsWith("http://") || it.startsWith("https://") } + ?: "https://www.youtube.com/watch?v=$key" + +private fun MetaTrailer.isPlayableYouTubeTrailerCandidate(): Boolean = + key.isNotBlank() && site.equals("YouTube", ignoreCase = true) + +private fun MetaTrailer.heroTrailerPriority(): Int { + val isSeriesTrailer = seasonNumber != null + val isTrailerType = type.equals("Trailer", ignoreCase = true) + return when { + !isSeriesTrailer && isTrailerType && official -> 70 + !isSeriesTrailer && isTrailerType -> 60 + !isSeriesTrailer && official -> 50 + !isSeriesTrailer -> 40 + isTrailerType && official -> 30 + isTrailerType -> 20 + official -> 10 + else -> 0 + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 0edab5d08..66ec4c1f7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -472,11 +472,42 @@ fun MetaDetailsScreen( var trailerLoading by remember(meta.id) { mutableStateOf(false) } var trailerErrorMessage by remember(meta.id) { mutableStateOf(null) } var trailerRequestToken by remember(meta.id) { mutableIntStateOf(0) } + var isLeavingDetails by remember(meta.id) { mutableStateOf(false) } + val heroTrailerCandidate = remember(meta.trailers) { + selectHeroTrailer(meta.trailers) + } + val heroTrailerPlaybackEnabled = AppFeaturePolicy.heroTrailerPlaybackSupported && + inAppTrailerPlaybackEnabled && + metaScreenSettingsUiState.heroTrailerPlayback + var heroTrailerPlaybackSource by remember(meta.id, heroTrailerCandidate?.id) { mutableStateOf(null) } + var heroTrailerReady by remember(meta.id, heroTrailerCandidate?.id) { mutableStateOf(false) } + var heroTrailerFinished by remember(meta.id, heroTrailerCandidate?.id) { mutableStateOf(false) } + val heroTrailerMuted by HeroTrailerAudioState.muted.collectAsStateWithLifecycle() + LaunchedEffect(heroTrailerPlaybackEnabled, heroTrailerCandidate?.id, heroTrailerCandidate?.key) { + heroTrailerPlaybackSource = null + heroTrailerReady = false + heroTrailerFinished = false + if (!heroTrailerPlaybackEnabled || heroTrailerCandidate == null) { + return@LaunchedEffect + } + val resolvedSource = runCatching { + TrailerPlaybackResolver.resolveFromYouTubeUrl(heroTrailerCandidate.youtubePlaybackUrl()) + }.getOrNull() + if (resolvedSource == null) { + heroTrailerFinished = true + } else { + heroTrailerPlaybackSource = resolvedSource + } + } + val onBackFromDetails: () -> Unit = { + isLeavingDetails = true + heroTrailerReady = false + heroTrailerFinished = true + onBack() + } val resolveTrailer: (MetaTrailer) -> Unit = remember(meta.id, inAppTrailerPlaybackEnabled, uriHandler) { { trailer -> - val youtubeUrl = trailer.key.takeIf { - it.startsWith("http://") || it.startsWith("https://") - } ?: "https://www.youtube.com/watch?v=${trailer.key}" + val youtubeUrl = trailer.youtubePlaybackUrl() if (!inAppTrailerPlaybackEnabled) { runCatching { uriHandler.openUri(youtubeUrl) } } else { @@ -672,6 +703,15 @@ fun MetaDetailsScreen( var heroHeightPx by remember(meta.id) { mutableIntStateOf(0) } val thresholdPx = (heroHeightPx - safeAreaTopPx).coerceAtLeast(0f) val headerTarget = if (heroHeightPx > 0 && scrollState.value > thresholdPx) 1f else 0f + val heroTrailerSourceUrl = heroTrailerPlaybackSource + ?.videoUrl + ?.takeIf { it.isNotBlank() && heroTrailerPlaybackEnabled && !heroTrailerFinished && !isLeavingDetails } + val heroTrailerSourceAudioUrl = heroTrailerPlaybackSource + ?.audioUrl + ?.takeIf { heroTrailerSourceUrl != null && it.isNotBlank() } + val heroTrailerPlayWhenReady = heroTrailerSourceUrl != null && + !isLeavingDetails && + (heroHeightPx == 0 || scrollState.value <= thresholdPx) val headerProgress by animateFloatAsState( targetValue = headerTarget, animationSpec = tween( @@ -718,6 +758,27 @@ fun MetaDetailsScreen( contentMaxWidth = contentMaxWidth, scrollOffset = scrollState.value, onHeightChanged = { heroHeightPx = it }, + heroTrailerSourceUrl = heroTrailerSourceUrl, + heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl, + heroTrailerReady = heroTrailerReady, + heroTrailerPlayWhenReady = heroTrailerPlayWhenReady, + heroTrailerMuted = heroTrailerMuted, + onHeroTrailerMuteToggle = { + HeroTrailerAudioState.toggleMuted() + }, + onHeroTrailerReady = { + if (!heroTrailerFinished) { + heroTrailerReady = true + } + }, + onHeroTrailerEnded = { + heroTrailerReady = false + heroTrailerFinished = true + }, + onHeroTrailerError = { + heroTrailerReady = false + heroTrailerFinished = true + }, ) Column( @@ -831,7 +892,7 @@ fun MetaDetailsScreen( if (headerProgress <= 0.05f) { NuvioBackButton( - onClick = onBack, + onClick = onBackFromDetails, modifier = Modifier.padding( start = 12.dp, top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp, @@ -845,7 +906,7 @@ fun MetaDetailsScreen( meta = meta, isSaved = isSaved, progress = headerProgress, - onBack = onBack, + onBack = onBackFromDetails, onToggleSaved = toggleSaved, modifier = Modifier.zIndex(2f), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt index 8d4f8c0fe..f056bbe07 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt @@ -43,6 +43,7 @@ data class MetaScreenSectionItem( data class MetaScreenSettingsUiState( val items: List = emptyList(), val cinematicBackground: Boolean = false, + val heroTrailerPlayback: Boolean = false, val tabLayout: Boolean = false, val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, val blurUnwatchedEpisodes: Boolean = false, @@ -79,6 +80,8 @@ private data class StoredMetaScreenSectionPreference( private data class StoredMetaScreenSettingsPayload( val items: List = emptyList(), val cinematicBackground: Boolean = false, + @SerialName("hero_trailer_playback") + val heroTrailerPlayback: Boolean = false, @SerialName("tvStyleLayout") val tabLayout: Boolean = false, val episodeCardStyle: String = "horizontal", @@ -157,6 +160,7 @@ object MetaScreenSettingsRepository { private var hasLoaded = false private var preferences: MutableMap = mutableMapOf() private var cinematicBackground: Boolean = false + private var heroTrailerPlayback: Boolean = false private var tabLayout: Boolean = false private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal private var blurUnwatchedEpisodes: Boolean = false @@ -173,6 +177,7 @@ object MetaScreenSettingsRepository { }.getOrNull() if (parsed != null) { cinematicBackground = parsed.cinematicBackground + heroTrailerPlayback = parsed.heroTrailerPlayback tabLayout = parsed.tabLayout episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle) ?: MetaEpisodeCardStyle.Horizontal @@ -193,6 +198,7 @@ object MetaScreenSettingsRepository { hasLoaded = false preferences.clear() cinematicBackground = false + heroTrailerPlayback = false tabLayout = false episodeCardStyle = MetaEpisodeCardStyle.Horizontal blurUnwatchedEpisodes = false @@ -207,6 +213,13 @@ object MetaScreenSettingsRepository { persist() } + fun setHeroTrailerPlayback(enabled: Boolean) { + ensureLoaded() + heroTrailerPlayback = enabled + publish() + persist() + } + fun setTabLayout(enabled: Boolean) { ensureLoaded() tabLayout = enabled @@ -245,6 +258,7 @@ object MetaScreenSettingsRepository { hasLoaded = false preferences.clear() cinematicBackground = false + heroTrailerPlayback = false tabLayout = false episodeCardStyle = MetaEpisodeCardStyle.Horizontal blurUnwatchedEpisodes = false @@ -254,12 +268,14 @@ object MetaScreenSettingsRepository { internal fun applyFromSync( items: List, cinematicBackground: Boolean, + heroTrailerPlayback: Boolean = false, tabLayout: Boolean, episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, blurUnwatchedEpisodes: Boolean = false, ) { ensureLoaded() this.cinematicBackground = cinematicBackground + this.heroTrailerPlayback = heroTrailerPlayback this.tabLayout = tabLayout this.episodeCardStyle = episodeCardStyle this.blurUnwatchedEpisodes = blurUnwatchedEpisodes @@ -286,6 +302,7 @@ object MetaScreenSettingsRepository { ensureLoaded() preferences.clear() cinematicBackground = false + heroTrailerPlayback = false tabLayout = false episodeCardStyle = MetaEpisodeCardStyle.Horizontal blurUnwatchedEpisodes = false @@ -353,6 +370,7 @@ object MetaScreenSettingsRepository { ) }, cinematicBackground = cinematicBackground, + heroTrailerPlayback = heroTrailerPlayback, tabLayout = tabLayout, episodeCardStyle = episodeCardStyle, blurUnwatchedEpisodes = blurUnwatchedEpisodes, @@ -365,6 +383,7 @@ object MetaScreenSettingsRepository { StoredMetaScreenSettingsPayload( items = preferences.values.sortedBy { it.order }, cinematicBackground = cinematicBackground, + heroTrailerPlayback = heroTrailerPlayback, tabLayout = tabLayout, episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle), blurUnwatchedEpisodes = blurUnwatchedEpisodes, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt index 4c60ee240..51e6181b3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt @@ -1,6 +1,10 @@ package com.nuvio.app.features.details.components +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -13,6 +17,8 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -35,12 +41,26 @@ fun DetailHero( scrollOffset: Int = 0, contentMaxWidth: Dp = 560.dp, onHeightChanged: (Int) -> Unit = {}, + heroTrailerSourceUrl: String? = null, + heroTrailerSourceAudioUrl: String? = null, + heroTrailerReady: Boolean = false, + heroTrailerPlayWhenReady: Boolean = false, + heroTrailerMuted: Boolean = true, + onHeroTrailerMuteToggle: () -> Unit = {}, + onHeroTrailerReady: () -> Unit = {}, + onHeroTrailerEnded: () -> Unit = {}, + onHeroTrailerError: () -> Unit = {}, modifier: Modifier = Modifier, ) { BoxWithConstraints( modifier = modifier.fillMaxWidth(), ) { val heroHeight = detailHeroHeight(maxWidth, isTablet) + val trailerAlpha by animateFloatAsState( + targetValue = if (heroTrailerReady) 1f else 0f, + animationSpec = tween(durationMillis = 300), + label = "detail_hero_trailer_alpha", + ) Box( modifier = Modifier @@ -78,6 +98,35 @@ fun DetailHero( .background(MaterialTheme.colorScheme.surface), ) } + if (heroTrailerSourceUrl != null) { + HeroTrailerPlayerSurface( + sourceUrl = heroTrailerSourceUrl, + sourceAudioUrl = heroTrailerSourceAudioUrl, + playWhenReady = heroTrailerPlayWhenReady, + muted = heroTrailerMuted, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = trailerAlpha + translationY = scrollOffset * 0.5f + scaleX = 1.08f + scaleY = 1.08f + }, + onReady = onHeroTrailerReady, + onEnded = onHeroTrailerEnded, + onError = onHeroTrailerError, + ) + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + enabled = heroTrailerReady, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onHeroTrailerMuteToggle, + ), + ) + } Box( modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.kt new file mode 100644 index 000000000..cdfd68e9b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.details.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +expect fun HeroTrailerPlayerSurface( + sourceUrl: String, + sourceAudioUrl: String?, + playWhenReady: Boolean, + muted: Boolean, + modifier: Modifier, + onReady: () -> Unit, + onEnded: () -> Unit, + onError: () -> Unit, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index 72a3b86fe..ae78fb8d3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -10,6 +10,7 @@ interface PlayerEngineController { fun seekBy(offsetMs: Long) fun retry() fun setPlaybackSpeed(speed: Float) + fun setMuted(muted: Boolean) {} fun getAudioTracks(): List fun getSubtitleTracks(): List fun selectAudioTrack(index: Int) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt index ac932b93b..ab7a57eeb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt @@ -47,6 +47,8 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.nuvio.app.core.build.AppFeaturePolicy +import com.nuvio.app.core.build.TrailerPlaybackMode import com.nuvio.app.core.ui.NuvioActionLabel import com.nuvio.app.features.details.MetaEpisodeCardStyle import com.nuvio.app.features.details.MetaScreenSectionItem @@ -70,6 +72,8 @@ import nuvio.composeapp.generated.resources.settings_meta_comments import nuvio.composeapp.generated.resources.settings_meta_comments_description import nuvio.composeapp.generated.resources.settings_meta_details import nuvio.composeapp.generated.resources.settings_meta_details_description +import nuvio.composeapp.generated.resources.settings_meta_hero_trailer_playback +import nuvio.composeapp.generated.resources.settings_meta_hero_trailer_playback_description import nuvio.composeapp.generated.resources.settings_meta_episode_cards import nuvio.composeapp.generated.resources.settings_meta_episode_cards_description import nuvio.composeapp.generated.resources.settings_meta_episode_style_horizontal @@ -105,6 +109,8 @@ internal fun LazyListScope.metaScreenSettingsContent( isTablet: Boolean, uiState: MetaScreenSettingsUiState, ) { + val showHeroTrailerPlaybackSetting = AppFeaturePolicy.heroTrailerPlaybackSupported && + AppFeaturePolicy.trailerPlaybackMode == TrailerPlaybackMode.IN_APP item { SettingsSection( title = stringResource(Res.string.settings_meta_section_appearance), @@ -118,6 +124,16 @@ internal fun LazyListScope.metaScreenSettingsContent( isTablet = isTablet, onCheckedChange = { MetaScreenSettingsRepository.setCinematicBackground(it) }, ) + if (showHeroTrailerPlaybackSetting) { + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_meta_hero_trailer_playback), + description = stringResource(Res.string.settings_meta_hero_trailer_playback_description), + checked = uiState.heroTrailerPlayback, + isTablet = isTablet, + onCheckedChange = { MetaScreenSettingsRepository.setHeroTrailerPlayback(it) }, + ) + } SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( title = stringResource(Res.string.settings_meta_tab_layout), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/HeroTrailerSelectorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/HeroTrailerSelectorTest.kt new file mode 100644 index 000000000..1bd938ce6 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/HeroTrailerSelectorTest.kt @@ -0,0 +1,76 @@ +package com.nuvio.app.features.details + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class HeroTrailerSelectorTest { + + @Test + fun `selects official non-season YouTube trailer first`() { + val trailers = listOf( + trailer(id = "teaser", type = "Teaser", official = true, publishedAt = "2026-02-01"), + trailer(id = "season", type = "Trailer", official = true, seasonNumber = 2, publishedAt = "2026-03-01"), + trailer(id = "official", type = "Trailer", official = true, publishedAt = "2026-01-01"), + ) + + assertEquals("official", selectHeroTrailer(trailers)?.id) + } + + @Test + fun `falls back to addon-style YouTube trailer when official flag is absent`() { + val trailers = listOf( + trailer(id = "clip", type = "Clip", official = false, publishedAt = "2026-02-01"), + trailer(id = "addon", type = "Trailer", official = false, publishedAt = "2026-01-01"), + ) + + assertEquals("addon", selectHeroTrailer(trailers)?.id) + } + + @Test + fun `uses season trailer when only season trailers are available`() { + val trailers = listOf( + trailer(id = "season-one", type = "Trailer", official = false, seasonNumber = 1), + trailer(id = "season-two", type = "Trailer", official = true, seasonNumber = 2), + ) + + assertEquals("season-two", selectHeroTrailer(trailers)?.id) + } + + @Test + fun `filters out non YouTube trailers`() { + val trailers = listOf( + trailer(id = "vimeo", site = "Vimeo", type = "Trailer", official = true), + trailer(id = "youtube", site = "YouTube", type = "Teaser", official = false), + ) + + assertEquals("youtube", selectHeroTrailer(trailers)?.id) + } + + @Test + fun `returns null for empty or unsupported trailer lists`() { + assertNull(selectHeroTrailer(emptyList())) + assertNull(selectHeroTrailer(listOf(trailer(id = "blank", key = "")))) + assertNull(selectHeroTrailer(listOf(trailer(id = "other", site = "Vimeo")))) + } + + private fun trailer( + id: String, + key: String = id, + site: String = "YouTube", + type: String = "Trailer", + official: Boolean = false, + publishedAt: String? = null, + seasonNumber: Int? = null, + ): MetaTrailer = + MetaTrailer( + id = id, + key = key, + name = id, + site = site, + type = type, + official = official, + publishedAt = publishedAt, + seasonNumber = seasonNumber, + ) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt index ce51ea36e..2dda9682e 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt @@ -4,6 +4,7 @@ actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = false actual val p2pEnabled: Boolean = false actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL + actual val heroTrailerPlaybackSupported: Boolean = false actual val inAppUpdaterEnabled: Boolean = false actual val imdbRatingLogoEnabled: Boolean = true } diff --git a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt index 99fa511da..5f7b3114f 100644 --- a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt +++ b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt @@ -4,6 +4,7 @@ actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = false actual val p2pEnabled: Boolean = false actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL + actual val heroTrailerPlaybackSupported: Boolean = false actual val inAppUpdaterEnabled: Boolean = false actual val imdbRatingLogoEnabled: Boolean = false } diff --git a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.ios.kt b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.ios.kt new file mode 100644 index 000000000..85aeb5016 --- /dev/null +++ b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.ios.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.details.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier + +@Composable +actual fun HeroTrailerPlayerSurface( + sourceUrl: String, + sourceAudioUrl: String?, + playWhenReady: Boolean, + muted: Boolean, + modifier: Modifier, + onReady: () -> Unit, + onEnded: () -> Unit, + onError: () -> Unit, +) { + LaunchedEffect(sourceUrl) { + onError() + } +} diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt index a73b50c07..670f8092a 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt @@ -4,6 +4,7 @@ actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = true actual val p2pEnabled: Boolean = false actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP + actual val heroTrailerPlaybackSupported: Boolean = false actual val inAppUpdaterEnabled: Boolean = false actual val imdbRatingLogoEnabled: Boolean = true } diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.ios.kt new file mode 100644 index 000000000..3fe719d52 --- /dev/null +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.ios.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.details.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +actual fun HeroTrailerPlayerSurface( + sourceUrl: String, + sourceAudioUrl: String?, + playWhenReady: Boolean, + muted: Boolean, + modifier: Modifier, + onReady: () -> Unit, + onEnded: () -> Unit, + onError: () -> Unit, +) = Unit diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt index 423c7ba75..20f1f6fad 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt @@ -31,6 +31,7 @@ interface NuvioPlayerBridge { gamma: Int, ) fun setPlaybackSpeed(speed: Float) + fun setMuted(muted: Boolean) fun setResizeMode(mode: Int) // 0=Fit, 1=Fill, 2=Zoom fun getAudioTrackCount(): Int fun getAudioTrackIndex(at: Int): Int diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 1a980a2b5..5fa4ffe74 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -87,6 +87,10 @@ actual fun PlatformPlayerSurface( bridge.setPlaybackSpeed(speed) } + override fun setMuted(muted: Boolean) { + bridge.setMuted(muted) + } + override fun getAudioTracks(): List { val count = bridge.getAudioTrackCount() return (0 until count).map { i ->