mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-18 05:15:41 +00:00
feat(android): metahero trailer playback
This commit is contained in:
parent
53bd43963b
commit
150081f832
22 changed files with 608 additions and 5 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
Loading…
Reference in a new issue