diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index c8467df71..46e7f87b3 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -31,6 +31,10 @@ kotlin { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.activity.compose) implementation(libs.ktor.client.android) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.exoplayer.dash) + implementation(libs.androidx.media3.exoplayer.hls) + implementation(libs.androidx.media3.ui) } commonMain.dependencies { implementation(libs.coil.compose) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt new file mode 100644 index 000000000..c48119fde --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -0,0 +1,169 @@ +package com.nuvio.app.features.player + +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.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.platform.LocalContext +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.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +actual fun PlatformPlayerSurface( + sourceUrl: String, + modifier: Modifier, + playWhenReady: Boolean, + resizeMode: PlayerResizeMode, + onControllerReady: (PlayerEngineController) -> Unit, + onSnapshot: (PlayerPlaybackSnapshot) -> Unit, + onError: (String?) -> Unit, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val latestOnSnapshot = rememberUpdatedState(onSnapshot) + val latestOnError = rememberUpdatedState(onError) + val exoPlayer = remember(sourceUrl) { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(sourceUrl)) + prepare() + this.playWhenReady = playWhenReady + } + } + + DisposableEffect(exoPlayer) { + val listener = object : Player.Listener { + override fun onPlayerError(error: PlaybackException) { + latestOnError.value(error.localizedMessage ?: "Unable to play this stream.") + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY) { + latestOnError.value(null) + } + latestOnSnapshot.value(exoPlayer.snapshot()) + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + latestOnSnapshot.value(exoPlayer.snapshot()) + } + + override fun onPlaybackParametersChanged(playbackParameters: androidx.media3.common.PlaybackParameters) { + latestOnSnapshot.value(exoPlayer.snapshot()) + } + } + exoPlayer.addListener(listener) + onDispose { + exoPlayer.removeListener(listener) + } + } + + DisposableEffect(exoPlayer, lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> exoPlayer.playWhenReady = playWhenReady + Lifecycle.Event.ON_STOP -> exoPlayer.pause() + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + exoPlayer.release() + } + } + + LaunchedEffect(exoPlayer, playWhenReady) { + exoPlayer.playWhenReady = playWhenReady + latestOnSnapshot.value(exoPlayer.snapshot()) + } + + LaunchedEffect(exoPlayer) { + onControllerReady( + object : PlayerEngineController { + override fun play() { + exoPlayer.playWhenReady = true + exoPlayer.play() + } + + override fun pause() { + exoPlayer.pause() + } + + override fun seekTo(positionMs: Long) { + exoPlayer.seekTo(positionMs.coerceAtLeast(0L)) + } + + override fun seekBy(offsetMs: Long) { + exoPlayer.seekTo((exoPlayer.currentPosition + offsetMs).coerceAtLeast(0L)) + } + + override fun retry() { + exoPlayer.prepare() + exoPlayer.playWhenReady = true + } + + override fun setPlaybackSpeed(speed: Float) { + exoPlayer.setPlaybackSpeed(speed) + } + } + ) + } + + LaunchedEffect(exoPlayer) { + while (isActive) { + latestOnSnapshot.value(exoPlayer.snapshot()) + delay(250L) + } + } + + AndroidView( + modifier = modifier, + factory = { viewContext -> + PlayerView(viewContext).apply { + useController = false + layoutParams = android.view.ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) + player = exoPlayer + keepScreenOn = true + this.resizeMode = resizeMode.toExoResizeMode() + setShutterBackgroundColor(android.graphics.Color.BLACK) + } + }, + update = { playerView -> + playerView.player = exoPlayer + playerView.resizeMode = resizeMode.toExoResizeMode() + }, + ) +} + +private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot = + PlayerPlaybackSnapshot( + isLoading = playbackState == Player.STATE_IDLE || playbackState == Player.STATE_BUFFERING, + isPlaying = isPlaying, + isEnded = playbackState == Player.STATE_ENDED, + durationMs = duration.coerceAtLeast(0L), + positionMs = currentPosition.coerceAtLeast(0L), + bufferedPositionMs = bufferedPosition.coerceAtLeast(0L), + playbackSpeed = playbackParameters.speed, + ) + +private fun PlayerResizeMode.toExoResizeMode(): Int = + when (this) { + PlayerResizeMode.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT + PlayerResizeMode.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL + PlayerResizeMode.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt new file mode 100644 index 000000000..e829d1515 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt @@ -0,0 +1,53 @@ +package com.nuvio.app.features.player + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.ActivityInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +@Composable +actual fun LockPlayerToLandscape() { + val activity = LocalContext.current.findActivity() ?: return + + DisposableEffect(activity) { + val previousOrientation = activity.requestedOrientation + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + + onDispose { + activity.requestedOrientation = previousOrientation + } + } +} + +@Composable +actual fun EnterImmersivePlayerMode() { + val activity = LocalContext.current.findActivity() ?: return + + DisposableEffect(activity) { + val window = activity.window + val controller = WindowCompat.getInsetsController(window, window.decorView) + val previousBehavior = controller.systemBarsBehavior + + controller.hide(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + onDispose { + controller.show(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = previousBehavior + } + } +} + +private tailrec fun Context.findActivity(): Activity? = + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 2dc1a9882..b93745f39 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -5,8 +5,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Search @@ -24,9 +22,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.zIndex import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -42,6 +38,8 @@ import com.nuvio.app.features.details.MetaDetailsScreen import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.HomeScreen import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.player.PlayerRoute +import com.nuvio.app.features.player.PlayerScreen import com.nuvio.app.features.search.SearchScreen import com.nuvio.app.features.settings.SettingsScreen import com.nuvio.app.features.streams.StreamsRepository @@ -187,30 +185,16 @@ fun App() { } }, ) { innerPadding -> - Box( + AppScreen( + tab = selectedTab, modifier = Modifier .fillMaxSize() .padding(innerPadding), - ) { - AppScreenTab.entries.forEach { tab -> - val tabAlpha by animateFloatAsState( - targetValue = if (selectedTab == tab) 1f else 0f, - animationSpec = tween(durationMillis = 220), - label = "tab_alpha_${tab.name}", - ) - AppScreen( - tab = tab, - modifier = Modifier - .fillMaxSize() - .alpha(tabAlpha) - .zIndex(if (selectedTab == tab) 1f else 0f), - onCatalogClick = onCatalogClick, - onPosterClick = { meta -> - navController.navigate(DetailRoute(type = meta.type, id = meta.id)) - }, - ) - } - } + onCatalogClick = onCatalogClick, + onPosterClick = { meta -> + navController.navigate(DetailRoute(type = meta.type, id = meta.id)) + }, + ) } NavHost( @@ -247,6 +231,26 @@ fun App() { episodeNumber = route.episodeNumber, episodeTitle = route.episodeTitle, episodeThumbnail = route.episodeThumbnail, + onStreamSelected = { stream -> + val sourceUrl = stream.directPlaybackUrl + if (sourceUrl != null) { + navController.navigate( + PlayerRoute( + title = route.title, + sourceUrl = sourceUrl, + logo = route.logo, + poster = route.poster, + background = route.background, + seasonNumber = route.seasonNumber, + episodeNumber = route.episodeNumber, + episodeTitle = route.episodeTitle, + streamTitle = stream.streamLabel, + streamSubtitle = stream.streamSubtitle, + providerName = stream.addonName, + ) + ) + } + }, onBack = { StreamsRepository.clear() navController.popBackStack() @@ -254,6 +258,24 @@ fun App() { modifier = Modifier.fillMaxSize(), ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + PlayerScreen( + title = route.title, + sourceUrl = route.sourceUrl, + logo = route.logo, + poster = route.poster, + background = route.background, + seasonNumber = route.seasonNumber, + episodeNumber = route.episodeNumber, + episodeTitle = route.episodeTitle, + streamTitle = route.streamTitle, + streamSubtitle = route.streamSubtitle, + providerName = route.providerName, + onBack = { navController.popBackStack() }, + modifier = Modifier.fillMaxSize(), + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() CatalogScreen( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt index 64f32551e..1010cd3c2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt @@ -77,6 +77,59 @@ private val NuvioTypography = Typography( ), ) +private val NuvioTypeTokens = NuvioTypeScale( + labelXs = TextStyle( + fontSize = 11.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.Medium, + ), + labelSm = TextStyle( + fontSize = 12.sp, + lineHeight = 15.sp, + fontWeight = FontWeight.Medium, + ), + bodySm = TextStyle( + fontSize = 13.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Normal, + ), + bodyMd = TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Normal, + ), + bodyLg = TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.Medium, + ), + titleSm = TextStyle( + fontSize = 18.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.Bold, + ), + titleMd = TextStyle( + fontSize = 22.sp, + lineHeight = 26.sp, + fontWeight = FontWeight.Bold, + ), + titleLg = TextStyle( + fontSize = 28.sp, + lineHeight = 32.sp, + fontWeight = FontWeight.Bold, + ), + displaySm = TextStyle( + fontSize = 32.sp, + lineHeight = 36.sp, + fontWeight = FontWeight.ExtraBold, + ), + displayMd = TextStyle( + fontSize = 48.sp, + lineHeight = 52.sp, + fontWeight = FontWeight.ExtraBold, + ), +) + @Composable fun NuvioTheme( darkTheme: Boolean = isSystemInDarkTheme(), @@ -88,6 +141,7 @@ fun NuvioTheme( density = density.density, fontScale = 1f, ), + LocalNuvioTypeScale provides NuvioTypeTokens, ) { MaterialTheme( colorScheme = if (darkTheme) NuvioDarkColors else NuvioDarkColors, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTypeScale.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTypeScale.kt new file mode 100644 index 000000000..91ac705b4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTypeScale.kt @@ -0,0 +1,40 @@ +package com.nuvio.app.core.ui + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +data class NuvioTypeScale( + val labelXs: TextStyle, + val labelSm: TextStyle, + val bodySm: TextStyle, + val bodyMd: TextStyle, + val bodyLg: TextStyle, + val titleSm: TextStyle, + val titleMd: TextStyle, + val titleLg: TextStyle, + val displaySm: TextStyle, + val displayMd: TextStyle, +) + +internal val LocalNuvioTypeScale = staticCompositionLocalOf { + NuvioTypeScale( + labelXs = TextStyle(fontSize = 11.sp, lineHeight = 14.sp, fontWeight = FontWeight.Medium), + labelSm = TextStyle(fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.Medium), + bodySm = TextStyle(fontSize = 13.sp, lineHeight = 18.sp, fontWeight = FontWeight.Normal), + bodyMd = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal), + bodyLg = TextStyle(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.Medium), + titleSm = TextStyle(fontSize = 18.sp, lineHeight = 22.sp, fontWeight = FontWeight.Bold), + titleMd = TextStyle(fontSize = 22.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold), + titleLg = TextStyle(fontSize = 28.sp, lineHeight = 32.sp, fontWeight = FontWeight.Bold), + displaySm = TextStyle(fontSize = 32.sp, lineHeight = 36.sp, fontWeight = FontWeight.ExtraBold), + displayMd = TextStyle(fontSize = 48.sp, lineHeight = 52.sp, fontWeight = FontWeight.ExtraBold), + ) +} + +val MaterialTheme.nuvioTypeScale: NuvioTypeScale + @Composable + get() = LocalNuvioTypeScale.current 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 new file mode 100644 index 000000000..a8bca1cca --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -0,0 +1,24 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +interface PlayerEngineController { + fun play() + fun pause() + fun seekTo(positionMs: Long) + fun seekBy(offsetMs: Long) + fun retry() + fun setPlaybackSpeed(speed: Float) +} + +@Composable +expect fun PlatformPlayerSurface( + sourceUrl: String, + modifier: Modifier = Modifier, + playWhenReady: Boolean = true, + resizeMode: PlayerResizeMode = PlayerResizeMode.Fit, + onControllerReady: (PlayerEngineController) -> Unit, + onSnapshot: (PlayerPlaybackSnapshot) -> Unit, + onError: (String?) -> Unit, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt new file mode 100644 index 000000000..219e5abcd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -0,0 +1,34 @@ +package com.nuvio.app.features.player + +import kotlinx.serialization.Serializable + +@Serializable +data class PlayerRoute( + val title: String, + val sourceUrl: String, + val logo: String? = null, + val poster: String? = null, + val background: String? = null, + val seasonNumber: Int? = null, + val episodeNumber: Int? = null, + val episodeTitle: String? = null, + val streamTitle: String, + val streamSubtitle: String? = null, + val providerName: String, +) + +enum class PlayerResizeMode { + Fit, + Fill, + Zoom, +} + +data class PlayerPlaybackSnapshot( + val isLoading: Boolean = true, + val isPlaying: Boolean = false, + val isEnded: Boolean = false, + val durationMs: Long = 0L, + val positionMs: Long = 0L, + val bufferedPositionMs: Long = 0L, + val playbackSpeed: Float = 1f, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt new file mode 100644 index 000000000..d315e8c09 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt @@ -0,0 +1,9 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.Composable + +@Composable +expect fun LockPlayerToLandscape() + +@Composable +expect fun EnterImmersivePlayerMode() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt new file mode 100644 index 000000000..ba05d6b3a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -0,0 +1,1125 @@ +package com.nuvio.app.features.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.AspectRatio +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.Forward10 +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Replay10 +import androidx.compose.material.icons.rounded.Speed +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.nuvioTypeScale +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.max + +@Composable +fun PlayerScreen( + title: String, + sourceUrl: String, + providerName: String, + streamTitle: String, + streamSubtitle: String?, + onBack: () -> Unit, + modifier: Modifier = Modifier, + logo: String? = null, + poster: String? = null, + background: String? = null, + seasonNumber: Int? = null, + episodeNumber: Int? = null, + episodeTitle: String? = null, +) { + LockPlayerToLandscape() + EnterImmersivePlayerMode() + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(Color.Black), + ) { + val metrics = remember(maxWidth) { PlayerLayoutMetrics.fromWidth(maxWidth) } + val scope = rememberCoroutineScope() + var controlsVisible by rememberSaveable { mutableStateOf(true) } + var shouldPlay by rememberSaveable(sourceUrl) { mutableStateOf(true) } + var resizeMode by rememberSaveable { mutableStateOf(PlayerResizeMode.Fit) } + var layoutSize by remember { mutableStateOf(IntSize.Zero) } + var playbackSnapshot by remember { mutableStateOf(PlayerPlaybackSnapshot()) } + var playerController by remember { mutableStateOf(null) } + var errorMessage by remember { mutableStateOf(null) } + var scrubbingPositionMs by remember { mutableStateOf(null) } + var pausedOverlayVisible by remember { mutableStateOf(false) } + var gestureMessage by remember { mutableStateOf(null) } + var gestureMessageJob by remember { mutableStateOf(null) } + var initialLoadCompleted by remember(sourceUrl) { mutableStateOf(false) } + val backdropArtwork = background ?: poster + val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs + val isEpisode = seasonNumber != null && episodeNumber != null + + fun showGestureMessage(message: String) { + gestureMessageJob?.cancel() + gestureMessage = message + gestureMessageJob = scope.launch { + delay(900) + gestureMessage = null + } + } + + fun togglePlayback() { + if (playbackSnapshot.isPlaying) { + shouldPlay = false + playerController?.pause() + } else { + if (playbackSnapshot.isEnded) { + playerController?.seekTo(0L) + } + shouldPlay = true + playerController?.play() + } + controlsVisible = true + } + + fun seekBy(offsetMs: Long) { + playerController?.seekBy(offsetMs) + controlsVisible = true + val seconds = offsetMs / 1000L + if (seconds != 0L) { + showGestureMessage(if (seconds > 0) "+${seconds}s" else "${seconds}s") + } + } + + fun cycleResizeMode() { + resizeMode = resizeMode.next() + showGestureMessage(resizeMode.label) + controlsVisible = true + } + + fun cyclePlaybackSpeed() { + val speeds = listOf(1f, 1.25f, 1.5f, 2f) + val current = playbackSnapshot.playbackSpeed + val next = speeds.firstOrNull { it > current + 0.01f } ?: speeds.first() + playerController?.setPlaybackSpeed(next) + showGestureMessage("${next}x") + controlsVisible = true + } + + LaunchedEffect(sourceUrl) { + errorMessage = null + scrubbingPositionMs = null + initialLoadCompleted = false + } + + LaunchedEffect(controlsVisible, playbackSnapshot.isPlaying, playbackSnapshot.isLoading, errorMessage) { + if (!controlsVisible || !playbackSnapshot.isPlaying || playbackSnapshot.isLoading || errorMessage != null) { + return@LaunchedEffect + } + delay(3500) + controlsVisible = false + } + + LaunchedEffect(playbackSnapshot.isPlaying, playbackSnapshot.isLoading, playbackSnapshot.durationMs, errorMessage) { + pausedOverlayVisible = false + if (playbackSnapshot.isPlaying || playbackSnapshot.isLoading || playbackSnapshot.durationMs <= 0L || errorMessage != null) { + return@LaunchedEffect + } + delay(5000) + pausedOverlayVisible = true + } + + Box( + modifier = Modifier + .fillMaxSize() + .onSizeChanged { layoutSize = it } + .pointerInput(controlsVisible, playerController) { + detectTapGestures( + onTap = { offset -> + val centerStart = layoutSize.width * 0.4f + val centerEnd = layoutSize.width * 0.6f + if (controlsVisible && offset.x in centerStart..centerEnd) { + controlsVisible = false + } else { + controlsVisible = !controlsVisible + } + }, + onDoubleTap = { offset -> + when { + offset.x < layoutSize.width * 0.4f -> seekBy(-10_000L) + offset.x > layoutSize.width * 0.6f -> seekBy(10_000L) + else -> controlsVisible = !controlsVisible + } + }, + ) + }, + ) { + PlatformPlayerSurface( + sourceUrl = sourceUrl, + modifier = Modifier.fillMaxSize(), + playWhenReady = shouldPlay, + resizeMode = resizeMode, + onControllerReady = { controller -> + playerController = controller + }, + onSnapshot = { snapshot -> + playbackSnapshot = snapshot + if (!snapshot.isLoading) { + initialLoadCompleted = true + } + if (snapshot.isEnded) { + shouldPlay = false + controlsVisible = true + } + }, + onError = { message -> + errorMessage = message + if (message != null) { + controlsVisible = true + } + }, + ) + + if (pausedOverlayVisible && !controlsVisible) { + PauseMetadataOverlay( + title = title, + isEpisode = isEpisode, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + streamSubtitle = streamSubtitle, + providerName = providerName, + metrics = metrics, + modifier = Modifier.fillMaxSize(), + ) + } + + AnimatedVisibility( + visible = controlsVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + PlayerControlsShell( + title = title, + streamTitle = streamTitle, + providerName = providerName, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + playbackSnapshot = playbackSnapshot, + displayedPositionMs = displayedPositionMs, + metrics = metrics, + resizeMode = resizeMode, + onBack = onBack, + onTogglePlayback = ::togglePlayback, + onSeekBack = { seekBy(-10_000L) }, + onSeekForward = { seekBy(10_000L) }, + onResizeModeClick = ::cycleResizeMode, + onSpeedClick = ::cyclePlaybackSpeed, + onScrubChange = { positionMs -> scrubbingPositionMs = positionMs }, + onScrubFinished = { positionMs -> + scrubbingPositionMs = null + playerController?.seekTo(positionMs) + }, + modifier = Modifier.fillMaxSize(), + ) + } + + AnimatedVisibility( + visible = !initialLoadCompleted && errorMessage == null, + enter = fadeIn(), + exit = fadeOut(), + ) { + OpeningOverlay( + artwork = backdropArtwork, + logo = logo, + onBack = onBack, + modifier = Modifier.fillMaxSize(), + ) + } + + AnimatedVisibility( + visible = gestureMessage != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = Modifier.fillMaxSize(), + ) { + GestureFeedbackPill( + message = gestureMessage.orEmpty(), + modifier = Modifier + .align(Alignment.TopCenter) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)) + .padding(top = 40.dp), + ) + } + } + + if (errorMessage != null) { + ErrorModal( + message = errorMessage.orEmpty(), + onDismiss = onBack, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } +} + +@Composable +private fun PlayerControlsShell( + title: String, + streamTitle: String, + providerName: String, + seasonNumber: Int?, + episodeNumber: Int?, + episodeTitle: String?, + playbackSnapshot: PlayerPlaybackSnapshot, + displayedPositionMs: Long, + metrics: PlayerLayoutMetrics, + resizeMode: PlayerResizeMode, + onBack: () -> Unit, + onTogglePlayback: () -> Unit, + onSeekBack: () -> Unit, + onSeekForward: () -> Unit, + onResizeModeClick: () -> Unit, + onSpeedClick: () -> Unit, + onScrubChange: (Long) -> Unit, + onScrubFinished: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .align(Alignment.TopCenter) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.7f), + Color.Transparent, + ), + ), + ), + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(220.dp) + .align(Alignment.BottomCenter) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.7f), + ), + ), + ), + ) + + PlayerHeader( + title = title, + streamTitle = streamTitle, + providerName = providerName, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + backendLabel = "Android · Media3 ExoPlayer", + metrics = metrics, + onBack = onBack, + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)) + .padding(horizontal = metrics.horizontalPadding, vertical = metrics.verticalPadding), + ) + + CenterControls( + snapshot = playbackSnapshot, + metrics = metrics, + onSeekBack = onSeekBack, + onSeekForward = onSeekForward, + onTogglePlayback = onTogglePlayback, + modifier = Modifier + .align(Alignment.Center) + .padding(bottom = metrics.centerLift), + ) + + ProgressControls( + playbackSnapshot = playbackSnapshot, + displayedPositionMs = displayedPositionMs, + metrics = metrics, + resizeMode = resizeMode, + onScrubChange = onScrubChange, + onScrubFinished = onScrubFinished, + onResizeModeClick = onResizeModeClick, + onSpeedClick = onSpeedClick, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = metrics.horizontalPadding) + .padding(bottom = metrics.sliderBottomOffset), + ) + } +} + +@Composable +private fun PlayerHeader( + title: String, + streamTitle: String, + providerName: String, + seasonNumber: Int?, + episodeNumber: Int?, + episodeTitle: String?, + backendLabel: String, + metrics: PlayerLayoutMetrics, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val typeScale = MaterialTheme.nuvioTypeScale + Column(modifier = modifier) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = title, + style = typeScale.titleLg.copy( + fontSize = metrics.titleSize, + lineHeight = metrics.titleSize * 1.16f, + fontWeight = FontWeight.Bold, + ), + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) { + Text( + text = "S${seasonNumber}E${episodeNumber} • $episodeTitle", + style = typeScale.bodyMd.copy( + fontSize = metrics.episodeInfoSize, + lineHeight = metrics.episodeInfoSize * 1.3f, + ), + color = Color.White.copy(alpha = 0.9f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = streamTitle, + style = typeScale.labelSm.copy( + fontSize = metrics.metadataSize, + lineHeight = metrics.metadataSize * 1.25f, + ), + color = Color.White.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = providerName, + style = typeScale.labelSm.copy( + fontSize = metrics.metadataSize, + lineHeight = metrics.metadataSize * 1.25f, + fontStyle = FontStyle.Italic, + ), + color = Color.White.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = backendLabel, + style = typeScale.labelXs, + color = Color.White.copy(alpha = 0.9f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Box( + modifier = Modifier + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.35f)) + .clickable(onClick = onBack) + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "Close player", + tint = Color.White, + modifier = Modifier.size(metrics.headerIconSize), + ) + } + } + } +} + +@Composable +private fun CenterControls( + snapshot: PlayerPlaybackSnapshot, + metrics: PlayerLayoutMetrics, + onSeekBack: () -> Unit, + onSeekForward: () -> Unit, + onTogglePlayback: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(metrics.centerGap), + verticalAlignment = Alignment.CenterVertically, + ) { + SideControlButton( + icon = Icons.Rounded.Replay10, + contentDescription = "Seek backward 10 seconds", + metrics = metrics, + onClick = onSeekBack, + ) + PlayPauseControlButton( + isPlaying = snapshot.isPlaying, + isBuffering = snapshot.isLoading, + metrics = metrics, + onClick = onTogglePlayback, + ) + SideControlButton( + icon = Icons.Rounded.Forward10, + contentDescription = "Seek forward 10 seconds", + metrics = metrics, + onClick = onSeekForward, + ) + } +} + +@Composable +private fun SideControlButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + contentDescription: String, + metrics: PlayerLayoutMetrics, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.28f)) + .clickable(onClick = onClick) + .padding(metrics.sideButtonPadding), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = Color.White, + modifier = Modifier.size(metrics.sideIconSize), + ) + } +} + +@Composable +private fun PlayPauseControlButton( + isPlaying: Boolean, + isBuffering: Boolean, + metrics: PlayerLayoutMetrics, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.28f)) + .clickable(onClick = onClick) + .padding(metrics.playButtonPadding), + contentAlignment = Alignment.Center, + ) { + if (isBuffering) { + CircularProgressIndicator( + color = Color.White, + strokeWidth = 3.dp, + modifier = Modifier.size(metrics.playIconSize), + ) + } else { + Icon( + imageVector = if (isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow, + contentDescription = if (isPlaying) "Pause" else "Play", + tint = Color.White, + modifier = Modifier.size(metrics.playIconSize), + ) + } + } +} + +@Composable +private fun ProgressControls( + playbackSnapshot: PlayerPlaybackSnapshot, + displayedPositionMs: Long, + metrics: PlayerLayoutMetrics, + resizeMode: PlayerResizeMode, + onScrubChange: (Long) -> Unit, + onScrubFinished: (Long) -> Unit, + onResizeModeClick: () -> Unit, + onSpeedClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L) + Column(modifier = modifier) { + Slider( + modifier = Modifier + .fillMaxWidth() + .height(metrics.sliderTouchHeight) + .graphicsLayer(scaleY = metrics.sliderScaleY), + value = displayedPositionMs.coerceIn(0L, durationMs).toFloat(), + onValueChange = { value -> onScrubChange(value.toLong()) }, + onValueChangeFinished = { onScrubFinished(displayedPositionMs.coerceIn(0L, durationMs)) }, + valueRange = 0f..durationMs.toFloat(), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp) + .padding(top = 4.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TimePill(text = formatPlaybackTime(displayedPositionMs), fontSize = metrics.timeSize) + TimePill(text = formatPlaybackTime(durationMs), fontSize = metrics.timeSize) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Surface( + color = Color.Black.copy(alpha = 0.5f), + shape = RoundedCornerShape(24.dp), + modifier = Modifier.border( + width = 1.dp, + color = Color.White.copy(alpha = 0.2f), + shape = RoundedCornerShape(24.dp), + ), + ) { + Row( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + PlayerActionPillButton( + icon = Icons.Rounded.AspectRatio, + label = resizeMode.label, + onClick = onResizeModeClick, + ) + PlayerActionPillButton( + icon = Icons.Rounded.Speed, + label = "${playbackSnapshot.playbackSpeed}x", + onClick = onSpeedClick, + ) + } + } + } + } +} + +@Composable +private fun TimePill( + text: String, + fontSize: androidx.compose.ui.unit.TextUnit, +) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color.Black.copy(alpha = 0.5f)) + .border(1.dp, Color.White.copy(alpha = 0.2f), RoundedCornerShape(12.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { + Text( + text = text, + style = MaterialTheme.nuvioTypeScale.labelSm.copy( + fontSize = fontSize, + lineHeight = fontSize * 1.25f, + fontWeight = FontWeight.Medium, + ), + color = Color.White, + ) + } +} + +@Composable +private fun PlayerActionPillButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(22.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + Text( + text = label, + style = MaterialTheme.nuvioTypeScale.labelSm, + color = Color.White, + ) + } +} + +@Composable +private fun OpeningOverlay( + artwork: String?, + logo: String?, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .background(Color.Black.copy(alpha = 0.85f)), + ) { + if (artwork != null) { + AsyncImage( + model = artwork, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.3f), + Color.Black.copy(alpha = 0.6f), + Color.Black.copy(alpha = 0.8f), + Color.Black.copy(alpha = 0.9f), + ), + ), + ), + ) + } + + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)) + .padding(top = 20.dp, end = 20.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.3f)) + .clickable(onClick = onBack) + .padding(10.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "Close player", + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (logo != null) { + AsyncImage( + model = logo, + contentDescription = null, + modifier = Modifier + .width(300.dp) + .height(180.dp), + contentScale = ContentScale.Fit, + ) + } else { + CircularProgressIndicator( + color = Color(0xFFE50914), + strokeWidth = 3.dp, + modifier = Modifier.size(54.dp), + ) + } + } + } +} + +@Composable +private fun GestureFeedbackPill( + message: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .clip(RoundedCornerShape(24.dp)) + .background(Color.Black.copy(alpha = 0.75f)) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Color.White.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Speed, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp), + ) + } + Text( + text = message, + style = MaterialTheme.nuvioTypeScale.bodyLg.copy(fontWeight = FontWeight.SemiBold), + color = Color.White, + ) + } +} + +@Composable +private fun PauseMetadataOverlay( + title: String, + isEpisode: Boolean, + seasonNumber: Int?, + episodeNumber: Int?, + episodeTitle: String?, + streamSubtitle: String?, + providerName: String, + metrics: PlayerLayoutMetrics, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background( + Brush.horizontalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.85f), + Color.Black.copy(alpha = 0.45f), + Color.Transparent, + ), + ), + ) + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(horizontal = 24.dp, vertical = 24.dp), + verticalArrangement = Arrangement.Bottom, + ) { + Text( + text = "You're watching", + style = MaterialTheme.nuvioTypeScale.bodyLg, + color = Color(0xFFB8B8B8), + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = title, + style = MaterialTheme.nuvioTypeScale.displayMd.copy( + fontSize = max(metrics.titleSize.value * 1.8f, 32f).sp, + fontWeight = FontWeight.ExtraBold, + ), + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(8.dp)) + val episodeInfo = if (isEpisode && seasonNumber != null && episodeNumber != null) { + buildString { + append("S") + append(seasonNumber) + append(" • E") + append(episodeNumber) + if (!episodeTitle.isNullOrBlank()) { + append(" • ") + append(episodeTitle) + } + } + } else { + providerName + } + Text( + text = episodeInfo, + style = MaterialTheme.nuvioTypeScale.bodyLg, + color = Color(0xFFCCCCCC), + ) + if (!streamSubtitle.isNullOrBlank()) { + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = streamSubtitle, + style = MaterialTheme.nuvioTypeScale.bodyLg.copy(lineHeight = 24.sp), + color = Color(0xFFD6D6D6), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun ErrorModal( + message: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.7f)), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = modifier + .fillMaxWidth(0.8f) + .widthIn(max = 400.dp), + shape = RoundedCornerShape(20.dp), + color = Color(0xFF1A1A1A), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(Color(0xFFEF4444).copy(alpha = 0.1f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.ErrorOutline, + contentDescription = null, + tint = Color(0xFFEF4444), + modifier = Modifier.size(32.dp), + ) + } + Text( + text = "Playback error", + style = MaterialTheme.nuvioTypeScale.titleMd.copy(fontWeight = FontWeight.Bold), + color = Color.White, + ) + Text( + text = message, + style = MaterialTheme.nuvioTypeScale.bodyLg.copy(lineHeight = 22.sp), + color = Color.White.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onDismiss), + color = Color.White, + shape = RoundedCornerShape(12.dp), + ) { + Text( + text = "Dismiss", + modifier = Modifier.padding(vertical = 12.dp), + style = MaterialTheme.nuvioTypeScale.bodyLg.copy(fontWeight = FontWeight.Bold), + color = Color.Black, + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +private data class PlayerLayoutMetrics( + val horizontalPadding: Dp, + val verticalPadding: Dp, + val titleSize: TextUnit, + val episodeInfoSize: TextUnit, + val metadataSize: TextUnit, + val centerGap: Dp, + val centerLift: Dp, + val sliderBottomOffset: Dp, + val sliderTouchHeight: Dp, + val sliderScaleY: Float, + val timeSize: TextUnit, + val headerIconSize: Dp, + val sideButtonPadding: Dp, + val sideIconSize: Dp, + val playButtonPadding: Dp, + val playIconSize: Dp, +) { + companion object { + fun fromWidth(width: Dp): PlayerLayoutMetrics = + when { + width >= 1440.dp -> PlayerLayoutMetrics( + horizontalPadding = 28.dp, + verticalPadding = 24.dp, + titleSize = 28.dp.value.sp, + episodeInfoSize = 16.dp.value.sp, + metadataSize = 14.dp.value.sp, + centerGap = 112.dp, + centerLift = 24.dp, + sliderBottomOffset = 28.dp, + sliderTouchHeight = 28.dp, + sliderScaleY = 0.72f, + timeSize = 14.dp.value.sp, + headerIconSize = 24.dp, + sideButtonPadding = 14.dp, + sideIconSize = 34.dp, + playButtonPadding = 18.dp, + playIconSize = 44.dp, + ) + width >= 1024.dp -> PlayerLayoutMetrics( + horizontalPadding = 24.dp, + verticalPadding = 20.dp, + titleSize = 24.dp.value.sp, + episodeInfoSize = 15.dp.value.sp, + metadataSize = 13.dp.value.sp, + centerGap = 88.dp, + centerLift = 18.dp, + sliderBottomOffset = 24.dp, + sliderTouchHeight = 26.dp, + sliderScaleY = 0.74f, + timeSize = 13.dp.value.sp, + headerIconSize = 22.dp, + sideButtonPadding = 13.dp, + sideIconSize = 32.dp, + playButtonPadding = 16.dp, + playIconSize = 42.dp, + ) + width >= 768.dp -> PlayerLayoutMetrics( + horizontalPadding = 20.dp, + verticalPadding = 16.dp, + titleSize = 22.dp.value.sp, + episodeInfoSize = 14.dp.value.sp, + metadataSize = 12.dp.value.sp, + centerGap = 72.dp, + centerLift = 14.dp, + sliderBottomOffset = 20.dp, + sliderTouchHeight = 24.dp, + sliderScaleY = 0.78f, + timeSize = 12.dp.value.sp, + headerIconSize = 20.dp, + sideButtonPadding = 12.dp, + sideIconSize = 30.dp, + playButtonPadding = 15.dp, + playIconSize = 38.dp, + ) + else -> PlayerLayoutMetrics( + horizontalPadding = 20.dp, + verticalPadding = 16.dp, + titleSize = 18.dp.value.sp, + episodeInfoSize = 14.dp.value.sp, + metadataSize = 12.dp.value.sp, + centerGap = 56.dp, + centerLift = 10.dp, + sliderBottomOffset = 16.dp, + sliderTouchHeight = 22.dp, + sliderScaleY = 0.82f, + timeSize = 12.dp.value.sp, + headerIconSize = 20.dp, + sideButtonPadding = 10.dp, + sideIconSize = 26.dp, + playButtonPadding = 13.dp, + playIconSize = 34.dp, + ) + } + } +} + +private fun PlayerResizeMode.next(): PlayerResizeMode = + when (this) { + PlayerResizeMode.Fit -> PlayerResizeMode.Fill + PlayerResizeMode.Fill -> PlayerResizeMode.Zoom + PlayerResizeMode.Zoom -> PlayerResizeMode.Fit + } + +private val PlayerResizeMode.label: String + get() = when (this) { + PlayerResizeMode.Fit -> "Fit" + PlayerResizeMode.Fill -> "Fill" + PlayerResizeMode.Zoom -> "Zoom" + } + +private fun formatPlaybackTime(positionMs: Long): String { + val totalSeconds = (positionMs / 1000L).coerceAtLeast(0L) + val seconds = totalSeconds % 60 + val minutes = (totalSeconds / 60) % 60 + val hours = totalSeconds / 3600 + return if (hours > 0) { + "${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" + } else { + "${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 0802e7b54..a1df0e2f0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -17,6 +17,9 @@ data class StreamItem( val streamSubtitle: String? get() = description + val directPlaybackUrl: String? + get() = url ?: externalUrl + val hasPlayableSource: Boolean get() = url != null || infoHash != null || externalUrl != null } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index e37660e51..85e475a68 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -83,7 +83,10 @@ object StreamsRepository { val jobs = streamAddons.map { manifest -> async { val encodedId = videoId.encodeForPath() - val url = "${manifest.transportUrl}/stream/$type/$encodedId.json" + val baseUrl = manifest.transportUrl + .substringBefore("?") + .removeSuffix("/manifest.json") + val url = "$baseUrl/stream/$type/$encodedId.json" log.d { "Fetching streams from: $url" } runCatching { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 2b07aa4f9..697bcd9f5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -75,6 +75,7 @@ fun StreamsScreen( episodeNumber: Int? = null, episodeTitle: String? = null, episodeThumbnail: String? = null, + onStreamSelected: (StreamItem) -> Unit = {}, onBack: () -> Unit, modifier: Modifier = Modifier, ) { @@ -166,6 +167,7 @@ fun StreamsScreen( StreamList( uiState = uiState, + onStreamSelected = onStreamSelected, modifier = Modifier.weight(1f), ) } @@ -414,6 +416,7 @@ private fun FilterChip( @Composable private fun StreamList( uiState: StreamsUiState, + onStreamSelected: (StreamItem) -> Unit, modifier: Modifier = Modifier, ) { val filteredGroups = uiState.filteredGroups @@ -444,7 +447,11 @@ private fun StreamList( else -> { filteredGroups.forEach { group -> - streamSection(group = group, showHeader = uiState.selectedFilter == null) + streamSection( + group = group, + showHeader = uiState.selectedFilter == null, + onStreamSelected = onStreamSelected, + ) } if (anyLoading) { item { @@ -462,6 +469,7 @@ private fun StreamList( private fun LazyListScope.streamSection( group: AddonStreamGroup, showHeader: Boolean, + onStreamSelected: (StreamItem) -> Unit, ) { if (group.streams.isEmpty() && !group.isLoading) return @@ -478,7 +486,14 @@ private fun LazyListScope.streamSection( items = group.streams, key = { stream -> "${group.addonId}_${stream.url ?: stream.infoHash ?: stream.streamLabel}" }, ) { stream -> - StreamCard(stream = stream) + StreamCard( + stream = stream, + onClick = { + if (stream.directPlaybackUrl != null) { + onStreamSelected(stream) + } + }, + ) Spacer(modifier = Modifier.height(10.dp)) } } @@ -533,14 +548,17 @@ private fun StreamSectionHeader( @Composable private fun StreamCard( stream: StreamItem, + onClick: () -> Unit, modifier: Modifier = Modifier, ) { + val isEnabled = stream.directPlaybackUrl != null Row( modifier = modifier .fillMaxWidth() .heightIn(min = 68.dp) .clip(RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.surface) + .clickable(enabled = isEnabled, onClick = onClick) .padding(14.dp), verticalAlignment = Alignment.Top, ) { 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 new file mode 100644 index 000000000..6ac680b94 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -0,0 +1,44 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.graphics.Color + +@Composable +actual fun PlatformPlayerSurface( + sourceUrl: String, + modifier: Modifier, + playWhenReady: Boolean, + resizeMode: PlayerResizeMode, + onControllerReady: (PlayerEngineController) -> Unit, + onSnapshot: (PlayerPlaybackSnapshot) -> Unit, + onError: (String?) -> Unit, +) { + val controller = remember { + object : PlayerEngineController { + override fun play() = Unit + override fun pause() = Unit + override fun seekTo(positionMs: Long) = Unit + override fun seekBy(offsetMs: Long) = Unit + override fun retry() = Unit + override fun setPlaybackSpeed(speed: Float) = Unit + } + } + + LaunchedEffect(sourceUrl) { + onControllerReady(controller) + onSnapshot(PlayerPlaybackSnapshot(isLoading = false)) + onError("Playback is not implemented on iOS yet.") + } + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black), + ) +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt new file mode 100644 index 000000000..c4437c2c3 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt @@ -0,0 +1,9 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.Composable + +@Composable +actual fun LockPlayerToLandscape() = Unit + +@Composable +actual fun EnterImmersivePlayerMode() = Unit diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d762241a0..ace26be83 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ kotlin = "2.3.0" kotlinx-serialization = "1.8.1" ktor = "3.4.1" material3 = "1.10.0-alpha05" +androidx-media3 = "1.9.2" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -44,6 +45,10 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" } +androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "androidx-media3" } +androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" } +androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }