Merge remote-tracking branch 'upstream/cmp-rewrite' into feat-volume-boost

This commit is contained in:
Luqman Fadlli 2026-06-06 03:17:16 +07:00
commit c075178ff8
31 changed files with 1329 additions and 697 deletions

View file

@ -238,7 +238,6 @@ kotlin {
baseName = "ComposeApp"
isStatic = true
freeCompilerArgs += listOf("-Xbinary=bundleId=$iosFrameworkBundleId")
export("com.mohamedrejeb.calf:calf-ui:${libs.versions.calf.get()}")
}
}
@ -275,7 +274,6 @@ 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)
@ -352,6 +350,14 @@ android {
manifest.srcFile("src/androidFull/AndroidManifest.xml")
java.srcDir(fullCommonSourceDir)
}
splits {
abi {
isEnable = true
reset()
include("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
isUniversalApk = false
}
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"

View file

@ -29,6 +29,10 @@
-keep class com.nuvio.app.features.streams.StreamsScreenKt { *; }
-keep class com.nuvio.app.features.streams.StreamsScreenKt$* { *; }
# Avoid R8 producing verifier-invalid bytecode for the large player composable.
-keep class com.nuvio.app.features.player.PlayerScreenKt { *; }
-keep class com.nuvio.app.features.player.PlayerScreenKt$* { *; }
# QuickJS plugin runtime is dynamic; keep runtime and app plugin classes.
-keep class com.dokar.quickjs.** { *; }
-keep class com.nuvio.app.features.plugins.** { *; }

View file

@ -56,7 +56,6 @@ 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
@ -68,7 +67,6 @@ 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
@ -101,11 +99,8 @@ 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
@ -804,7 +799,6 @@ 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 {
@ -1583,8 +1577,7 @@ private fun MainAppContent(
modifier = Modifier.fillMaxSize(),
)
}
val streamRouteDestination: @Composable (NavBackStackEntry) -> Unit =
streamRouteDestination@{ backStackEntry ->
composable<StreamRoute> { backStackEntry ->
val route = backStackEntry.toRoute<StreamRoute>()
val launch = remember(route.launchId) {
StreamLaunchStore.get(route.launchId)
@ -1594,7 +1587,7 @@ private fun MainAppContent(
StreamsRepository.clear()
navController.popBackStack()
}
return@streamRouteDestination
return@composable
}
val pauseDescription = launch.pauseDescription
val streamRouteScope = rememberCoroutineScope()
@ -1604,9 +1597,7 @@ private fun MainAppContent(
DisposableEffect(lifecycleOwner, route.launchId) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_DESTROY) {
if (route.launchId !in streamLaunchIdsPreservedForPlayerReturn) {
StreamLaunchStore.remove(route.launchId)
}
StreamLaunchStore.remove(route.launchId)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
@ -1675,21 +1666,6 @@ 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?,
@ -1697,7 +1673,7 @@ private fun MainAppContent(
replaceStreamRoute: Boolean,
) {
val infoHash = stream.p2pInfoHash ?: return
val sentinelUrl = p2pSentinelUrl(infoHash, stream.fileIdx)
val sentinelUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
if (playerSettings.streamReuseLastLinkEnabled) {
val cacheKey = StreamLinkCacheRepository.contentKey(
type = launch.type,
@ -1714,11 +1690,12 @@ private fun MainAppContent(
addonId = stream.addonId,
requestHeaders = emptyMap(),
responseHeaders = emptyMap(),
filename = stream.behaviorHints.filename,
filename = stream.p2pFilename,
videoSize = stream.behaviorHints.videoSize,
infoHash = infoHash,
fileIdx = stream.fileIdx,
sources = stream.sources,
fileIdx = stream.p2pFileIdx,
magnetUri = stream.torrentMagnetUri,
sources = stream.p2pSourceHints,
bingeGroup = stream.behaviorHints.bingeGroup,
)
}
@ -1745,19 +1722,21 @@ private fun MainAppContent(
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
parentMetaType = launch.parentMetaType ?: launch.type,
torrentInfoHash = infoHash,
torrentFileIdx = stream.fileIdx,
torrentFilename = stream.behaviorHints.filename,
torrentFileIdx = stream.p2pFileIdx,
torrentFilename = stream.p2pFilename,
torrentMagnetUri = stream.torrentMagnetUri,
torrentTrackers = stream.p2pTrackers,
initialPositionMs = resolvedResumePositionMs ?: 0L,
initialProgressFraction = resolvedResumeProgressFraction,
returnStreamLaunchId = if (isIos && !replaceStreamRoute) route.launchId else null,
)
val launchId = PlayerLaunchStore.put(playerLaunch)
StreamsRepository.cancelLoading()
navigateToPlayer(
playerLaunch = playerLaunch,
replaceStreamRoute = replaceStreamRoute,
)
navController.navigate(PlayerRoute(launchId = launchId)) {
if (replaceStreamRoute) {
popUpTo<StreamRoute> { inclusive = true }
}
}
}
fun requestOrOpenP2pStream(
@ -1814,10 +1793,10 @@ private fun MainAppContent(
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
if (cached != null) {
if (cached.url.isBlank() && !cached.infoHash.isNullOrBlank()) {
if (cached.url.isBlank() && (!cached.infoHash.isNullOrBlank() || !cached.magnetUri.isNullOrBlank())) {
val cachedStream = StreamItem(
name = cached.streamName,
url = null,
url = cached.magnetUri,
infoHash = cached.infoHash,
fileIdx = cached.fileIdx,
sources = cached.sources,
@ -1873,10 +1852,10 @@ private fun MainAppContent(
}
StreamsRepository.clear()
reuseNavigated = true
navigateToPlayer(
playerLaunch = playerLaunch,
replaceStreamRoute = true,
)
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId)) {
popUpTo<StreamRoute> { inclusive = true }
}
}
}
@ -2005,10 +1984,10 @@ private fun MainAppContent(
}
StreamsRepository.consumeAutoPlay()
StreamsRepository.cancelLoading()
navigateToPlayer(
playerLaunch = playerLaunch,
replaceStreamRoute = true,
)
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId)) {
popUpTo<StreamRoute> { inclusive = true }
}
}
if (!hasResolvedVideoId) {
@ -2018,7 +1997,7 @@ private fun MainAppContent(
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
return@streamRouteDestination
return@composable
}
fun openSelectedStream(
@ -2120,7 +2099,6 @@ 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)) {
@ -2129,8 +2107,11 @@ private fun MainAppContent(
return
}
val launchId = PlayerLaunchStore.put(playerLaunch)
StreamsRepository.cancelLoading()
navigateToPlayer(playerLaunch)
navController.navigate(
PlayerRoute(launchId = launchId)
)
}
// Hide overlay when reuse navigated to external player (prevents reload from showing it again)
@ -2140,142 +2121,90 @@ private fun MainAppContent(
}
}
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,
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,
)
}
} else {
closeStreamRoute()
}
}
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 = {
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,
)
},
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,
)
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 = {
@ -2329,6 +2258,7 @@ private fun MainAppContent(
torrentInfoHash = launch.torrentInfoHash,
torrentFileIdx = launch.torrentFileIdx,
torrentFilename = launch.torrentFilename,
torrentMagnetUri = launch.torrentMagnetUri,
torrentTrackers = launch.torrentTrackers,
initialPositionMs = launch.initialPositionMs,
initialProgressFraction = launch.initialProgressFraction,
@ -2336,14 +2266,6 @@ 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,11 +4,9 @@ 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
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -17,6 +15,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -26,57 +26,24 @@ 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
import com.mohamedrejeb.calf.ui.sheet.AdaptiveSheetState
import com.mohamedrejeb.calf.ui.sheet.rememberAdaptiveSheetState
typealias NuvioBottomSheetState = AdaptiveSheetState
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun rememberNuvioBottomSheetState(
skipPartiallyExpanded: Boolean = !isIos,
): NuvioBottomSheetState =
rememberAdaptiveSheetState(skipPartiallyExpanded = skipPartiallyExpanded)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NuvioModalBottomSheet(
onDismissRequest: () -> Unit,
sheetState: NuvioBottomSheetState,
sheetState: SheetState,
modifier: Modifier = Modifier,
containerColor: Color = MaterialTheme.colorScheme.surface,
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) {
modifier.fillMaxSize()
} else {
modifier
}
val sheetContent: @Composable ColumnScope.() -> Unit = {
if (isIos && iosContentTopPadding > 0.dp) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = iosContentTopPadding),
content = content,
)
} else {
content()
}
}
AdaptiveBottomSheet(
ModalBottomSheet(
onDismissRequest = onDismissRequest,
adaptiveSheetState = sheetState,
modifier = sheetModifier,
sheetState = sheetState,
modifier = modifier,
containerColor = containerColor,
contentColor = contentColor,
shape = shape,
@ -85,7 +52,7 @@ fun NuvioModalBottomSheet(
} else {
null
},
content = sheetContent,
content = content,
)
}
@ -137,7 +104,7 @@ fun NuvioBottomSheetActionRow(
@OptIn(ExperimentalMaterial3Api::class)
suspend fun dismissNuvioBottomSheet(
sheetState: NuvioBottomSheetState,
sheetState: SheetState,
onDismiss: () -> Unit,
) {
if (sheetState.isVisible) {
@ -155,4 +122,4 @@ private fun NuvioBottomSheetDragHandle() {
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.65f)),
)
}
}

View file

@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.Replay
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@ -51,7 +52,7 @@ fun NuvioContinueWatchingActionSheet(
onRemove: () -> Unit,
) {
if (item == null) return
val sheetState = rememberNuvioBottomSheetState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
fun dismissAfter(action: () -> Unit) {

View file

@ -18,7 +18,9 @@ import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -49,7 +51,7 @@ fun NuvioDropdownChip(
modifier: Modifier = Modifier,
) {
var isSheetVisible by remember { mutableStateOf(false) }
val sheetState = rememberNuvioBottomSheetState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
Row(
@ -115,7 +117,7 @@ private fun NuvioDropdownOptionsSheet(
title: String,
options: List<NuvioDropdownOption>,
selectedKey: String?,
sheetState: NuvioBottomSheetState,
sheetState: SheetState,
onDismiss: () -> Unit,
onSelected: (NuvioDropdownOption) -> Unit,
) {

View file

@ -23,6 +23,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@ -56,7 +57,7 @@ fun NuvioPosterActionSheet(
onToggleWatched: () -> Unit,
) {
if (item == null) return
val sheetState = rememberNuvioBottomSheetState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
NuvioModalBottomSheet(

View file

@ -44,7 +44,7 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import com.nuvio.app.core.ui.rememberNuvioBottomSheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -1918,7 +1918,7 @@ private fun GenrePickerSheet(
onSelect: (String?) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberNuvioBottomSheetState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
NuvioModalBottomSheet(
onDismissRequest = onDismiss,

View file

@ -24,7 +24,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import com.nuvio.app.core.ui.rememberNuvioBottomSheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
@ -51,7 +51,7 @@ fun CommentDetailSheet(
onNext: () -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberNuvioBottomSheetState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scrollState = rememberScrollState()
LaunchedEffect(comment.id) {

View file

@ -12,7 +12,7 @@ import androidx.compose.material.icons.filled.PlaylistAddCheckCircle
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import com.nuvio.app.core.ui.rememberNuvioBottomSheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
@ -46,7 +46,7 @@ fun EpisodeWatchedActionSheet(
showPlayManually: Boolean = false,
onPlayManually: (() -> Unit)? = null,
) {
val sheetState = rememberNuvioBottomSheetState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
NuvioModalBottomSheet(
@ -140,7 +140,7 @@ fun SeasonWatchedActionSheet(
onToggleSeasonWatched: () -> Unit,
onMarkPreviousSeasonsWatched: () -> Unit,
) {
val sheetState = rememberNuvioBottomSheetState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
NuvioModalBottomSheet(

View file

@ -18,7 +18,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import com.nuvio.app.core.ui.rememberNuvioBottomSheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -67,7 +67,7 @@ fun TrailerPlayerPopup(
}
}.joinToString(separator = "")
val sheetState = rememberNuvioBottomSheetState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
var playerError by remember(playbackSource?.videoUrl, playbackSource?.audioUrl) {
mutableStateOf<String?>(null)

View file

@ -45,6 +45,9 @@ object P2pSettingsRepository {
if (p2pEnabled == enabled) return
p2pEnabled = enabled
P2pSettingsStorage.saveP2pEnabled(enabled)
if (!enabled) {
P2pStreamingEngine.shutdown()
}
publish()
}
@ -94,6 +97,7 @@ data class P2pStreamRequest(
val infoHash: String,
val fileIdx: Int?,
val filename: String? = null,
val magnetUri: String? = null,
val trackers: List<String> = emptyList(),
)
@ -119,6 +123,8 @@ class P2pStreamingException(message: String) : Exception(message)
expect object P2pStreamingEngine {
val state: StateFlow<P2pStreamingState>
fun warmup()
fun cooldownWarmup()
suspend fun startStream(request: P2pStreamRequest): String
fun stopStream()
fun shutdown()

View file

@ -45,10 +45,10 @@ data class PlayerLaunch(
val torrentInfoHash: String? = null,
val torrentFileIdx: Int? = null,
val torrentFilename: String? = null,
val torrentMagnetUri: String? = null,
val torrentTrackers: List<String> = emptyList(),
val initialPositionMs: Long = 0L,
val initialProgressFraction: Float? = null,
val returnStreamLaunchId: Long? = null,
)
object PlayerLaunchStore {

View file

@ -180,6 +180,7 @@ fun PlayerScreen(
torrentInfoHash: String? = null,
torrentFileIdx: Int? = null,
torrentFilename: String? = null,
torrentMagnetUri: String? = null,
torrentTrackers: List<String> = emptyList(),
initialPositionMs: Long = 0L,
initialProgressFraction: Float? = null,
@ -250,6 +251,7 @@ fun PlayerScreen(
var activeTorrentInfoHash by rememberSaveable { mutableStateOf(torrentInfoHash) }
var activeTorrentFileIdx by rememberSaveable { mutableStateOf(torrentFileIdx) }
var activeTorrentFilename by rememberSaveable { mutableStateOf(torrentFilename) }
var activeTorrentMagnetUri by rememberSaveable { mutableStateOf(torrentMagnetUri) }
var activeTorrentTrackers by remember { mutableStateOf(torrentTrackers) }
var p2pResolvedSourceUrl by remember { mutableStateOf<String?>(null) }
val activePlaybackIdentity = activeTorrentInfoHash
@ -1249,6 +1251,7 @@ fun PlayerScreen(
activeTorrentInfoHash = null
activeTorrentFileIdx = null
activeTorrentFilename = null
activeTorrentMagnetUri = null
activeTorrentTrackers = emptyList()
p2pResolvedSourceUrl = null
}
@ -1276,11 +1279,12 @@ fun PlayerScreen(
addonId = stream.addonId,
requestHeaders = emptyMap(),
responseHeaders = emptyMap(),
filename = stream.behaviorHints.filename,
filename = stream.p2pFilename,
videoSize = stream.behaviorHints.videoSize,
infoHash = infoHash,
fileIdx = stream.fileIdx,
sources = stream.sources,
fileIdx = stream.p2pFileIdx,
magnetUri = stream.torrentMagnetUri,
sources = stream.p2pSourceHints,
bingeGroup = stream.behaviorHints.bingeGroup,
)
}
@ -1301,13 +1305,14 @@ fun PlayerScreen(
season = activeSeasonNumber,
episode = activeEpisodeNumber,
)
activeSourceUrl = p2pSentinelUrl(infoHash, stream.fileIdx)
activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
activeSourceAudioUrl = null
activeSourceHeaders = emptyMap()
activeSourceResponseHeaders = emptyMap()
activeTorrentInfoHash = infoHash
activeTorrentFileIdx = stream.fileIdx
activeTorrentFilename = stream.behaviorHints.filename
activeTorrentFileIdx = stream.p2pFileIdx
activeTorrentFilename = stream.p2pFilename
activeTorrentMagnetUri = stream.torrentMagnetUri
activeTorrentTrackers = stream.p2pTrackers
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
@ -1359,13 +1364,14 @@ fun PlayerScreen(
season = episode.season,
episode = episode.episode,
)
activeSourceUrl = p2pSentinelUrl(infoHash, stream.fileIdx)
activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
activeSourceAudioUrl = null
activeSourceHeaders = emptyMap()
activeSourceResponseHeaders = emptyMap()
activeTorrentInfoHash = infoHash
activeTorrentFileIdx = stream.fileIdx
activeTorrentFilename = stream.behaviorHints.filename
activeTorrentFileIdx = stream.p2pFileIdx
activeTorrentFilename = stream.p2pFilename
activeTorrentMagnetUri = stream.torrentMagnetUri
activeTorrentTrackers = stream.p2pTrackers
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
@ -1984,6 +1990,7 @@ fun PlayerScreen(
activeTorrentInfoHash,
activeTorrentFileIdx,
activeTorrentFilename,
activeTorrentMagnetUri,
activeTorrentTrackers,
p2pSettingsUiState.p2pEnabled,
) {
@ -2000,6 +2007,7 @@ fun PlayerScreen(
p2pResolvedSourceUrl = null
val requestedFileIdx = activeTorrentFileIdx
val requestedFilename = activeTorrentFilename
val requestedMagnetUri = activeTorrentMagnetUri
val requestedTrackers = activeTorrentTrackers
errorMessage = null
playerController = null
@ -2013,6 +2021,7 @@ fun PlayerScreen(
infoHash = infoHash,
fileIdx = requestedFileIdx,
filename = requestedFilename,
magnetUri = requestedMagnetUri,
trackers = requestedTrackers,
),
)

View file

@ -65,7 +65,7 @@ import nuvio.composeapp.generated.resources.settings_appearance_section_theme
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import androidx.compose.material3.ExperimentalMaterial3Api
import com.nuvio.app.core.ui.rememberNuvioBottomSheetState
import androidx.compose.material3.rememberModalBottomSheetState
@OptIn(ExperimentalLayoutApi::class)
internal fun LazyListScope.appearanceSettingsContent(
@ -196,7 +196,7 @@ private fun AppearanceLanguageBottomSheet(
onLanguageSelected: (AppLanguage) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberNuvioBottomSheetState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
val options = remember {
AppLanguage.entries.map { language ->

View file

@ -16,6 +16,7 @@ data class CachedStreamLink(
val videoSize: Long? = null,
val infoHash: String? = null,
val fileIdx: Int? = null,
val magnetUri: String? = null,
val sources: List<String> = emptyList(),
val bingeGroup: String? = null,
)
@ -52,6 +53,7 @@ object StreamLinkCacheRepository {
videoSize: Long? = null,
infoHash: String? = null,
fileIdx: Int? = null,
magnetUri: String? = null,
sources: List<String> = emptyList(),
bingeGroup: String? = null,
) {
@ -67,6 +69,7 @@ object StreamLinkCacheRepository {
videoSize = videoSize,
infoHash = infoHash,
fileIdx = fileIdx,
magnetUri = magnetUri,
sources = sources,
bingeGroup = bingeGroup,
)
@ -92,7 +95,7 @@ object StreamLinkCacheRepository {
StreamLinkCacheStorage.removeEntry(hashedKey(contentKey))
return null
}
if (entry.url.isBlank() && entry.infoHash.isNullOrBlank()) {
if (entry.url.isBlank() && entry.infoHash.isNullOrBlank() && entry.magnetUri.isNullOrBlank()) {
StreamLinkCacheStorage.removeEntry(hashedKey(contentKey))
return null
}

View file

@ -36,7 +36,7 @@ data class StreamItem(
.firstOrNull { !it.isMagnetLink() }
val torrentMagnetUri: String?
get() = listOfNotNull(url, externalUrl)
get() = listOfNotNull(url, externalUrl, clientResolve?.magnetUri)
.firstOrNull { it.isMagnetLink() }
val isDirectDebridStream: Boolean
@ -61,10 +61,17 @@ data class StreamItem(
val p2pInfoHash: String?
get() = infoHash.normalizedInfoHash()
?: clientResolve?.infoHash.normalizedInfoHash()
?: clientResolve?.magnetUri.extractBtihInfoHash()
?: torrentMagnetUri.extractBtihInfoHash()
val p2pFileIdx: Int?
get() = fileIdx ?: clientResolve?.fileIdx
val p2pFilename: String?
get() = behaviorHints.filename ?: clientResolve?.filename
val p2pTrackers: List<String>
get() = sources
get() = p2pSourceHints
.asSequence()
.filter { it.startsWith("tracker:") }
.map { it.removePrefix("tracker:").trim() }
@ -72,6 +79,10 @@ data class StreamItem(
.distinct()
.toList()
val p2pSourceHints: List<String>
get() = (sources + clientResolve?.sources.orEmpty())
.distinct()
val isAddonDebridCandidate: Boolean
get() = isInstalledAddonStream && (needsLocalDebridResolve || isDirectDebridStream)

View file

@ -53,6 +53,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -86,11 +87,13 @@ import com.nuvio.app.core.ui.dismissNuvioBottomSheet
import com.nuvio.app.features.downloads.DownloadsRepository
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.material3.ExperimentalMaterial3Api
import com.nuvio.app.core.ui.rememberNuvioBottomSheetState
import androidx.compose.material3.rememberModalBottomSheetState
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.features.debrid.DebridProviders
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.p2p.P2pSettingsRepository
import com.nuvio.app.features.p2p.P2pStreamingEngine
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import kotlinx.coroutines.launch
@ -129,7 +132,6 @@ fun StreamsScreen(
) -> Unit = { _, _, _, _ -> },
onBack: () -> Unit,
modifier: Modifier = Modifier,
showBackButton: Boolean = true,
) {
val uiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
val playerSettings by remember {
@ -140,6 +142,10 @@ fun StreamsScreen(
DebridSettingsRepository.ensureLoaded()
DebridSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val p2pSettings by remember {
P2pSettingsRepository.ensureLoaded()
P2pSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val watchProgressUiState by remember {
WatchProgressRepository.ensureLoaded()
WatchProgressRepository.uiState
@ -181,6 +187,15 @@ fun StreamsScreen(
}
}
DisposableEffect(P2pSettingsRepository.isVisible, p2pSettings.p2pEnabled) {
if (P2pSettingsRepository.isVisible && p2pSettings.p2pEnabled) {
P2pStreamingEngine.warmup()
}
onDispose {
P2pStreamingEngine.cooldownWarmup()
}
}
LaunchedEffect(type, videoId, seasonNumber, episodeNumber, manualSelection) {
StreamsRepository.load(
type = type,
@ -263,15 +278,13 @@ fun StreamsScreen(
.padding(start = 12.dp, top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (showBackButton) {
NuvioBackButton(
onClick = onBack,
modifier = Modifier
.size(40.dp),
containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.45f),
contentColor = MaterialTheme.colorScheme.onBackground,
)
}
NuvioBackButton(
onClick = onBack,
modifier = Modifier
.size(40.dp),
containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.45f),
contentColor = MaterialTheme.colorScheme.onBackground,
)
Box(
modifier = Modifier
@ -1129,7 +1142,7 @@ private fun StreamActionsSheet(
) {
if (stream == null) return
val sheetState = rememberNuvioBottomSheetState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
NuvioModalBottomSheet(

View file

@ -35,7 +35,6 @@ object TmdbMetadataService {
private val entityBrowseCache = mutableMapOf<String, TmdbEntityBrowseData>()
private val entityHeaderCache = mutableMapOf<String, TmdbEntityHeader>()
private val entityRailCache = mutableMapOf<String, List<MetaPreview>>()
private val previewArtworkCache = mutableMapOf<String, TmdbPreviewArtwork>()
suspend fun fetchPersonDetail(
personId: Int,
@ -79,20 +78,17 @@ object TmdbMetadataService {
val preferCrew = preferCrewCredits ?: shouldPreferCrewCredits(person.knownForDepartment)
val castMovieCredits = mapPersonMovieCreditsFromCast(credits?.cast.orEmpty(), language)
val crewMovieCredits = mapPersonMovieCreditsFromCrew(credits?.crew.orEmpty(), language)
val movieCredits = when {
preferCrew && crewMovieCredits.isNotEmpty() -> crewMovieCredits
castMovieCredits.isNotEmpty() -> castMovieCredits
else -> crewMovieCredits
}
val castTvCredits = mapPersonTvCreditsFromCast(credits?.cast.orEmpty(), language)
val crewTvCredits = mapPersonTvCreditsFromCrew(credits?.crew.orEmpty(), language)
val tvCredits = when {
preferCrew && crewTvCredits.isNotEmpty() -> crewTvCredits
castTvCredits.isNotEmpty() -> castTvCredits
else -> crewTvCredits
val (castMovieCredits, crewMovieCredits, castTvCredits, crewTvCredits) = coroutineScope {
val castMovieDeferred = async { mapPersonMovieCreditsFromCast(credits?.cast.orEmpty()) }
val crewMovieDeferred = async { mapPersonMovieCreditsFromCrew(credits?.crew.orEmpty()) }
val castTvDeferred = async { mapPersonTvCreditsFromCast(credits?.cast.orEmpty()) }
val crewTvDeferred = async { mapPersonTvCreditsFromCrew(credits?.crew.orEmpty()) }
PersonCreditBuckets(
castMovieCredits = castMovieDeferred.await(),
crewMovieCredits = crewMovieDeferred.await(),
castTvCredits = castTvDeferred.await(),
crewTvCredits = crewTvDeferred.await(),
)
}
val detail = PersonDetail(
@ -104,8 +100,16 @@ object TmdbMetadataService {
placeOfBirth = person.placeOfBirth?.takeIf { it.isNotBlank() },
profilePhoto = buildImageUrl(person.profilePath, "w500"),
knownFor = person.knownForDepartment?.takeIf { it.isNotBlank() },
movieCredits = movieCredits,
tvCredits = tvCredits,
movieCredits = selectPreferredCredits(
preferCrew = preferCrew,
castCredits = castMovieCredits,
crewCredits = crewMovieCredits,
),
tvCredits = selectPreferredCredits(
preferCrew = preferCrew,
castCredits = castTvCredits,
crewCredits = crewTvCredits,
),
)
personCache[cacheKey] = detail
detail
@ -120,160 +124,133 @@ object TmdbMetadataService {
return department.isNotBlank() && department != "acting" && department != "actors"
}
private suspend fun mapPersonMovieCreditsFromCast(
private data class PersonCreditBuckets(
val castMovieCredits: List<MetaPreview>,
val crewMovieCredits: List<MetaPreview>,
val castTvCredits: List<MetaPreview>,
val crewTvCredits: List<MetaPreview>,
)
private fun selectPreferredCredits(
preferCrew: Boolean,
castCredits: List<MetaPreview>,
crewCredits: List<MetaPreview>,
): List<MetaPreview> = when {
preferCrew && crewCredits.isNotEmpty() -> crewCredits
castCredits.isNotEmpty() -> castCredits
else -> crewCredits
}
private fun mapPersonMovieCreditsFromCast(
cast: List<TmdbPersonCreditCast>,
language: String,
): List<MetaPreview> = coroutineScope {
): List<MetaPreview> {
val seen = mutableSetOf<Int>()
cast
return cast
.filter { it.mediaType == "movie" && (it.posterPath != null || it.backdropPath != null) }
.sortedByDescending { it.voteAverage ?: 0.0 }
.mapNotNull { credit ->
if (!seen.add(credit.id)) return@mapNotNull null
val title = credit.title ?: credit.name ?: return@mapNotNull null
async {
val artwork = fetchPreviewArtwork(
tmdbId = credit.id,
mediaType = "movie",
language = language,
)
val poster = buildImageUrl(credit.posterPath, "w500")
?: buildImageUrl(credit.backdropPath, "w780")
?: artwork?.backdrop
?: return@async null
MetaPreview(
id = "tmdb:${credit.id}",
type = "movie",
name = title,
poster = poster,
banner = buildImageUrl(credit.backdropPath, "w780") ?: artwork?.backdrop,
logo = artwork?.logo,
description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = credit.releaseDate?.take(4),
rawReleaseDate = credit.releaseDate,
popularity = credit.popularity,
)
}
val poster = buildImageUrl(credit.posterPath, "w500")
?: buildImageUrl(credit.backdropPath, "w780")
?: return@mapNotNull null
MetaPreview(
id = "tmdb:${credit.id}",
type = "movie",
name = title,
poster = poster,
banner = buildImageUrl(credit.backdropPath, "w780"),
logo = null,
description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = credit.releaseDate?.take(4),
rawReleaseDate = credit.releaseDate,
popularity = credit.popularity,
)
}
.awaitAll()
.filterNotNull()
}
private suspend fun mapPersonMovieCreditsFromCrew(
private fun mapPersonMovieCreditsFromCrew(
crew: List<TmdbPersonCreditCrew>,
language: String,
): List<MetaPreview> = coroutineScope {
): List<MetaPreview> {
val seen = mutableSetOf<Int>()
crew
return crew
.filter { it.mediaType == "movie" && (it.posterPath != null || it.backdropPath != null) }
.sortedByDescending { it.voteAverage ?: 0.0 }
.mapNotNull { credit ->
if (!seen.add(credit.id)) return@mapNotNull null
val title = credit.title ?: credit.name ?: return@mapNotNull null
async {
val artwork = fetchPreviewArtwork(
tmdbId = credit.id,
mediaType = "movie",
language = language,
)
val poster = buildImageUrl(credit.posterPath, "w500")
?: buildImageUrl(credit.backdropPath, "w780")
?: artwork?.backdrop
?: return@async null
MetaPreview(
id = "tmdb:${credit.id}",
type = "movie",
name = title,
poster = poster,
banner = buildImageUrl(credit.backdropPath, "w780") ?: artwork?.backdrop,
logo = artwork?.logo,
description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = credit.releaseDate?.take(4),
rawReleaseDate = credit.releaseDate,
popularity = credit.popularity,
)
}
val poster = buildImageUrl(credit.posterPath, "w500")
?: buildImageUrl(credit.backdropPath, "w780")
?: return@mapNotNull null
MetaPreview(
id = "tmdb:${credit.id}",
type = "movie",
name = title,
poster = poster,
banner = buildImageUrl(credit.backdropPath, "w780"),
logo = null,
description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = credit.releaseDate?.take(4),
rawReleaseDate = credit.releaseDate,
popularity = credit.popularity,
)
}
.awaitAll()
.filterNotNull()
}
private suspend fun mapPersonTvCreditsFromCast(
private fun mapPersonTvCreditsFromCast(
cast: List<TmdbPersonCreditCast>,
language: String,
): List<MetaPreview> = coroutineScope {
): List<MetaPreview> {
val seen = mutableSetOf<Int>()
cast
return cast
.filter { it.mediaType == "tv" && (it.posterPath != null || it.backdropPath != null) }
.sortedByDescending { it.voteAverage ?: 0.0 }
.mapNotNull { credit ->
if (!seen.add(credit.id)) return@mapNotNull null
val title = credit.name ?: credit.title ?: return@mapNotNull null
async {
val artwork = fetchPreviewArtwork(
tmdbId = credit.id,
mediaType = "tv",
language = language,
)
val poster = buildImageUrl(credit.posterPath, "w500")
?: buildImageUrl(credit.backdropPath, "w780")
?: artwork?.backdrop
?: return@async null
MetaPreview(
id = "tmdb:${credit.id}",
type = "series",
name = title,
poster = poster,
banner = buildImageUrl(credit.backdropPath, "w780") ?: artwork?.backdrop,
logo = artwork?.logo,
description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = credit.firstAirDate?.take(4),
rawReleaseDate = credit.firstAirDate,
popularity = credit.popularity,
)
}
val poster = buildImageUrl(credit.posterPath, "w500")
?: buildImageUrl(credit.backdropPath, "w780")
?: return@mapNotNull null
MetaPreview(
id = "tmdb:${credit.id}",
type = "series",
name = title,
poster = poster,
banner = buildImageUrl(credit.backdropPath, "w780"),
logo = null,
description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = credit.firstAirDate?.take(4),
rawReleaseDate = credit.firstAirDate,
popularity = credit.popularity,
)
}
.awaitAll()
.filterNotNull()
}
private suspend fun mapPersonTvCreditsFromCrew(
private fun mapPersonTvCreditsFromCrew(
crew: List<TmdbPersonCreditCrew>,
language: String,
): List<MetaPreview> = coroutineScope {
): List<MetaPreview> {
val seen = mutableSetOf<Int>()
crew
return crew
.filter { it.mediaType == "tv" && (it.posterPath != null || it.backdropPath != null) }
.sortedByDescending { it.voteAverage ?: 0.0 }
.mapNotNull { credit ->
if (!seen.add(credit.id)) return@mapNotNull null
val title = credit.name ?: credit.title ?: return@mapNotNull null
async {
val artwork = fetchPreviewArtwork(
tmdbId = credit.id,
mediaType = "tv",
language = language,
)
val poster = buildImageUrl(credit.posterPath, "w500")
?: buildImageUrl(credit.backdropPath, "w780")
?: artwork?.backdrop
?: return@async null
MetaPreview(
id = "tmdb:${credit.id}",
type = "series",
name = title,
poster = poster,
banner = buildImageUrl(credit.backdropPath, "w780") ?: artwork?.backdrop,
logo = artwork?.logo,
description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = credit.firstAirDate?.take(4),
rawReleaseDate = credit.firstAirDate,
popularity = credit.popularity,
)
}
val poster = buildImageUrl(credit.posterPath, "w500")
?: buildImageUrl(credit.backdropPath, "w780")
?: return@mapNotNull null
MetaPreview(
id = "tmdb:${credit.id}",
type = "series",
name = title,
poster = poster,
banner = buildImageUrl(credit.backdropPath, "w780"),
logo = null,
description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = credit.firstAirDate?.take(4),
rawReleaseDate = credit.firstAirDate,
popularity = credit.popularity,
)
}
.awaitAll()
.filterNotNull()
}
suspend fun fetchEntityBrowse(
@ -289,37 +266,43 @@ object TmdbMetadataService {
val cacheKey = "${entityKind.routeValue}:$entityId:$normalizedSourceType:$language"
entityBrowseCache[cacheKey]?.let { return@withContext it }
val header = fetchEntityHeader(
entityKind = entityKind,
entityId = entityId,
fallbackName = fallbackName,
language = language,
)
val rails = buildEntityMediaOrder(entityKind, normalizedSourceType)
.flatMap { mediaType ->
TmdbEntityRailType.entries.mapNotNull { railType ->
val pageResult = fetchEntityRailPage(
entityKind = entityKind,
entityId = entityId,
mediaType = mediaType,
railType = railType,
language = language,
page = 1,
)
if (pageResult.items.isEmpty()) {
null
} else {
TmdbEntityRail(
mediaType = mediaType,
railType = railType,
items = pageResult.items,
currentPage = 1,
hasMore = pageResult.hasMore,
)
val (header, rails) = coroutineScope {
val headerDeferred = async {
fetchEntityHeader(
entityKind = entityKind,
entityId = entityId,
fallbackName = fallbackName,
language = language,
)
}
val railDeferreds = buildEntityMediaOrder(entityKind, normalizedSourceType)
.flatMap { mediaType ->
TmdbEntityRailType.entries.map { railType ->
async {
val pageResult = fetchEntityRailPage(
entityKind = entityKind,
entityId = entityId,
mediaType = mediaType,
railType = railType,
language = language,
page = 1,
)
if (pageResult.items.isEmpty()) {
null
} else {
TmdbEntityRail(
mediaType = mediaType,
railType = railType,
items = pageResult.items,
currentPage = 1,
hasMore = pageResult.hasMore,
)
}
}
}
}
}
headerDeferred.await() to railDeferreds.awaitAll().filterNotNull()
}
if (header == null && rails.isEmpty()) return@withContext null
@ -397,16 +380,10 @@ object TmdbMetadataService {
val results = response?.results.orEmpty()
val totalPages = response?.totalPages ?: page
val mappedItems = coroutineScope {
results
.filter { it.id > 0 }
.map { item ->
async { mapEntityDiscoverResult(item, mediaType, language) }
}
.awaitAll()
.filterNotNull()
.take(ENTITY_RAIL_MAX_ITEMS)
}
val mappedItems = results
.filter { it.id > 0 }
.mapNotNull { item -> mapEntityDiscoverResult(item, mediaType) }
.take(ENTITY_RAIL_MAX_ITEMS)
TmdbEntityRailPageResult(
items = mappedItems,
@ -488,10 +465,9 @@ object TmdbMetadataService {
return header
}
private suspend fun mapEntityDiscoverResult(
private fun mapEntityDiscoverResult(
result: TmdbDiscoverResult,
mediaType: TmdbEntityMediaType,
language: String,
): MetaPreview? {
val title = result.title?.takeIf { it.isNotBlank() }
?: result.name?.takeIf { it.isNotBlank() }
@ -499,15 +475,8 @@ object TmdbMetadataService {
?: result.originalName?.takeIf { it.isNotBlank() }
?: return null
val artwork = fetchPreviewArtwork(
tmdbId = result.id,
mediaType = mediaType.value,
language = language,
)
val poster = buildImageUrl(result.posterPath, "w500")
?: buildImageUrl(result.backdropPath, "w780")
?: artwork?.backdrop
?: return null
val releaseInfo = when (mediaType) {
TmdbEntityMediaType.MOVIE -> result.releaseDate?.take(4)
@ -518,60 +487,13 @@ object TmdbMetadataService {
type = if (mediaType == TmdbEntityMediaType.TV) "series" else "movie",
name = title,
poster = poster,
banner = buildImageUrl(result.backdropPath, "w780") ?: artwork?.backdrop,
logo = artwork?.logo,
banner = buildImageUrl(result.backdropPath, "w780"),
logo = null,
description = result.overview?.takeIf { it.isNotBlank() },
releaseInfo = releaseInfo,
)
}
private data class TmdbPreviewArtwork(
val backdrop: String?,
val logo: String?,
)
private suspend fun fetchPreviewArtwork(
tmdbId: Int,
mediaType: String,
language: String,
): TmdbPreviewArtwork? = withContext(Dispatchers.Default) {
val normalizedLanguage = normalizeTmdbLanguage(language)
val cacheKey = "$tmdbId:$mediaType:$normalizedLanguage:preview_artwork"
previewArtworkCache[cacheKey]?.let { cached ->
return@withContext cached.takeIf { it.backdrop != null || it.logo != null }
}
val includeImageLanguage = buildString {
append(normalizedLanguage.substringBefore("-"))
append(",")
append(normalizedLanguage)
append(",en,null")
}
val response = coroutineScope {
val details = async {
fetch<TmdbDetailsResponse>(
endpoint = "$mediaType/$tmdbId",
query = mapOf("language" to normalizedLanguage),
)
}
val images = async {
fetch<TmdbImagesResponse>(
endpoint = "$mediaType/$tmdbId/images",
query = mapOf("include_image_language" to includeImageLanguage),
)
}
details.await() to images.await()
}
val artwork = TmdbPreviewArtwork(
backdrop = buildImageUrl(response.first?.backdropPath, "w1280"),
logo = buildImageUrl(response.second?.logos.orEmpty().selectBestLocalizedImagePath(normalizedLanguage), "w500"),
)
previewArtworkCache[cacheKey] = artwork
artwork.takeIf { it.backdrop != null || it.logo != null }
}
private fun buildEntityMediaOrder(
entityKind: TmdbEntityKind,
sourceType: String,

View file

@ -0,0 +1,57 @@
package com.nuvio.app.features.streams
import kotlin.test.Test
import kotlin.test.assertEquals
class StreamItemP2pMetadataTest {
@Test
fun usesClientResolveP2pMetadataWhenTopLevelFieldsAreMissing() {
val magnet = "magnet:?xt=urn:btih:ABC123&dn=Movie&tr=udp%3A%2F%2Fmagnet.test%3A80%2Fannounce"
val stream = StreamItem(
name = "Resolved torrent",
addonName = "Addon",
addonId = "addon:test",
sources = listOf(
"tracker:udp://base.test:80/announce",
"dht:ABC123",
),
clientResolve = StreamClientResolve(
infoHash = "ABC123",
fileIdx = 2,
magnetUri = magnet,
filename = "movie.mkv",
sources = listOf(
"tracker:udp://client.test:80/announce",
"tracker:udp://base.test:80/announce",
),
),
)
assertEquals("ABC123", stream.p2pInfoHash)
assertEquals(2, stream.p2pFileIdx)
assertEquals("movie.mkv", stream.p2pFilename)
assertEquals(magnet, stream.torrentMagnetUri)
assertEquals(
listOf(
"udp://base.test:80/announce",
"udp://client.test:80/announce",
),
stream.p2pTrackers,
)
}
@Test
fun extractsP2pInfoHashFromClientResolveMagnet() {
val stream = StreamItem(
name = "Magnet-only torrent",
addonName = "Addon",
addonId = "addon:test",
clientResolve = StreamClientResolve(
magnetUri = "magnet:?xt=urn:btih:def456&dn=Movie",
),
)
assertEquals("def456", stream.p2pInfoHash)
assertEquals("magnet:?xt=urn:btih:def456&dn=Movie", stream.torrentMagnetUri)
}
}

View file

@ -8,6 +8,14 @@ actual object P2pStreamingEngine {
private val _state = MutableStateFlow<P2pStreamingState>(P2pStreamingState.Idle)
actual val state: StateFlow<P2pStreamingState> = _state.asStateFlow()
actual fun warmup() {
_state.value = P2pStreamingState.Idle
}
actual fun cooldownWarmup() {
_state.value = P2pStreamingState.Idle
}
actual suspend fun startStream(request: P2pStreamRequest): String {
val message = "P2P streaming is not available on this platform"
_state.value = P2pStreamingState.Error(message)

View file

@ -14,7 +14,6 @@ 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"
@ -53,7 +52,6 @@ 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" }

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=73
MARKETING_VERSION=0.2.2
CURRENT_PROJECT_VERSION=74
MARKETING_VERSION=0.2.3

View file

@ -62,6 +62,7 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
func setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) }
func getVolume() -> Float { playerVC?.getVolume() ?? 1.0 }
func setVolume(volume: Float) { playerVC?.setVolume(volume) }
func setMuted(muted: Bool) { playerVC?.setMuted(muted) }
func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) }
// Audio tracks
@ -549,6 +550,10 @@ final class MPVPlayerViewController: UIViewController {
var gainDb = 20.0 * log10(clamped)
checkError(mpv_set_property(mpv, "volume", MPV_FORMAT_DOUBLE, &baseVolume))
checkError(mpv_set_property(mpv, "volume-gain", MPV_FORMAT_DOUBLE, &gainDb))
func setMuted(_ muted: Bool) {
guard mpv != nil else { return }
setFlag("mute", muted)
}
func setResize(_ mode: Int) {

View file

@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TORRSERVER_DIR="${TORRSERVER_DIR:-"${ROOT_DIR}/vendor/TorrServer/server"}"
OUT_DIR="${OUT_DIR:-"${ROOT_DIR}/composeApp/src/androidMain/jniLibs"}"
GO_BIN="${GO_BIN:-go}"
UPX_BIN="${UPX_BIN:-upx}"
SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-"${HOME}/Library/Android/sdk"}}"
NDK_ROOT="${ANDROID_NDK_HOME:-}"
export GOCACHE="${GOCACHE:-"${ROOT_DIR}/build/go-cache"}"
export GOMODCACHE="${GOMODCACHE:-"${ROOT_DIR}/build/go-mod-cache"}"
if ! command -v "${GO_BIN}" >/dev/null 2>&1; then
echo "Go toolchain not found. Install Go or set GO_BIN=/path/to/go." >&2
exit 1
fi
if [[ ! -d "${TORRSERVER_DIR}" ]]; then
echo "TorrServer source not found at ${TORRSERVER_DIR}" >&2
exit 1
fi
if [[ -z "${NDK_ROOT}" ]]; then
if [[ ! -d "${SDK_ROOT}/ndk" ]]; then
echo "Android NDK not found. Set ANDROID_HOME, ANDROID_SDK_ROOT, or ANDROID_NDK_HOME." >&2
exit 1
fi
NDK_VERSION="$(ls -1 "${SDK_ROOT}/ndk" | sort | tail -n 1)"
NDK_ROOT="${SDK_ROOT}/ndk/${NDK_VERSION}"
fi
PREBUILT_ROOT="${NDK_ROOT}/toolchains/llvm/prebuilt"
HOST_TAG=""
for candidate in darwin-x86_64 linux-x86_64; do
if [[ -d "${PREBUILT_ROOT}/${candidate}" ]]; then
HOST_TAG="${candidate}"
break
fi
done
if [[ -z "${HOST_TAG}" ]]; then
echo "Could not find an LLVM prebuilt toolchain under ${PREBUILT_ROOT}" >&2
exit 1
fi
TOOLCHAIN="${PREBUILT_ROOT}/${HOST_TAG}"
LDFLAGS="${LDFLAGS:-"-s -w -checklinkname=0"}"
BUILD_FLAGS=(-tags=nosqlite -trimpath -ldflags="${LDFLAGS}")
mkdir -p "${GOCACHE}" "${GOMODCACHE}"
build_abi() {
local abi="$1"
local goarch="$2"
local goarm="$3"
local triple="$4"
local api_level="$5"
local cc="${TOOLCHAIN}/bin/${triple}${api_level}-clang"
local cxx="${TOOLCHAIN}/bin/${triple}${api_level}-clang++"
local output="${OUT_DIR}/${abi}/libtorrserver.so"
if [[ ! -x "${cc}" ]]; then
echo "Compiler not found: ${cc}" >&2
exit 1
fi
mkdir -p "$(dirname "${output}")"
echo "Building ${abi} -> ${output}"
local env_vars=(
GOOS=android
GOARCH="${goarch}"
CGO_ENABLED=1
CC="${cc}"
CXX="${cxx}"
)
if [[ -n "${goarm}" ]]; then
env_vars+=(GOARM="${goarm}")
fi
(
cd "${TORRSERVER_DIR}"
env "${env_vars[@]}" "${GO_BIN}" build "${BUILD_FLAGS[@]}" -o "${output}" ./cmd
)
chmod 755 "${output}"
if [[ "${USE_UPX:-0}" == "1" ]] && command -v "${UPX_BIN}" >/dev/null 2>&1; then
"${UPX_BIN}" -q "${output}" || echo "UPX compression failed for ${output}; leaving uncompressed" >&2
fi
}
build_abi "arm64-v8a" "arm64" "" "aarch64-linux-android" "21"
build_abi "armeabi-v7a" "arm" "7" "armv7a-linux-androideabi" "21"
build_abi "x86" "386" "" "i686-linux-android" "21"
build_abi "x86_64" "amd64" "" "x86_64-linux-android" "21"
echo "TorrServer Android binaries updated in ${OUT_DIR}"

View file

@ -140,46 +140,75 @@ android_flavor_task_part() {
esac
}
android_apk_path() {
android_apk_dir() {
local flavor="$1"
case "$flavor" in
full)
echo "$ROOT_DIR/composeApp/build/outputs/apk/full/debug/composeApp-full-debug.apk"
echo "$ROOT_DIR/composeApp/build/outputs/apk/full/debug"
;;
playstore)
echo "$ROOT_DIR/composeApp/build/outputs/apk/playstore/debug/composeApp-playstore-debug.apk"
echo "$ROOT_DIR/composeApp/build/outputs/apk/playstore/debug"
;;
esac
}
build_android_apk() {
android_device_primary_abi() {
local serial="$1"
adb -s "$serial" shell getprop ro.product.cpu.abi | tr -d '\r'
}
android_split_apk_path() {
local flavor="$1"
local flavor_task_part
local serial="$2"
local apk_dir
local abi
local apk_path
flavor_task_part="$(android_flavor_task_part "$flavor")"
apk_path="$(android_apk_path "$flavor")"
apk_dir="$(android_apk_dir "$flavor")"
abi="$(android_device_primary_abi "$serial")"
echo "Building Android $flavor debug APK..." >&2
"$GRADLEW" ":composeApp:assemble${flavor_task_part}Debug" >&2
apk_path="$(find "$apk_dir" -maxdepth 1 -type f -name "*-${abi}-debug.apk" | sort | head -n 1)"
if [[ -z "$apk_path" ]]; then
apk_path="$(find "$apk_dir" -maxdepth 1 -type f -name "*${abi}*.apk" | sort | head -n 1)"
fi
if [[ ! -f "$apk_path" ]]; then
echo "Expected APK not found at: $apk_path" >&2
if [[ -z "$apk_path" || ! -f "$apk_path" ]]; then
echo "Expected split APK for ABI '$abi' not found in: $apk_dir" >&2
find "$apk_dir" -maxdepth 1 -type f -name "*.apk" -print >&2 || true
exit 1
fi
printf '%s\n' "$apk_path"
}
build_android_apks() {
local flavor="$1"
local flavor_task_part
local apk_dir
flavor_task_part="$(android_flavor_task_part "$flavor")"
apk_dir="$(android_apk_dir "$flavor")"
echo "Building Android $flavor debug split APKs..." >&2
"$GRADLEW" ":composeApp:assemble${flavor_task_part}Debug" >&2
if ! find "$apk_dir" -maxdepth 1 -type f -name "*.apk" | grep -q .; then
echo "Expected split APKs not found in: $apk_dir" >&2
exit 1
fi
}
install_and_launch_android() {
local device_label="$1"
local apk_path="$2"
local flavor="$2"
shift 2
local apk_path
local serial
for serial in "$@"; do
echo "Installing on $device_label $serial..."
apk_path="$(android_split_apk_path "$flavor" "$serial")"
echo "Installing on $device_label $serial: $apk_path"
adb -s "$serial" install -r "$apk_path"
echo "Launching app on $serial..."
@ -248,10 +277,9 @@ run_android_emulator() {
wait_for_android_emulator "$serial"
done
local apk_path
apk_path="$(build_android_apk "$flavor")"
build_android_apks "$flavor"
install_and_launch_android "emulator" "$apk_path" "${booted_serials[@]}"
install_and_launch_android "emulator" "$flavor" "${booted_serials[@]}"
}
run_android_physical() {
@ -280,10 +308,9 @@ run_android_physical() {
wait_for_android_device "$serial"
done
local apk_path
apk_path="$(build_android_apk "$flavor")"
build_android_apks "$flavor"
install_and_launch_android "physical device" "$apk_path" "${serials[@]}"
install_and_launch_android "physical device" "$flavor" "${serials[@]}"
}
run_ios_simulator() {