mirror of
https://github.com/NuvioMedia/NuvioMobile.git
synced 2026-07-26 17:12:16 +00:00
Merge remote-tracking branch 'upstream/cmp-rewrite' into feat-volume-boost
This commit is contained in:
commit
c075178ff8
31 changed files with 1329 additions and 697 deletions
|
|
@ -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}"
|
||||
|
|
|
|||
4
composeApp/proguard-rules.pro
vendored
4
composeApp/proguard-rules.pro
vendored
|
|
@ -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.** { *; }
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
CURRENT_PROJECT_VERSION=73
|
||||
MARKETING_VERSION=0.2.2
|
||||
CURRENT_PROJECT_VERSION=74
|
||||
MARKETING_VERSION=0.2.3
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
96
scripts/build-torrserver-android.sh
Executable file
96
scripts/build-torrserver-android.sh
Executable 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}"
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue