mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-09 16:38:10 +00:00
android player init
This commit is contained in:
parent
69920504ac
commit
19a078c28d
16 changed files with 1644 additions and 28 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Reference in a new issue