feat(ios): use bottomsheet for streamscrean

This commit is contained in:
tapframe 2026-06-02 22:07:51 +05:30
parent fc272ef3a6
commit 739dd56f05
6 changed files with 211 additions and 107 deletions

View file

@ -238,6 +238,7 @@ kotlin {
baseName = "ComposeApp"
isStatic = true
freeCompilerArgs += listOf("-Xbinary=bundleId=$iosFrameworkBundleId")
export("com.mohamedrejeb.calf:calf-ui:${libs.versions.calf.get()}")
}
}
@ -274,6 +275,7 @@ kotlin {
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3)
implementation(libs.coil.svg)
api(libs.calf.ui)
implementation("dev.chrisbanes.haze:haze:1.7.2")
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)

View file

@ -56,6 +56,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.zIndex
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@ -67,6 +68,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.dialog
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import coil3.ImageLoader
@ -99,8 +101,11 @@ import com.nuvio.app.core.ui.NuvioTheme
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
import com.nuvio.app.core.ui.NativeNavigationTab
import com.nuvio.app.core.ui.NativeTabBridge
import com.nuvio.app.core.ui.NuvioModalBottomSheet
import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported
import com.nuvio.app.core.ui.dismissNuvioBottomSheet
import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle
import com.nuvio.app.core.ui.rememberNuvioBottomSheetState
import com.nuvio.app.features.auth.AuthScreen
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CatalogRepository
@ -799,6 +804,7 @@ private fun MainAppContent(
var profileSwitchLoading by remember { mutableStateOf(false) }
var resumePromptItem by remember { mutableStateOf<ContinueWatchingItem?>(null) }
var lastExternalPlayerLaunch by remember { mutableStateOf<PlayerLaunch?>(null) }
val streamLaunchIdsPreservedForPlayerReturn = remember { mutableSetOf<Long>() }
val launchExternalPlayer = rememberExternalPlayerLauncher { result ->
if (result != null && result.positionMs > 0L) {
coroutineScope.launch {
@ -1577,7 +1583,8 @@ private fun MainAppContent(
modifier = Modifier.fillMaxSize(),
)
}
composable<StreamRoute> { backStackEntry ->
val streamRouteDestination: @Composable (NavBackStackEntry) -> Unit =
streamRouteDestination@{ backStackEntry ->
val route = backStackEntry.toRoute<StreamRoute>()
val launch = remember(route.launchId) {
StreamLaunchStore.get(route.launchId)
@ -1587,7 +1594,7 @@ private fun MainAppContent(
StreamsRepository.clear()
navController.popBackStack()
}
return@composable
return@streamRouteDestination
}
val pauseDescription = launch.pauseDescription
val streamRouteScope = rememberCoroutineScope()
@ -1597,7 +1604,9 @@ private fun MainAppContent(
DisposableEffect(lifecycleOwner, route.launchId) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_DESTROY) {
StreamLaunchStore.remove(route.launchId)
if (route.launchId !in streamLaunchIdsPreservedForPlayerReturn) {
StreamLaunchStore.remove(route.launchId)
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
@ -1666,6 +1675,21 @@ private fun MainAppContent(
fun p2pSentinelUrl(infoHash: String, fileIdx: Int?): String =
"torrent://$infoHash${fileIdx?.let { "?index=$it" }.orEmpty()}"
fun navigateToPlayer(
playerLaunch: PlayerLaunch,
replaceStreamRoute: Boolean = false,
) {
playerLaunch.returnStreamLaunchId?.let { streamLaunchId ->
streamLaunchIdsPreservedForPlayerReturn.add(streamLaunchId)
}
val playerLaunchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = playerLaunchId)) {
if (replaceStreamRoute) {
popUpTo<StreamRoute> { inclusive = true }
}
}
}
fun openP2pStream(
stream: StreamItem,
resolvedResumePositionMs: Long?,
@ -1726,15 +1750,14 @@ private fun MainAppContent(
torrentTrackers = stream.p2pTrackers,
initialPositionMs = resolvedResumePositionMs ?: 0L,
initialProgressFraction = resolvedResumeProgressFraction,
returnStreamLaunchId = if (isIos && !replaceStreamRoute) route.launchId else null,
)
val launchId = PlayerLaunchStore.put(playerLaunch)
StreamsRepository.cancelLoading()
navController.navigate(PlayerRoute(launchId = launchId)) {
if (replaceStreamRoute) {
popUpTo<StreamRoute> { inclusive = true }
}
}
navigateToPlayer(
playerLaunch = playerLaunch,
replaceStreamRoute = replaceStreamRoute,
)
}
fun requestOrOpenP2pStream(
@ -1850,10 +1873,10 @@ private fun MainAppContent(
}
StreamsRepository.clear()
reuseNavigated = true
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId)) {
popUpTo<StreamRoute> { inclusive = true }
}
navigateToPlayer(
playerLaunch = playerLaunch,
replaceStreamRoute = true,
)
}
}
@ -1982,10 +2005,10 @@ private fun MainAppContent(
}
StreamsRepository.consumeAutoPlay()
StreamsRepository.cancelLoading()
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId)) {
popUpTo<StreamRoute> { inclusive = true }
}
navigateToPlayer(
playerLaunch = playerLaunch,
replaceStreamRoute = true,
)
}
if (!hasResolvedVideoId) {
@ -1995,7 +2018,7 @@ private fun MainAppContent(
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
return@composable
return@streamRouteDestination
}
fun openSelectedStream(
@ -2097,6 +2120,7 @@ private fun MainAppContent(
parentMetaType = launch.parentMetaType ?: launch.type,
initialPositionMs = resolvedResumePositionMs ?: 0L,
initialProgressFraction = resolvedResumeProgressFraction,
returnStreamLaunchId = if (isIos) route.launchId else null,
)
if (!forceInternal && (forceExternal || playerSettings.externalPlayerEnabled)) {
@ -2105,11 +2129,8 @@ private fun MainAppContent(
return
}
val launchId = PlayerLaunchStore.put(playerLaunch)
StreamsRepository.cancelLoading()
navController.navigate(
PlayerRoute(launchId = launchId)
)
navigateToPlayer(playerLaunch)
}
// Hide overlay when reuse navigated to external player (prevents reload from showing it again)
@ -2119,90 +2140,142 @@ private fun MainAppContent(
}
}
Box(modifier = Modifier.fillMaxSize()) {
StreamsScreen(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
parentMetaType = launch.parentMetaType ?: launch.type,
title = launch.title,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
seasonNumber = launch.seasonNumber,
episodeNumber = launch.episodeNumber,
episodeTitle = launch.episodeTitle,
episodeThumbnail = launch.episodeThumbnail,
resumePositionMs = launch.resumePositionMs,
resumeProgressFraction = launch.resumeProgressFraction,
manualSelection = launch.manualSelection,
startFromBeginning = launch.startFromBeginning,
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = false,
forceInternal = false,
val streamSheetState = rememberNuvioBottomSheetState(skipPartiallyExpanded = true)
fun closeStreamRoute() {
streamLaunchIdsPreservedForPlayerReturn.remove(route.launchId)
StreamsRepository.clear()
navController.popBackStack()
}
fun dismissStreamRoute() {
if (isIos) {
streamRouteScope.launch {
dismissNuvioBottomSheet(
sheetState = streamSheetState,
onDismiss = ::closeStreamRoute,
)
},
onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = openExternally,
forceInternal = !openExternally,
)
},
onBack = {
StreamsRepository.clear()
navController.popBackStack()
},
modifier = Modifier.fillMaxSize(),
)
pendingP2pStreamOpen?.let { pending ->
P2pConsentDialog(
onEnableP2p = {
P2pSettingsRepository.setP2pEnabled(true)
pendingP2pStreamOpen = null
openP2pStream(
stream = pending.stream,
resolvedResumePositionMs = pending.resumePositionMs,
resolvedResumeProgressFraction = pending.resumeProgressFraction,
replaceStreamRoute = pending.isAutoPlay,
)
},
onDismiss = {
if (pending.isAutoPlay) {
StreamsRepository.skipAutoPlayStream(pending.stream)
StreamsRepository.consumeAutoPlay()
}
pendingP2pStreamOpen = null
},
)
}
} else {
closeStreamRoute()
}
if (resolvingDebridStream) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.82f)),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator(color = Color.White)
Text(
text = stringResource(Res.string.streams_finding_source),
color = Color.White.copy(alpha = 0.82f),
style = MaterialTheme.typography.bodyMedium,
}
val streamRouteContent: @Composable (Modifier) -> Unit = { contentModifier ->
Box(modifier = contentModifier) {
StreamsScreen(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
parentMetaType = launch.parentMetaType ?: launch.type,
title = launch.title,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
seasonNumber = launch.seasonNumber,
episodeNumber = launch.episodeNumber,
episodeTitle = launch.episodeTitle,
episodeThumbnail = launch.episodeThumbnail,
resumePositionMs = launch.resumePositionMs,
resumeProgressFraction = launch.resumeProgressFraction,
manualSelection = launch.manualSelection,
startFromBeginning = launch.startFromBeginning,
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = false,
forceInternal = false,
)
},
onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = openExternally,
forceInternal = !openExternally,
)
},
onBack = { dismissStreamRoute() },
modifier = Modifier.fillMaxSize(),
showBackButton = !isIos,
)
pendingP2pStreamOpen?.let { pending ->
P2pConsentDialog(
onEnableP2p = {
P2pSettingsRepository.setP2pEnabled(true)
pendingP2pStreamOpen = null
openP2pStream(
stream = pending.stream,
resolvedResumePositionMs = pending.resumePositionMs,
resolvedResumeProgressFraction = pending.resumeProgressFraction,
replaceStreamRoute = pending.isAutoPlay,
)
},
onDismiss = {
if (pending.isAutoPlay) {
StreamsRepository.skipAutoPlayStream(pending.stream)
StreamsRepository.consumeAutoPlay()
}
pendingP2pStreamOpen = null
},
)
}
if (resolvingDebridStream) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.82f)),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator(color = Color.White)
Text(
text = stringResource(Res.string.streams_finding_source),
color = Color.White.copy(alpha = 0.82f),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
if (isIos) {
NuvioModalBottomSheet(
onDismissRequest = { closeStreamRoute() },
sheetState = streamSheetState,
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
iosContentTopPadding = 0.dp,
) {
streamRouteContent(
Modifier
.fillMaxWidth()
.weight(1f),
)
}
} else {
streamRouteContent(Modifier.fillMaxSize())
}
}
if (isIos) {
dialog<StreamRoute>(
dialogProperties = DialogProperties(
usePlatformDefaultWidth = false,
),
) { backStackEntry ->
streamRouteDestination(backStackEntry)
}
} else {
composable<StreamRoute> { backStackEntry ->
streamRouteDestination(backStackEntry)
}
}
composable<PlayerRoute>(
enterTransition = {
@ -2263,6 +2336,14 @@ private fun MainAppContent(
ResumePromptRepository.markPlayerExitedNormally()
PlayerLaunchStore.remove(route.launchId)
navController.popBackStack()
launch.returnStreamLaunchId?.let { streamLaunchId ->
if (isIos && StreamLaunchStore.get(streamLaunchId) != null) {
navController.navigate(StreamRoute(launchId = streamLaunchId)) {
launchSingleTop = true
}
}
streamLaunchIdsPreservedForPlayerReturn.remove(streamLaunchId)
}
},
onOpenInExternalPlayer = { request ->
val playerLaunch = PlayerLaunch(

View file

@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
@ -25,6 +26,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.nuvio.app.isIos
import com.mohamedrejeb.calf.ui.sheet.AdaptiveBottomSheet
@ -50,6 +52,7 @@ fun NuvioModalBottomSheet(
contentColor: Color = MaterialTheme.colorScheme.onSurface,
shape: Shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
showDragHandle: Boolean = true,
iosContentTopPadding: Dp = 28.dp,
content: @Composable ColumnScope.() -> Unit,
) {
val sheetModifier = if (isIos) {
@ -57,6 +60,18 @@ fun NuvioModalBottomSheet(
} else {
modifier
}
val sheetContent: @Composable ColumnScope.() -> Unit = {
if (isIos && iosContentTopPadding > 0.dp) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = iosContentTopPadding),
content = content,
)
} else {
content()
}
}
AdaptiveBottomSheet(
onDismissRequest = onDismissRequest,
@ -70,7 +85,7 @@ fun NuvioModalBottomSheet(
} else {
null
},
content = content,
content = sheetContent,
)
}

View file

@ -48,6 +48,7 @@ data class PlayerLaunch(
val torrentTrackers: List<String> = emptyList(),
val initialPositionMs: Long = 0L,
val initialProgressFraction: Float? = null,
val returnStreamLaunchId: Long? = null,
)
object PlayerLaunchStore {

View file

@ -129,6 +129,7 @@ fun StreamsScreen(
) -> Unit = { _, _, _, _ -> },
onBack: () -> Unit,
modifier: Modifier = Modifier,
showBackButton: Boolean = true,
) {
val uiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
val playerSettings by remember {
@ -262,13 +263,15 @@ fun StreamsScreen(
.padding(start = 12.dp, top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
NuvioBackButton(
onClick = onBack,
modifier = Modifier
.size(40.dp),
containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.45f),
contentColor = MaterialTheme.colorScheme.onBackground,
)
if (showBackButton) {
NuvioBackButton(
onClick = onBack,
modifier = Modifier
.size(40.dp),
containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.45f),
contentColor = MaterialTheme.colorScheme.onBackground,
)
}
Box(
modifier = Modifier

View file

@ -14,6 +14,7 @@ androidx-work = "2.10.3"
androidx-testExt = "1.3.0"
composeMultiplatform = "1.11.1"
coil = "3.5.0-beta01"
calf = "0.11.0"
kermit = "2.0.5"
junit = "4.13.2"
kotlin = "2.3.0"
@ -52,6 +53,7 @@ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil"
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
calf-ui = { module = "com.mohamedrejeb.calf:calf-ui", version.ref = "calf" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }