From 739dd56f050aa73dbe9ac03c41766fd840d6e1d3 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:07:51 +0530 Subject: [PATCH] feat(ios): use bottomsheet for streamscrean --- composeApp/build.gradle.kts | 2 + .../commonMain/kotlin/com/nuvio/app/App.kt | 279 +++++++++++------- .../com/nuvio/app/core/ui/NuvioBottomSheet.kt | 17 +- .../nuvio/app/features/player/PlayerModels.kt | 1 + .../app/features/streams/StreamsScreen.kt | 17 +- gradle/libs.versions.toml | 2 + 6 files changed, 211 insertions(+), 107 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b1b9fdbe5..64eb7e7e1 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index f3fa03306..444573b93 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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(null) } var lastExternalPlayerLaunch by remember { mutableStateOf(null) } + val streamLaunchIdsPreservedForPlayerReturn = remember { mutableSetOf() } val launchExternalPlayer = rememberExternalPlayerLauncher { result -> if (result != null && result.positionMs > 0L) { coroutineScope.launch { @@ -1577,7 +1583,8 @@ private fun MainAppContent( modifier = Modifier.fillMaxSize(), ) } - composable { backStackEntry -> + val streamRouteDestination: @Composable (NavBackStackEntry) -> Unit = + streamRouteDestination@{ backStackEntry -> val route = backStackEntry.toRoute() 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 { 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 { 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 { 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 { 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( + dialogProperties = DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { backStackEntry -> + streamRouteDestination(backStackEntry) + } + } else { + composable { backStackEntry -> + streamRouteDestination(backStackEntry) + } } composable( 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( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioBottomSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioBottomSheet.kt index 22ce83b9d..0938c63db 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioBottomSheet.kt @@ -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, ) } 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 index f9cc53f0a..d4bcc0ecf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -48,6 +48,7 @@ data class PlayerLaunch( val torrentTrackers: List = emptyList(), val initialPositionMs: Long = 0L, val initialProgressFraction: Float? = null, + val returnStreamLaunchId: Long? = null, ) object PlayerLaunchStore { 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 97a1c28d5..b107d575c 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 @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 39ae4c826..8e64fbdf3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }