mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
feat: integrate Nuvio Engine
This commit is contained in:
parent
66f4d433ea
commit
1b88c5abef
32 changed files with 2132 additions and 521 deletions
|
|
@ -226,6 +226,7 @@ val iosDistributionSourceDir = if (iosDistribution == "full") {
|
|||
"src/iosAppStore/kotlin"
|
||||
}
|
||||
val iosFrameworkBundleId = "com.nuvio.media"
|
||||
val nuvioEngineAppleFramework = rootProject.file("../nuvio-engine/platform/apple/NuvioEngine.xcframework")
|
||||
val fullCommonSourceDir = project.file("src/fullCommonMain/kotlin")
|
||||
val generatedRuntimeConfigDir = layout.buildDirectory.dir("generated/runtime-config/kotlin")
|
||||
val requestedGradleTasks = gradle.startParameter.taskNames.map { taskName ->
|
||||
|
|
@ -329,12 +330,28 @@ kotlin {
|
|||
)
|
||||
|
||||
iosTargets.forEach { iosTarget ->
|
||||
val nuvioEngineSlice = if (iosTarget.name == "iosArm64") {
|
||||
"ios-arm64"
|
||||
} else {
|
||||
"ios-arm64_x86_64-simulator"
|
||||
}
|
||||
val nuvioEngineSliceDirectory = nuvioEngineAppleFramework.resolve(nuvioEngineSlice)
|
||||
iosTarget.compilations.getByName("main") {
|
||||
cinterops {
|
||||
create("commoncrypto") {
|
||||
defFile(project.file("src/nativeInterop/cinterop/commoncrypto.def"))
|
||||
compilerOpts("-I${project.projectDir}/src/nativeInterop/cinterop")
|
||||
}
|
||||
if (iosDistribution == "full") {
|
||||
check(nuvioEngineSliceDirectory.resolve("libCNuvioEngine.a").isFile) {
|
||||
"Build the local Nuvio Engine Apple XCFramework before compiling iOS Full."
|
||||
}
|
||||
create("nuvioengine") {
|
||||
defFile(project.file("src/nativeInterop/cinterop/nuvioengine.def"))
|
||||
compilerOpts("-I${nuvioEngineSliceDirectory.resolve("Headers").absolutePath}")
|
||||
extraOpts("-libraryPath", nuvioEngineSliceDirectory.absolutePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (iosDistribution == "full") {
|
||||
|
|
@ -354,6 +371,14 @@ kotlin {
|
|||
baseName = "ComposeApp"
|
||||
isStatic = true
|
||||
freeCompilerArgs += listOf("-Xbinary=bundleId=$iosFrameworkBundleId")
|
||||
if (iosDistribution == "full") {
|
||||
linkerOpts(
|
||||
"-lc++",
|
||||
"-framework", "Security",
|
||||
"-framework", "SystemConfiguration",
|
||||
"-framework", "CoreFoundation",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -392,6 +417,7 @@ kotlin {
|
|||
implementation(libs.androidx.media3.container)
|
||||
implementation(libs.androidx.media3.extractor)
|
||||
implementation(libs.mpv.android.lib)
|
||||
implementation("com.nuvio:nuvio-engine-android:0.1.0-SNAPSHOT")
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("lib-*.aar"))))
|
||||
if (androidDistribution == "full") {
|
||||
implementation(files("libs/quickjs-kt-android-1.0.5-nuvio.aar"))
|
||||
|
|
|
|||
|
|
@ -11,12 +11,16 @@ internal object PlatformPlaybackDataSourceFactory {
|
|||
defaultRequestHeaders: Map<String, String>,
|
||||
defaultResponseHeaders: Map<String, String>,
|
||||
useYoutubeChunkedPlayback: Boolean,
|
||||
useLongReadTimeout: Boolean = false,
|
||||
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
|
||||
): DataSource.Factory {
|
||||
val networkFactory: DataSource.Factory = if (useYoutubeChunkedPlayback) {
|
||||
YoutubeChunkedDataSourceFactory(defaultRequestHeaders = defaultRequestHeaders)
|
||||
} else {
|
||||
PlayerPlaybackNetworking.createHttpDataSourceFactory(defaultRequestHeaders)
|
||||
PlayerPlaybackNetworking.createHttpDataSourceFactory(
|
||||
defaultRequestHeaders,
|
||||
useLongReadTimeout,
|
||||
)
|
||||
}
|
||||
val subtitleHeaderFactory = SubtitleRequestHeaderDataSourceFactory(
|
||||
upstreamFactory = networkFactory,
|
||||
|
|
@ -32,4 +36,4 @@ internal object PlatformPlaybackDataSourceFactory {
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
package com.nuvio.app.features.p2p
|
||||
|
||||
import com.nuvio.engine.NuvioUploadMode
|
||||
import com.nuvio.engine.NuvioTorrentProfile
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class P2pStreamingEngineAndroidTest {
|
||||
@Test
|
||||
fun appOwnedRoutesDisableAutomaticInactivityExpiry() {
|
||||
val stateDirectory = File("state")
|
||||
val cacheDirectory = File("cache")
|
||||
|
||||
val uploading = buildNuvioEngineConfig(
|
||||
stateDirectory = stateDirectory,
|
||||
cacheDirectory = cacheDirectory,
|
||||
uploadEnabled = true,
|
||||
torrentProfile = P2pTorrentProfile.FAST,
|
||||
diskCacheCapacityBytes = P2pCacheSize.GB_5.bytes,
|
||||
)
|
||||
val downloadOnly = buildNuvioEngineConfig(
|
||||
stateDirectory = stateDirectory,
|
||||
cacheDirectory = cacheDirectory,
|
||||
uploadEnabled = false,
|
||||
torrentProfile = P2pTorrentProfile.SOFT,
|
||||
diskCacheCapacityBytes = P2pCacheSize.NONE.bytes,
|
||||
)
|
||||
|
||||
assertEquals(0, uploading.streamInactivityTimeoutMilliseconds)
|
||||
assertEquals(NuvioUploadMode.Unlimited, uploading.uploadMode)
|
||||
assertEquals(NuvioTorrentProfile.Fast, uploading.torrentProfile)
|
||||
assertEquals(P2pCacheSize.GB_5.bytes, uploading.diskCacheCapacityBytes)
|
||||
assertEquals(0, downloadOnly.streamInactivityTimeoutMilliseconds)
|
||||
assertEquals(NuvioUploadMode.Disabled, downloadOnly.uploadMode)
|
||||
assertEquals(NuvioTorrentProfile.Soft, downloadOnly.torrentProfile)
|
||||
assertEquals(0L, downloadOnly.diskCacheCapacityBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchingUnsolicitedStopBecomesTerminalError() {
|
||||
val error = unexpectedStreamStopError(
|
||||
requestId = 0L,
|
||||
eventStreamId = "stream",
|
||||
currentStreamId = "stream",
|
||||
message = "stream expired after inactivity",
|
||||
fallbackMessage = "unknown",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
P2pStreamingState.Error("stream expired after inactivity"),
|
||||
error,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitAndStaleStopsAreIgnored() {
|
||||
assertNull(
|
||||
unexpectedStreamStopError(
|
||||
requestId = 7L,
|
||||
eventStreamId = "stream",
|
||||
currentStreamId = "stream",
|
||||
message = "stopped",
|
||||
fallbackMessage = "unknown",
|
||||
)
|
||||
)
|
||||
assertNull(
|
||||
unexpectedStreamStopError(
|
||||
requestId = 0L,
|
||||
eventStreamId = "old-stream",
|
||||
currentStreamId = "stream",
|
||||
message = "stopped",
|
||||
fallbackMessage = "unknown",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankStopMessageUsesFallback() {
|
||||
assertEquals(
|
||||
P2pStreamingState.Error("unknown"),
|
||||
unexpectedStreamStopError(
|
||||
requestId = 0L,
|
||||
eventStreamId = "stream",
|
||||
currentStreamId = "stream",
|
||||
message = " ",
|
||||
fallbackMessage = "unknown",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -9,6 +9,8 @@ internal actual object P2pSettingsStorage {
|
|||
private const val p2pEnabledKey = "p2p_enabled"
|
||||
private const val enableUploadKey = "enable_upload"
|
||||
private const val hideTorrentStatsKey = "hide_torrent_stats"
|
||||
private const val torrentProfileKey = "torrent_profile"
|
||||
private const val cacheSizeKey = "cache_size"
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
||||
|
|
@ -37,6 +39,18 @@ internal actual object P2pSettingsStorage {
|
|||
saveBoolean(hideTorrentStatsKey, enabled)
|
||||
}
|
||||
|
||||
actual fun loadTorrentProfile(): String? = loadString(torrentProfileKey)
|
||||
|
||||
actual fun saveTorrentProfile(profile: String) {
|
||||
saveString(torrentProfileKey, profile)
|
||||
}
|
||||
|
||||
actual fun loadCacheSize(): String? = loadString(cacheSizeKey)
|
||||
|
||||
actual fun saveCacheSize(size: String) {
|
||||
saveString(cacheSizeKey, size)
|
||||
}
|
||||
|
||||
private fun loadBoolean(keyBase: String): Boolean? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(keyBase)
|
||||
|
|
@ -53,4 +67,14 @@ internal actual object P2pSettingsStorage {
|
|||
?.putBoolean(ProfileScopedKey.of(keyBase), value)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
private fun loadString(keyBase: String): String? =
|
||||
preferences?.getString(ProfileScopedKey.of(keyBase), null)
|
||||
|
||||
private fun saveString(keyBase: String, value: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(ProfileScopedKey.of(keyBase), value)
|
||||
?.apply()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,7 @@ import android.util.Log
|
|||
import android.util.TypedValue
|
||||
import android.graphics.Typeface
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.util.AttributeSet
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -79,6 +80,12 @@ import java.net.HttpURLConnection
|
|||
import java.net.URL
|
||||
|
||||
private const val TAG = "NuvioPlayer"
|
||||
private const val PLAYER_DIAGNOSTIC_TAG = "NuvioPlayerDiag"
|
||||
|
||||
private class PlaybackDiagnostics {
|
||||
var prepareStartedAtMs: Long = 0L
|
||||
var attempt: Int = 0
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
|
|
@ -92,8 +99,11 @@ actual fun PlatformPlayerSurface(
|
|||
useYoutubeChunkedPlayback: Boolean,
|
||||
modifier: Modifier,
|
||||
playWhenReady: Boolean,
|
||||
initialPositionMs: Long?,
|
||||
initialPositionRequestKey: String?,
|
||||
resizeMode: PlayerResizeMode,
|
||||
useNativeController: Boolean,
|
||||
onInitialPositionHandled: (key: String, handled: Boolean) -> Unit,
|
||||
onControllerReady: (PlayerEngineController) -> Unit,
|
||||
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
|
||||
onError: (String?) -> Unit,
|
||||
|
|
@ -109,6 +119,7 @@ actual fun PlatformPlayerSurface(
|
|||
sanitizePlaybackResponseHeaders(sourceResponseHeaders),
|
||||
normalizeStreamType(streamType).orEmpty(),
|
||||
useYoutubeChunkedPlayback,
|
||||
initialPositionRequestKey.orEmpty(),
|
||||
)
|
||||
var activeEngine by remember(playerSourceKey, playerSettings.androidPlaybackEngine) {
|
||||
mutableStateOf(playerSettings.androidPlaybackEngine.initialAndroidEngine())
|
||||
|
|
@ -125,13 +136,19 @@ actual fun PlatformPlayerSurface(
|
|||
useYoutubeChunkedPlayback = useYoutubeChunkedPlayback,
|
||||
modifier = modifier,
|
||||
playWhenReady = playWhenReady,
|
||||
initialPositionMs = initialPositionMs,
|
||||
initialPositionRequestKey = initialPositionRequestKey,
|
||||
resizeMode = resizeMode,
|
||||
useNativeController = useNativeController,
|
||||
onInitialPositionHandled = onInitialPositionHandled,
|
||||
onControllerReady = onControllerReady,
|
||||
onSnapshot = onSnapshot,
|
||||
onError = { message ->
|
||||
if (message != null && playerSettings.androidPlaybackEngine == AndroidPlaybackEngine.Auto) {
|
||||
Log.w(TAG, "ExoPlayer failed; falling back to libmpv: $message")
|
||||
initialPositionRequestKey?.let { key ->
|
||||
onInitialPositionHandled(key, false)
|
||||
}
|
||||
activeEngine = ResolvedAndroidPlaybackEngine.Libmpv
|
||||
onError(null)
|
||||
} else {
|
||||
|
|
@ -139,21 +156,28 @@ actual fun PlatformPlayerSurface(
|
|||
}
|
||||
},
|
||||
)
|
||||
ResolvedAndroidPlaybackEngine.Libmpv -> LibmpvPlayerSurface(
|
||||
sourceUrl = sourceUrl,
|
||||
sourceAudioUrl = sourceAudioUrl,
|
||||
sourceHeaders = sourceHeaders,
|
||||
externalSubtitles = externalSubtitles,
|
||||
modifier = modifier,
|
||||
playWhenReady = playWhenReady,
|
||||
resizeMode = resizeMode,
|
||||
videoOutput = playerSettings.androidLibmpvVideoOutput,
|
||||
hardwareDecodingEnabled = playerSettings.androidLibmpvHardwareDecodingEnabled,
|
||||
yuv420pEnabled = playerSettings.androidLibmpvYuv420pEnabled,
|
||||
onControllerReady = onControllerReady,
|
||||
onSnapshot = onSnapshot,
|
||||
onError = onError,
|
||||
)
|
||||
ResolvedAndroidPlaybackEngine.Libmpv -> {
|
||||
LaunchedEffect(initialPositionRequestKey) {
|
||||
initialPositionRequestKey?.let { key ->
|
||||
onInitialPositionHandled(key, false)
|
||||
}
|
||||
}
|
||||
LibmpvPlayerSurface(
|
||||
sourceUrl = sourceUrl,
|
||||
sourceAudioUrl = sourceAudioUrl,
|
||||
sourceHeaders = sourceHeaders,
|
||||
externalSubtitles = externalSubtitles,
|
||||
modifier = modifier,
|
||||
playWhenReady = playWhenReady,
|
||||
resizeMode = resizeMode,
|
||||
videoOutput = playerSettings.androidLibmpvVideoOutput,
|
||||
hardwareDecodingEnabled = playerSettings.androidLibmpvHardwareDecodingEnabled,
|
||||
yuv420pEnabled = playerSettings.androidLibmpvYuv420pEnabled,
|
||||
onControllerReady = onControllerReady,
|
||||
onSnapshot = onSnapshot,
|
||||
onError = onError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -181,8 +205,11 @@ private fun ExoPlayerSurface(
|
|||
useYoutubeChunkedPlayback: Boolean,
|
||||
modifier: Modifier,
|
||||
playWhenReady: Boolean,
|
||||
initialPositionMs: Long?,
|
||||
initialPositionRequestKey: String?,
|
||||
resizeMode: PlayerResizeMode,
|
||||
useNativeController: Boolean,
|
||||
onInitialPositionHandled: (key: String, handled: Boolean) -> Unit,
|
||||
onControllerReady: (PlayerEngineController) -> Unit,
|
||||
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
|
||||
onError: (String?) -> Unit,
|
||||
|
|
@ -191,6 +218,7 @@ private fun ExoPlayerSurface(
|
|||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val latestOnSnapshot = rememberUpdatedState(onSnapshot)
|
||||
val latestOnError = rememberUpdatedState(onError)
|
||||
val latestOnInitialPositionHandled = rememberUpdatedState(onInitialPositionHandled)
|
||||
val latestPlayWhenReady = rememberUpdatedState(playWhenReady)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
|
|
@ -219,7 +247,9 @@ private fun ExoPlayerSurface(
|
|||
sanitizedSourceResponseHeaders,
|
||||
normalizedStreamType.orEmpty(),
|
||||
useYoutubeChunkedPlayback,
|
||||
initialPositionRequestKey.orEmpty(),
|
||||
)
|
||||
val playbackDiagnostics = remember(playerSourceKey) { PlaybackDiagnostics() }
|
||||
var subtitleDelayMs by remember(playerSourceKey) { mutableStateOf(0) }
|
||||
var selectedExternalSubtitleMimeType by remember(playerSourceKey) { mutableStateOf<String?>(null) }
|
||||
val latestSubtitleDelayMs = rememberUpdatedState(subtitleDelayMs)
|
||||
|
|
@ -262,6 +292,7 @@ private fun ExoPlayerSurface(
|
|||
}
|
||||
val dataSourceFactory = remember(
|
||||
context,
|
||||
sourceUrl,
|
||||
sanitizedSourceHeaders,
|
||||
sanitizedSourceResponseHeaders,
|
||||
useYoutubeChunkedPlayback,
|
||||
|
|
@ -272,6 +303,7 @@ private fun ExoPlayerSurface(
|
|||
defaultRequestHeaders = sanitizedSourceHeaders,
|
||||
defaultResponseHeaders = sanitizedSourceResponseHeaders,
|
||||
useYoutubeChunkedPlayback = useYoutubeChunkedPlayback,
|
||||
useLongReadTimeout = isLoopbackPlaybackSource(sourceUrl),
|
||||
externalSubtitles = externalSubtitles,
|
||||
)
|
||||
}
|
||||
|
|
@ -302,6 +334,7 @@ private fun ExoPlayerSurface(
|
|||
normalizedStreamType,
|
||||
useYoutubeChunkedPlayback,
|
||||
effectiveDecoderPriority,
|
||||
initialPositionRequestKey,
|
||||
) {
|
||||
val renderersFactory = SubtitleOffsetRenderersFactory(
|
||||
context = context,
|
||||
|
|
@ -389,9 +422,28 @@ private fun ExoPlayerSurface(
|
|||
onDispose { nowPlayingController.release() }
|
||||
}
|
||||
|
||||
LaunchedEffect(exoPlayer, resolvedMediaItem) {
|
||||
LaunchedEffect(exoPlayer, resolvedMediaItem, initialPositionRequestKey) {
|
||||
val mediaItem = resolvedMediaItem ?: return@LaunchedEffect
|
||||
exoPlayer.setPlaybackMediaItem(mediaItem, fallbackStartPositionMs)
|
||||
val requestedStartPositionMs = fallbackStartPositionMs
|
||||
?: initialPositionMs?.takeIf { it > 0L }
|
||||
playbackDiagnostics.attempt += 1
|
||||
playbackDiagnostics.prepareStartedAtMs = SystemClock.elapsedRealtime()
|
||||
Log.i(
|
||||
PLAYER_DIAGNOSTIC_TAG,
|
||||
"prepare begin attempt=${playbackDiagnostics.attempt} " +
|
||||
"source=${diagnosticPlaybackSource(sourceUrl)} audioSource=${!sourceAudioUrl.isNullOrBlank()} " +
|
||||
"mime=${mediaItem.localConfiguration?.mimeType ?: "auto"} " +
|
||||
"startPositionMs=${requestedStartPositionMs ?: 0L}",
|
||||
)
|
||||
exoPlayer.setPlaybackMediaItem(mediaItem, requestedStartPositionMs)
|
||||
if (fallbackStartPositionMs == null) {
|
||||
initialPositionRequestKey?.let { key ->
|
||||
latestOnInitialPositionHandled.value(
|
||||
key,
|
||||
requestedStartPositionMs != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
|
||||
|
|
@ -451,6 +503,14 @@ private fun ExoPlayerSurface(
|
|||
val listener = object : Player.Listener {
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
syncPlayerViewKeepScreenOn()
|
||||
Log.e(
|
||||
PLAYER_DIAGNOSTIC_TAG,
|
||||
"error attempt=${playbackDiagnostics.attempt} " +
|
||||
"elapsedMs=${diagnosticElapsedSince(playbackDiagnostics.prepareStartedAtMs)} " +
|
||||
"code=${error.errorCodeName} cause=${error.cause?.javaClass?.simpleName ?: "none"} " +
|
||||
"message=${diagnosticPlayerMessage(error.message)}",
|
||||
error,
|
||||
)
|
||||
|
||||
val isSourceError = error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW ||
|
||||
error.errorCode == PlaybackException.ERROR_CODE_IO_UNSPECIFIED ||
|
||||
|
|
@ -503,6 +563,14 @@ private fun ExoPlayerSurface(
|
|||
else -> "UNKNOWN($playbackState)"
|
||||
}
|
||||
Log.d(TAG, "onPlaybackStateChanged: $stateName")
|
||||
Log.i(
|
||||
PLAYER_DIAGNOSTIC_TAG,
|
||||
"state=$stateName attempt=${playbackDiagnostics.attempt} " +
|
||||
"elapsedMs=${diagnosticElapsedSince(playbackDiagnostics.prepareStartedAtMs)} " +
|
||||
"positionMs=${exoPlayer.currentPosition.coerceAtLeast(0L)} " +
|
||||
"bufferedMs=${exoPlayer.bufferedPosition.coerceAtLeast(0L)} " +
|
||||
"bufferedPercent=${exoPlayer.bufferedPercentage} playWhenReady=${exoPlayer.playWhenReady}",
|
||||
)
|
||||
if (playbackState == Player.STATE_READY) {
|
||||
fallbackStartPositionMs = null
|
||||
latestOnError.value(null)
|
||||
|
|
@ -517,10 +585,25 @@ private fun ExoPlayerSurface(
|
|||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
Log.i(
|
||||
PLAYER_DIAGNOSTIC_TAG,
|
||||
"isPlaying=$isPlaying attempt=${playbackDiagnostics.attempt} " +
|
||||
"elapsedMs=${diagnosticElapsedSince(playbackDiagnostics.prepareStartedAtMs)} " +
|
||||
"positionMs=${exoPlayer.currentPosition.coerceAtLeast(0L)}",
|
||||
)
|
||||
syncPlayerViewKeepScreenOn()
|
||||
dispatchExoPlayerSnapshot()
|
||||
}
|
||||
|
||||
override fun onRenderedFirstFrame() {
|
||||
Log.i(
|
||||
PLAYER_DIAGNOSTIC_TAG,
|
||||
"firstFrame attempt=${playbackDiagnostics.attempt} " +
|
||||
"elapsedMs=${diagnosticElapsedSince(playbackDiagnostics.prepareStartedAtMs)} " +
|
||||
"positionMs=${exoPlayer.currentPosition.coerceAtLeast(0L)}",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPlaybackParametersChanged(playbackParameters: androidx.media3.common.PlaybackParameters) {
|
||||
dispatchExoPlayerSnapshot()
|
||||
}
|
||||
|
|
@ -1895,6 +1978,26 @@ private fun guessSubtitleMime(url: String): String {
|
|||
}
|
||||
}
|
||||
|
||||
private fun diagnosticElapsedSince(startedAtMs: Long): Long =
|
||||
if (startedAtMs <= 0L) -1L else (SystemClock.elapsedRealtime() - startedAtMs).coerceAtLeast(0L)
|
||||
|
||||
private fun diagnosticPlaybackSource(value: String): String = runCatching {
|
||||
val uri = Uri.parse(value)
|
||||
val host = uri.host.orEmpty()
|
||||
val isLoopback = host == "127.0.0.1" || host == "localhost" || host == "::1"
|
||||
"scheme=${uri.scheme ?: "none"},host=${host.ifBlank { "none" }},port=${uri.port},loopback=$isLoopback"
|
||||
}.getOrDefault("unparseable")
|
||||
|
||||
private fun isLoopbackPlaybackSource(value: String): Boolean = runCatching {
|
||||
when (Uri.parse(value).host.orEmpty().lowercase()) {
|
||||
"127.0.0.1", "localhost", "::1" -> true
|
||||
else -> false
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun diagnosticPlayerMessage(value: String?): String =
|
||||
value?.replace('\n', ' ')?.replace('\r', ' ')?.take(160) ?: "none"
|
||||
|
||||
internal class SubtitleRequestHeaderDataSourceFactory(
|
||||
private val upstreamFactory: DataSource.Factory,
|
||||
private val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
|
||||
|
|
|
|||
|
|
@ -60,10 +60,28 @@ internal object PlayerPlaybackNetworking {
|
|||
.build()
|
||||
}
|
||||
|
||||
fun createHttpDataSourceFactory(defaultHeaders: Map<String, String> = emptyMap()): DataSource.Factory {
|
||||
private val loopbackPlaybackHttpClient: OkHttpClient by lazy {
|
||||
playbackHttpClient.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
val request = chain.request()
|
||||
val requestChain = if (isLoopbackHost(request.url.host)) {
|
||||
chain.withReadTimeout(65, TimeUnit.SECONDS)
|
||||
} else {
|
||||
chain
|
||||
}
|
||||
requestChain.proceed(request)
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
fun createHttpDataSourceFactory(
|
||||
defaultHeaders: Map<String, String> = emptyMap(),
|
||||
useLongReadTimeout: Boolean = false,
|
||||
): DataSource.Factory {
|
||||
val requestHeaders = sanitizeHeaders(defaultHeaders)
|
||||
val baseClient = if (useLongReadTimeout) loopbackPlaybackHttpClient else playbackHttpClient
|
||||
val client = requestHeaders.headerValue("Authorization")?.let { authorization ->
|
||||
playbackHttpClient.newBuilder()
|
||||
baseClient.newBuilder()
|
||||
.addNetworkInterceptor { chain ->
|
||||
val request = chain.request()
|
||||
if (request.header("Authorization") == null) {
|
||||
|
|
@ -77,7 +95,7 @@ internal object PlayerPlaybackNetworking {
|
|||
}
|
||||
}
|
||||
.build()
|
||||
} ?: playbackHttpClient
|
||||
} ?: baseClient
|
||||
|
||||
return OkHttpDataSource.Factory(client).apply {
|
||||
setDefaultRequestProperties(requestHeaders)
|
||||
|
|
@ -90,8 +108,12 @@ internal object PlayerPlaybackNetworking {
|
|||
fun createDataSourceFactory(
|
||||
context: Context,
|
||||
defaultHeaders: Map<String, String> = emptyMap(),
|
||||
useLongReadTimeout: Boolean = false,
|
||||
): DataSource.Factory {
|
||||
return DefaultDataSource.Factory(context, createHttpDataSourceFactory(defaultHeaders))
|
||||
return DefaultDataSource.Factory(
|
||||
context,
|
||||
createHttpDataSourceFactory(defaultHeaders, useLongReadTimeout),
|
||||
)
|
||||
}
|
||||
|
||||
fun openConnection(
|
||||
|
|
@ -133,6 +155,11 @@ internal object PlayerPlaybackNetworking {
|
|||
}
|
||||
}.toMap()
|
||||
|
||||
private fun isLoopbackHost(host: String): Boolean = when (host.lowercase()) {
|
||||
"127.0.0.1", "localhost", "::1" -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
private fun withDefaultUserAgent(headers: Map<String, String>): Map<String, String> {
|
||||
val sanitized = sanitizeHeaders(headers)
|
||||
if (sanitized.headerValue("User-Agent") != null) return sanitized
|
||||
|
|
|
|||
|
|
@ -10,9 +10,13 @@ internal object PlatformPlaybackDataSourceFactory {
|
|||
defaultRequestHeaders: Map<String, String>,
|
||||
defaultResponseHeaders: Map<String, String>,
|
||||
useYoutubeChunkedPlayback: Boolean,
|
||||
useLongReadTimeout: Boolean = false,
|
||||
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
|
||||
): DataSource.Factory {
|
||||
val httpFactory = PlayerPlaybackNetworking.createHttpDataSourceFactory(defaultRequestHeaders)
|
||||
val httpFactory = PlayerPlaybackNetworking.createHttpDataSourceFactory(
|
||||
defaultRequestHeaders,
|
||||
useLongReadTimeout,
|
||||
)
|
||||
val subtitleHeaderFactory = SubtitleRequestHeaderDataSourceFactory(
|
||||
upstreamFactory = httpFactory,
|
||||
externalSubtitles = externalSubtitles
|
||||
|
|
@ -27,4 +31,4 @@ internal object PlatformPlaybackDataSourceFactory {
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2011,12 +2011,35 @@
|
|||
<string name="settings_p2p_subtitle">Allow peer-to-peer (torrent) streams</string>
|
||||
<string name="settings_p2p_hide_stats_title">Hide torrent stats</string>
|
||||
<string name="settings_p2p_hide_stats_subtitle">Hide buffer, seeds, peers and download speed during loading and playback</string>
|
||||
<string name="settings_p2p_profile_title">Torrent profile</string>
|
||||
<string name="settings_p2p_profile_soft">Soft</string>
|
||||
<string name="settings_p2p_profile_balanced">Balanced</string>
|
||||
<string name="settings_p2p_profile_fast">Fast</string>
|
||||
<string name="settings_p2p_profile_soft_description">Uses fewer peer connections to reduce battery and network load</string>
|
||||
<string name="settings_p2p_profile_balanced_description">Recommended balance of startup speed and connection stability</string>
|
||||
<string name="settings_p2p_profile_fast_description">Uses more peer connections for difficult or high-bitrate torrents</string>
|
||||
<string name="settings_p2p_cache_size_title">Torrent cache size</string>
|
||||
<string name="settings_p2p_cache_none">No persistent cache</string>
|
||||
<string name="settings_p2p_cache_2_gb">2 GB</string>
|
||||
<string name="settings_p2p_cache_5_gb">5 GB</string>
|
||||
<string name="settings_p2p_cache_10_gb">10 GB</string>
|
||||
<string name="settings_p2p_clear_cache_title">Clear torrent cache</string>
|
||||
<string name="settings_p2p_clear_cache_usage">%1$s currently used</string>
|
||||
<string name="settings_p2p_clear_cache_usage_pending">Usage is measured after P2P starts; tap to clear cached torrent data</string>
|
||||
<string name="settings_p2p_clear_cache_clearing">Clearing inactive torrent data…</string>
|
||||
<string name="settings_p2p_clear_cache_playback_active">Stop P2P playback before clearing the cache</string>
|
||||
<string name="settings_p2p_clear_cache_done">Cleared %1$s of inactive torrent data</string>
|
||||
<string name="settings_p2p_clear_cache_failed">Could not clear the torrent cache</string>
|
||||
<string name="player_torrent_peer_info">%1$d seeds · %2$d peers</string>
|
||||
<string name="player_torrent_buffered_status">%1$s buffered · %2$s · %3$s</string>
|
||||
<string name="player_torrent_status">%1$s · %2$s</string>
|
||||
<string name="player_torrent_stats">%1$d peers · %2$d seeds · %3$d%%</string>
|
||||
<string name="player_torrent_connecting_peers">Connecting to peers…</string>
|
||||
<string name="player_torrent_starting_engine">Starting P2P engine…</string>
|
||||
<string name="player_torrent_fetching_metadata">Fetching torrent metadata…</string>
|
||||
<string name="player_torrent_preparing_stream">Preparing torrent stream…</string>
|
||||
<string name="player_torrent_connecting_status">%1$s · %2$s · %3$s</string>
|
||||
<string name="player_torrent_loading_status">%1$s downloaded · %2$s · %3$s</string>
|
||||
<string name="player_error_failed_start_torrent">Failed to start torrent: %1$s</string>
|
||||
<string name="player_error_torrent">Torrent error: %1$s</string>
|
||||
<!-- Person & TMDB entity detail (pass7) -->
|
||||
|
|
|
|||
|
|
@ -8,7 +8,35 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
data class P2pSettingsUiState(
|
||||
val p2pEnabled: Boolean = false,
|
||||
val enableUpload: Boolean = true,
|
||||
val hideTorrentStats: Boolean = true,
|
||||
val hideTorrentStats: Boolean = false,
|
||||
val torrentProfile: P2pTorrentProfile = P2pTorrentProfile.BALANCED,
|
||||
val cacheSize: P2pCacheSize = P2pCacheSize.GB_2,
|
||||
)
|
||||
|
||||
enum class P2pTorrentProfile {
|
||||
SOFT,
|
||||
BALANCED,
|
||||
FAST,
|
||||
}
|
||||
|
||||
enum class P2pCacheSize(val bytes: Long) {
|
||||
NONE(0L),
|
||||
GB_2(2L * 1024L * 1024L * 1024L),
|
||||
GB_5(5L * 1024L * 1024L * 1024L),
|
||||
GB_10(10L * 1024L * 1024L * 1024L),
|
||||
}
|
||||
|
||||
data class P2pCacheUiState(
|
||||
val usedBytes: Long = 0L,
|
||||
val protectedBytes: Long = 0L,
|
||||
val isClearing: Boolean = false,
|
||||
val hasMeasurement: Boolean = false,
|
||||
)
|
||||
|
||||
data class P2pCacheClearResult(
|
||||
val reclaimedBytes: Long,
|
||||
val remainingBytes: Long,
|
||||
val protectedBytes: Long,
|
||||
)
|
||||
|
||||
object P2pSettingsRepository {
|
||||
|
|
@ -21,7 +49,9 @@ object P2pSettingsRepository {
|
|||
private var hasLoaded = false
|
||||
private var p2pEnabled = false
|
||||
private var enableUpload = true
|
||||
private var hideTorrentStats = true
|
||||
private var hideTorrentStats = false
|
||||
private var torrentProfile = P2pTorrentProfile.BALANCED
|
||||
private var cacheSize = P2pCacheSize.GB_2
|
||||
|
||||
fun ensureLoaded() {
|
||||
if (hasLoaded) return
|
||||
|
|
@ -36,7 +66,9 @@ object P2pSettingsRepository {
|
|||
hasLoaded = false
|
||||
p2pEnabled = false
|
||||
enableUpload = true
|
||||
hideTorrentStats = true
|
||||
hideTorrentStats = false
|
||||
torrentProfile = P2pTorrentProfile.BALANCED
|
||||
cacheSize = P2pCacheSize.GB_2
|
||||
publish()
|
||||
}
|
||||
|
||||
|
|
@ -64,11 +96,33 @@ object P2pSettingsRepository {
|
|||
publish()
|
||||
}
|
||||
|
||||
fun setTorrentProfile(profile: P2pTorrentProfile) {
|
||||
ensureLoaded()
|
||||
if (torrentProfile == profile) return
|
||||
torrentProfile = profile
|
||||
P2pSettingsStorage.saveTorrentProfile(profile.name)
|
||||
publish()
|
||||
}
|
||||
|
||||
fun setCacheSize(size: P2pCacheSize) {
|
||||
ensureLoaded()
|
||||
if (cacheSize == size) return
|
||||
cacheSize = size
|
||||
P2pSettingsStorage.saveCacheSize(size.name)
|
||||
publish()
|
||||
}
|
||||
|
||||
private fun loadFromDisk() {
|
||||
hasLoaded = true
|
||||
p2pEnabled = P2pSettingsStorage.loadP2pEnabled() ?: false
|
||||
enableUpload = P2pSettingsStorage.loadEnableUpload() ?: true
|
||||
hideTorrentStats = P2pSettingsStorage.loadHideTorrentStats() ?: true
|
||||
hideTorrentStats = P2pSettingsStorage.loadHideTorrentStats() ?: false
|
||||
torrentProfile = P2pSettingsStorage.loadTorrentProfile()
|
||||
?.let { stored -> P2pTorrentProfile.entries.firstOrNull { it.name == stored } }
|
||||
?: P2pTorrentProfile.BALANCED
|
||||
cacheSize = P2pSettingsStorage.loadCacheSize()
|
||||
?.let { stored -> P2pCacheSize.entries.firstOrNull { it.name == stored } }
|
||||
?: P2pCacheSize.GB_2
|
||||
publish()
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +131,8 @@ object P2pSettingsRepository {
|
|||
p2pEnabled = p2pEnabled,
|
||||
enableUpload = enableUpload,
|
||||
hideTorrentStats = hideTorrentStats,
|
||||
torrentProfile = torrentProfile,
|
||||
cacheSize = cacheSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -88,6 +144,10 @@ internal expect object P2pSettingsStorage {
|
|||
fun saveEnableUpload(enabled: Boolean)
|
||||
fun loadHideTorrentStats(): Boolean?
|
||||
fun saveHideTorrentStats(enabled: Boolean)
|
||||
fun loadTorrentProfile(): String?
|
||||
fun saveTorrentProfile(profile: String)
|
||||
fun loadCacheSize(): String?
|
||||
fun saveCacheSize(size: String)
|
||||
}
|
||||
|
||||
data class P2pStreamRequest(
|
||||
|
|
@ -97,9 +157,56 @@ data class P2pStreamRequest(
|
|||
val trackers: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
internal fun canonicalP2pInfoHash(infoHash: String): String {
|
||||
val canonical = infoHash.trim().lowercase()
|
||||
require((canonical.length == 40 || canonical.length == 64) &&
|
||||
canonical.all { it in '0'..'9' || it in 'a'..'f' }) {
|
||||
"Torrent info hash must be 40 or 64 hexadecimal characters"
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
internal fun buildP2pMagnetUri(infoHash: String, trackers: List<String>): String {
|
||||
val canonicalHash = canonicalP2pInfoHash(infoHash)
|
||||
val topic = if (canonicalHash.length == 40) {
|
||||
"urn:btih:$canonicalHash"
|
||||
} else {
|
||||
"urn:btmh:1220$canonicalHash"
|
||||
}
|
||||
val trackerParameters = trackers.filter(String::isNotBlank).distinct().joinToString("") { tracker ->
|
||||
"&tr=${tracker.encodeP2pQueryValue()}"
|
||||
}
|
||||
return "magnet:?xt=$topic$trackerParameters"
|
||||
}
|
||||
|
||||
private fun String.encodeP2pQueryValue(): String = buildString {
|
||||
for (byte in this@encodeP2pQueryValue.encodeToByteArray()) {
|
||||
val value = byte.toInt() and 0xff
|
||||
if ((value in 'a'.code..'z'.code) ||
|
||||
(value in 'A'.code..'Z'.code) ||
|
||||
(value in '0'.code..'9'.code) ||
|
||||
value == '-'.code || value == '.'.code || value == '_'.code || value == '~'.code) {
|
||||
append(value.toChar())
|
||||
} else {
|
||||
append('%')
|
||||
append(HEX_DIGITS[value ushr 4])
|
||||
append(HEX_DIGITS[value and 0x0f])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val HEX_DIGITS = "0123456789ABCDEF"
|
||||
|
||||
sealed class P2pStreamingState {
|
||||
data object Idle : P2pStreamingState()
|
||||
data object Connecting : P2pStreamingState()
|
||||
|
||||
data class Connecting(
|
||||
val phase: String = "starting_engine",
|
||||
val downloadSpeed: Long = 0L,
|
||||
val uploadSpeed: Long = 0L,
|
||||
val peers: Int = 0,
|
||||
val seeds: Int = 0,
|
||||
) : P2pStreamingState()
|
||||
|
||||
data class Streaming(
|
||||
val localUrl: String,
|
||||
|
|
@ -109,7 +216,9 @@ sealed class P2pStreamingState {
|
|||
val seeds: Int,
|
||||
val bufferProgress: Float,
|
||||
val totalProgress: Float,
|
||||
val preloadedBytes: Long = 0L,
|
||||
val downloadedBytes: Long = 0L,
|
||||
val verifiedBytes: Long = 0L,
|
||||
val deliveredBytes: Long = 0L,
|
||||
) : P2pStreamingState()
|
||||
|
||||
data class Error(val message: String) : P2pStreamingState()
|
||||
|
|
@ -119,7 +228,9 @@ class P2pStreamingException(message: String) : Exception(message)
|
|||
|
||||
expect object P2pStreamingEngine {
|
||||
val state: StateFlow<P2pStreamingState>
|
||||
val cacheState: StateFlow<P2pCacheUiState>
|
||||
suspend fun startStream(request: P2pStreamRequest): String
|
||||
suspend fun clearCache(): P2pCacheClearResult
|
||||
fun stopStream()
|
||||
fun shutdown()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,8 +65,11 @@ expect fun PlatformPlayerSurface(
|
|||
useYoutubeChunkedPlayback: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
playWhenReady: Boolean = true,
|
||||
initialPositionMs: Long? = null,
|
||||
initialPositionRequestKey: String? = null,
|
||||
resizeMode: PlayerResizeMode = PlayerResizeMode.Fit,
|
||||
useNativeController: Boolean = false,
|
||||
onInitialPositionHandled: (key: String, handled: Boolean) -> Unit = { _, _ -> },
|
||||
onControllerReady: (PlayerEngineController) -> Unit,
|
||||
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
|
||||
onError: (String?) -> Unit,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
|
|
@ -247,30 +246,23 @@ internal fun OpeningOverlay(
|
|||
val showHorizontalProgress = progressActive && logo == null
|
||||
if (!message.isNullOrBlank() || showHorizontalProgress) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Crossfade(
|
||||
targetState = message?.takeIf { it.isNotBlank() },
|
||||
animationSpec = tween(durationMillis = 260),
|
||||
label = "openingOverlayMessageCrossfade",
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp),
|
||||
) { loadingMessage ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (loadingMessage != null) {
|
||||
Text(
|
||||
text = loadingMessage,
|
||||
color = Color.White.copy(alpha = 0.72f),
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp),
|
||||
)
|
||||
}
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
message?.takeIf { it.isNotBlank() }?.let { loadingMessage ->
|
||||
Text(
|
||||
text = loadingMessage,
|
||||
color = Color.White.copy(alpha = 0.72f),
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showHorizontalProgress) {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,8 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() {
|
|||
return@LaunchedEffect
|
||||
}
|
||||
if (!P2pSettingsRepository.isVisible || !p2pSettingsUiState.p2pEnabled) {
|
||||
p2pResolvedSourceUrl = null
|
||||
P2pStreamingEngine.stopStream()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
|
|
@ -142,6 +144,11 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() {
|
|||
LaunchedEffect(p2pStreamingState, activeTorrentInfoHash) {
|
||||
val state = p2pStreamingState
|
||||
if (activeTorrentInfoHash != null && state is P2pStreamingState.Error) {
|
||||
p2pResolvedSourceUrl = null
|
||||
playerController = null
|
||||
playerControllerSourceUrl = null
|
||||
playbackSnapshot = PlayerPlaybackSnapshot()
|
||||
initialLoadCompleted = true
|
||||
errorMessage = getString(Res.string.player_error_torrent, state.message)
|
||||
controlsVisible = !playerControlsLocked
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,8 +64,8 @@ internal class PlayerScreenRuntime(
|
|||
lateinit var hapticFeedback: HapticFeedback
|
||||
|
||||
var playerSettingsUiState: PlayerSettingsUiState = PlayerSettingsUiState()
|
||||
var p2pSettingsUiState: P2pSettingsUiState = P2pSettingsUiState()
|
||||
var p2pStreamingState: P2pStreamingState = P2pStreamingState.Idle
|
||||
var p2pSettingsUiState by mutableStateOf(P2pSettingsUiState())
|
||||
var p2pStreamingState by mutableStateOf<P2pStreamingState>(P2pStreamingState.Idle)
|
||||
var metaScreenSettingsUiState: MetaScreenSettingsUiState = MetaScreenSettingsUiState()
|
||||
var watchedUiState: WatchedUiState = WatchedUiState()
|
||||
var watchProgressUiState: WatchProgressUiState = WatchProgressUiState()
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
|
|||
val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null
|
||||
val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback
|
||||
val isP2pPlaybackActive = activeTorrentInfoHash != null
|
||||
val p2pConnecting = p2pStreamingState as? P2pStreamingState.Connecting
|
||||
val p2pStats = p2pStreamingState as? P2pStreamingState.Streaming
|
||||
val p2pPeerInfo = p2pStats?.let { stats ->
|
||||
org.jetbrains.compose.resources.stringResource(
|
||||
|
|
@ -34,20 +35,35 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
|
|||
)
|
||||
}
|
||||
val p2pDownloadSpeed = p2pStats?.let { formatP2pSpeed(it.downloadSpeed) }
|
||||
val p2pLoadingBytes = p2pStats?.let { maxOf(it.downloadedBytes, it.deliveredBytes) } ?: 0L
|
||||
val connectingPeerInfo = p2pConnecting?.let { state ->
|
||||
org.jetbrains.compose.resources.stringResource(
|
||||
nuvio.composeapp.generated.resources.Res.string.player_torrent_peer_info,
|
||||
state.seeds,
|
||||
state.peers,
|
||||
)
|
||||
}
|
||||
val p2pInitialLoadingMessage = when {
|
||||
!isP2pPlaybackActive || initialLoadCompleted -> null
|
||||
p2pStreamingState is P2pStreamingState.Connecting -> {
|
||||
org.jetbrains.compose.resources.stringResource(
|
||||
nuvio.composeapp.generated.resources.Res.string.player_torrent_connecting_peers,
|
||||
)
|
||||
p2pConnecting != null -> {
|
||||
if (p2pSettingsUiState.hideTorrentStats) {
|
||||
p2pConnectingPhaseLabel(p2pConnecting.phase)
|
||||
} else {
|
||||
org.jetbrains.compose.resources.stringResource(
|
||||
nuvio.composeapp.generated.resources.Res.string.player_torrent_connecting_status,
|
||||
p2pConnectingPhaseLabel(p2pConnecting.phase),
|
||||
connectingPeerInfo.orEmpty(),
|
||||
formatP2pSpeed(p2pConnecting.downloadSpeed),
|
||||
)
|
||||
}
|
||||
}
|
||||
p2pStats != null -> {
|
||||
if (p2pSettingsUiState.hideTorrentStats) {
|
||||
null
|
||||
} else {
|
||||
org.jetbrains.compose.resources.stringResource(
|
||||
nuvio.composeapp.generated.resources.Res.string.player_torrent_buffered_status,
|
||||
formatP2pMegabytes(p2pStats.preloadedBytes),
|
||||
nuvio.composeapp.generated.resources.Res.string.player_torrent_loading_status,
|
||||
formatP2pMegabytes(p2pLoadingBytes),
|
||||
p2pPeerInfo.orEmpty(),
|
||||
p2pDownloadSpeed.orEmpty(),
|
||||
)
|
||||
|
|
@ -57,9 +73,15 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
|
|||
nuvio.composeapp.generated.resources.Res.string.player_torrent_starting_engine,
|
||||
)
|
||||
}
|
||||
val bufferedAheadMs = (playbackSnapshot.bufferedPositionMs - playbackSnapshot.positionMs)
|
||||
.coerceAtLeast(0L)
|
||||
val p2pInitialLoadingProgress = when {
|
||||
!isP2pPlaybackActive || initialLoadCompleted || p2pStats == null -> null
|
||||
else -> (p2pStats.preloadedBytes.toFloat() / P2pInitialPreloadTargetBytes.toFloat()).coerceIn(0f, 1f)
|
||||
else -> p2pInitialLoadingProgress(
|
||||
bufferedAheadMs = bufferedAheadMs,
|
||||
downloadedBytes = p2pStats.downloadedBytes,
|
||||
deliveredBytes = p2pStats.deliveredBytes,
|
||||
)
|
||||
}
|
||||
val showP2pRebufferStats = isP2pPlaybackActive &&
|
||||
initialLoadCompleted &&
|
||||
|
|
@ -116,6 +138,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
|
|||
),
|
||||
) {
|
||||
val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl
|
||||
val initialPositionRequestKey = currentInitialPositionRequestKey()
|
||||
if (playerSurfaceSourceUrl != null) {
|
||||
PlatformPlayerSurface(
|
||||
sourceUrl = playerSurfaceSourceUrl,
|
||||
|
|
@ -126,7 +149,14 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
|
|||
streamType = activeStreamType,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
playWhenReady = shouldPlay,
|
||||
initialPositionMs = activeInitialPositionMs.takeIf { it > 0L },
|
||||
initialPositionRequestKey = initialPositionRequestKey,
|
||||
resizeMode = resizeMode,
|
||||
onInitialPositionHandled = { key, handled ->
|
||||
if (key == currentInitialPositionRequestKey()) {
|
||||
initialSeekApplied = handled
|
||||
}
|
||||
},
|
||||
onControllerReady = { controller ->
|
||||
playerController = controller
|
||||
playerControllerSourceUrl = activeSourceUrl
|
||||
|
|
@ -187,6 +217,24 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun p2pConnectingPhaseLabel(phase: String): String = when (phase) {
|
||||
"add_magnet" -> org.jetbrains.compose.resources.stringResource(
|
||||
nuvio.composeapp.generated.resources.Res.string.player_torrent_fetching_metadata,
|
||||
)
|
||||
"prepare_stream", "attach_route" -> org.jetbrains.compose.resources.stringResource(
|
||||
nuvio.composeapp.generated.resources.Res.string.player_torrent_preparing_stream,
|
||||
)
|
||||
else -> org.jetbrains.compose.resources.stringResource(
|
||||
nuvio.composeapp.generated.resources.Res.string.player_torrent_starting_engine,
|
||||
)
|
||||
}
|
||||
|
||||
private fun PlayerScreenRuntime.currentInitialPositionRequestKey(): String? {
|
||||
val positionMs = activeInitialPositionMs.takeIf { it > 0L } ?: return null
|
||||
return "$activePlaybackIdentity:${activeVideoId.orEmpty()}:$positionMs"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, isEpisode: Boolean) {
|
||||
val isInPip = rememberIsInPictureInPicture()
|
||||
|
|
|
|||
|
|
@ -15,9 +15,39 @@ internal const val PlayerVerticalGestureTouchSlopMultiplier = 3f
|
|||
internal const val PlayerVerticalGestureMinHeightFraction = 0.06f
|
||||
internal const val PlayerVerticalGestureDominanceRatio = 1.2f
|
||||
internal const val PlayerSeekProgressSyncDebounceMs = 700L
|
||||
internal const val P2pInitialPreloadTargetBytes = 5_242_880L
|
||||
internal const val P2pInitialByteProgressMidpoint = 5_242_880L
|
||||
internal const val P2pInitialBufferTargetMs = 10_000L
|
||||
internal const val P2pInitialNetworkStageWeight = 0.45f
|
||||
internal const val P2pInitialDeliveryStageWeight = 0.30f
|
||||
internal const val P2pInitialPlayerStageStart = 0.75f
|
||||
internal const val P2pInitialLoadingMaximum = 0.95f
|
||||
internal const val NEXT_EPISODE_HARD_TIMEOUT_MS = 120_000L
|
||||
|
||||
internal fun p2pInitialLoadingProgress(
|
||||
bufferedAheadMs: Long,
|
||||
downloadedBytes: Long,
|
||||
deliveredBytes: Long,
|
||||
): Float {
|
||||
val networkProgress = saturatingProgress(downloadedBytes, P2pInitialByteProgressMidpoint) *
|
||||
P2pInitialNetworkStageWeight
|
||||
val deliveryProgress = saturatingProgress(deliveredBytes, P2pInitialByteProgressMidpoint) *
|
||||
P2pInitialDeliveryStageWeight
|
||||
val engineProgress = networkProgress + deliveryProgress
|
||||
val playerProgress = if (bufferedAheadMs > 0L) {
|
||||
P2pInitialPlayerStageStart +
|
||||
(bufferedAheadMs.toFloat() / P2pInitialBufferTargetMs.toFloat()).coerceIn(0f, 1f) *
|
||||
(P2pInitialLoadingMaximum - P2pInitialPlayerStageStart)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
return maxOf(engineProgress, playerProgress).coerceIn(0f, P2pInitialLoadingMaximum)
|
||||
}
|
||||
|
||||
private fun saturatingProgress(value: Long, midpoint: Long): Float {
|
||||
val safeValue = value.coerceAtLeast(0L).toDouble()
|
||||
return (safeValue / (safeValue + midpoint.toDouble())).toFloat()
|
||||
}
|
||||
|
||||
internal val PlayerSideGestureSystemEdgeExclusion = 72.dp
|
||||
internal val PlayerSliderOverlayGap = 12.dp
|
||||
internal val PlayerTimeRowHeight = 36.dp
|
||||
|
|
|
|||
|
|
@ -75,7 +75,12 @@ import com.nuvio.app.features.player.formatPlaybackSpeedLabel
|
|||
import com.nuvio.app.features.player.languageLabelForCode
|
||||
import com.nuvio.app.features.player.toStorageHexString
|
||||
import com.nuvio.app.features.p2p.P2pConsentDialog
|
||||
import com.nuvio.app.features.p2p.P2pCacheClearResult
|
||||
import com.nuvio.app.features.p2p.P2pCacheSize
|
||||
import com.nuvio.app.features.p2p.P2pSettingsRepository
|
||||
import com.nuvio.app.features.p2p.P2pStreamingEngine
|
||||
import com.nuvio.app.features.p2p.P2pStreamingState
|
||||
import com.nuvio.app.features.p2p.P2pTorrentProfile
|
||||
import com.nuvio.app.features.plugins.PluginsUiState
|
||||
import com.nuvio.app.features.plugins.PluginRepository
|
||||
import com.nuvio.app.features.streams.StreamAutoPlayMode
|
||||
|
|
@ -144,6 +149,31 @@ private fun formatStep(value: Float): String {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun p2pProfileLabel(profile: P2pTorrentProfile): String = when (profile) {
|
||||
P2pTorrentProfile.SOFT -> stringResource(Res.string.settings_p2p_profile_soft)
|
||||
P2pTorrentProfile.BALANCED -> stringResource(Res.string.settings_p2p_profile_balanced)
|
||||
P2pTorrentProfile.FAST -> stringResource(Res.string.settings_p2p_profile_fast)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun p2pCacheSizeLabel(size: P2pCacheSize): String = when (size) {
|
||||
P2pCacheSize.NONE -> stringResource(Res.string.settings_p2p_cache_none)
|
||||
P2pCacheSize.GB_2 -> stringResource(Res.string.settings_p2p_cache_2_gb)
|
||||
P2pCacheSize.GB_5 -> stringResource(Res.string.settings_p2p_cache_5_gb)
|
||||
P2pCacheSize.GB_10 -> stringResource(Res.string.settings_p2p_cache_10_gb)
|
||||
}
|
||||
|
||||
private fun formatP2pCacheBytes(bytes: Long): String {
|
||||
val gibibyte = 1024.0 * 1024.0 * 1024.0
|
||||
val mebibyte = 1024.0 * 1024.0
|
||||
return if (bytes >= gibibyte) {
|
||||
"${kotlin.math.round(bytes / gibibyte * 10.0) / 10.0} GB"
|
||||
} else {
|
||||
"${kotlin.math.round(bytes / mebibyte * 10.0) / 10.0} MB"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun addonSubtitleStartupModeLabel(mode: AddonSubtitleStartupMode): String =
|
||||
when (mode) {
|
||||
|
|
@ -298,12 +328,19 @@ private fun PlaybackSettingsSection(
|
|||
var showAutoPlayPluginSelectionDialog by remember { mutableStateOf(false) }
|
||||
var showAutoPlayRegexDialog by remember { mutableStateOf(false) }
|
||||
var showP2pConsentDialog by remember { mutableStateOf(false) }
|
||||
var showP2pProfileDialog by remember { mutableStateOf(false) }
|
||||
var showP2pCacheSizeDialog by remember { mutableStateOf(false) }
|
||||
var p2pCacheClearResult by remember { mutableStateOf<P2pCacheClearResult?>(null) }
|
||||
var p2pCacheClearFailed by remember { mutableStateOf(false) }
|
||||
val pluginsEnabled = AppFeaturePolicy.pluginsEnabled
|
||||
val autoPlayPlayerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val p2pSettings by remember {
|
||||
P2pSettingsRepository.ensureLoaded()
|
||||
P2pSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val p2pCacheState by P2pStreamingEngine.cacheState.collectAsStateWithLifecycle()
|
||||
val p2pStreamingState by P2pStreamingEngine.state.collectAsStateWithLifecycle()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val availableExternalPlayers = ExternalPlayerPlatform.availablePlayers()
|
||||
val selectedExternalPlayer = availableExternalPlayers.firstOrNull {
|
||||
it.id == autoPlayPlayerSettings.externalPlayerId
|
||||
|
|
@ -641,6 +678,56 @@ private fun PlaybackSettingsSection(
|
|||
isTablet = isTablet,
|
||||
onCheckedChange = P2pSettingsRepository::setHideTorrentStats,
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.settings_p2p_profile_title),
|
||||
description = p2pProfileLabel(p2pSettings.torrentProfile),
|
||||
isTablet = isTablet,
|
||||
onClick = { showP2pProfileDialog = true },
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.settings_p2p_cache_size_title),
|
||||
description = p2pCacheSizeLabel(p2pSettings.cacheSize),
|
||||
isTablet = isTablet,
|
||||
onClick = { showP2pCacheSizeDialog = true },
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
val cacheClearAvailable = p2pStreamingState !is P2pStreamingState.Connecting &&
|
||||
p2pStreamingState !is P2pStreamingState.Streaming &&
|
||||
!p2pCacheState.isClearing
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.settings_p2p_clear_cache_title),
|
||||
description = when {
|
||||
p2pCacheState.isClearing ->
|
||||
stringResource(Res.string.settings_p2p_clear_cache_clearing)
|
||||
!cacheClearAvailable ->
|
||||
stringResource(Res.string.settings_p2p_clear_cache_playback_active)
|
||||
p2pCacheClearFailed ->
|
||||
stringResource(Res.string.settings_p2p_clear_cache_failed)
|
||||
p2pCacheClearResult != null -> stringResource(
|
||||
Res.string.settings_p2p_clear_cache_done,
|
||||
formatP2pCacheBytes(p2pCacheClearResult!!.reclaimedBytes),
|
||||
)
|
||||
!p2pCacheState.hasMeasurement ->
|
||||
stringResource(Res.string.settings_p2p_clear_cache_usage_pending)
|
||||
else -> stringResource(
|
||||
Res.string.settings_p2p_clear_cache_usage,
|
||||
formatP2pCacheBytes(p2pCacheState.usedBytes),
|
||||
)
|
||||
},
|
||||
enabled = cacheClearAvailable,
|
||||
isTablet = isTablet,
|
||||
onClick = {
|
||||
p2pCacheClearResult = null
|
||||
p2pCacheClearFailed = false
|
||||
coroutineScope.launch {
|
||||
runCatching { P2pStreamingEngine.clearCache() }
|
||||
.onSuccess { p2pCacheClearResult = it }
|
||||
.onFailure { p2pCacheClearFailed = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1182,6 +1269,45 @@ private fun PlaybackSettingsSection(
|
|||
)
|
||||
}
|
||||
|
||||
if (showP2pProfileDialog) {
|
||||
IosEnumSelectionDialog(
|
||||
title = stringResource(Res.string.settings_p2p_profile_title),
|
||||
options = P2pTorrentProfile.entries,
|
||||
selected = p2pSettings.torrentProfile,
|
||||
label = { p2pProfileLabel(it) },
|
||||
description = { profile ->
|
||||
when (profile) {
|
||||
P2pTorrentProfile.SOFT ->
|
||||
stringResource(Res.string.settings_p2p_profile_soft_description)
|
||||
P2pTorrentProfile.BALANCED ->
|
||||
stringResource(Res.string.settings_p2p_profile_balanced_description)
|
||||
P2pTorrentProfile.FAST ->
|
||||
stringResource(Res.string.settings_p2p_profile_fast_description)
|
||||
}
|
||||
},
|
||||
onSelect = { profile ->
|
||||
P2pSettingsRepository.setTorrentProfile(profile)
|
||||
showP2pProfileDialog = false
|
||||
},
|
||||
onDismiss = { showP2pProfileDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showP2pCacheSizeDialog) {
|
||||
IosEnumSelectionDialog(
|
||||
title = stringResource(Res.string.settings_p2p_cache_size_title),
|
||||
options = P2pCacheSize.entries,
|
||||
selected = p2pSettings.cacheSize,
|
||||
label = { p2pCacheSizeLabel(it) },
|
||||
onSelect = { size ->
|
||||
P2pSettingsRepository.setCacheSize(size)
|
||||
p2pCacheClearResult = null
|
||||
showP2pCacheSizeDialog = false
|
||||
},
|
||||
onDismiss = { showP2pCacheSizeDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showSecondaryAudioDialog) {
|
||||
val originalHint = stringResource(Res.string.settings_playback_option_original_hint)
|
||||
LanguageSelectionDialog(
|
||||
|
|
@ -2143,7 +2269,7 @@ private fun <T> IosEnumSelectionDialog(
|
|||
title: String,
|
||||
options: List<T>,
|
||||
selected: T,
|
||||
label: (T) -> String,
|
||||
label: @Composable (T) -> String,
|
||||
description: @Composable (T) -> String? = { null },
|
||||
onSelect: (T) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
package com.nuvio.app.features.p2p
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class P2pMagnetTest {
|
||||
@Test
|
||||
fun v1HashAndTrackersAreCanonicalEncodedAndDeduplicated() {
|
||||
val hash = "ABCDEF0123456789ABCDEF0123456789ABCDEF01"
|
||||
val tracker = "udp://tracker.example:80/announce?key=hello world"
|
||||
val magnet = buildP2pMagnetUri(hash, listOf("", tracker, " ", tracker))
|
||||
|
||||
assertTrue(magnet.startsWith("magnet:?xt=urn:btih:${hash.lowercase()}"))
|
||||
assertEquals(1, magnet.windowed("&tr=".length).count { it == "&tr=" })
|
||||
assertTrue(magnet.endsWith("udp%3A%2F%2Ftracker.example%3A80%2Fannounce%3Fkey%3Dhello%20world"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v2HashUsesMultihashTopic() {
|
||||
val hash = "a".repeat(64)
|
||||
assertTrue(buildP2pMagnetUri(hash, emptyList()).contains("xt=urn:btmh:1220$hash"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidHashIsRejectedBeforeNativeWork() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
buildP2pMagnetUri("not-a-hash", emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.nuvio.app.features.p2p
|
||||
|
||||
import com.nuvio.app.features.player.p2pInitialLoadingProgress
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
class P2pTelemetryTest {
|
||||
@Test
|
||||
fun torrentStatsAreVisibleByDefault() {
|
||||
assertFalse(P2pSettingsUiState().hideTorrentStats)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectingStateCarriesStartupTelemetry() {
|
||||
val state = P2pStreamingState.Connecting(
|
||||
phase = "add_magnet",
|
||||
downloadSpeed = 2_000_000L,
|
||||
peers = 12,
|
||||
seeds = 7,
|
||||
)
|
||||
|
||||
assertEquals("add_magnet", state.phase)
|
||||
assertEquals(2_000_000L, state.downloadSpeed)
|
||||
assertEquals(12, state.peers)
|
||||
assertEquals(7, state.seeds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialProgressUsesReadinessStagesWithoutCompletingDuringBuffering() {
|
||||
val target = 5_242_880L
|
||||
|
||||
assertEquals(0f, p2pInitialLoadingProgress(0L, 0L, 0L))
|
||||
assertEquals(0.225f, p2pInitialLoadingProgress(0L, target, 0L))
|
||||
assertEquals(0.375f, p2pInitialLoadingProgress(0L, target, target))
|
||||
assertEquals(0.85f, p2pInitialLoadingProgress(5_000L, target, target))
|
||||
assertEquals(0.95f, p2pInitialLoadingProgress(15_000L, Long.MAX_VALUE, Long.MAX_VALUE))
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
actual object P2pStreamingEngine {
|
||||
private val _state = MutableStateFlow<P2pStreamingState>(P2pStreamingState.Idle)
|
||||
actual val state: StateFlow<P2pStreamingState> = _state.asStateFlow()
|
||||
private val _cacheState = MutableStateFlow(P2pCacheUiState())
|
||||
actual val cacheState: StateFlow<P2pCacheUiState> = _cacheState.asStateFlow()
|
||||
|
||||
actual suspend fun startStream(request: P2pStreamRequest): String {
|
||||
val message = "P2P streaming is not available on this platform"
|
||||
|
|
@ -14,6 +16,9 @@ actual object P2pStreamingEngine {
|
|||
throw P2pStreamingException(message)
|
||||
}
|
||||
|
||||
actual suspend fun clearCache(): P2pCacheClearResult =
|
||||
P2pCacheClearResult(reclaimedBytes = 0L, remainingBytes = 0L, protectedBytes = 0L)
|
||||
|
||||
actual fun stopStream() {
|
||||
_state.value = P2pStreamingState.Idle
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ actual object AppFeaturePolicy {
|
|||
actual val supportersContributorsPageEnabled: Boolean = true
|
||||
actual val accountDeletionEnabled: Boolean = false
|
||||
actual val personalMediaAddonCopyEnabled: Boolean = false
|
||||
actual val p2pEnabled: Boolean = false
|
||||
actual val p2pEnabled: Boolean = true
|
||||
actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP
|
||||
actual val heroTrailerPlaybackSupported: Boolean = false
|
||||
actual val inAppUpdaterEnabled: Boolean = false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,563 @@
|
|||
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
|
||||
|
||||
package com.nuvio.app.features.p2p
|
||||
|
||||
import cnames.structs.nuvio_engine
|
||||
import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_EVENT_DISK_CACHE_RECLAIMED
|
||||
import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_EVENT_STREAM_PREPARED
|
||||
import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_EVENT_STREAM_STOPPED
|
||||
import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_EVENT_TORRENT_ERROR
|
||||
import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_EVENT_TORRENT_METADATA_READY
|
||||
import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_STATUS_NO_EVENT
|
||||
import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_STATUS_OK
|
||||
import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_TORRENT_PROFILE_BALANCED
|
||||
import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_TORRENT_PROFILE_FAST
|
||||
import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_TORRENT_PROFILE_SOFT
|
||||
import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_UPLOAD_DISABLED
|
||||
import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_UPLOAD_UNLIMITED
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_add_torrent
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_config
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_config_init_sized
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_create
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_destroy
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_event
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_event_init_sized
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_get_stats
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_get_stream_stats
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_poll_event
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_prepare_stream
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_reclaim_disk_cache
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_stats
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_stats_init_sized
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_status_message
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_stop_stream
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_stream_request
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_stream_request_init_sized
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_stream_stats
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_stream_stats_init_sized
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_torrent_request
|
||||
import com.nuvio.app.features.p2p.native.nuvio_engine_torrent_request_init_sized
|
||||
import kotlinx.cinterop.CPointer
|
||||
import kotlinx.cinterop.CPointerVar
|
||||
import kotlinx.cinterop.ULongVar
|
||||
import kotlinx.cinterop.alloc
|
||||
import kotlinx.cinterop.cstr
|
||||
import kotlinx.cinterop.memScoped
|
||||
import kotlinx.cinterop.ptr
|
||||
import kotlinx.cinterop.sizeOf
|
||||
import kotlinx.cinterop.toKString
|
||||
import kotlinx.cinterop.value
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSHomeDirectory
|
||||
import platform.Foundation.NSLog
|
||||
|
||||
private const val StatsPollIntervalMs = 250L
|
||||
private const val StartupStatsPollIntervalMs = 1_000L
|
||||
private const val MemoryCacheCapacityBytes = 64L * 1024L * 1024L
|
||||
|
||||
actual object P2pStreamingEngine {
|
||||
private data class EngineConfigurationKey(
|
||||
val uploadEnabled: Boolean,
|
||||
val torrentProfile: P2pTorrentProfile,
|
||||
val diskCacheCapacityBytes: Long,
|
||||
)
|
||||
|
||||
private data class NativeEvent(
|
||||
val type: UInt,
|
||||
val requestId: ULong,
|
||||
val torrentId: String?,
|
||||
val message: String?,
|
||||
val fileIndex: UInt?,
|
||||
val fileSize: ULong,
|
||||
val streamId: String?,
|
||||
val streamUrl: String?,
|
||||
)
|
||||
|
||||
private data class NativeStream(
|
||||
val id: String,
|
||||
val url: String,
|
||||
val torrentId: String,
|
||||
val fileIndex: Int,
|
||||
val fileSize: Long,
|
||||
)
|
||||
|
||||
private data class AggregateStats(
|
||||
val peers: Int,
|
||||
val seeds: Int,
|
||||
val downloadSpeed: Long,
|
||||
val uploadSpeed: Long,
|
||||
val payloadDownloaded: Long,
|
||||
val diskUsed: Long,
|
||||
val diskProtected: Long,
|
||||
)
|
||||
|
||||
private data class StreamStats(
|
||||
val fileSize: Long,
|
||||
val contiguousReady: Long,
|
||||
val verified: Long,
|
||||
val delivered: Long,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow<P2pStreamingState>(P2pStreamingState.Idle)
|
||||
actual val state: StateFlow<P2pStreamingState> = _state.asStateFlow()
|
||||
private val _cacheState = MutableStateFlow(P2pCacheUiState())
|
||||
actual val cacheState: StateFlow<P2pCacheUiState> = _cacheState.asStateFlow()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val lifecycleMutex = Mutex()
|
||||
private var engine: CPointer<nuvio_engine>? = null
|
||||
private var engineConfigurationKey: EngineConfigurationKey? = null
|
||||
private var currentTorrentId: String? = null
|
||||
private var currentStream: NativeStream? = null
|
||||
private var statsJob: Job? = null
|
||||
private var generation = 0L
|
||||
private val knownTorrentIds = mutableSetOf<String>()
|
||||
|
||||
actual suspend fun startStream(request: P2pStreamRequest): String = lifecycleMutex.withLock {
|
||||
stopLocked(shutdownEngine = false)
|
||||
generation += 1
|
||||
val streamGeneration = generation
|
||||
_state.value = P2pStreamingState.Connecting()
|
||||
var phase = "build_magnet"
|
||||
var startupStatsJob: Job? = null
|
||||
var preparedStream: NativeStream? = null
|
||||
try {
|
||||
val magnet = buildP2pMagnetUri(
|
||||
request.infoHash,
|
||||
(DefaultTrackers + request.trackers).distinct(),
|
||||
)
|
||||
phase = "ensure_engine"
|
||||
val activeEngine = ensureEngine()
|
||||
val baseline = readAggregateStats(activeEngine).payloadDownloaded
|
||||
startupStatsJob = startStartupStatsPolling(activeEngine, streamGeneration) { phase }
|
||||
|
||||
phase = "add_magnet"
|
||||
val canonicalHash = canonicalP2pInfoHash(request.infoHash)
|
||||
val torrentId = if (canonicalHash in knownTorrentIds) {
|
||||
canonicalHash
|
||||
} else {
|
||||
addMagnet(activeEngine, magnet).also(knownTorrentIds::add)
|
||||
}
|
||||
currentCoroutineContext().ensureActive()
|
||||
|
||||
phase = "prepare_stream"
|
||||
val stream = prepareStream(
|
||||
activeEngine,
|
||||
torrentId,
|
||||
request.fileIdx,
|
||||
request.filename,
|
||||
)
|
||||
preparedStream = stream
|
||||
currentCoroutineContext().ensureActive()
|
||||
currentTorrentId = torrentId
|
||||
currentStream = stream
|
||||
phase = "attach_route"
|
||||
|
||||
val aggregate = readAggregateStats(activeEngine)
|
||||
publishStreaming(streamGeneration, stream, aggregate, baseline, null)
|
||||
startStatsPolling(activeEngine, stream, streamGeneration, baseline)
|
||||
log("stream ready fileIndex=${stream.fileIndex} fileBytes=${stream.fileSize}")
|
||||
stream.url
|
||||
} catch (cancellation: CancellationException) {
|
||||
withContext(NonCancellable) {
|
||||
preparedStream?.let { runCatching { stopNativeStream(engine, it.id) } }
|
||||
}
|
||||
currentTorrentId = null
|
||||
currentStream = null
|
||||
_state.value = P2pStreamingState.Idle
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
preparedStream?.let { runCatching { stopNativeStream(engine, it.id) } }
|
||||
currentTorrentId = null
|
||||
currentStream = null
|
||||
val message = error.message ?: "Unable to start torrent stream"
|
||||
_state.value = P2pStreamingState.Error(message)
|
||||
log("stream failed phase=$phase error=$message")
|
||||
throw P2pStreamingException(message)
|
||||
} finally {
|
||||
startupStatsJob?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun clearCache(): P2pCacheClearResult = lifecycleMutex.withLock {
|
||||
check(_state.value !is P2pStreamingState.Connecting &&
|
||||
_state.value !is P2pStreamingState.Streaming) {
|
||||
"Torrent cache cannot be cleared during active playback"
|
||||
}
|
||||
_cacheState.value = _cacheState.value.copy(isClearing = true)
|
||||
try {
|
||||
val activeEngine = ensureEngine()
|
||||
val before = readAggregateStats(activeEngine)
|
||||
reclaimDiskCache(activeEngine)
|
||||
val after = readAggregateStats(activeEngine)
|
||||
updateCacheState(after)
|
||||
P2pCacheClearResult(
|
||||
reclaimedBytes = (before.diskUsed - after.diskUsed).coerceAtLeast(0L),
|
||||
remainingBytes = after.diskUsed,
|
||||
protectedBytes = after.diskProtected,
|
||||
)
|
||||
} finally {
|
||||
_cacheState.value = _cacheState.value.copy(isClearing = false)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun stopStream() {
|
||||
scope.launch {
|
||||
lifecycleMutex.withLock { stopLocked(shutdownEngine = false) }
|
||||
}
|
||||
}
|
||||
|
||||
actual fun shutdown() {
|
||||
scope.launch {
|
||||
lifecycleMutex.withLock { stopLocked(shutdownEngine = true) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun stopLocked(shutdownEngine: Boolean) {
|
||||
generation += 1
|
||||
statsJob?.cancel()
|
||||
statsJob = null
|
||||
val stream = currentStream
|
||||
currentStream = null
|
||||
currentTorrentId = null
|
||||
_state.value = P2pStreamingState.Idle
|
||||
stream?.let { runCatching { stopNativeStream(engine, it.id) } }
|
||||
if (shutdownEngine) closeEngine()
|
||||
}
|
||||
|
||||
private fun startStartupStatsPolling(
|
||||
activeEngine: CPointer<nuvio_engine>,
|
||||
streamGeneration: Long,
|
||||
phase: () -> String,
|
||||
): Job = scope.launch {
|
||||
while (isActive && generation == streamGeneration) {
|
||||
runCatching { readAggregateStats(activeEngine) }.getOrNull()?.let { stats ->
|
||||
if (_state.value is P2pStreamingState.Connecting) {
|
||||
_state.value = P2pStreamingState.Connecting(
|
||||
phase = phase(),
|
||||
downloadSpeed = stats.downloadSpeed,
|
||||
uploadSpeed = stats.uploadSpeed,
|
||||
peers = stats.peers,
|
||||
seeds = stats.seeds,
|
||||
)
|
||||
updateCacheState(stats)
|
||||
}
|
||||
}
|
||||
delay(StartupStatsPollIntervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startStatsPolling(
|
||||
activeEngine: CPointer<nuvio_engine>,
|
||||
stream: NativeStream,
|
||||
streamGeneration: Long,
|
||||
payloadBaseline: Long,
|
||||
) {
|
||||
statsJob?.cancel()
|
||||
statsJob = scope.launch {
|
||||
while (isActive && generation == streamGeneration) {
|
||||
try {
|
||||
val aggregate = readAggregateStats(activeEngine)
|
||||
val route = readStreamStats(activeEngine, stream.id)
|
||||
updateCacheState(aggregate)
|
||||
publishStreaming(streamGeneration, stream, aggregate, payloadBaseline, route)
|
||||
} catch (error: Throwable) {
|
||||
if (error is CancellationException) throw error
|
||||
if (generation == streamGeneration) {
|
||||
_state.value = P2pStreamingState.Error(
|
||||
error.message ?: "Torrent stream stopped unexpectedly"
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
delay(StatsPollIntervalMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishStreaming(
|
||||
streamGeneration: Long,
|
||||
stream: NativeStream,
|
||||
aggregate: AggregateStats,
|
||||
payloadBaseline: Long,
|
||||
route: StreamStats?,
|
||||
) {
|
||||
if (generation != streamGeneration || currentStream?.id != stream.id) return
|
||||
_state.value = P2pStreamingState.Streaming(
|
||||
localUrl = stream.url,
|
||||
downloadSpeed = aggregate.downloadSpeed,
|
||||
uploadSpeed = aggregate.uploadSpeed,
|
||||
peers = aggregate.peers,
|
||||
seeds = aggregate.seeds,
|
||||
bufferProgress = ratio(route?.contiguousReady ?: 0L, route?.fileSize ?: stream.fileSize),
|
||||
totalProgress = ratio(route?.verified ?: 0L, route?.fileSize ?: stream.fileSize),
|
||||
downloadedBytes = (aggregate.payloadDownloaded - payloadBaseline).coerceAtLeast(0L),
|
||||
verifiedBytes = route?.verified ?: 0L,
|
||||
deliveredBytes = route?.delivered ?: 0L,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun ensureEngine(): CPointer<nuvio_engine> {
|
||||
P2pSettingsRepository.ensureLoaded()
|
||||
val settings = P2pSettingsRepository.uiState.value
|
||||
val configuration = EngineConfigurationKey(
|
||||
uploadEnabled = settings.enableUpload,
|
||||
torrentProfile = settings.torrentProfile,
|
||||
diskCacheCapacityBytes = settings.cacheSize.bytes,
|
||||
)
|
||||
engine?.takeIf { engineConfigurationKey == configuration }?.let { return it }
|
||||
closeEngine()
|
||||
|
||||
val stateDirectory = "${NSHomeDirectory()}/Library/Application Support/NuvioEngine/state"
|
||||
val cacheDirectory = "${NSHomeDirectory()}/Library/Caches/NuvioEngine/payload"
|
||||
createDirectory(stateDirectory)
|
||||
createDirectory(cacheDirectory)
|
||||
val created = memScoped {
|
||||
val config = alloc<nuvio_engine_config>()
|
||||
nuvio_engine_config_init_sized(config.ptr, sizeOf<nuvio_engine_config>().toUInt())
|
||||
config.data_directory = stateDirectory.cstr.getPointer(this)
|
||||
config.cache_directory = cacheDirectory.cstr.getPointer(this)
|
||||
config.memory_cache_capacity_bytes = MemoryCacheCapacityBytes.toULong()
|
||||
config.disk_cache_capacity_bytes = configuration.diskCacheCapacityBytes.toULong()
|
||||
config.upload_mode = if (configuration.uploadEnabled) {
|
||||
NUVIO_ENGINE_UPLOAD_UNLIMITED
|
||||
} else {
|
||||
NUVIO_ENGINE_UPLOAD_DISABLED
|
||||
}
|
||||
config.upload_limit_bytes_per_second = 0uL
|
||||
config.stream_inactivity_timeout_milliseconds = 0u
|
||||
config.warm_torrent_timeout_milliseconds = 60_000u
|
||||
config.tls_ca_bundle_path = null
|
||||
config.torrent_profile = when (configuration.torrentProfile) {
|
||||
P2pTorrentProfile.SOFT -> NUVIO_ENGINE_TORRENT_PROFILE_SOFT
|
||||
P2pTorrentProfile.BALANCED -> NUVIO_ENGINE_TORRENT_PROFILE_BALANCED
|
||||
P2pTorrentProfile.FAST -> NUVIO_ENGINE_TORRENT_PROFILE_FAST
|
||||
}
|
||||
val output = alloc<CPointerVar<nuvio_engine>>()
|
||||
checkStatus(nuvio_engine_create(config.ptr, output.ptr))
|
||||
output.value ?: throw P2pStreamingException("Nuvio Engine returned an empty handle")
|
||||
}
|
||||
engine = created
|
||||
engineConfigurationKey = configuration
|
||||
log("engine created configuration=$configuration")
|
||||
return created
|
||||
}
|
||||
|
||||
private fun closeEngine() {
|
||||
val activeEngine = engine ?: return
|
||||
engine = null
|
||||
engineConfigurationKey = null
|
||||
knownTorrentIds.clear()
|
||||
nuvio_engine_destroy(activeEngine)
|
||||
}
|
||||
|
||||
private suspend fun addMagnet(activeEngine: CPointer<nuvio_engine>, magnet: String): String {
|
||||
val requestId = memScoped {
|
||||
val request = alloc<nuvio_engine_torrent_request>()
|
||||
nuvio_engine_torrent_request_init_sized(
|
||||
request.ptr,
|
||||
sizeOf<nuvio_engine_torrent_request>().toUInt(),
|
||||
)
|
||||
request.magnet_uri = magnet.cstr.getPointer(this)
|
||||
request.source_type = 0u
|
||||
val output = alloc<ULongVar>()
|
||||
checkStatus(nuvio_engine_add_torrent(activeEngine, request.ptr, output.ptr))
|
||||
output.value
|
||||
}
|
||||
return awaitEvent(activeEngine, requestId, NUVIO_ENGINE_EVENT_TORRENT_METADATA_READY)
|
||||
.torrentId
|
||||
?: throw P2pStreamingException("Torrent metadata event omitted its identifier")
|
||||
}
|
||||
|
||||
private suspend fun prepareStream(
|
||||
activeEngine: CPointer<nuvio_engine>,
|
||||
torrentId: String,
|
||||
fileIndex: Int?,
|
||||
filename: String?,
|
||||
): NativeStream {
|
||||
val requestId = memScoped {
|
||||
val request = alloc<nuvio_engine_stream_request>()
|
||||
nuvio_engine_stream_request_init_sized(
|
||||
request.ptr,
|
||||
sizeOf<nuvio_engine_stream_request>().toUInt(),
|
||||
)
|
||||
request.torrent_id = torrentId.cstr.getPointer(this)
|
||||
request.file_index = fileIndex?.toUInt() ?: UInt.MAX_VALUE
|
||||
request.filename_hint = filename?.cstr?.getPointer(this)
|
||||
val output = alloc<ULongVar>()
|
||||
checkStatus(nuvio_engine_prepare_stream(activeEngine, request.ptr, output.ptr))
|
||||
output.value
|
||||
}
|
||||
val event = awaitEvent(activeEngine, requestId, NUVIO_ENGINE_EVENT_STREAM_PREPARED)
|
||||
return NativeStream(
|
||||
id = event.streamId
|
||||
?: throw P2pStreamingException("Stream event omitted its identifier"),
|
||||
url = event.streamUrl
|
||||
?: throw P2pStreamingException("Stream event omitted its URL"),
|
||||
torrentId = event.torrentId ?: torrentId,
|
||||
fileIndex = event.fileIndex?.toInt() ?: fileIndex ?: -1,
|
||||
fileSize = event.fileSize.toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun stopNativeStream(activeEngine: CPointer<nuvio_engine>?, streamId: String) {
|
||||
if (activeEngine == null) return
|
||||
val requestId = memScoped {
|
||||
val output = alloc<ULongVar>()
|
||||
checkStatus(nuvio_engine_stop_stream(activeEngine, streamId, output.ptr))
|
||||
output.value
|
||||
}
|
||||
awaitEvent(activeEngine, requestId, NUVIO_ENGINE_EVENT_STREAM_STOPPED)
|
||||
}
|
||||
|
||||
private suspend fun reclaimDiskCache(activeEngine: CPointer<nuvio_engine>) {
|
||||
val requestId = memScoped {
|
||||
val output = alloc<ULongVar>()
|
||||
checkStatus(nuvio_engine_reclaim_disk_cache(activeEngine, 0uL, output.ptr))
|
||||
output.value
|
||||
}
|
||||
awaitEvent(activeEngine, requestId, NUVIO_ENGINE_EVENT_DISK_CACHE_RECLAIMED)
|
||||
}
|
||||
|
||||
private suspend fun awaitEvent(
|
||||
activeEngine: CPointer<nuvio_engine>,
|
||||
requestId: ULong,
|
||||
expectedType: UInt,
|
||||
): NativeEvent {
|
||||
while (true) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
when (val event = pollEvent(activeEngine)) {
|
||||
null -> delay(20L)
|
||||
else -> {
|
||||
if (event.requestId == requestId && event.type == NUVIO_ENGINE_EVENT_TORRENT_ERROR) {
|
||||
throw P2pStreamingException(event.message ?: "Torrent engine command failed")
|
||||
}
|
||||
if (event.requestId == requestId && event.type == expectedType) return event
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pollEvent(activeEngine: CPointer<nuvio_engine>): NativeEvent? = memScoped {
|
||||
val event = alloc<nuvio_engine_event>()
|
||||
nuvio_engine_event_init_sized(event.ptr, sizeOf<nuvio_engine_event>().toUInt())
|
||||
val status = nuvio_engine_poll_event(activeEngine, event.ptr)
|
||||
if (status == NUVIO_ENGINE_STATUS_NO_EVENT) return@memScoped null
|
||||
checkStatus(status)
|
||||
NativeEvent(
|
||||
type = event.type,
|
||||
requestId = event.request_id,
|
||||
torrentId = event.torrent_id.toKString().ifBlank { null },
|
||||
message = event.message.toKString().ifBlank { null },
|
||||
fileIndex = event.file_index.takeUnless { it == UInt.MAX_VALUE },
|
||||
fileSize = event.file_size,
|
||||
streamId = event.stream_id.toKString().ifBlank { null },
|
||||
streamUrl = event.stream_url.toKString().ifBlank { null },
|
||||
)
|
||||
}
|
||||
|
||||
private fun readAggregateStats(activeEngine: CPointer<nuvio_engine>): AggregateStats = memScoped {
|
||||
val stats = alloc<nuvio_engine_stats>()
|
||||
nuvio_engine_stats_init_sized(stats.ptr, sizeOf<nuvio_engine_stats>().toUInt())
|
||||
checkStatus(nuvio_engine_get_stats(activeEngine, stats.ptr))
|
||||
AggregateStats(
|
||||
peers = stats.connected_peers.toInt(),
|
||||
seeds = stats.connected_seeds.toInt(),
|
||||
downloadSpeed = stats.download_rate_bytes_per_second.toLong(),
|
||||
uploadSpeed = stats.upload_rate_bytes_per_second.toLong(),
|
||||
payloadDownloaded = stats.total_payload_download_bytes.toLong(),
|
||||
diskUsed = stats.disk_cache_used_bytes.toLong(),
|
||||
diskProtected = stats.disk_cache_protected_bytes.toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun readStreamStats(
|
||||
activeEngine: CPointer<nuvio_engine>,
|
||||
streamId: String,
|
||||
): StreamStats = memScoped {
|
||||
val stats = alloc<nuvio_engine_stream_stats>()
|
||||
nuvio_engine_stream_stats_init_sized(
|
||||
stats.ptr,
|
||||
sizeOf<nuvio_engine_stream_stats>().toUInt(),
|
||||
)
|
||||
checkStatus(nuvio_engine_get_stream_stats(activeEngine, streamId, stats.ptr))
|
||||
StreamStats(
|
||||
fileSize = stats.file_size.toLong(),
|
||||
contiguousReady = stats.contiguous_ready_bytes.toLong(),
|
||||
verified = stats.verified_file_bytes.toLong(),
|
||||
delivered = stats.delivered_bytes.toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateCacheState(stats: AggregateStats) {
|
||||
_cacheState.value = _cacheState.value.copy(
|
||||
usedBytes = stats.diskUsed,
|
||||
protectedBytes = stats.diskProtected,
|
||||
hasMeasurement = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun checkStatus(status: UInt) {
|
||||
if (status == NUVIO_ENGINE_STATUS_OK) return
|
||||
val message = nuvio_engine_status_message(status)?.toKString()
|
||||
?: "Nuvio Engine status $status"
|
||||
throw P2pStreamingException(message)
|
||||
}
|
||||
|
||||
private fun createDirectory(path: String) {
|
||||
val manager = NSFileManager.defaultManager
|
||||
check(manager.createDirectoryAtPath(
|
||||
path,
|
||||
withIntermediateDirectories = true,
|
||||
attributes = null,
|
||||
error = null,
|
||||
)) { "Could not create Nuvio Engine directory" }
|
||||
}
|
||||
|
||||
private fun ratio(value: Long, total: Long): Float =
|
||||
if (total <= 0L) 0f else (value.toDouble() / total.toDouble()).toFloat().coerceIn(0f, 1f)
|
||||
|
||||
private fun log(message: String) {
|
||||
NSLog("NuvioP2PDiag: $message")
|
||||
}
|
||||
|
||||
private val DefaultTrackers = listOf(
|
||||
"udp://zer0day.ch:1337/announce",
|
||||
"udp://tracker.publictracker.xyz:6969/announce",
|
||||
"udp://tracker.opentrackr.org:1337/announce",
|
||||
"udp://open.demonii.com:1337/announce",
|
||||
"udp://open.stealth.si:80/announce",
|
||||
"http://tracker.renfei.net:8080/announce",
|
||||
"udp://udp.tracker.projectk.org:23333/announce",
|
||||
"udp://tracker.tryhackx.org:6969/announce",
|
||||
"udp://tracker.torrent.eu.org:451/announce",
|
||||
"udp://tracker.theoks.net:6969/announce",
|
||||
"udp://tracker.startwork.cv:1337/announce",
|
||||
"udp://tracker.qu.ax:6969/announce",
|
||||
"udp://tracker.plx.im:6969/announce",
|
||||
"udp://tracker.nyaa.vc:6969/announce",
|
||||
"udp://tracker.iperson.xyz:6969/announce",
|
||||
"udp://tracker.gmi.gd:6969/announce",
|
||||
"udp://tracker.fnix.net:6969/announce",
|
||||
"udp://tracker.flatuslifir.is:6969/announce",
|
||||
"udp://tracker.ducks.party:1984/announce",
|
||||
"udp://tracker.bluefrog.pw:2710/announce",
|
||||
)
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ internal actual object P2pSettingsStorage {
|
|||
private const val p2pEnabledKey = "p2p_enabled"
|
||||
private const val enableUploadKey = "enable_upload"
|
||||
private const val hideTorrentStatsKey = "hide_torrent_stats"
|
||||
private const val torrentProfileKey = "torrent_profile"
|
||||
private const val cacheSizeKey = "cache_size"
|
||||
|
||||
actual fun loadP2pEnabled(): Boolean? =
|
||||
loadBoolean(p2pEnabledKey)
|
||||
|
|
@ -29,6 +31,18 @@ internal actual object P2pSettingsStorage {
|
|||
saveBoolean(hideTorrentStatsKey, enabled)
|
||||
}
|
||||
|
||||
actual fun loadTorrentProfile(): String? = loadString(torrentProfileKey)
|
||||
|
||||
actual fun saveTorrentProfile(profile: String) {
|
||||
saveString(torrentProfileKey, profile)
|
||||
}
|
||||
|
||||
actual fun loadCacheSize(): String? = loadString(cacheSizeKey)
|
||||
|
||||
actual fun saveCacheSize(size: String) {
|
||||
saveString(cacheSizeKey, size)
|
||||
}
|
||||
|
||||
private fun loadBoolean(keyBase: String): Boolean? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = ProfileScopedKey.of(keyBase)
|
||||
|
|
@ -38,4 +52,11 @@ internal actual object P2pSettingsStorage {
|
|||
private fun saveBoolean(keyBase: String, value: Boolean) {
|
||||
NSUserDefaults.standardUserDefaults.setBool(value, forKey = ProfileScopedKey.of(keyBase))
|
||||
}
|
||||
|
||||
private fun loadString(keyBase: String): String? =
|
||||
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(keyBase))
|
||||
|
||||
private fun saveString(keyBase: String, value: String) {
|
||||
NSUserDefaults.standardUserDefaults.setObject(value, forKey = ProfileScopedKey.of(keyBase))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,8 +51,11 @@ actual fun PlatformPlayerSurface(
|
|||
useYoutubeChunkedPlayback: Boolean,
|
||||
modifier: Modifier,
|
||||
playWhenReady: Boolean,
|
||||
initialPositionMs: Long?,
|
||||
initialPositionRequestKey: String?,
|
||||
resizeMode: PlayerResizeMode,
|
||||
useNativeController: Boolean,
|
||||
onInitialPositionHandled: (key: String, handled: Boolean) -> Unit,
|
||||
onControllerReady: (PlayerEngineController) -> Unit,
|
||||
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
|
||||
onError: (String?) -> Unit,
|
||||
|
|
|
|||
3
composeApp/src/nativeInterop/cinterop/nuvioengine.def
Normal file
3
composeApp/src/nativeInterop/cinterop/nuvioengine.def
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
headers = nuvio_engine/nuvio_engine.h
|
||||
package = com.nuvio.app.features.p2p.native
|
||||
staticLibraries = libCNuvioEngine.a
|
||||
|
|
@ -405,6 +405,11 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-framework",
|
||||
SystemConfiguration,
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.media;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
|
@ -435,6 +440,11 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-framework",
|
||||
SystemConfiguration,
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
# -s, --serial <id> ADB device serial (optional, for multi-device)
|
||||
# -p, --package <name> Android package/applicationId (default: debug build)
|
||||
# -t, --tag <regex> Additional logcat tag filter regex (default: all app)
|
||||
# --p2p Focus on Nuvio Engine startup/playback diagnostics
|
||||
# -c, --clear Clear logcat buffer before streaming
|
||||
# -h, --help Show help
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -21,6 +22,7 @@ PACKAGE="${NUVIO_LOG_PACKAGE:-com.nuviodebug.com}"
|
|||
SERIAL=""
|
||||
CLEAR_BUFFER=false
|
||||
TAG_FILTER=""
|
||||
P2P_MODE=false
|
||||
STREAM_PID=""
|
||||
USER_QUIT=false
|
||||
|
||||
|
|
@ -33,6 +35,7 @@ LOG_FILE="${LOG_DIR}/debug_$(date +%Y%m%d_%H%M%S).log"
|
|||
|
||||
# ── Noise suppression ───────────────────────────────────────────────────────
|
||||
NOISE_TAGS='EGL_emulation|OpenGLRenderer|eglCodecCommon|goldfish|gralloc|hwcomposer|SurfaceFlinger|chatty|ConfigStore|libEGL|MediaCodec|AudioTrack|AudioFlinger|BufferQueueProducer|GraphicBufferSource|OMXClient'
|
||||
P2P_TAGS='NuvioP2PDiag|NuvioPlayerDiag|P2pStreamingEngine|NuvioPlayer'
|
||||
|
||||
# ── ANSI colour codes ───────────────────────────────────────────────────────
|
||||
RST='\033[0m'
|
||||
|
|
@ -62,6 +65,7 @@ Options:
|
|||
-s, --serial <id> ADB device serial (optional)
|
||||
-p, --package <name> Android package/applicationId (default: com.nuviodebug.com)
|
||||
-t, --tag <regex> Additional grep regex to filter log tags
|
||||
--p2p Focus on engine phases, route telemetry, and player startup
|
||||
-c, --clear Clear logcat buffer before streaming
|
||||
-h, --help Show this help
|
||||
|
||||
|
|
@ -73,6 +77,8 @@ Examples:
|
|||
./scripts/debug_logs.sh
|
||||
./scripts/debug_logs.sh --serial emulator-5554
|
||||
./scripts/debug_logs.sh --package com.nuvio.app
|
||||
./scripts/debug_logs.sh --clear --p2p
|
||||
./scripts/debug_logs.sh --serial emulator-5554 --clear --p2p
|
||||
./scripts/debug_logs.sh --tag 'MetaDetailsRepo|SeriesContent'
|
||||
./scripts/debug_logs.sh --clear --tag 'Sync|Auth'
|
||||
EOF
|
||||
|
|
@ -84,12 +90,21 @@ while [[ $# -gt 0 ]]; do
|
|||
-s|--serial) SERIAL="${2:-}"; shift 2 ;;
|
||||
-p|--package) PACKAGE="${2:-}"; shift 2 ;;
|
||||
-t|--tag) TAG_FILTER="${2:-}"; shift 2 ;;
|
||||
--p2p) P2P_MODE=true; shift ;;
|
||||
-c|--clear) CLEAR_BUFFER=true; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if $P2P_MODE; then
|
||||
if [[ -n "$TAG_FILTER" ]]; then
|
||||
TAG_FILTER="${P2P_TAGS}|${TAG_FILTER}"
|
||||
else
|
||||
TAG_FILTER="$P2P_TAGS"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── ADB setup ────────────────────────────────────────────────────────────────
|
||||
ADB=(adb)
|
||||
if [[ -n "$SERIAL" ]]; then
|
||||
|
|
@ -104,6 +119,9 @@ fi
|
|||
|
||||
DEVICE_MODEL=$("${ADB[@]}" shell getprop ro.product.model 2>/dev/null | tr -d '\r' || echo "unknown")
|
||||
ANDROID_VER=$("${ADB[@]}" shell getprop ro.build.version.release 2>/dev/null | tr -d '\r' || echo "?")
|
||||
ANDROID_SDK=$("${ADB[@]}" shell getprop ro.build.version.sdk 2>/dev/null | tr -d '\r' || echo "?")
|
||||
DEVICE_ABI=$("${ADB[@]}" shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r' || echo "unknown")
|
||||
DEVICE_SERIAL=$("${ADB[@]}" get-serialno 2>/dev/null | tr -d '\r' || echo "unknown")
|
||||
|
||||
if $CLEAR_BUFFER; then
|
||||
"${ADB[@]}" logcat -c
|
||||
|
|
@ -129,6 +147,22 @@ if [[ -z "$APP_UID" ]]; then
|
|||
APP_UID="$(uid_from_dumpsys || true)"
|
||||
fi
|
||||
|
||||
APP_VERSION=$("${ADB[@]}" shell dumpsys package "$PACKAGE" 2>/dev/null \
|
||||
| tr -d '\r' \
|
||||
| sed -nE 's/.*versionName=([^[:space:]]+).*/\1/p' \
|
||||
| head -n1)
|
||||
APP_VERSION="${APP_VERSION:-unknown}"
|
||||
|
||||
write_session_metadata() {
|
||||
{
|
||||
echo "# Nuvio Android diagnostic capture"
|
||||
echo "# started=$(date '+%Y-%m-%dT%H:%M:%S%z')"
|
||||
echo "# serial=${DEVICE_SERIAL} model=${DEVICE_MODEL} android=${ANDROID_VER} sdk=${ANDROID_SDK} abi=${DEVICE_ABI}"
|
||||
echo "# package=${PACKAGE} version=${APP_VERSION} uid=${APP_UID:-unknown}"
|
||||
echo "# p2pMode=${P2P_MODE} filter=${TAG_FILTER:-all-app-logs}"
|
||||
} >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
# ── Colour-coding function ──────────────────────────────────────────────────
|
||||
colorize_line() {
|
||||
local line="$1"
|
||||
|
|
@ -219,7 +253,9 @@ print_banner() {
|
|||
echo -e "${CLR_HEADER}${BOLD} ╚══════════════════════════════════════════════════════╝${RST}"
|
||||
echo -e ""
|
||||
echo -e " ${CLR_META}Device:${RST} ${CLR_ACCENT}${DEVICE_MODEL}${RST} ${CLR_META}(Android ${ANDROID_VER})${RST}"
|
||||
echo -e " ${CLR_META}ABI / SDK:${RST} ${CLR_ACCENT}${DEVICE_ABI} / ${ANDROID_SDK}${RST}"
|
||||
echo -e " ${CLR_META}Package:${RST} ${CLR_ACCENT}${PACKAGE}${RST}"
|
||||
echo -e " ${CLR_META}App version:${RST} ${CLR_ACCENT}${APP_VERSION}${RST}"
|
||||
echo -e " ${CLR_META}Log file:${RST} ${CLR_ACCENT}${LOG_FILE}${RST}"
|
||||
if [[ -n "$TAG_FILTER" ]]; then
|
||||
echo -e " ${CLR_META}Tag filter:${RST} ${CLR_ACCENT}${TAG_FILTER}${RST}"
|
||||
|
|
@ -256,6 +292,7 @@ run_with_hotkeys() {
|
|||
c|C)
|
||||
"${ADB[@]}" logcat -c >/dev/null 2>&1 || true
|
||||
: > "$LOG_FILE"
|
||||
write_session_metadata
|
||||
clear_terminal
|
||||
;;
|
||||
q|Q)
|
||||
|
|
@ -280,6 +317,7 @@ cleanup() {
|
|||
trap cleanup EXIT INT TERM
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
write_session_metadata
|
||||
print_banner
|
||||
|
||||
if [[ -n "$APP_UID" ]]; then
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ dependencyResolutionManagement {
|
|||
}
|
||||
}
|
||||
mavenCentral()
|
||||
maven {
|
||||
name = "localNuvioEngine"
|
||||
url = uri("../nuvio-engine/platform/android/engine/build/repository")
|
||||
content { includeGroup("com.nuvio") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue