feat(android): metahero trailer playback

This commit is contained in:
tapframe 2026-06-04 01:01:15 +05:30
parent 53bd43963b
commit 150081f832
22 changed files with 608 additions and 5 deletions

View file

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

View file

@ -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<HeroTrailerTextureContainer?>(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)
}
}

View file

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

View file

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

View file

@ -710,6 +710,8 @@
<string name="settings_meta_comments_description">Reviews from Trakt</string>
<string name="settings_meta_details">Details</string>
<string name="settings_meta_details_description">Runtime, status, release, language, and related info.</string>
<string name="settings_meta_hero_trailer_playback">Hero Trailer Playback</string>
<string name="settings_meta_hero_trailer_playback_description">Play trailer previews in the metadata hero when a trailer is available.</string>
<string name="settings_meta_episode_cards">Episode Cards</string>
<string name="settings_meta_episode_cards_description">Choose how episodes are rendered on the metadata screen.</string>
<string name="settings_meta_episode_style_horizontal">Horizontal</string>

View file

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

View file

@ -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<Boolean> = _muted.asStateFlow()
fun toggleMuted() {
_muted.value = !_muted.value
}
}

View file

@ -0,0 +1,36 @@
package com.nuvio.app.features.details
internal fun selectHeroTrailer(trailers: List<MetaTrailer>): MetaTrailer? =
trailers
.asSequence()
.filter { it.isPlayableYouTubeTrailerCandidate() }
.maxWithOrNull(
compareBy<MetaTrailer>(
{ 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
}
}

View file

@ -472,11 +472,42 @@ fun MetaDetailsScreen(
var trailerLoading by remember(meta.id) { mutableStateOf(false) }
var trailerErrorMessage by remember(meta.id) { mutableStateOf<String?>(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<TrailerPlaybackSource?>(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),
)

View file

@ -43,6 +43,7 @@ data class MetaScreenSectionItem(
data class MetaScreenSettingsUiState(
val items: List<MetaScreenSectionItem> = 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<StoredMetaScreenSectionPreference> = 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<MetaScreenSectionKey, StoredMetaScreenSectionPreference> = 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<MetaScreenSectionItem>,
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,

View file

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

View file

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

View file

@ -10,6 +10,7 @@ interface PlayerEngineController {
fun seekBy(offsetMs: Long)
fun retry()
fun setPlaybackSpeed(speed: Float)
fun setMuted(muted: Boolean) {}
fun getAudioTracks(): List<AudioTrack>
fun getSubtitleTracks(): List<SubtitleTrack>
fun selectAudioTrack(index: Int)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -87,6 +87,10 @@ actual fun PlatformPlayerSurface(
bridge.setPlaybackSpeed(speed)
}
override fun setMuted(muted: Boolean) {
bridge.setMuted(muted)
}
override fun getAudioTracks(): List<AudioTrack> {
val count = bridge.getAudioTrackCount()
return (0 until count).map { i ->