android player init

This commit is contained in:
tapframe 2026-03-11 23:15:12 +05:30
parent 69920504ac
commit 19a078c28d
16 changed files with 1644 additions and 28 deletions

View file

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

View file

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

View file

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

View file

@ -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<PlayerRoute> { backStackEntry ->
val route = backStackEntry.toRoute<PlayerRoute>()
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<CatalogRoute> { backStackEntry ->
val route = backStackEntry.toRoute<CatalogRoute>()
CatalogScreen(

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
package com.nuvio.app.features.player
import androidx.compose.runtime.Composable
@Composable
expect fun LockPlayerToLandscape()
@Composable
expect fun EnterImmersivePlayerMode()

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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