mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-18 16:01:44 +00:00
Implement torrent streaming core, playback buffering, and standalone Android packaging fixes
This commit is contained in:
parent
9b330b8226
commit
267f63ecff
37 changed files with 3146 additions and 407 deletions
|
|
@ -4,6 +4,10 @@ apply plugin: "com.facebook.react"
|
|||
apply plugin: "io.sentry.android.gradle"
|
||||
|
||||
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
||||
def resolveTargetAbis = {
|
||||
def configured = project.findProperty("reactNativeArchitectures") ?: "armeabi-v7a,arm64-v8a,x86,x86_64"
|
||||
configured.split(",").collect { it.trim() }.findAll { it }
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the configuration block to customize your React Native Android app.
|
||||
|
|
@ -21,6 +25,11 @@ react {
|
|||
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
|
||||
bundleCommand = "export:embed"
|
||||
|
||||
// Optional: build a debug APK with embedded JS so it doesn't require Metro.
|
||||
if ((findProperty("standaloneDebug") ?: "false").toBoolean()) {
|
||||
debuggableVariants = []
|
||||
}
|
||||
|
||||
/* Folders */
|
||||
// The root of your project, i.e. where "package.json" lives. Default is '../..'
|
||||
// root = file("../../")
|
||||
|
|
@ -106,8 +115,8 @@ android {
|
|||
abi {
|
||||
enable true
|
||||
reset()
|
||||
include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
|
||||
universalApk true
|
||||
include(*resolveTargetAbis())
|
||||
universalApk(resolveTargetAbis().size() > 1)
|
||||
}
|
||||
density {
|
||||
enable false
|
||||
|
|
@ -250,6 +259,15 @@ dependencies {
|
|||
// MPV Player library
|
||||
implementation files("libs/libmpv-release.aar")
|
||||
|
||||
// Torrent streaming engine
|
||||
def jlibtorrentVersion = '2.0.12.7'
|
||||
implementation("com.frostwire:jlibtorrent:${jlibtorrentVersion}")
|
||||
implementation("com.frostwire:jlibtorrent-android-arm:${jlibtorrentVersion}")
|
||||
implementation("com.frostwire:jlibtorrent-android-arm64:${jlibtorrentVersion}")
|
||||
implementation("com.frostwire:jlibtorrent-android-x86:${jlibtorrentVersion}")
|
||||
implementation("com.frostwire:jlibtorrent-android-x86_64:${jlibtorrentVersion}")
|
||||
implementation("org.nanohttpd:nanohttpd:2.3.1")
|
||||
|
||||
// Google Cast Framework
|
||||
implementation "com.google.android.gms:play-services-cast-framework:${safeExtGet('castFrameworkVersion', '+')}"
|
||||
}
|
||||
|
|
|
|||
3
android/app/proguard-rules.pro
vendored
3
android/app/proguard-rules.pro
vendored
|
|
@ -26,3 +26,6 @@
|
|||
**[] $VALUES;
|
||||
public *;
|
||||
}
|
||||
|
||||
# jlibtorrent JNI
|
||||
-keep class com.frostwire.jlibtorrent.swig.libtorrent_jni { *; }
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import com.facebook.react.defaults.DefaultReactNativeHost
|
|||
import expo.modules.ApplicationLifecycleDispatcher
|
||||
import expo.modules.ReactNativeHostWrapper
|
||||
import com.nuvio.app.mpv.MpvPackage
|
||||
import com.nuvio.app.torrent.TorrentStreamingPackage
|
||||
|
||||
class MainApplication : Application(), ReactApplication {
|
||||
|
||||
|
|
@ -25,7 +26,8 @@ class MainApplication : Application(), ReactApplication {
|
|||
override fun getPackages(): List<ReactPackage> =
|
||||
PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
add(com.nuvio.app.mpv.MpvPackage())
|
||||
add(MpvPackage())
|
||||
add(TorrentStreamingPackage())
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
|
||||
|
|
|
|||
|
|
@ -156,12 +156,14 @@ class MPVView @JvmOverloads constructor(
|
|||
|
||||
MPVLib.setOptionString("ao", "audiotrack,opensles")
|
||||
|
||||
// Limit demuxer cache based on Android version (like mpvKt)
|
||||
val cacheMegs = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O_MR1) 64 else 32
|
||||
// Use a larger demuxer cache window to reduce rebuffering on unstable links.
|
||||
val cacheMegs = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O_MR1) 192 else 96
|
||||
MPVLib.setOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}")
|
||||
MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}")
|
||||
MPVLib.setOptionString("cache", "yes")
|
||||
MPVLib.setOptionString("cache-secs", "30")
|
||||
MPVLib.setOptionString("cache-secs", "120")
|
||||
MPVLib.setOptionString("demuxer-readahead-secs", "120")
|
||||
MPVLib.setOptionString("cache-pause-wait", "5")
|
||||
|
||||
MPVLib.setOptionString("network-timeout", "60")
|
||||
MPVLib.setOptionString("ytdl", "no")
|
||||
|
|
@ -595,14 +597,20 @@ class MPVView @JvmOverloads constructor(
|
|||
MPV_EVENT_END_FILE -> {
|
||||
Log.d(TAG, "MPV_EVENT_END_FILE")
|
||||
|
||||
// Heuristic: If duration is effectively 0 at end of file, it's a load error
|
||||
// Heuristic: certain links don't report duration reliably and still end correctly.
|
||||
// Treat them as completed if we already played a meaningful amount.
|
||||
val duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0
|
||||
val timePos = MPVLib.getPropertyDouble("time-pos") ?: 0.0
|
||||
val eofReached = MPVLib.getPropertyBoolean("eof-reached") ?: false
|
||||
|
||||
Log.d(TAG, "End stats - Duration: $duration, Time: $timePos, EOF: $eofReached")
|
||||
|
||||
if (duration < 1.0 && !eofReached) {
|
||||
if (eofReached) {
|
||||
onEndCallback?.invoke()
|
||||
} else if (timePos > 10.0) {
|
||||
// Playback advanced enough to consider this a normal end.
|
||||
onEndCallback?.invoke()
|
||||
} else if (duration < 1.0 && timePos < 1.0) {
|
||||
val customError = "Unable to play media. Source may be unreachable."
|
||||
Log.e(TAG, "Playback error detected (heuristic): $customError")
|
||||
onErrorCallback?.invoke(customError)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,933 @@
|
|||
package com.nuvio.app.torrent
|
||||
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.ReadableArray
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
import com.frostwire.jlibtorrent.AddTorrentParams
|
||||
import com.frostwire.jlibtorrent.Priority
|
||||
import com.frostwire.jlibtorrent.SessionManager
|
||||
import com.frostwire.jlibtorrent.SessionParams
|
||||
import com.frostwire.jlibtorrent.SettingsPack
|
||||
import com.frostwire.jlibtorrent.Sha1Hash
|
||||
import com.frostwire.jlibtorrent.TorrentFlags
|
||||
import com.frostwire.jlibtorrent.TorrentHandle
|
||||
import com.frostwire.jlibtorrent.TorrentInfo
|
||||
import fi.iki.elonen.NanoHTTPD
|
||||
import java.io.EOFException
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.RandomAccessFile
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
class TorrentStreamingModule(private val context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {
|
||||
companion object {
|
||||
private const val TAG = "TorrentStreamingModule"
|
||||
private const val HANDLE_TIMEOUT_MS = 30_000L
|
||||
private const val METADATA_TIMEOUT_MS = 120_000L
|
||||
private const val PIECE_WAIT_MS = 45_000L
|
||||
private const val INITIAL_PIECE_WAIT_MS = 15_000L
|
||||
private const val STREAMING_WINDOW_PIECES = 112
|
||||
private const val STREAMING_BOOST_MIN_STEP = 8
|
||||
private const val PREFETCH_WINDOW_PIECES = 256
|
||||
private const val PREFETCH_ADVANCE_STEP = 24
|
||||
private const val PREFETCH_POLL_INTERVAL_MS = 550L
|
||||
private const val HTTP_HOST = "127.0.0.1"
|
||||
private const val IO_CHUNK_SIZE = 64 * 1024
|
||||
}
|
||||
|
||||
private data class StreamingTuning(
|
||||
val streamingWindowPieces: Int,
|
||||
val prefetchWindowPieces: Int,
|
||||
val prefetchAdvanceStep: Int,
|
||||
val pieceDeadlineStepMs: Int,
|
||||
val boostNearPieces: Int,
|
||||
val boostMidPieces: Int
|
||||
)
|
||||
|
||||
private data class ActiveTorrentStream(
|
||||
val streamId: String,
|
||||
val infoHash: String,
|
||||
val saveDir: File,
|
||||
val handle: TorrentHandle,
|
||||
val fileIndex: Int,
|
||||
val fileName: String,
|
||||
val filePath: File,
|
||||
val fileSize: Long,
|
||||
val fileOffset: Long,
|
||||
val pieceLength: Int,
|
||||
val totalPieces: Int,
|
||||
val fileStartPiece: Int,
|
||||
val fileEndPiece: Int,
|
||||
val streamingWindowPieces: Int,
|
||||
val prefetchWindowPieces: Int,
|
||||
val prefetchAdvanceStep: Int,
|
||||
val pieceDeadlineStepMs: Int,
|
||||
val boostNearPieces: Int,
|
||||
val boostMidPieces: Int,
|
||||
@Volatile var nextPrefetchPiece: Int = 0,
|
||||
@Volatile var lastBoostedPiece: Int = -1
|
||||
)
|
||||
|
||||
private val lock = Any()
|
||||
private val ioExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
private val prefetchExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
private val activeStreams = ConcurrentHashMap<String, ActiveTorrentStream>()
|
||||
|
||||
@Volatile
|
||||
private var sessionManager: SessionManager? = null
|
||||
|
||||
@Volatile
|
||||
private var httpServer: TorrentHttpServer? = null
|
||||
|
||||
override fun getName(): String = "TorrentStreamingModule"
|
||||
|
||||
@ReactMethod
|
||||
fun prepareStream(options: ReadableMap, promise: Promise) {
|
||||
val magnetUri = options.getString("magnetUri")
|
||||
if (magnetUri.isNullOrBlank()) {
|
||||
promise.reject("TORRENT_INVALID_INPUT", "Missing magnetUri")
|
||||
return
|
||||
}
|
||||
|
||||
val streamTitle = if (options.hasKey("streamTitle") && !options.isNull("streamTitle")) {
|
||||
options.getString("streamTitle")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val preferredFileIndex = if (options.hasKey("fileIndex") && !options.isNull("fileIndex")) {
|
||||
options.getDouble("fileIndex").toInt()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val trackers = mutableListOf<String>()
|
||||
if (options.hasKey("trackers") && !options.isNull("trackers")) {
|
||||
val arr = options.getArray("trackers")
|
||||
if (arr != null) {
|
||||
trackers.addAll(readableArrayToStringList(arr))
|
||||
}
|
||||
}
|
||||
|
||||
val networkMbps = if (options.hasKey("networkMbps") && !options.isNull("networkMbps")) {
|
||||
options.getDouble("networkMbps")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
ioExecutor.execute {
|
||||
try {
|
||||
val result = prepareStreamInternal(
|
||||
magnetUri = magnetUri,
|
||||
streamTitle = streamTitle,
|
||||
preferredFileIndex = preferredFileIndex,
|
||||
trackers = trackers,
|
||||
networkMbps = networkMbps
|
||||
)
|
||||
promise.resolve(result)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "prepareStream failed", e)
|
||||
promise.reject("TORRENT_PREPARE_FAILED", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun stopStream(streamId: String, promise: Promise) {
|
||||
ioExecutor.execute {
|
||||
try {
|
||||
synchronized(lock) {
|
||||
val stream = activeStreams.remove(streamId)
|
||||
if (stream != null) {
|
||||
stopStreamLocked(stream, cleanupFiles = true)
|
||||
}
|
||||
}
|
||||
promise.resolve(true)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "stopStream failed", e)
|
||||
promise.reject("TORRENT_STOP_FAILED", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun stopAllStreams(promise: Promise) {
|
||||
ioExecutor.execute {
|
||||
try {
|
||||
synchronized(lock) {
|
||||
stopAllStreamsLocked(cleanupFiles = true)
|
||||
}
|
||||
promise.resolve(true)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "stopAllStreams failed", e)
|
||||
promise.reject("TORRENT_STOP_ALL_FAILED", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
super.invalidate()
|
||||
try {
|
||||
ioExecutor.execute {
|
||||
synchronized(lock) {
|
||||
shutdownLocked()
|
||||
}
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
} finally {
|
||||
ioExecutor.shutdown()
|
||||
prefetchExecutor.shutdownNow()
|
||||
try {
|
||||
ioExecutor.awaitTermination(1500, TimeUnit.MILLISECONDS)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
try {
|
||||
prefetchExecutor.awaitTermination(1500, TimeUnit.MILLISECONDS)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareStreamInternal(
|
||||
magnetUri: String,
|
||||
streamTitle: String?,
|
||||
preferredFileIndex: Int?,
|
||||
trackers: List<String>,
|
||||
networkMbps: Double?
|
||||
): com.facebook.react.bridge.WritableMap {
|
||||
synchronized(lock) {
|
||||
ensureSessionStartedLocked()
|
||||
ensureHttpServerStartedLocked()
|
||||
stopAllStreamsLocked(cleanupFiles = true)
|
||||
}
|
||||
|
||||
val finalMagnet = mergeTrackersIntoMagnet(magnetUri, trackers)
|
||||
val parsedParams = AddTorrentParams.parseMagnetUri(finalMagnet)
|
||||
val infoHash = parsedParams.getInfoHashes().getBest()
|
||||
val infoHashHex = infoHash.toHex().lowercase(Locale.US)
|
||||
|
||||
val saveDir = File(getBaseTorrentDir(), infoHashHex)
|
||||
if (!saveDir.exists()) {
|
||||
saveDir.mkdirs()
|
||||
}
|
||||
|
||||
val sm = sessionManager ?: throw IllegalStateException("Torrent session is not initialized")
|
||||
sm.download(finalMagnet, saveDir, TorrentFlags.SEQUENTIAL_DOWNLOAD)
|
||||
|
||||
val handle = waitForHandle(infoHash, HANDLE_TIMEOUT_MS)
|
||||
?: throw IllegalStateException("Timed out while waiting for torrent handle")
|
||||
|
||||
handle.resume()
|
||||
|
||||
val torrentInfo = waitForMetadata(handle, METADATA_TIMEOUT_MS)
|
||||
?: throw IllegalStateException("Timed out while waiting for torrent metadata")
|
||||
|
||||
val selectedFileIndex = selectPlayableFileIndex(torrentInfo, preferredFileIndex)
|
||||
if (selectedFileIndex < 0) {
|
||||
throw IllegalStateException("No playable video file found in torrent")
|
||||
}
|
||||
|
||||
prioritizeSelectedFile(handle, torrentInfo, selectedFileIndex)
|
||||
|
||||
val fs = torrentInfo.files()
|
||||
val relativePath = fs.filePath(selectedFileIndex).ifBlank { "stream.bin" }
|
||||
val absolutePath = fs.filePath(selectedFileIndex, saveDir.absolutePath)
|
||||
val filePath = File(absolutePath)
|
||||
val fileSize = fs.fileSize(selectedFileIndex)
|
||||
val fileOffset = fs.fileOffset(selectedFileIndex)
|
||||
val pieceLength = max(1, torrentInfo.pieceLength())
|
||||
val totalPieces = max(1, torrentInfo.numPieces())
|
||||
val firstPiece = (fileOffset / pieceLength.toLong()).toInt().coerceIn(0, totalPieces - 1)
|
||||
val lastPiece = ((fileOffset + max(1L, fileSize) - 1L) / pieceLength.toLong())
|
||||
.toInt()
|
||||
.coerceIn(firstPiece, totalPieces - 1)
|
||||
val tuning = deriveStreamingTuning(networkMbps)
|
||||
|
||||
val streamId = UUID.randomUUID().toString()
|
||||
val stream = ActiveTorrentStream(
|
||||
streamId = streamId,
|
||||
infoHash = infoHashHex,
|
||||
saveDir = saveDir,
|
||||
handle = handle,
|
||||
fileIndex = selectedFileIndex,
|
||||
fileName = relativePath,
|
||||
filePath = filePath,
|
||||
fileSize = fileSize,
|
||||
fileOffset = fileOffset,
|
||||
pieceLength = pieceLength,
|
||||
totalPieces = totalPieces,
|
||||
fileStartPiece = firstPiece,
|
||||
fileEndPiece = lastPiece,
|
||||
streamingWindowPieces = tuning.streamingWindowPieces,
|
||||
prefetchWindowPieces = tuning.prefetchWindowPieces,
|
||||
prefetchAdvanceStep = tuning.prefetchAdvanceStep,
|
||||
pieceDeadlineStepMs = tuning.pieceDeadlineStepMs,
|
||||
boostNearPieces = tuning.boostNearPieces,
|
||||
boostMidPieces = tuning.boostMidPieces,
|
||||
nextPrefetchPiece = firstPiece
|
||||
)
|
||||
|
||||
synchronized(lock) {
|
||||
activeStreams[streamId] = stream
|
||||
}
|
||||
|
||||
boostPieceWindow(stream, firstPiece, stream.prefetchWindowPieces)
|
||||
startPrefetchLoop(stream)
|
||||
waitForPiece(stream, firstPiece, INITIAL_PIECE_WAIT_MS)
|
||||
|
||||
val serverPort = httpServer?.listeningPort
|
||||
?: throw IllegalStateException("Torrent HTTP bridge is not running")
|
||||
val playbackUrl = "http://$HTTP_HOST:$serverPort/torrent/$streamId"
|
||||
|
||||
val result = Arguments.createMap()
|
||||
result.putString("streamId", streamId)
|
||||
result.putString("playbackUrl", playbackUrl)
|
||||
result.putString("infoHash", infoHashHex)
|
||||
result.putString("fileName", stream.fileName)
|
||||
result.putDouble("fileSize", stream.fileSize.toDouble())
|
||||
result.putString("mimeType", guessMimeType(stream.fileName))
|
||||
result.putString("streamTitle", streamTitle)
|
||||
result.putDouble("networkMbps", networkMbps ?: 0.0)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun deriveStreamingTuning(networkMbps: Double?): StreamingTuning {
|
||||
val mbps = if (networkMbps != null && networkMbps.isFinite() && networkMbps > 0.0) {
|
||||
networkMbps
|
||||
} else {
|
||||
20.0
|
||||
}
|
||||
|
||||
return when {
|
||||
mbps <= 1.5 -> StreamingTuning(
|
||||
streamingWindowPieces = 64,
|
||||
prefetchWindowPieces = 128,
|
||||
prefetchAdvanceStep = 12,
|
||||
pieceDeadlineStepMs = 160,
|
||||
boostNearPieces = 14,
|
||||
boostMidPieces = 28
|
||||
)
|
||||
mbps <= 5.0 -> StreamingTuning(
|
||||
streamingWindowPieces = 88,
|
||||
prefetchWindowPieces = 192,
|
||||
prefetchAdvanceStep = 18,
|
||||
pieceDeadlineStepMs = 145,
|
||||
boostNearPieces = 18,
|
||||
boostMidPieces = 36
|
||||
)
|
||||
mbps <= 20.0 -> StreamingTuning(
|
||||
streamingWindowPieces = STREAMING_WINDOW_PIECES,
|
||||
prefetchWindowPieces = PREFETCH_WINDOW_PIECES,
|
||||
prefetchAdvanceStep = PREFETCH_ADVANCE_STEP,
|
||||
pieceDeadlineStepMs = 120,
|
||||
boostNearPieces = 24,
|
||||
boostMidPieces = 48
|
||||
)
|
||||
mbps <= 80.0 -> StreamingTuning(
|
||||
streamingWindowPieces = 144,
|
||||
prefetchWindowPieces = 320,
|
||||
prefetchAdvanceStep = 32,
|
||||
pieceDeadlineStepMs = 100,
|
||||
boostNearPieces = 28,
|
||||
boostMidPieces = 56
|
||||
)
|
||||
mbps <= 250.0 -> StreamingTuning(
|
||||
streamingWindowPieces = 176,
|
||||
prefetchWindowPieces = 384,
|
||||
prefetchAdvanceStep = 40,
|
||||
pieceDeadlineStepMs = 85,
|
||||
boostNearPieces = 32,
|
||||
boostMidPieces = 64
|
||||
)
|
||||
else -> StreamingTuning(
|
||||
streamingWindowPieces = 224,
|
||||
prefetchWindowPieces = 448,
|
||||
prefetchAdvanceStep = 48,
|
||||
pieceDeadlineStepMs = 70,
|
||||
boostNearPieces = 36,
|
||||
boostMidPieces = 72
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureSessionStartedLocked() {
|
||||
if (sessionManager?.isRunning == true) {
|
||||
return
|
||||
}
|
||||
|
||||
val settings = SettingsPack()
|
||||
.downloadRateLimit(0)
|
||||
.uploadRateLimit(0)
|
||||
.activeDownloads(8)
|
||||
.activeSeeds(2)
|
||||
.connectionsLimit(200)
|
||||
.alertQueueSize(20000)
|
||||
|
||||
val params = SessionParams(settings)
|
||||
val sm = SessionManager(false)
|
||||
sm.start(params)
|
||||
sessionManager = sm
|
||||
}
|
||||
|
||||
private fun ensureHttpServerStartedLocked() {
|
||||
if (httpServer != null) {
|
||||
return
|
||||
}
|
||||
|
||||
val server = TorrentHttpServer(0)
|
||||
server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false)
|
||||
httpServer = server
|
||||
}
|
||||
|
||||
private fun shutdownLocked() {
|
||||
stopAllStreamsLocked(cleanupFiles = true)
|
||||
try {
|
||||
httpServer?.stop()
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
httpServer = null
|
||||
|
||||
try {
|
||||
sessionManager?.stop()
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
sessionManager = null
|
||||
}
|
||||
|
||||
private fun stopAllStreamsLocked(cleanupFiles: Boolean) {
|
||||
val current = activeStreams.values.toList()
|
||||
activeStreams.clear()
|
||||
current.forEach { stream ->
|
||||
stopStreamLocked(stream, cleanupFiles)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopStreamLocked(stream: ActiveTorrentStream, cleanupFiles: Boolean) {
|
||||
try {
|
||||
sessionManager?.remove(stream.handle)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
|
||||
if (cleanupFiles) {
|
||||
deleteRecursively(stream.saveDir)
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForHandle(infoHash: Sha1Hash, timeoutMs: Long): TorrentHandle? {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
while (System.currentTimeMillis() - startedAt < timeoutMs) {
|
||||
val handle = sessionManager?.find(infoHash)
|
||||
if (handle != null && handle.isValid) {
|
||||
return handle
|
||||
}
|
||||
try {
|
||||
Thread.sleep(200)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
break
|
||||
}
|
||||
}
|
||||
val fallback = sessionManager?.find(infoHash)
|
||||
return if (fallback != null && fallback.isValid) fallback else null
|
||||
}
|
||||
|
||||
private fun waitForMetadata(handle: TorrentHandle, timeoutMs: Long): TorrentInfo? {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
while (System.currentTimeMillis() - startedAt < timeoutMs) {
|
||||
try {
|
||||
val ti = handle.torrentFile()
|
||||
if (ti != null && ti.isValid && ti.numFiles() > 0) {
|
||||
return ti
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
Thread.sleep(250)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
break
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun selectPlayableFileIndex(torrentInfo: TorrentInfo, preferredFileIndex: Int?): Int {
|
||||
val fs = torrentInfo.files()
|
||||
val totalFiles = fs.numFiles()
|
||||
if (totalFiles <= 0) return -1
|
||||
|
||||
if (preferredFileIndex != null && preferredFileIndex in 0 until totalFiles) {
|
||||
val preferredPath = fs.filePath(preferredFileIndex).lowercase(Locale.US)
|
||||
if (isLikelyVideoFile(preferredPath)) {
|
||||
return preferredFileIndex
|
||||
}
|
||||
}
|
||||
|
||||
var bestIndex = -1
|
||||
var bestScore = Long.MIN_VALUE
|
||||
var fallbackLargestIndex = 0
|
||||
var fallbackLargestSize = Long.MIN_VALUE
|
||||
|
||||
for (i in 0 until totalFiles) {
|
||||
val size = fs.fileSize(i)
|
||||
val path = fs.filePath(i)
|
||||
val lowerPath = path.lowercase(Locale.US)
|
||||
|
||||
if (size > fallbackLargestSize) {
|
||||
fallbackLargestSize = size
|
||||
fallbackLargestIndex = i
|
||||
}
|
||||
|
||||
if (!isLikelyVideoFile(lowerPath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
var score = size
|
||||
if (lowerPath.contains("/sample") || lowerPath.contains("sample.")) score -= 500_000_000L
|
||||
if (lowerPath.contains("trailer")) score -= 300_000_000L
|
||||
if (lowerPath.contains("extras")) score -= 200_000_000L
|
||||
if (lowerPath.contains("featurette")) score -= 200_000_000L
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
return if (bestIndex >= 0) bestIndex else fallbackLargestIndex
|
||||
}
|
||||
|
||||
private fun prioritizeSelectedFile(handle: TorrentHandle, torrentInfo: TorrentInfo, selectedFileIndex: Int) {
|
||||
val filesCount = max(1, torrentInfo.numFiles())
|
||||
val priorities = Priority.array(Priority.IGNORE, filesCount)
|
||||
priorities[selectedFileIndex] = Priority.SEVEN
|
||||
handle.prioritizeFiles(priorities)
|
||||
}
|
||||
|
||||
private fun waitForPiece(stream: ActiveTorrentStream, pieceIndex: Int, timeoutMs: Long): Boolean {
|
||||
if (!stream.handle.isValid) {
|
||||
return false
|
||||
}
|
||||
|
||||
val safePiece = pieceIndex.coerceIn(stream.fileStartPiece, stream.fileEndPiece)
|
||||
val startedAt = System.currentTimeMillis()
|
||||
|
||||
while (System.currentTimeMillis() - startedAt < timeoutMs) {
|
||||
try {
|
||||
if (stream.handle.havePiece(safePiece)) {
|
||||
return true
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
return false
|
||||
}
|
||||
|
||||
boostPieceWindow(stream, safePiece)
|
||||
|
||||
try {
|
||||
Thread.sleep(120)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
stream.handle.havePiece(safePiece)
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun boostPieceWindow(
|
||||
stream: ActiveTorrentStream,
|
||||
fromPiece: Int,
|
||||
windowPieces: Int? = null
|
||||
) {
|
||||
if (!stream.handle.isValid) {
|
||||
return
|
||||
}
|
||||
|
||||
val startPiece = fromPiece.coerceIn(stream.fileStartPiece, stream.fileEndPiece)
|
||||
if (stream.lastBoostedPiece >= 0 && startPiece <= stream.lastBoostedPiece + STREAMING_BOOST_MIN_STEP) {
|
||||
return
|
||||
}
|
||||
|
||||
val effectiveWindowPieces = max(1, windowPieces ?: stream.streamingWindowPieces)
|
||||
val endPiece = min(stream.fileEndPiece, startPiece + effectiveWindowPieces)
|
||||
for (piece in startPiece..endPiece) {
|
||||
try {
|
||||
val pieceOffset = piece - startPiece
|
||||
val priority = when {
|
||||
pieceOffset < stream.boostNearPieces -> Priority.SEVEN
|
||||
pieceOffset < stream.boostMidPieces -> Priority.SIX
|
||||
else -> Priority.FOUR
|
||||
}
|
||||
stream.handle.piecePriority(piece, priority)
|
||||
stream.handle.setPieceDeadline(piece, pieceOffset * stream.pieceDeadlineStepMs)
|
||||
} catch (_: Throwable) {
|
||||
break
|
||||
}
|
||||
}
|
||||
stream.lastBoostedPiece = startPiece
|
||||
}
|
||||
|
||||
private fun startPrefetchLoop(stream: ActiveTorrentStream) {
|
||||
prefetchExecutor.execute {
|
||||
while (true) {
|
||||
val isStillActive = synchronized(lock) {
|
||||
activeStreams[stream.streamId] === stream
|
||||
}
|
||||
if (!isStillActive) {
|
||||
break
|
||||
}
|
||||
|
||||
if (!stream.handle.isValid) {
|
||||
break
|
||||
}
|
||||
|
||||
val fromPiece = stream.nextPrefetchPiece.coerceIn(stream.fileStartPiece, stream.fileEndPiece)
|
||||
val nextMissing = findNextMissingPiece(stream, fromPiece)
|
||||
if (nextMissing < 0) {
|
||||
break
|
||||
}
|
||||
|
||||
boostPieceWindow(stream, nextMissing, stream.prefetchWindowPieces)
|
||||
stream.nextPrefetchPiece = min(stream.fileEndPiece, nextMissing + stream.prefetchAdvanceStep)
|
||||
|
||||
try {
|
||||
Thread.sleep(PREFETCH_POLL_INTERVAL_MS)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findNextMissingPiece(stream: ActiveTorrentStream, fromPiece: Int): Int {
|
||||
if (!stream.handle.isValid) return -1
|
||||
|
||||
val start = stream.fileStartPiece
|
||||
val end = stream.fileEndPiece
|
||||
val pivot = fromPiece.coerceIn(start, end)
|
||||
|
||||
for (piece in pivot..end) {
|
||||
try {
|
||||
if (!stream.handle.havePiece(piece)) return piece
|
||||
} catch (_: Throwable) {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
for (piece in start until pivot) {
|
||||
try {
|
||||
if (!stream.handle.havePiece(piece)) return piece
|
||||
} catch (_: Throwable) {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun mergeTrackersIntoMagnet(magnetUri: String, trackers: List<String>): String {
|
||||
if (trackers.isEmpty() || !magnetUri.startsWith("magnet:?")) {
|
||||
return magnetUri
|
||||
}
|
||||
|
||||
val existingTrackers = mutableSetOf<String>()
|
||||
val query = magnetUri.substringAfter('?', "")
|
||||
if (query.isNotBlank()) {
|
||||
query.split('&').forEach { part ->
|
||||
if (part.startsWith("tr=", ignoreCase = true)) {
|
||||
val encoded = part.substringAfter("tr=", "")
|
||||
val decoded = URLDecoder.decode(encoded, Charsets.UTF_8.name())
|
||||
if (decoded.isNotBlank()) {
|
||||
existingTrackers.add(decoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val builder = StringBuilder(magnetUri)
|
||||
trackers.forEach { tracker ->
|
||||
val normalized = tracker.trim()
|
||||
if (normalized.isNotBlank() && existingTrackers.add(normalized)) {
|
||||
builder.append("&tr=").append(URLEncoder.encode(normalized, Charsets.UTF_8.name()))
|
||||
}
|
||||
}
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private fun getBaseTorrentDir(): File {
|
||||
val dir = File(context.cacheDir, "torrent-stream")
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
private fun deleteRecursively(file: File) {
|
||||
if (!file.exists()) return
|
||||
if (file.isDirectory) {
|
||||
file.listFiles()?.forEach { child ->
|
||||
deleteRecursively(child)
|
||||
}
|
||||
}
|
||||
file.delete()
|
||||
}
|
||||
|
||||
private fun readableArrayToStringList(arr: ReadableArray): List<String> {
|
||||
val out = mutableListOf<String>()
|
||||
for (i in 0 until arr.size()) {
|
||||
if (!arr.isNull(i)) {
|
||||
val value = arr.getString(i)
|
||||
if (!value.isNullOrBlank()) {
|
||||
out.add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun guessMimeType(path: String): String {
|
||||
val lower = path.lowercase(Locale.US)
|
||||
return when {
|
||||
lower.endsWith(".mkv") -> "video/x-matroska"
|
||||
lower.endsWith(".mp4") || lower.endsWith(".m4v") -> "video/mp4"
|
||||
lower.endsWith(".webm") -> "video/webm"
|
||||
lower.endsWith(".avi") -> "video/x-msvideo"
|
||||
lower.endsWith(".mov") -> "video/quicktime"
|
||||
lower.endsWith(".ts") || lower.endsWith(".m2ts") -> "video/mp2t"
|
||||
lower.endsWith(".mpg") || lower.endsWith(".mpeg") -> "video/mpeg"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLikelyVideoFile(path: String): Boolean {
|
||||
return path.endsWith(".mkv") ||
|
||||
path.endsWith(".mp4") ||
|
||||
path.endsWith(".m4v") ||
|
||||
path.endsWith(".avi") ||
|
||||
path.endsWith(".webm") ||
|
||||
path.endsWith(".mov") ||
|
||||
path.endsWith(".ts") ||
|
||||
path.endsWith(".m2ts") ||
|
||||
path.endsWith(".mpg") ||
|
||||
path.endsWith(".mpeg")
|
||||
}
|
||||
|
||||
private fun parseRangeHeader(rangeHeader: String?, fileSize: Long): Pair<Long, Long>? {
|
||||
if (rangeHeader.isNullOrBlank() || !rangeHeader.startsWith("bytes=") || fileSize <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
val range = rangeHeader.removePrefix("bytes=").trim()
|
||||
val parts = range.split("-", limit = 2)
|
||||
if (parts.size != 2) return null
|
||||
|
||||
val startText = parts[0].trim()
|
||||
val endText = parts[1].trim()
|
||||
|
||||
val start: Long
|
||||
val end: Long
|
||||
|
||||
when {
|
||||
startText.isBlank() -> {
|
||||
val suffixLength = endText.toLongOrNull() ?: return null
|
||||
if (suffixLength <= 0) return null
|
||||
start = max(0L, fileSize - suffixLength)
|
||||
end = fileSize - 1
|
||||
}
|
||||
|
||||
endText.isBlank() -> {
|
||||
start = startText.toLongOrNull() ?: return null
|
||||
end = fileSize - 1
|
||||
}
|
||||
|
||||
else -> {
|
||||
start = startText.toLongOrNull() ?: return null
|
||||
end = endText.toLongOrNull() ?: return null
|
||||
}
|
||||
}
|
||||
|
||||
if (start < 0 || start >= fileSize) return null
|
||||
if (end < start) return null
|
||||
|
||||
return start to min(end, fileSize - 1)
|
||||
}
|
||||
|
||||
private inner class TorrentInputStream(
|
||||
private val stream: ActiveTorrentStream,
|
||||
startOffset: Long,
|
||||
private val length: Long
|
||||
) : InputStream() {
|
||||
private var offsetInFile = startOffset
|
||||
private var remaining = length
|
||||
private var raf: RandomAccessFile? = null
|
||||
|
||||
override fun read(): Int {
|
||||
val single = ByteArray(1)
|
||||
val read = read(single, 0, 1)
|
||||
return if (read <= 0) -1 else single[0].toInt() and 0xFF
|
||||
}
|
||||
|
||||
override fun read(buffer: ByteArray, off: Int, len: Int): Int {
|
||||
if (remaining <= 0) return -1
|
||||
if (len <= 0) return 0
|
||||
|
||||
val requested = min(len.toLong(), remaining).toInt()
|
||||
val globalOffset = stream.fileOffset + offsetInFile
|
||||
val pieceIndex = (globalOffset / stream.pieceLength.toLong()).toInt().coerceIn(0, stream.totalPieces - 1)
|
||||
|
||||
if (!waitForPiece(stream, pieceIndex, PIECE_WAIT_MS)) {
|
||||
throw EOFException("Timed out waiting for piece $pieceIndex")
|
||||
}
|
||||
boostPieceWindow(stream, pieceIndex)
|
||||
|
||||
val pieceEndOffsetGlobal = (pieceIndex + 1L) * stream.pieceLength.toLong()
|
||||
val maxInPiece = max(1L, pieceEndOffsetGlobal - globalOffset)
|
||||
val chunkSize = min(requested.toLong(), min(maxInPiece, IO_CHUNK_SIZE.toLong())).toInt()
|
||||
|
||||
val randomAccessFile = ensureFileOpen()
|
||||
|
||||
var bytesRead = -1
|
||||
var attempts = 0
|
||||
while (bytesRead <= 0 && attempts < 20) {
|
||||
randomAccessFile.seek(offsetInFile)
|
||||
bytesRead = randomAccessFile.read(buffer, off, chunkSize)
|
||||
if (bytesRead <= 0) {
|
||||
attempts++
|
||||
try {
|
||||
Thread.sleep(80)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bytesRead <= 0) {
|
||||
throw EOFException("Unable to read torrent bytes from disk")
|
||||
}
|
||||
|
||||
offsetInFile += bytesRead.toLong()
|
||||
remaining -= bytesRead.toLong()
|
||||
return bytesRead
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
try {
|
||||
raf?.close()
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
raf = null
|
||||
super.close()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun ensureFileOpen(): RandomAccessFile {
|
||||
raf?.let { return it }
|
||||
|
||||
var attempts = 0
|
||||
while (attempts < 120) {
|
||||
if (stream.filePath.exists()) {
|
||||
val open = RandomAccessFile(stream.filePath, "r")
|
||||
raf = open
|
||||
return open
|
||||
}
|
||||
attempts++
|
||||
try {
|
||||
Thread.sleep(100)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
throw IOException("Torrent file does not exist yet: ${stream.filePath.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
private inner class TorrentHttpServer(port: Int) : NanoHTTPD(HTTP_HOST, port) {
|
||||
override fun serve(session: IHTTPSession): Response {
|
||||
return try {
|
||||
val uri = session.uri ?: "/"
|
||||
if (!uri.startsWith("/torrent/")) {
|
||||
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found")
|
||||
} else {
|
||||
serveTorrentRequest(session)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "HTTP serve failed", e)
|
||||
newFixedLengthResponse(Response.Status.INTERNAL_ERROR, "text/plain", "Internal error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun serveTorrentRequest(session: IHTTPSession): Response {
|
||||
if (session.method != Method.GET && session.method != Method.HEAD) {
|
||||
return newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "Method not allowed")
|
||||
}
|
||||
|
||||
val streamId = session.uri.removePrefix("/torrent/").substringBefore('/')
|
||||
val stream = activeStreams[streamId]
|
||||
?: return newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Stream not found")
|
||||
|
||||
val rangeHeader = session.headers["range"]
|
||||
val parsedRange = parseRangeHeader(rangeHeader, stream.fileSize)
|
||||
|
||||
val start = parsedRange?.first ?: 0L
|
||||
val end = parsedRange?.second ?: (stream.fileSize - 1L)
|
||||
|
||||
if (start < 0 || end < start || start >= stream.fileSize) {
|
||||
val invalid = newFixedLengthResponse(
|
||||
Response.Status.RANGE_NOT_SATISFIABLE,
|
||||
"text/plain",
|
||||
"Requested range not satisfiable"
|
||||
)
|
||||
invalid.addHeader("Accept-Ranges", "bytes")
|
||||
invalid.addHeader("Content-Range", "bytes */${stream.fileSize}")
|
||||
return invalid
|
||||
}
|
||||
|
||||
val length = end - start + 1L
|
||||
val status = if (parsedRange != null) Response.Status.PARTIAL_CONTENT else Response.Status.OK
|
||||
val mimeType = guessMimeType(stream.fileName)
|
||||
|
||||
val response = if (session.method == Method.HEAD) {
|
||||
newFixedLengthResponse(status, mimeType, "")
|
||||
} else {
|
||||
val input = TorrentInputStream(stream, start, length)
|
||||
newFixedLengthResponse(status, mimeType, input, length)
|
||||
}
|
||||
|
||||
response.addHeader("Accept-Ranges", "bytes")
|
||||
response.addHeader("Content-Length", length.toString())
|
||||
response.addHeader("Connection", "keep-alive")
|
||||
if (parsedRange != null) {
|
||||
response.addHeader("Content-Range", "bytes $start-$end/${stream.fileSize}")
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.nuvio.app.torrent
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class TorrentStreamingPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(TorrentStreamingModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,12 @@ allprojects {
|
|||
google()
|
||||
mavenCentral()
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
maven {
|
||||
url "https://dl.frostwire.com/maven"
|
||||
content {
|
||||
includeGroup "com.frostwire"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
97
docs/IOS_TORRENT_ENGINE_PATH.md
Normal file
97
docs/IOS_TORRENT_ENGINE_PATH.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# iOS Torrent Engine Path (Native, Not Desktop Service Port)
|
||||
|
||||
## Goal
|
||||
Enable native torrent playback on iOS/iPadOS/tvOS with the same JS contract already used on Android:
|
||||
- `prepareStream({ magnetUri, streamTitle, fileIndex, trackers, networkMbps })`
|
||||
- `stopStream(streamId)`
|
||||
- `stopAllStreams()`
|
||||
|
||||
The player receives a local playback URL such as `http://127.0.0.1:<port>/torrent/<streamId>`.
|
||||
|
||||
## Why Not Port Desktop Stremio Service
|
||||
- `stremio-shell` is a desktop shell that starts a separate streaming server process in production.
|
||||
- `stremio-service` packaging targets desktop distributions (macOS dmg, Windows exe, Linux deb/rpm/flatpak).
|
||||
- iOS needs an embedded native engine lifecycle (memory/background constraints), not a desktop-style sidecar service process.
|
||||
|
||||
## Research Snapshot (2026-02-19)
|
||||
### Torrent Core Candidates
|
||||
- `arvidn/libtorrent`: mature BitTorrent core, highly active, strongest long-term choice.
|
||||
- `frostwire/frostwire-jlibtorrent`: excellent for Android/JVM, but not a native Swift/iOS API surface.
|
||||
|
||||
### iOS Client/Engine References
|
||||
- `XITRIX/iTorrent`: active iOS torrent app; uses `libtorrent-rasterbar` and local HTTP playback bridging.
|
||||
- `XITRIX/LibTorrent-Swift`: active Swift wrapper project used by iTorrent.
|
||||
- `danylokos/SwiftyTorrent`: useful reference implementation, but older push cadence and Carthage-era stack.
|
||||
- `siuying/peerflix-ios`: archived and stale for modern production use.
|
||||
|
||||
### Local HTTP Bridge Candidates
|
||||
- `swisspol/GCDWebServer`: historically common, but archived.
|
||||
- `swhitty/FlyingFox`: active, modern Swift Concurrency HTTP server with range request support.
|
||||
|
||||
### Requested Check: VLC BitTorrent
|
||||
- `johang/vlc-bittorrent` is a VLC plugin (C/C++ plugin target), useful as concept/reference, but not a direct iOS-native app integration path for Nuvio’s React Native architecture.
|
||||
|
||||
## Decision
|
||||
Use:
|
||||
1. `libtorrent-rasterbar` as the iOS/tvOS torrent core.
|
||||
2. A thin Swift bridge layer (`TorrentStreamingModule`) exposing Nuvio’s existing JS API.
|
||||
3. A local HTTP range bridge based on `FlyingFox` (preferred) or a custom equivalent if tighter control is needed.
|
||||
|
||||
Avoid:
|
||||
- Desktop `stremio-service` process porting.
|
||||
- Archived HTTP server dependencies as primary infrastructure.
|
||||
- iOS torrent clients as direct code imports; use them as implementation references only.
|
||||
|
||||
## Target iOS Architecture
|
||||
1. `TorrentSessionActor`
|
||||
- Owns `libtorrent` session lifecycle, torrent handles, and shutdown.
|
||||
|
||||
2. `TorrentStreamCoordinator`
|
||||
- File selection, piece windowing, and seek-aware reprioritization.
|
||||
- Network-aware prefetch profile (slow/medium/fast/ultra), matching Android strategy.
|
||||
|
||||
3. `LocalPlaybackServer`
|
||||
- Serves `/torrent/{streamId}` with full `Range` support (`206`, `Content-Range`, `Accept-Ranges`).
|
||||
- Keeps buffered reads aggressive during play/pause up to cache policy limits.
|
||||
|
||||
4. `TorrentStreamingModule` (RN bridge)
|
||||
- `prepareStream`, `stopStream`, `stopAllStreams`.
|
||||
- Returns `{ streamId, playbackUrl, infoHash, fileName, fileSize, mimeType }`.
|
||||
|
||||
## Buffering/Robustness Rules
|
||||
- Keep forward prefetch active during pause (subject to thermal/memory policy).
|
||||
- Maintain high-priority piece window around playback pointer and seek target.
|
||||
- Keep a wider low-priority prefetch window for smoother long-form playback.
|
||||
- Resume quickly after app foreground/background transitions.
|
||||
- Treat near-tail transient I/O failures as graceful completion when safe.
|
||||
|
||||
## Disk/Cache Policy
|
||||
- Configurable base cache directory (default app container cache).
|
||||
- Per-infohash subfolders.
|
||||
- LRU cleanup and max cache budget.
|
||||
- Optional “keep completed file” mode off by default.
|
||||
|
||||
## Integration Status In This Branch
|
||||
- JS service already supports native torrent module discovery on Android/iOS.
|
||||
- Stream selection now uses capability checks instead of Android-only assumptions.
|
||||
- `KSPlayerCore` carries `torrentStreamId` and performs stop on close/unmount/switch.
|
||||
- Android torrent prefetch is now network-tuned (very-slow to ultra-fast profiles).
|
||||
|
||||
## Remaining iOS Work
|
||||
1. Implement and register `TorrentStreamingModule` on iOS.
|
||||
2. Integrate `libtorrent-rasterbar` (XCFramework/static linkage path).
|
||||
3. Implement local HTTP range bridge and seek-aware scheduler.
|
||||
4. Add settings UI for cache path + cache budget.
|
||||
5. Validate on real devices: pause buffering, seek storms, tokenized links, EOS behavior.
|
||||
|
||||
## Primary References
|
||||
- https://github.com/Stremio/stremio-shell
|
||||
- https://github.com/Stremio/stremio-service
|
||||
- https://github.com/arvidn/libtorrent
|
||||
- https://github.com/XITRIX/iTorrent
|
||||
- https://github.com/XITRIX/LibTorrent-Swift
|
||||
- https://github.com/danylokos/SwiftyTorrent
|
||||
- https://github.com/siuying/peerflix-ios
|
||||
- https://github.com/swhitty/FlyingFox
|
||||
- https://github.com/swisspol/GCDWebServer
|
||||
- https://github.com/johang/vlc-bittorrent
|
||||
74
docs/TORRENT_ENGINE_RESEARCH_2026-02-19.md
Normal file
74
docs/TORRENT_ENGINE_RESEARCH_2026-02-19.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# Torrent Engine Research (2026-02-19)
|
||||
|
||||
## Scope
|
||||
- Android torrent streaming engine choice and viability.
|
||||
- iOS/tvOS torrent engine choice and integration strategy.
|
||||
- Check requested references (`Stremio`, `vlc-bittorrent`) and pick lowest-risk modern path.
|
||||
|
||||
## Android Findings
|
||||
### Selected
|
||||
- `frostwire/frostwire-jlibtorrent`
|
||||
- GitHub: 498 stars, recently updated.
|
||||
- Works directly from Android/Kotlin, stable JVM binding around libtorrent.
|
||||
- Current branch now uses `2.0.12.7` artifacts from FrostWire Maven.
|
||||
|
||||
### Why
|
||||
- Native Android integration is straightforward compared to desktop-plugin or Node-sidecar designs.
|
||||
- Mature dependency chain and direct access to session/piece priority APIs.
|
||||
|
||||
## iOS Findings
|
||||
### Core Engine
|
||||
- `arvidn/libtorrent` remains the strongest base core.
|
||||
|
||||
### Real-World iOS References
|
||||
- `XITRIX/iTorrent`
|
||||
- 2,953 stars, actively maintained.
|
||||
- Uses `libtorrent-rasterbar` and local HTTP serving for playback.
|
||||
- `XITRIX/LibTorrent-Swift`
|
||||
- Active Swift wrapper used by iTorrent.
|
||||
- `danylokos/SwiftyTorrent`
|
||||
- Useful reference, but older push cadence and older toolchain approach.
|
||||
- `siuying/peerflix-ios`
|
||||
- Archived and stale; not suitable for modern production path.
|
||||
|
||||
### Local HTTP Layer
|
||||
- `swisspol/GCDWebServer` is archived.
|
||||
- `swhitty/FlyingFox` is active and supports range-friendly file serving patterns.
|
||||
|
||||
### Requested Check: `johang/vlc-bittorrent`
|
||||
- Valuable as VLC plugin reference, but not a direct RN iOS integration path.
|
||||
- Architecture is plugin-for-VLC, not embedded app-native bridge for Nuvio.
|
||||
|
||||
## Stremio Research Summary
|
||||
- `stremio-shell` desktop runtime expects a separate streaming server flow.
|
||||
- `stremio-service` packaging is desktop-oriented.
|
||||
- This confirms Nuvio mobile should use embedded native torrent engine modules per platform, not desktop service porting.
|
||||
|
||||
## Decision
|
||||
1. Android: stay on jlibtorrent-based native module (already integrated).
|
||||
2. iOS/tvOS: use libtorrent core + Swift bridge + local HTTP range bridge (prefer active server stack like FlyingFox or equivalent custom).
|
||||
3. Keep one JS contract across platforms (`prepareStream/stopStream/stopAllStreams`) with platform-native implementations.
|
||||
|
||||
## Data Points (GitHub API, 2026-02-19)
|
||||
- `frostwire/frostwire-jlibtorrent`: stars 498, pushed 2026-02-14
|
||||
- `arvidn/libtorrent`: stars 5,847, pushed 2026-02-18
|
||||
- `XITRIX/iTorrent`: stars 2,953, pushed 2026-01-29
|
||||
- `XITRIX/LibTorrent-Swift`: stars 8, pushed 2026-02-03
|
||||
- `danylokos/SwiftyTorrent`: stars 128, pushed 2024-04-20
|
||||
- `siuying/peerflix-ios`: stars 84, archived, pushed 2017-03-18
|
||||
- `swisspol/GCDWebServer`: stars 6,615, archived, pushed 2022-10-05
|
||||
- `swhitty/FlyingFox`: stars 626, pushed 2026-01-23
|
||||
- `johang/vlc-bittorrent`: stars 472, pushed 2026-02-04
|
||||
|
||||
## Links
|
||||
- https://github.com/frostwire/frostwire-jlibtorrent
|
||||
- https://github.com/arvidn/libtorrent
|
||||
- https://github.com/XITRIX/iTorrent
|
||||
- https://github.com/XITRIX/LibTorrent-Swift
|
||||
- https://github.com/danylokos/SwiftyTorrent
|
||||
- https://github.com/siuying/peerflix-ios
|
||||
- https://github.com/swhitty/FlyingFox
|
||||
- https://github.com/swisspol/GCDWebServer
|
||||
- https://github.com/johang/vlc-bittorrent
|
||||
- https://github.com/Stremio/stremio-shell
|
||||
- https://github.com/Stremio/stremio-service
|
||||
66
package-lock.json
generated
66
package-lock.json
generated
|
|
@ -1540,6 +1540,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
|
|
@ -2097,6 +2098,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz",
|
||||
"integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"anser": "^1.4.9",
|
||||
"pretty-format": "^29.7.0",
|
||||
|
|
@ -2585,6 +2587,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz",
|
||||
"integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jimp/core": "^0.22.12"
|
||||
}
|
||||
|
|
@ -2770,6 +2773,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.13.5.tgz",
|
||||
"integrity": "sha512-4U5okwjRqDPkjB572hfZtLXJ/LGfCo6vDwUB2KIPEUoSgqbIlw+UrbnaqVp3GS+dRvhMD27F2JObpHpYRlpF0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@lottiefiles/dotlottie-web": "0.44.0"
|
||||
},
|
||||
|
|
@ -3125,7 +3129,7 @@
|
|||
"version": "0.72.8",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz",
|
||||
"integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4",
|
||||
|
|
@ -3246,6 +3250,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.25.tgz",
|
||||
"integrity": "sha512-zQeWK9txDePWbYfqTs0C6jeRdJTm/7VhQtW/1IbJNDi9/rFIRzZule8bdQPAnf8QWUsNujRmi1J9OG/hhfbalg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-navigation/core": "^7.13.6",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
|
|
@ -3887,6 +3892,7 @@
|
|||
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.21.3",
|
||||
"@svgr/babel-preset": "8.1.0",
|
||||
|
|
@ -4107,6 +4113,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
|
|
@ -4116,8 +4123,9 @@
|
|||
"version": "0.72.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz",
|
||||
"integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-native/virtualized-lists": "^0.72.4",
|
||||
"@types/react": "*"
|
||||
|
|
@ -4653,6 +4661,7 @@
|
|||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
|
|
@ -5056,6 +5065,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
|
|
@ -6285,6 +6295,7 @@
|
|||
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.29.tgz",
|
||||
"integrity": "sha512-9C90gyOzV83y2S3XzCbRDCuKYNaiyCzuP9ketv46acHCEZn+QTamPK/DobdghoSiofCmlfoaiD6/SzfxDiHMnw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@expo/cli": "54.0.19",
|
||||
|
|
@ -6488,6 +6499,7 @@
|
|||
"resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz",
|
||||
"integrity": "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ua-parser-js": "^0.7.33"
|
||||
},
|
||||
|
|
@ -6515,6 +6527,7 @@
|
|||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
||||
"integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react-native": "*"
|
||||
|
|
@ -6525,6 +6538,7 @@
|
|||
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.10.tgz",
|
||||
"integrity": "sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fontfaceobserver": "^2.1.0"
|
||||
},
|
||||
|
|
@ -6620,6 +6634,7 @@
|
|||
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz",
|
||||
"integrity": "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"rtl-detect": "^1.0.2"
|
||||
},
|
||||
|
|
@ -7679,6 +7694,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
|
|
@ -10585,6 +10601,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -10625,6 +10642,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
|
|
@ -10682,6 +10700,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.4.tgz",
|
||||
"integrity": "sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/create-cache-key-function": "^29.7.0",
|
||||
"@react-native/assets-registry": "0.81.4",
|
||||
|
|
@ -10770,6 +10789,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-native-bottom-tabs/-/react-native-bottom-tabs-1.1.0.tgz",
|
||||
"integrity": "sha512-Uu1gvM3i1Hb4DjVvR/38J1QVQEs0RkPc7K6yon99HgvRWWOyLs7kjPDhUswtb8ije4pKW712skIXWJ0lgKzbyQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"react-freeze": "^1.0.0",
|
||||
"sf-symbols-typescript": "^2.0.0",
|
||||
|
|
@ -10800,6 +10820,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.29.1.tgz",
|
||||
"integrity": "sha512-du3qmv0e3Sm7qsd9SfmHps+AggLiylcBBQ8ztz7WUtd8ZjKs5V3kekAbi9R2W9bRLSg47Ntp4GGMYZOhikQdZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@egjs/hammerjs": "^2.0.17",
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
|
|
@ -10898,6 +10919,7 @@
|
|||
"integrity": "sha512-hcvjTu9YJE9fMmnAUvhG8CxvYLpOuMQ/2eyi/S6GyrecezF6Rmk/uRQEL6v09BRFWA/xRVZNQVulQPS+2HS3mQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
|
|
@ -10963,6 +10985,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.0.tgz",
|
||||
"integrity": "sha512-frhu5b8/m/VvaMWz48V8RxcsXnE3hrlErQ5chr21MzAeDCpY4X14sQjvm+jvu3aOI+7Cz2atdRpyhhIuqxVaXg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"react-native-is-edge-to-edge": "1.2.1",
|
||||
"semver": "7.7.3"
|
||||
|
|
@ -11002,6 +11025,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
|
||||
"integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
|
|
@ -11012,6 +11036,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.18.0.tgz",
|
||||
"integrity": "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"react-freeze": "^1.0.0",
|
||||
"warn-once": "^0.1.0"
|
||||
|
|
@ -11026,6 +11051,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.1.tgz",
|
||||
"integrity": "sha512-ZUD1xwc3Hwo4cOmOLumjJVoc7lEf9oQFlHnLmgccLC19fNm6LVEdtB+Cnip6gEi0PG3wfvVzskViEtrySQP8Fw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"css-select": "^5.1.0",
|
||||
"css-tree": "^1.1.3",
|
||||
|
|
@ -11254,6 +11280,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.6",
|
||||
"@react-native/normalize-colors": "^0.74.1",
|
||||
|
|
@ -11510,6 +11537,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -12976,6 +13004,24 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.0.23",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz",
|
||||
"integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.0.23"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.0.23",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz",
|
||||
"integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
|
|
@ -13030,6 +13076,19 @@
|
|||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
|
||||
"integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
|
|
@ -13104,8 +13163,9 @@
|
|||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { Stream } from '../types/metadata';
|
||||
import QualityBadge from './metadata/QualityBadge';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import { useDownloads } from '../contexts/DownloadsContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
|
@ -90,9 +89,28 @@ const StreamCard = memo(({
|
|||
|
||||
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
|
||||
|
||||
const getViabilityColor = useCallback((label?: string): string => {
|
||||
switch (label) {
|
||||
case 'Excellent':
|
||||
return theme.colors.success;
|
||||
case 'Good':
|
||||
return theme.colors.primary;
|
||||
case 'Fair':
|
||||
return theme.colors.warning || '#C9A227';
|
||||
case 'Risky':
|
||||
return theme.colors.error || '#C23C3C';
|
||||
default:
|
||||
return theme.colors.darkGray;
|
||||
}
|
||||
}, [theme.colors]);
|
||||
|
||||
const streamInfo = useMemo(() => {
|
||||
const title = stream.title || '';
|
||||
const name = stream.name || '';
|
||||
const hints = (stream.behaviorHints || {}) as any;
|
||||
const playbackViability = hints.playbackViability as
|
||||
| { label?: string; availableMbps?: number; requiredMbps?: number; seeders?: number; peers?: number }
|
||||
| undefined;
|
||||
|
||||
// Helper function to format size from bytes
|
||||
const formatSize = (bytes: number): string => {
|
||||
|
|
@ -118,10 +136,24 @@ const StreamCard = memo(({
|
|||
isDolby: title.toLowerCase().includes('dolby') || title.includes('DV'),
|
||||
size: sizeDisplay,
|
||||
isDebrid: stream.behaviorHints?.cached,
|
||||
isTorrent:
|
||||
(stream.url || '').startsWith('magnet:') ||
|
||||
!!stream.infoHash ||
|
||||
hints.type === 'torrent' ||
|
||||
!!hints.infoHash,
|
||||
viabilityLabel: playbackViability?.label,
|
||||
viabilityAvailableMbps: playbackViability?.availableMbps,
|
||||
viabilityRequiredMbps: playbackViability?.requiredMbps,
|
||||
seeders:
|
||||
typeof hints.seeders === 'number'
|
||||
? hints.seeders
|
||||
: typeof playbackViability?.seeders === 'number'
|
||||
? playbackViability.seeders
|
||||
: undefined,
|
||||
displayName: name || 'Unnamed Stream',
|
||||
subTitle: title && title !== name ? title : null
|
||||
};
|
||||
}, [stream.name, stream.title, stream.behaviorHints, stream.size]);
|
||||
}, [stream.name, stream.title, stream.behaviorHints, stream.size, stream.url, stream.infoHash]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -241,8 +273,46 @@ const StreamCard = memo(({
|
|||
</View>
|
||||
|
||||
<View style={styles.streamMetaRow}>
|
||||
{streamInfo.viabilityLabel && (
|
||||
<View style={[styles.chip, { backgroundColor: getViabilityColor(streamInfo.viabilityLabel) }]}>
|
||||
<Text style={[styles.chipText, { color: theme.colors.white }]}>
|
||||
{streamInfo.viabilityLabel.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{streamInfo.isTorrent && (
|
||||
<View style={[styles.chip, { backgroundColor: theme.colors.elevation2 }]}>
|
||||
<Text style={[styles.chipText, { color: theme.colors.highEmphasis }]}>TORRENT</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{streamInfo.isHDR && (
|
||||
<View style={[styles.chip, { backgroundColor: theme.colors.elevation2 }]}>
|
||||
<Text style={[styles.chipText, { color: theme.colors.highEmphasis }]}>HDR</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{streamInfo.isDolby && (
|
||||
<QualityBadge type="VISION" />
|
||||
<View style={[styles.chip, { backgroundColor: theme.colors.elevation2 }]}>
|
||||
<Text style={[styles.chipText, { color: theme.colors.highEmphasis }]}>DOLBY</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{typeof streamInfo.seeders === 'number' && (
|
||||
<View style={[styles.chip, { backgroundColor: theme.colors.elevation2 }]}>
|
||||
<Text style={[styles.chipText, { color: theme.colors.highEmphasis }]}>
|
||||
SEEDS {streamInfo.seeders}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{typeof streamInfo.viabilityRequiredMbps === 'number' && typeof streamInfo.viabilityAvailableMbps === 'number' && (
|
||||
<View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}>
|
||||
<Text style={[styles.chipText, { color: theme.colors.white }]}>
|
||||
{`${streamInfo.viabilityRequiredMbps.toFixed(1)} / ${streamInfo.viabilityAvailableMbps.toFixed(0)} Mbps`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{streamInfo.size && (
|
||||
|
|
|
|||
|
|
@ -829,8 +829,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
|
||||
lastTraktSyncRef.current = now;
|
||||
|
||||
// Fetch only playback progress (paused items with actual progress %)
|
||||
// Removed: history items and watched shows - redundant with local logic
|
||||
// Fetch playback progress (paused items with actual progress %)
|
||||
const playbackItems = await traktService.getPlaybackProgress();
|
||||
|
||||
try {
|
||||
|
|
@ -851,6 +850,61 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
// ignore
|
||||
}
|
||||
|
||||
const normalizeImdbId = (imdbId?: string): string | null => {
|
||||
if (!imdbId) return null;
|
||||
const trimmed = String(imdbId).trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.startsWith('tt') ? trimmed : `tt${trimmed}`;
|
||||
};
|
||||
|
||||
const watchedEpisodeTimestampsByShow = new Map<string, Map<string, number>>();
|
||||
const upsertWatchedEpisodeTimestamp = (
|
||||
showImdb: string,
|
||||
seasonNumber: number,
|
||||
episodeNumber: number,
|
||||
watchedAt?: string
|
||||
) => {
|
||||
if (!Number.isFinite(seasonNumber) || !Number.isFinite(episodeNumber)) return;
|
||||
const watchedAtTs = new Date(watchedAt).getTime();
|
||||
const safeTimestamp = Number.isFinite(watchedAtTs) ? watchedAtTs : 0;
|
||||
const episodeKey = `${seasonNumber}:${episodeNumber}`;
|
||||
let showMap = watchedEpisodeTimestampsByShow.get(showImdb);
|
||||
if (!showMap) {
|
||||
showMap = new Map<string, number>();
|
||||
watchedEpisodeTimestampsByShow.set(showImdb, showMap);
|
||||
}
|
||||
const existing = showMap.get(episodeKey) ?? 0;
|
||||
if (safeTimestamp >= existing) {
|
||||
showMap.set(episodeKey, safeTimestamp);
|
||||
}
|
||||
};
|
||||
|
||||
let watchedShows: any[] = [];
|
||||
try {
|
||||
watchedShows = await traktService.getWatchedShows();
|
||||
for (const watchedShow of watchedShows) {
|
||||
const normalizedShowId = normalizeImdbId(watchedShow?.show?.ids?.imdb);
|
||||
if (!normalizedShowId) continue;
|
||||
|
||||
if (Array.isArray(watchedShow?.seasons)) {
|
||||
for (const season of watchedShow.seasons) {
|
||||
if (!Array.isArray(season?.episodes)) continue;
|
||||
for (const episode of season.episodes) {
|
||||
upsertWatchedEpisodeTimestamp(
|
||||
normalizedShowId,
|
||||
Number(season.number),
|
||||
Number(episode.number),
|
||||
episode?.last_watched_at
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[TraktSync] Error preloading watched shows:', err);
|
||||
watchedShows = [];
|
||||
}
|
||||
|
||||
|
||||
|
||||
const traktBatch: ContinueWatchingItem[] = [];
|
||||
|
|
@ -901,15 +955,28 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
logger.log(`📺 [TraktPlayback] Adding movie ${item.movie.title} with ${item.progress.toFixed(1)}% progress`);
|
||||
|
||||
} else if (item.type === 'episode' && item.show?.ids?.imdb && item.episode) {
|
||||
const showImdb = item.show.ids.imdb.startsWith('tt')
|
||||
? item.show.ids.imdb
|
||||
: `tt${item.show.ids.imdb}`;
|
||||
const showImdb = normalizeImdbId(item.show.ids.imdb);
|
||||
if (!showImdb) continue;
|
||||
|
||||
// Check if recently removed
|
||||
const showKey = `series:${showImdb}`;
|
||||
if (recentlyRemovedRef.current.has(showKey)) continue;
|
||||
|
||||
const pausedAt = new Date(item.paused_at).getTime();
|
||||
const safePausedAt = Number.isFinite(pausedAt) ? pausedAt : 0;
|
||||
const episodeSeason = Number((item.episode as any).season);
|
||||
const episodeNumber = Number((item.episode as any).number ?? (item.episode as any).episode);
|
||||
if (!Number.isFinite(episodeSeason) || !Number.isFinite(episodeNumber)) continue;
|
||||
|
||||
// If Trakt already marks this episode watched at/after this playback timestamp,
|
||||
// treat this playback row as stale and skip it (prevents old episodes from resurfacing as Up Next).
|
||||
const watchedEpisodeTs = watchedEpisodeTimestampsByShow
|
||||
.get(showImdb)
|
||||
?.get(`${episodeSeason}:${episodeNumber}`);
|
||||
if (typeof watchedEpisodeTs === 'number' && watchedEpisodeTs >= (safePausedAt - 5000)) {
|
||||
logger.log(`[CW][Trakt] Skip stale playback ${showImdb} S${episodeSeason}E${episodeNumber}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cachedData = await getCachedMetadata('series', showImdb);
|
||||
if (!cachedData?.basicContent) continue;
|
||||
|
|
@ -919,8 +986,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
const metadata = cachedData.metadata;
|
||||
if (metadata?.videos) {
|
||||
const nextEpisode = findNextEpisode(
|
||||
item.episode.season,
|
||||
item.episode.number,
|
||||
episodeSeason,
|
||||
episodeNumber,
|
||||
metadata.videos,
|
||||
undefined, // No watched set needed, findNextEpisode handles it
|
||||
showImdb
|
||||
|
|
@ -933,7 +1000,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
id: showImdb,
|
||||
type: 'series',
|
||||
progress: 0, // Up next - no progress yet
|
||||
lastUpdated: pausedAt,
|
||||
lastUpdated: safePausedAt,
|
||||
season: nextEpisode.season,
|
||||
episode: nextEpisode.episode,
|
||||
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
|
||||
|
|
@ -950,39 +1017,35 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
id: showImdb,
|
||||
type: 'series',
|
||||
progress: item.progress,
|
||||
lastUpdated: pausedAt,
|
||||
season: item.episode.season,
|
||||
episode: item.episode.number,
|
||||
episodeTitle: item.episode.title || `Episode ${item.episode.number}`,
|
||||
lastUpdated: safePausedAt,
|
||||
season: episodeSeason,
|
||||
episode: episodeNumber,
|
||||
episodeTitle: item.episode.title || `Episode ${episodeNumber}`,
|
||||
addonId: undefined,
|
||||
traktPlaybackId: item.id, // Store playback ID for removal
|
||||
} as ContinueWatchingItem);
|
||||
|
||||
logger.log(`📺 [TraktPlayback] Adding ${item.show.title} S${item.episode.season}E${item.episode.number} with ${item.progress.toFixed(1)}% progress`);
|
||||
logger.log(`📺 [TraktPlayback] Adding ${item.show.title} S${episodeSeason}E${episodeNumber} with ${item.progress.toFixed(1)}% progress`);
|
||||
}
|
||||
} catch (err) {
|
||||
// Continue with other items
|
||||
}
|
||||
}
|
||||
|
||||
// STEP 2: Get watched shows and find "Up Next" episodes
|
||||
// This handles cases where episodes are fully completed and removed from playback progress
|
||||
// STEP 2: Use watched shows to find "Up Next" episodes
|
||||
// This handles cases where episodes are fully completed and removed from playback progress.
|
||||
try {
|
||||
const watchedShows = await traktService.getWatchedShows();
|
||||
const thirtyDaysAgoForShows = Date.now() - (30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const watchedShow of watchedShows) {
|
||||
try {
|
||||
if (!watchedShow.show?.ids?.imdb) continue;
|
||||
const showImdb = normalizeImdbId(watchedShow?.show?.ids?.imdb);
|
||||
if (!showImdb) continue;
|
||||
|
||||
// Skip shows that haven't been watched recently
|
||||
const lastWatchedAt = new Date(watchedShow.last_watched_at).getTime();
|
||||
if (lastWatchedAt < thirtyDaysAgoForShows) continue;
|
||||
|
||||
const showImdb = watchedShow.show.ids.imdb.startsWith('tt')
|
||||
? watchedShow.show.ids.imdb
|
||||
: `tt${watchedShow.show.ids.imdb}`;
|
||||
|
||||
// Check if recently removed
|
||||
const showKey = `series:${showImdb}`;
|
||||
if (recentlyRemovedRef.current.has(showKey)) continue;
|
||||
|
|
@ -996,7 +1059,15 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
for (const season of watchedShow.seasons) {
|
||||
for (const episode of season.episodes) {
|
||||
const episodeTimestamp = new Date(episode.last_watched_at).getTime();
|
||||
if (episodeTimestamp > latestEpisodeTimestamp) {
|
||||
const isMoreRecent = episodeTimestamp > latestEpisodeTimestamp;
|
||||
const isTimestampTieWithLaterEpisode =
|
||||
episodeTimestamp === latestEpisodeTimestamp &&
|
||||
(
|
||||
season.number > lastWatchedSeason ||
|
||||
(season.number === lastWatchedSeason && episode.number > lastWatchedEpisode)
|
||||
);
|
||||
|
||||
if (isMoreRecent || isTimestampTieWithLaterEpisode) {
|
||||
latestEpisodeTimestamp = episodeTimestamp;
|
||||
lastWatchedSeason = season.number;
|
||||
lastWatchedEpisode = episode.number;
|
||||
|
|
@ -1013,11 +1084,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
|
||||
// Build a set of watched episodes for this show
|
||||
const watchedEpisodeSet = new Set<string>();
|
||||
if (watchedShow.seasons) {
|
||||
for (const season of watchedShow.seasons) {
|
||||
for (const episode of season.episodes) {
|
||||
watchedEpisodeSet.add(`${showImdb}:${season.number}:${episode.number}`);
|
||||
}
|
||||
const watchedEpisodeMap = watchedEpisodeTimestampsByShow.get(showImdb);
|
||||
if (watchedEpisodeMap && watchedEpisodeMap.size > 0) {
|
||||
for (const episodeKey of watchedEpisodeMap.keys()) {
|
||||
watchedEpisodeSet.add(`${showImdb}:${episodeKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1310,7 +1380,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
// ignore
|
||||
}
|
||||
|
||||
setContinueWatchingItems(adjustedItems);
|
||||
await mergeBatchIntoState(adjustedItems);
|
||||
|
||||
// Fire-and-forget reconcile (don't block UI)
|
||||
if (reconcilePromises.length > 0) {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import { styles } from './utils/playerStyles';
|
|||
import { formatTime, isHlsStream, getHlsHeaders, defaultAndroidHeaders, parseSRT } from './utils/playerUtils';
|
||||
import { storageService } from '../../services/storageService';
|
||||
import stremioService from '../../services/stremioService';
|
||||
import { torrentStreamingService } from '../../services/torrentStreamingService';
|
||||
import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes';
|
||||
import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
|
@ -74,7 +75,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
const {
|
||||
uri, title = 'Episode Name', season, episode, episodeTitle, quality, year,
|
||||
streamProvider, streamName, headers, id, type, episodeId, imdbId,
|
||||
availableStreams: passedAvailableStreams, backdrop, groupedEpisodes
|
||||
availableStreams: passedAvailableStreams, backdrop, groupedEpisodes, torrentStreamId
|
||||
} = route.params;
|
||||
|
||||
// --- State & Custom Hooks ---
|
||||
|
|
@ -107,6 +108,10 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
const [useExoPlayer, setUseExoPlayer] = useState(!shouldUseMpvOnly);
|
||||
const hasExoPlayerFailed = useRef(false);
|
||||
const [showMpvSwitchAlert, setShowMpvSwitchAlert] = useState(false);
|
||||
const [openingBufferProgress, setOpeningBufferProgress] = useState(0);
|
||||
const openingBufferProgressRef = useRef(0);
|
||||
const openingOverlayCompletedRef = useRef(false);
|
||||
const openingFallbackTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
|
||||
// Sync useExoPlayer with settings when videoPlayerEngine is set to 'mpv'
|
||||
|
|
@ -234,6 +239,43 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
});
|
||||
|
||||
const fadeAnim = useRef(new Animated.Value(1)).current;
|
||||
const initialBufferTargetSec = useMemo(() => {
|
||||
const isTorrentPlayback = !!torrentStreamId || torrentStreamingService.isLocalTorrentPlaybackUrl(currentStreamUrl);
|
||||
if (isTorrentPlayback) {
|
||||
return 28;
|
||||
}
|
||||
const looksLikeHls =
|
||||
isHlsStream(currentStreamUrl) ||
|
||||
currentVideoType === 'm3u8' ||
|
||||
currentVideoType === 'hls';
|
||||
return looksLikeHls ? 12 : 20;
|
||||
}, [currentStreamUrl, currentVideoType, torrentStreamId]);
|
||||
|
||||
const completeOpeningOverlay = useCallback((force = false) => {
|
||||
if (openingOverlayCompletedRef.current) return;
|
||||
if (!force && openingBufferProgressRef.current < 0.999) return;
|
||||
|
||||
openingOverlayCompletedRef.current = true;
|
||||
if (openingFallbackTimeoutRef.current) {
|
||||
clearTimeout(openingFallbackTimeoutRef.current);
|
||||
openingFallbackTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
openingBufferProgressRef.current = 1;
|
||||
setOpeningBufferProgress(1);
|
||||
openingAnimation.completeOpeningAnimation();
|
||||
}, [openingAnimation]);
|
||||
|
||||
const updateOpeningBufferProgress = useCallback((nextProgress: number, forceComplete = false) => {
|
||||
const clamped = Math.max(openingBufferProgressRef.current, Math.max(0, Math.min(1, nextProgress)));
|
||||
if (clamped !== openingBufferProgressRef.current) {
|
||||
openingBufferProgressRef.current = clamped;
|
||||
setOpeningBufferProgress(clamped);
|
||||
}
|
||||
if (forceComplete || clamped >= 0.999) {
|
||||
completeOpeningOverlay(true);
|
||||
}
|
||||
}, [completeOpeningOverlay]);
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(fadeAnim, {
|
||||
|
|
@ -274,6 +316,26 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
openingAnimation.startOpeningAnimation();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
openingOverlayCompletedRef.current = false;
|
||||
openingBufferProgressRef.current = 0;
|
||||
setOpeningBufferProgress(0);
|
||||
|
||||
if (openingFallbackTimeoutRef.current) {
|
||||
clearTimeout(openingFallbackTimeoutRef.current);
|
||||
openingFallbackTimeoutRef.current = null;
|
||||
}
|
||||
}, [currentStreamUrl, torrentStreamId, currentVideoType]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (openingFallbackTimeoutRef.current) {
|
||||
clearTimeout(openingFallbackTimeoutRef.current);
|
||||
openingFallbackTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load subtitle settings on mount
|
||||
useEffect(() => {
|
||||
const loadSubtitleSettings = async () => {
|
||||
|
|
@ -367,7 +429,14 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}
|
||||
|
||||
playerState.setIsVideoLoaded(true);
|
||||
openingAnimation.completeOpeningAnimation();
|
||||
updateOpeningBufferProgress(0.2);
|
||||
|
||||
if (openingFallbackTimeoutRef.current) {
|
||||
clearTimeout(openingFallbackTimeoutRef.current);
|
||||
}
|
||||
openingFallbackTimeoutRef.current = setTimeout(() => {
|
||||
completeOpeningOverlay(true);
|
||||
}, 12000);
|
||||
|
||||
// Auto-select audio track based on preferences
|
||||
if (data.audioTracks && data.audioTracks.length > 0 && settings?.preferredAudioLanguage) {
|
||||
|
|
@ -437,16 +506,48 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}
|
||||
}, 300);
|
||||
}
|
||||
}, [id, type, episodeId, playerState.isMounted, watchProgress.initialPosition, useExoPlayer]);
|
||||
}, [id, type, episodeId, playerState.isMounted, watchProgress.initialPosition, useExoPlayer, updateOpeningBufferProgress, completeOpeningOverlay]);
|
||||
|
||||
const handleProgress = useCallback((data: any) => {
|
||||
if (playerState.isDragging.current || playerState.isSeeking.current || !playerState.isMounted.current || setupHook.isAppBackgrounded.current) return;
|
||||
const currentTimeInSeconds = data.currentTime;
|
||||
const playableDuration = data.playableDuration || currentTimeInSeconds;
|
||||
const bufferAhead = Math.max(0, playableDuration - currentTimeInSeconds);
|
||||
|
||||
if (!openingOverlayCompletedRef.current) {
|
||||
const progressFloor = playerState.isVideoLoaded ? 0.22 : 0.08;
|
||||
const progress = Math.max(progressFloor, Math.min(1, bufferAhead / initialBufferTargetSec));
|
||||
const shouldComplete = playerState.isVideoLoaded && progress >= 1;
|
||||
updateOpeningBufferProgress(progress, shouldComplete);
|
||||
}
|
||||
|
||||
if (Math.abs(currentTimeInSeconds - playerState.currentTime) > 0.5) {
|
||||
playerState.setCurrentTime(currentTimeInSeconds);
|
||||
playerState.setBuffered(data.playableDuration || currentTimeInSeconds);
|
||||
playerState.setBuffered(playableDuration);
|
||||
}
|
||||
}, [playerState.currentTime, playerState.isDragging, playerState.isSeeking, setupHook.isAppBackgrounded]);
|
||||
}, [playerState.currentTime, playerState.isDragging, playerState.isSeeking, setupHook.isAppBackgrounded, playerState.isVideoLoaded, initialBufferTargetSec, updateOpeningBufferProgress]);
|
||||
|
||||
const isLikelyTailPlaybackError = useCallback((message: string): boolean => {
|
||||
const lower = (message || '').toLowerCase();
|
||||
const isKnownTailIoError =
|
||||
lower.includes('error_code_io_unspecified') ||
|
||||
lower.includes('22000') ||
|
||||
lower.includes('unable to play media. source may be unreachable') ||
|
||||
lower.includes('source may be unreachable') ||
|
||||
lower.includes('source error');
|
||||
|
||||
if (!isKnownTailIoError) return false;
|
||||
|
||||
const current = playerState.currentTime || 0;
|
||||
const duration = playerState.duration || 0;
|
||||
|
||||
if (duration > 0) {
|
||||
const remaining = duration - current;
|
||||
return current > 0 && (remaining <= 2.5 || current / duration >= 0.985);
|
||||
}
|
||||
|
||||
return current > 45;
|
||||
}, [playerState.currentTime, playerState.duration]);
|
||||
|
||||
// Auto-select subtitles when both internal tracks and video are loaded
|
||||
// This ensures we wait for internal tracks before falling back to external
|
||||
|
|
@ -514,9 +615,20 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
const controlsTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (torrentStreamId) {
|
||||
void torrentStreamingService.stopStream(torrentStreamId);
|
||||
}
|
||||
if (navigation.canGoBack()) navigation.goBack();
|
||||
else navigation.reset({ index: 0, routes: [{ name: 'Home' }] } as any);
|
||||
}, [navigation]);
|
||||
}, [navigation, torrentStreamId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (torrentStreamId) {
|
||||
void torrentStreamingService.stopStream(torrentStreamId);
|
||||
}
|
||||
};
|
||||
}, [torrentStreamId]);
|
||||
|
||||
// Handle codec errors from ExoPlayer - silently switch to MPV
|
||||
const handleCodecError = useCallback(() => {
|
||||
|
|
@ -555,9 +667,34 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}, 500);
|
||||
}, [playerState.currentTime]);
|
||||
|
||||
const getEstimatedNetworkMbpsFromStream = useCallback((stream: any): number | undefined => {
|
||||
const hinted = stream?.behaviorHints?.playbackViability?.availableMbps;
|
||||
if (typeof hinted === 'number' && Number.isFinite(hinted) && hinted > 0) {
|
||||
return hinted;
|
||||
}
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
const handleSelectStream = async (newStream: any) => {
|
||||
if (newStream.url === currentStreamUrl) {
|
||||
let resolvedUri = newStream.url;
|
||||
let nextTorrentStreamId: string | undefined;
|
||||
|
||||
if (torrentStreamingService.isNativeSupported() && torrentStreamingService.isTorrentStream(newStream)) {
|
||||
try {
|
||||
const prepared = await torrentStreamingService.preparePlayback(
|
||||
newStream,
|
||||
title || newStream?.title || newStream?.name,
|
||||
{ networkMbps: getEstimatedNetworkMbpsFromStream(newStream) }
|
||||
);
|
||||
resolvedUri = prepared.playbackUrl;
|
||||
nextTorrentStreamId = prepared.streamId;
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || 'Failed to initialize torrent stream');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedUri === currentStreamUrl) {
|
||||
modals.setShowSourcesModal(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -575,18 +712,38 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
setTimeout(() => {
|
||||
(navigation as any).replace('PlayerAndroid', {
|
||||
...route.params,
|
||||
uri: newStream.url,
|
||||
uri: resolvedUri,
|
||||
quality: newQuality,
|
||||
streamProvider: newProvider,
|
||||
streamName: newStreamName,
|
||||
headers: newStream.headers,
|
||||
availableStreams: availableStreams
|
||||
headers: nextTorrentStreamId ? undefined : newStream.headers,
|
||||
availableStreams: availableStreams,
|
||||
torrentStreamId: nextTorrentStreamId,
|
||||
});
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleEpisodeStreamSelect = async (stream: any) => {
|
||||
if (!modals.selectedEpisodeForStreams) return;
|
||||
|
||||
let resolvedUri = stream.url;
|
||||
let nextTorrentStreamId: string | undefined;
|
||||
|
||||
if (torrentStreamingService.isNativeSupported() && torrentStreamingService.isTorrentStream(stream)) {
|
||||
try {
|
||||
const prepared = await torrentStreamingService.preparePlayback(
|
||||
stream,
|
||||
title || stream?.title || stream?.name,
|
||||
{ networkMbps: getEstimatedNetworkMbpsFromStream(stream) }
|
||||
);
|
||||
resolvedUri = prepared.playbackUrl;
|
||||
nextTorrentStreamId = prepared.streamId;
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || 'Failed to initialize torrent stream');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
modals.setShowEpisodeStreamsModal(false);
|
||||
playerState.setPaused(true);
|
||||
|
||||
|
|
@ -602,7 +759,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
// Wait for unmount to complete, then navigate
|
||||
setTimeout(() => {
|
||||
(navigation as any).replace('PlayerAndroid', {
|
||||
uri: stream.url,
|
||||
uri: resolvedUri,
|
||||
title: title,
|
||||
episodeTitle: ep.name,
|
||||
season: ep.season_number,
|
||||
|
|
@ -611,7 +768,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
year: year,
|
||||
streamProvider: newProvider,
|
||||
streamName: newStreamName,
|
||||
headers: stream.headers || undefined,
|
||||
headers: nextTorrentStreamId ? undefined : (stream.headers || undefined),
|
||||
id,
|
||||
type: 'series',
|
||||
episodeId: ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`,
|
||||
|
|
@ -619,6 +776,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
backdrop: backdrop || undefined,
|
||||
availableStreams: {},
|
||||
groupedEpisodes: groupedEpisodes,
|
||||
torrentStreamId: nextTorrentStreamId,
|
||||
});
|
||||
}, 300);
|
||||
};
|
||||
|
|
@ -745,6 +903,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
backdrop={backdrop || null}
|
||||
hasLogo={hasLogo}
|
||||
logo={metadata?.logo}
|
||||
loadingTitle={episodeTitle || title}
|
||||
loadingProgress={openingBufferProgress}
|
||||
backgroundFadeAnim={openingAnimation.backgroundFadeAnim}
|
||||
backdropImageOpacityAnim={openingAnimation.backdropImageOpacityAnim}
|
||||
onClose={handleClose}
|
||||
|
|
@ -792,11 +952,27 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
displayError = JSON.stringify(err);
|
||||
}
|
||||
|
||||
if (isLikelyTailPlaybackError(displayError)) {
|
||||
logger.warn('[AndroidVideoPlayer] Treating tail I/O error as graceful end', {
|
||||
error: displayError,
|
||||
currentTime: playerState.currentTime,
|
||||
duration: playerState.duration,
|
||||
});
|
||||
|
||||
if (!modals.showEpisodeStreamsModal) {
|
||||
playerState.setPaused(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
modals.setErrorDetails(displayError);
|
||||
modals.setShowErrorModal(true);
|
||||
}}
|
||||
onBuffer={(buf) => {
|
||||
playerState.setIsBuffering(buf.isBuffering);
|
||||
if (!buf.isBuffering && playerState.isVideoLoaded && openingBufferProgressRef.current >= 0.8) {
|
||||
completeOpeningOverlay(true);
|
||||
}
|
||||
}}
|
||||
onTracksChanged={(data) => {
|
||||
console.log('[AndroidVideoPlayer] onTracksChanged:', data);
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import { useTraktAutosync } from '../../hooks/useTraktAutosync';
|
|||
import { useMetadata } from '../../hooks/useMetadata';
|
||||
import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls';
|
||||
import stremioService from '../../services/stremioService';
|
||||
import { torrentStreamingService } from '../../services/torrentStreamingService';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// Utils
|
||||
|
|
@ -78,6 +79,7 @@ interface PlayerRouteParams {
|
|||
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
|
||||
headers?: Record<string, string>;
|
||||
initialPosition?: number;
|
||||
torrentStreamId?: string;
|
||||
}
|
||||
|
||||
const KSPlayerCore: React.FC = () => {
|
||||
|
|
@ -92,7 +94,8 @@ const KSPlayerCore: React.FC = () => {
|
|||
uri, title, episodeTitle, season, episode, id, type, quality, year,
|
||||
episodeId, imdbId, backdrop, availableStreams,
|
||||
headers, streamProvider, streamName,
|
||||
initialPosition: routeInitialPosition
|
||||
initialPosition: routeInitialPosition,
|
||||
torrentStreamId
|
||||
} = params;
|
||||
|
||||
const videoType = (params as any)?.videoType as string | undefined;
|
||||
|
|
@ -115,10 +118,11 @@ const KSPlayerCore: React.FC = () => {
|
|||
streamProvider,
|
||||
streamName,
|
||||
videoType,
|
||||
torrentStreamId,
|
||||
headersKeys: headerKeys,
|
||||
headersCount: headerKeys.length,
|
||||
});
|
||||
}, [uri, episodeId]);
|
||||
}, [uri, episodeId, torrentStreamId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!__DEV__) return;
|
||||
|
|
@ -566,9 +570,20 @@ const KSPlayerCore: React.FC = () => {
|
|||
// The useWatchProgress and useTraktAutosync hooks handle cleanup on unmount
|
||||
traktAutosync.handleProgressUpdate(currentTime, duration, true);
|
||||
traktAutosync.handlePlaybackEnd(currentTime, duration, 'user_close');
|
||||
if (torrentStreamId) {
|
||||
void torrentStreamingService.stopStream(torrentStreamId);
|
||||
}
|
||||
|
||||
navigation.goBack();
|
||||
}, [navigation, currentTime, duration, traktAutosync]);
|
||||
}, [navigation, currentTime, duration, traktAutosync, torrentStreamId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (torrentStreamId) {
|
||||
void torrentStreamingService.stopStream(torrentStreamId);
|
||||
}
|
||||
};
|
||||
}, [torrentStreamId]);
|
||||
|
||||
// Track selection handlers - update state, prop change triggers native update
|
||||
const handleSelectTextTrack = useCallback((trackId: number) => {
|
||||
|
|
@ -591,9 +606,44 @@ const KSPlayerCore: React.FC = () => {
|
|||
}
|
||||
}, [tracks, ksPlayerRef]);
|
||||
|
||||
const getEstimatedNetworkMbpsFromStream = useCallback((stream: any): number | undefined => {
|
||||
const hinted = stream?.behaviorHints?.playbackViability?.availableMbps;
|
||||
if (typeof hinted === 'number' && Number.isFinite(hinted) && hinted > 0) {
|
||||
return hinted;
|
||||
}
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
// Stream selection handler
|
||||
const handleSelectStream = async (newStream: any) => {
|
||||
if (newStream.url === uri) {
|
||||
let resolvedUri = newStream?.url;
|
||||
let nextTorrentStreamId: string | undefined;
|
||||
let resolvedHeaders = newStream?.headers;
|
||||
|
||||
const shouldPrepareNativeTorrent =
|
||||
torrentStreamingService.isNativeSupported() &&
|
||||
torrentStreamingService.isTorrentStream(newStream);
|
||||
|
||||
if (shouldPrepareNativeTorrent) {
|
||||
try {
|
||||
const prepared = await torrentStreamingService.preparePlayback(
|
||||
newStream,
|
||||
title || newStream?.title || newStream?.name,
|
||||
{ networkMbps: getEstimatedNetworkMbpsFromStream(newStream) }
|
||||
);
|
||||
resolvedUri = prepared.playbackUrl;
|
||||
nextTorrentStreamId = prepared.streamId;
|
||||
resolvedHeaders = undefined;
|
||||
} catch (error: any) {
|
||||
modals.setErrorDetails(error?.message || 'Failed to initialize torrent playback.');
|
||||
modals.setShowErrorModal(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedUri) return;
|
||||
|
||||
if (resolvedUri === uri && !nextTorrentStreamId) {
|
||||
modals.setShowSourcesModal(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -601,10 +651,11 @@ const KSPlayerCore: React.FC = () => {
|
|||
if (__DEV__) {
|
||||
logger.log('[KSPlayerCore] switching stream', {
|
||||
fromUri: typeof uri === 'string' ? uri.slice(0, 240) : uri,
|
||||
toUri: typeof newStream?.url === 'string' ? newStream.url.slice(0, 240) : newStream?.url,
|
||||
toUri: typeof resolvedUri === 'string' ? resolvedUri.slice(0, 240) : resolvedUri,
|
||||
newStreamHeadersKeys: Object.keys(newStream?.headers || {}),
|
||||
newProvider: newStream?.addonName || newStream?.name || newStream?.addon || 'Unknown',
|
||||
newName: newStream?.name || newStream?.title || 'Unknown',
|
||||
nextTorrentStreamId,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -616,14 +667,18 @@ const KSPlayerCore: React.FC = () => {
|
|||
const newStreamName = newStream.name || newStream.title || 'Unknown';
|
||||
|
||||
setTimeout(() => {
|
||||
if (torrentStreamId && torrentStreamId !== nextTorrentStreamId) {
|
||||
void torrentStreamingService.stopStream(torrentStreamId);
|
||||
}
|
||||
(navigation as any).replace('PlayerIOS', {
|
||||
...params,
|
||||
uri: newStream.url,
|
||||
uri: resolvedUri,
|
||||
quality: newQuality,
|
||||
streamProvider: newProvider,
|
||||
streamName: newStreamName,
|
||||
headers: newStream.headers,
|
||||
availableStreams: availableStreams
|
||||
headers: resolvedHeaders,
|
||||
availableStreams: availableStreams,
|
||||
torrentStreamId: nextTorrentStreamId,
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
|
@ -638,13 +693,40 @@ const KSPlayerCore: React.FC = () => {
|
|||
// Episode stream selection handler - navigates to new episode with selected stream
|
||||
const handleEpisodeStreamSelect = async (stream: any) => {
|
||||
if (!modals.selectedEpisodeForStreams) return;
|
||||
let resolvedUri = stream?.url;
|
||||
let nextTorrentStreamId: string | undefined;
|
||||
let resolvedHeaders = stream?.headers || undefined;
|
||||
|
||||
const shouldPrepareNativeTorrent =
|
||||
torrentStreamingService.isNativeSupported() &&
|
||||
torrentStreamingService.isTorrentStream(stream);
|
||||
|
||||
if (shouldPrepareNativeTorrent) {
|
||||
try {
|
||||
const prepared = await torrentStreamingService.preparePlayback(
|
||||
stream,
|
||||
title || stream?.title || stream?.name,
|
||||
{ networkMbps: getEstimatedNetworkMbpsFromStream(stream) }
|
||||
);
|
||||
resolvedUri = prepared.playbackUrl;
|
||||
nextTorrentStreamId = prepared.streamId;
|
||||
resolvedHeaders = undefined;
|
||||
} catch (error: any) {
|
||||
modals.setErrorDetails(error?.message || 'Failed to initialize torrent playback.');
|
||||
modals.setShowErrorModal(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedUri) return;
|
||||
|
||||
modals.setShowEpisodeStreamsModal(false);
|
||||
setPaused(true);
|
||||
const ep = modals.selectedEpisodeForStreams;
|
||||
|
||||
if (__DEV__) {
|
||||
logger.log('[KSPlayerCore] switching episode stream', {
|
||||
toUri: typeof stream?.url === 'string' ? stream.url.slice(0, 240) : stream?.url,
|
||||
toUri: typeof resolvedUri === 'string' ? resolvedUri.slice(0, 240) : resolvedUri,
|
||||
streamHeadersKeys: Object.keys(stream?.headers || {}),
|
||||
ep: {
|
||||
season: ep?.season_number,
|
||||
|
|
@ -652,6 +734,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
name: ep?.name,
|
||||
stremioId: ep?.stremioId,
|
||||
},
|
||||
nextTorrentStreamId,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -660,8 +743,11 @@ const KSPlayerCore: React.FC = () => {
|
|||
const newStreamName = stream.name || stream.title || 'Unknown Stream';
|
||||
|
||||
setTimeout(() => {
|
||||
if (torrentStreamId && torrentStreamId !== nextTorrentStreamId) {
|
||||
void torrentStreamingService.stopStream(torrentStreamId);
|
||||
}
|
||||
(navigation as any).replace('PlayerIOS', {
|
||||
uri: stream.url,
|
||||
uri: resolvedUri,
|
||||
title: title,
|
||||
episodeTitle: ep.name,
|
||||
season: ep.season_number,
|
||||
|
|
@ -670,12 +756,13 @@ const KSPlayerCore: React.FC = () => {
|
|||
year: year,
|
||||
streamProvider: newProvider,
|
||||
streamName: newStreamName,
|
||||
headers: stream.headers || undefined,
|
||||
headers: resolvedHeaders,
|
||||
id,
|
||||
type: 'series',
|
||||
episodeId: ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number} `,
|
||||
imdbId: imdbId ?? undefined,
|
||||
backdrop: backdrop || undefined,
|
||||
torrentStreamId: nextTorrentStreamId,
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -338,14 +338,17 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
requestHeaders: exoRequestHeadersArray,
|
||||
...(resolvedRnVideoType ? { type: resolvedRnVideoType } : null),
|
||||
bufferConfig: {
|
||||
minBufferMs: 10000,
|
||||
maxBufferMs: 20000,
|
||||
bufferForPlaybackMs: 2000,
|
||||
bufferForPlaybackAfterRebufferMs: 4000,
|
||||
minBufferMs: 45000,
|
||||
maxBufferMs: 1800000,
|
||||
bufferForPlaybackMs: 2500,
|
||||
bufferForPlaybackAfterRebufferMs: 6000,
|
||||
backBufferDurationMs: 300000,
|
||||
// @ts-ignore - Extra props supported by patched react-native-video
|
||||
minBufferMemoryReservePercent: 0.15,
|
||||
minBufferMemoryReservePercent: 0.05,
|
||||
// @ts-ignore - Extra props supported by patched react-native-video
|
||||
maxHeapAllocationPercent: 0.25,
|
||||
maxHeapAllocationPercent: 0.55,
|
||||
// @ts-ignore - Extra props supported by patched react-native-video
|
||||
cacheSizeMB: 2048,
|
||||
}
|
||||
} as any}
|
||||
paused={paused}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, StyleSheet, Platform, useWindowDimensions } from 'react-native';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
|
|
@ -12,6 +13,11 @@ import { Episode } from '../../../types/metadata';
|
|||
import { Stream } from '../../../types/streams';
|
||||
import { stremioService } from '../../../services/stremioService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import {
|
||||
estimateNetworkProfile,
|
||||
getPlaybackViabilityFromStream,
|
||||
rankStreamsByPlaybackViability,
|
||||
} from '../../../screens/streams/utils';
|
||||
|
||||
interface EpisodeStreamsModalProps {
|
||||
visible: boolean;
|
||||
|
|
@ -66,6 +72,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
|
|||
const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: Stream[]; addonName: string } }>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasErrors, setHasErrors] = useState<string[]>([]);
|
||||
const [networkMbps, setNetworkMbps] = useState(20);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && episode && metadata?.id) {
|
||||
|
|
@ -77,6 +84,20 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
|
|||
}
|
||||
}, [visible, episode, metadata?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = NetInfo.addEventListener(state => {
|
||||
setNetworkMbps(estimateNetworkProfile(state as any).estimatedDownlinkMbps);
|
||||
});
|
||||
|
||||
NetInfo.fetch()
|
||||
.then(state => setNetworkMbps(estimateNetworkProfile(state as any).estimatedDownlinkMbps))
|
||||
.catch(() => {
|
||||
// Keep default profile.
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
const fetchStreams = async () => {
|
||||
if (!episode || !metadata?.id) return;
|
||||
|
||||
|
|
@ -137,9 +158,17 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
|
|||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
const sortedProviders = useMemo<Array<[string, { streams: Stream[]; addonName: string }]>>(() => {
|
||||
return Object.entries(availableStreams).map(([providerId, providerData]) => [
|
||||
providerId,
|
||||
{
|
||||
...providerData,
|
||||
streams: rankStreamsByPlaybackViability((providerData.streams as any) || [], networkMbps) as any as Stream[],
|
||||
},
|
||||
]);
|
||||
}, [availableStreams, networkMbps]);
|
||||
|
||||
const sortedProviders = Object.entries(availableStreams);
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<View style={[StyleSheet.absoluteFill, { zIndex: 10000 }]}>
|
||||
|
|
@ -218,6 +247,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
|
|||
<View style={{ gap: 8 }}>
|
||||
{providerData.streams.map((stream, index) => {
|
||||
const quality = getQualityFromTitle(stream.title) || stream.quality;
|
||||
const viability = getPlaybackViabilityFromStream(stream as any);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
|
|
@ -241,6 +271,19 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
|
|||
<Text style={{ color: 'white', fontWeight: '700', fontSize: 14, flex: 1 }} numberOfLines={1}>
|
||||
{stream.name || t('player_ui.unknown_source')}
|
||||
</Text>
|
||||
{viability?.label ? (
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(255,255,255,0.12)',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 6,
|
||||
}}>
|
||||
<Text style={{ color: 'white', fontSize: 10, fontWeight: '700' }}>
|
||||
{viability.label.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<QualityBadge quality={quality} />
|
||||
</View>
|
||||
{stream.title && (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { View, TouchableOpacity, Animated, ActivityIndicator, StyleSheet, Image } from 'react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, TouchableOpacity, Animated, ActivityIndicator, StyleSheet, Image, Text } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Reanimated, {
|
||||
|
|
@ -18,6 +18,8 @@ interface LoadingOverlayProps {
|
|||
backdrop: string | null | undefined;
|
||||
hasLogo: boolean;
|
||||
logo: string | null | undefined;
|
||||
loadingTitle?: string;
|
||||
loadingProgress?: number;
|
||||
backgroundFadeAnim: Animated.Value;
|
||||
backdropImageOpacityAnim: Animated.Value;
|
||||
onClose: () => void;
|
||||
|
|
@ -30,6 +32,8 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
|||
backdrop,
|
||||
hasLogo,
|
||||
logo,
|
||||
loadingTitle,
|
||||
loadingProgress = 0,
|
||||
backgroundFadeAnim,
|
||||
backdropImageOpacityAnim,
|
||||
onClose,
|
||||
|
|
@ -38,6 +42,9 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
|||
}) => {
|
||||
const logoOpacity = useSharedValue(0);
|
||||
const logoScale = useSharedValue(1);
|
||||
const titleScale = useSharedValue(1);
|
||||
const titleOpacity = useSharedValue(1);
|
||||
const [titleWidth, setTitleWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && hasLogo && logo) {
|
||||
|
|
@ -74,13 +81,53 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
|||
}
|
||||
}, [visible, hasLogo, logo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
titleScale.value = 1;
|
||||
titleOpacity.value = 0.9;
|
||||
|
||||
titleScale.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1.03, {
|
||||
duration: 900,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
}),
|
||||
withTiming(1, {
|
||||
duration: 900,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
})
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
|
||||
titleOpacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.95, { duration: 900, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0.75, { duration: 900, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
}, [visible]);
|
||||
|
||||
const logoAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: logoOpacity.value,
|
||||
transform: [{ scale: logoScale.value }],
|
||||
}));
|
||||
|
||||
const titleAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: titleOpacity.value,
|
||||
transform: [{ scale: titleScale.value }],
|
||||
}));
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const clampedProgress = Math.max(0, Math.min(1, loadingProgress));
|
||||
const progressPercent = `${Math.round(clampedProgress * 100)}%` as `${number}%`;
|
||||
const progressWidth = titleWidth > 0 ? titleWidth * clampedProgress : 0;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
|
|
@ -126,7 +173,27 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
|||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.openingContent}>
|
||||
{hasLogo && logo ? (
|
||||
{loadingTitle ? (
|
||||
<Reanimated.View style={[localStyles.titleLoaderContainer, titleAnimatedStyle]}>
|
||||
<View style={[localStyles.titleTrack, titleWidth > 0 ? { width: titleWidth } : null]}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={localStyles.titleGhostText}
|
||||
onLayout={event => setTitleWidth(event.nativeEvent.layout.width)}
|
||||
>
|
||||
{loadingTitle}
|
||||
</Text>
|
||||
<View style={[localStyles.titleFillMask, { width: progressWidth }]}>
|
||||
<Text numberOfLines={1} style={[localStyles.titleFillText, titleWidth > 0 ? { width: titleWidth } : null]}>
|
||||
{loadingTitle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={localStyles.progressTrack}>
|
||||
<View style={[localStyles.progressFill, { width: progressPercent }]} />
|
||||
</View>
|
||||
</Reanimated.View>
|
||||
) : hasLogo && logo ? (
|
||||
<Reanimated.View style={[
|
||||
{
|
||||
alignItems: 'center',
|
||||
|
|
@ -150,4 +217,55 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const localStyles = StyleSheet.create({
|
||||
titleLoaderContainer: {
|
||||
width: '82%',
|
||||
maxWidth: 680,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
titleTrack: {
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
titleGhostText: {
|
||||
color: 'rgba(255,255,255,0.38)',
|
||||
fontSize: 36,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1.1,
|
||||
textAlign: 'center',
|
||||
},
|
||||
titleFillMask: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
titleFillText: {
|
||||
color: 'rgba(255,255,255,0.98)',
|
||||
fontSize: 36,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1.1,
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
progressTrack: {
|
||||
width: '78%',
|
||||
marginTop: 18,
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
backgroundColor: 'rgba(255,255,255,0.24)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
borderRadius: 999,
|
||||
backgroundColor: 'rgba(255,255,255,0.92)',
|
||||
},
|
||||
});
|
||||
|
||||
export default LoadingOverlay;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, StyleSheet, Platform, useWindowDimensions } from 'react-native';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
|
|
@ -9,6 +10,11 @@ import Animated, {
|
|||
} from 'react-native-reanimated';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stream } from '../../../types/streams';
|
||||
import {
|
||||
estimateNetworkProfile,
|
||||
getPlaybackViabilityFromStream,
|
||||
rankStreamsByPlaybackViability,
|
||||
} from '../../../screens/streams/utils';
|
||||
|
||||
interface SourcesModalProps {
|
||||
showSourcesModal: boolean;
|
||||
|
|
@ -61,14 +67,35 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
|
|||
const { t } = useTranslation();
|
||||
const { width } = useWindowDimensions();
|
||||
const MENU_WIDTH = Math.min(width * 0.85, 400);
|
||||
const [networkMbps, setNetworkMbps] = useState(20);
|
||||
|
||||
const handleClose = () => {
|
||||
setShowSourcesModal(false);
|
||||
};
|
||||
|
||||
if (!showSourcesModal) return null;
|
||||
useEffect(() => {
|
||||
const unsubscribe = NetInfo.addEventListener(state => {
|
||||
setNetworkMbps(estimateNetworkProfile(state as any).estimatedDownlinkMbps);
|
||||
});
|
||||
|
||||
const sortedProviders = Object.entries(availableStreams);
|
||||
NetInfo.fetch()
|
||||
.then(state => setNetworkMbps(estimateNetworkProfile(state as any).estimatedDownlinkMbps))
|
||||
.catch(() => {
|
||||
// Keep default profile.
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
const sortedProviders = useMemo<Array<[string, { streams: Stream[]; addonName: string }]>>(() => {
|
||||
return Object.entries(availableStreams).map(([providerId, providerData]) => [
|
||||
providerId,
|
||||
{
|
||||
...providerData,
|
||||
streams: rankStreamsByPlaybackViability((providerData.streams as any) || [], networkMbps) as any as Stream[],
|
||||
},
|
||||
]);
|
||||
}, [availableStreams, networkMbps]);
|
||||
|
||||
const handleStreamSelect = (stream: Stream) => {
|
||||
if (stream.url !== currentStreamUrl && !isChangingSource) {
|
||||
|
|
@ -86,6 +113,8 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
|
|||
return stream.url === currentStreamUrl;
|
||||
};
|
||||
|
||||
if (!showSourcesModal) return null;
|
||||
|
||||
return (
|
||||
<View style={[StyleSheet.absoluteFill, { zIndex: 10000 }]}>
|
||||
{/* Backdrop */}
|
||||
|
|
@ -168,6 +197,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
|
|||
{providerData.streams.map((stream, index) => {
|
||||
const isSelected = isStreamSelected(stream);
|
||||
const quality = getQualityFromTitle(stream.title) || stream.quality;
|
||||
const viability = getPlaybackViabilityFromStream(stream as any);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
|
|
@ -195,6 +225,23 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
|
|||
}} numberOfLines={1}>
|
||||
{stream.title || stream.name || t('player_ui.stream', { number: index + 1 })}
|
||||
</Text>
|
||||
{viability?.label ? (
|
||||
<View style={{
|
||||
backgroundColor: isSelected ? 'rgba(0,0,0,0.16)' : 'rgba(255,255,255,0.12)',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 6,
|
||||
}}>
|
||||
<Text style={{
|
||||
color: isSelected ? 'black' : 'white',
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
}}>
|
||||
{viability.label.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<QualityBadge quality={quality} />
|
||||
</View>
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ export const DiscoverBottomSheets = ({
|
|||
currentTheme,
|
||||
}: DiscoverBottomSheetsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isYearFilter = selectedCatalog?.filterLabel === 'year';
|
||||
const filterTitle = isYearFilter ? 'Select Year' : t('search.select_genre');
|
||||
const allFilterLabel = isYearFilter ? 'All Years' : t('search.all_genres');
|
||||
|
||||
const typeSnapPoints = useMemo(() => ['25%'], []);
|
||||
const catalogSnapPoints = useMemo(() => ['50%'], []);
|
||||
|
|
@ -140,7 +143,7 @@ export const DiscoverBottomSheets = ({
|
|||
>
|
||||
<View style={[styles.bottomSheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}>
|
||||
<Text style={[styles.bottomSheetTitle, { color: currentTheme.colors.white }]}>
|
||||
{t('search.select_genre')}
|
||||
{filterTitle}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => genreSheetRef.current?.dismiss()}>
|
||||
<MaterialIcons name="close" size={24} color={currentTheme.colors.lightGray} />
|
||||
|
|
@ -160,7 +163,7 @@ export const DiscoverBottomSheets = ({
|
|||
>
|
||||
<View style={styles.bottomSheetItemContent}>
|
||||
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
|
||||
{t('search.all_genres')}
|
||||
{allFilterLabel}
|
||||
</Text>
|
||||
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
|
||||
{t('search.show_all_content')}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ export const DiscoverSection = ({
|
|||
currentTheme,
|
||||
}: DiscoverSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isYearFilter = selectedCatalog?.filterLabel === 'year';
|
||||
const allFilterLabel = isYearFilter ? 'All Years' : t('search.all_genres');
|
||||
|
||||
return (
|
||||
<View style={styles.discoverContainer}>
|
||||
|
|
@ -94,14 +96,14 @@ export const DiscoverSection = ({
|
|||
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Genre Selector Chip - only show if catalog has genres */}
|
||||
{/* Filter Selector Chip - only show if catalog has options */}
|
||||
{availableGenres.length > 0 && (
|
||||
<TouchableOpacity
|
||||
style={[styles.discoverSelectorChip, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||
onPress={() => genreSheetRef.current?.present()}
|
||||
>
|
||||
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
|
||||
{selectedDiscoverGenre || t('search.all_genres')}
|
||||
{selectedDiscoverGenre || allFilterLabel}
|
||||
</Text>
|
||||
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
|
||||
</TouchableOpacity>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export interface DiscoverCatalog {
|
|||
catalogName: string;
|
||||
type: string;
|
||||
genres: string[];
|
||||
filterKey?: string | null;
|
||||
filterLabel?: 'genre' | 'year' | 'filter';
|
||||
}
|
||||
|
||||
// Enhanced responsive breakpoints
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
// Keep active native background tasks in memory (not persisted)
|
||||
const tasksRef = useRef<Map<string, any>>(new Map());
|
||||
const lastBytesRef = useRef<Map<string, { bytes: number; time: number }>>(new Map());
|
||||
const lastProgressUiUpdateRef = useRef<Map<string, { time: number; progress: number; bytes: number }>>(new Map());
|
||||
|
||||
// Persist and restore
|
||||
useEffect(() => {
|
||||
|
|
@ -438,18 +439,36 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
lastBytesRef.current.set(taskId, { bytes: bytesDownloaded, time: now });
|
||||
}
|
||||
|
||||
updateDownload(taskId, (d) => ({
|
||||
...d,
|
||||
downloadedBytes: typeof bytesDownloaded === 'number' ? bytesDownloaded : d.downloadedBytes,
|
||||
totalBytes: typeof bytesTotal === 'number' && bytesTotal > 0 ? bytesTotal : d.totalBytes,
|
||||
progress:
|
||||
typeof bytesDownloaded === 'number' && typeof bytesTotal === 'number' && bytesTotal > 0
|
||||
? Math.floor((bytesDownloaded / bytesTotal) * 100)
|
||||
: d.progress,
|
||||
speedBps,
|
||||
status: 'downloading',
|
||||
updatedAt: now,
|
||||
}));
|
||||
const computedTotalBytes = typeof bytesTotal === 'number' && bytesTotal > 0 ? bytesTotal : undefined;
|
||||
const computedProgress =
|
||||
typeof bytesDownloaded === 'number' && computedTotalBytes && computedTotalBytes > 0
|
||||
? Math.floor((bytesDownloaded / computedTotalBytes) * 100)
|
||||
: undefined;
|
||||
|
||||
// Throttle state updates during active downloads to prevent UI jank.
|
||||
const prevUi = lastProgressUiUpdateRef.current.get(taskId);
|
||||
const shouldUpdateUi = !prevUi
|
||||
|| now - prevUi.time >= 500
|
||||
|| (typeof computedProgress === 'number' && computedProgress !== prevUi.progress)
|
||||
|| (typeof bytesDownloaded === 'number' && Math.abs(bytesDownloaded - prevUi.bytes) >= 2 * 1024 * 1024);
|
||||
|
||||
if (shouldUpdateUi) {
|
||||
updateDownload(taskId, (d) => ({
|
||||
...d,
|
||||
downloadedBytes: typeof bytesDownloaded === 'number' ? bytesDownloaded : d.downloadedBytes,
|
||||
totalBytes: computedTotalBytes ?? d.totalBytes,
|
||||
progress: typeof computedProgress === 'number' ? computedProgress : d.progress,
|
||||
speedBps,
|
||||
status: 'downloading',
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
lastProgressUiUpdateRef.current.set(taskId, {
|
||||
time: now,
|
||||
progress: computedProgress ?? prevUi?.progress ?? 0,
|
||||
bytes: typeof bytesDownloaded === 'number' ? bytesDownloaded : prevUi?.bytes ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
const current = downloadsRef.current.find(x => x.id === taskId);
|
||||
if (current && typeof bytesDownloaded === 'number') {
|
||||
|
|
@ -489,6 +508,7 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
|
||||
tasksRef.current.delete(taskId);
|
||||
lastBytesRef.current.delete(taskId);
|
||||
lastProgressUiUpdateRef.current.delete(taskId);
|
||||
})
|
||||
.error(({ error }: any) => {
|
||||
updateDownload(taskId, (d) => ({
|
||||
|
|
@ -501,6 +521,9 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
stopLiveActivityForDownload(taskId, { title: current?.title, subtitle: 'Error', progressPercent: current?.progress });
|
||||
|
||||
console.log(`[DownloadsContext] Background download error: ${taskId}`, error);
|
||||
tasksRef.current.delete(taskId);
|
||||
lastBytesRef.current.delete(taskId);
|
||||
lastProgressUiUpdateRef.current.delete(taskId);
|
||||
});
|
||||
}, [maybeNotifyProgress, maybeUpdateLiveActivity, notifyCompleted, stopLiveActivityForDownload, updateDownload]);
|
||||
|
||||
|
|
@ -602,6 +625,7 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
}
|
||||
tasksRef.current.delete(d.id);
|
||||
lastBytesRef.current.delete(d.id);
|
||||
lastProgressUiUpdateRef.current.delete(d.id);
|
||||
}
|
||||
} catch {
|
||||
// Ignore per-item refresh failures
|
||||
|
|
@ -820,6 +844,7 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
} finally {
|
||||
tasksRef.current.delete(id);
|
||||
lastBytesRef.current.delete(id);
|
||||
lastProgressUiUpdateRef.current.delete(id);
|
||||
}
|
||||
|
||||
const item = downloadsRef.current.find(d => d.id === id);
|
||||
|
|
@ -832,6 +857,9 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
const removeDownload = useCallback(async (id: string) => {
|
||||
const item = downloadsRef.current.find(d => d.id === id);
|
||||
await stopLiveActivityForDownload(id, { title: item?.title, subtitle: 'Removed', progressPercent: item?.progress });
|
||||
tasksRef.current.delete(id);
|
||||
lastBytesRef.current.delete(id);
|
||||
lastProgressUiUpdateRef.current.delete(id);
|
||||
if (item?.fileUri && item.status === 'completed') {
|
||||
await FileSystem.deleteAsync(item.fileUri, { idempotent: true }).catch(() => { });
|
||||
}
|
||||
|
|
@ -862,5 +890,3 @@ export function useDownloads(): DownloadsContextValue {
|
|||
if (!ctx) throw new Error('useDownloads must be used within DownloadsProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -289,10 +289,36 @@ export const useSettings = () => {
|
|||
value: AppSettings[K],
|
||||
emitEvent: boolean = true
|
||||
) => {
|
||||
const newSettings = { ...settings, [key]: value };
|
||||
try {
|
||||
const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
|
||||
const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`;
|
||||
|
||||
const parseSettings = (json: string | null): Partial<AppSettings> | null => {
|
||||
if (!json) return null;
|
||||
try {
|
||||
return JSON.parse(json) as Partial<AppSettings>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Merge from persisted values first to avoid overwriting unrelated keys with defaults
|
||||
// when an update occurs before initial settings hydration completes.
|
||||
const [scopedJson, legacyJson] = await Promise.all([
|
||||
mmkvStorage.getItem(scopedKey),
|
||||
mmkvStorage.getItem(SETTINGS_STORAGE_KEY),
|
||||
]);
|
||||
const persistedScoped = parseSettings(scopedJson) || {};
|
||||
const persistedLegacy = parseSettings(legacyJson) || {};
|
||||
const memorySettings = isLoaded ? settings : {};
|
||||
const newSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...persistedLegacy,
|
||||
...persistedScoped,
|
||||
...memorySettings,
|
||||
[key]: value,
|
||||
};
|
||||
|
||||
// Write to both scoped key (multi-user aware) and legacy key for backward compatibility
|
||||
await Promise.all([
|
||||
mmkvStorage.setItem(scopedKey, JSON.stringify(newSettings)),
|
||||
|
|
@ -317,7 +343,7 @@ export const useSettings = () => {
|
|||
} catch (error) {
|
||||
if (__DEV__) console.error('Failed to save settings:', error);
|
||||
}
|
||||
}, [settings]);
|
||||
}, [isLoaded, settings]);
|
||||
|
||||
return {
|
||||
settings,
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ export type RootStackParamList = {
|
|||
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
|
||||
backdrop?: string;
|
||||
videoType?: string;
|
||||
torrentStreamId?: string;
|
||||
groupedEpisodes?: { [seasonNumber: number]: any[] };
|
||||
};
|
||||
PlayerAndroid: {
|
||||
|
|
@ -172,6 +173,7 @@ export type RootStackParamList = {
|
|||
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
|
||||
backdrop?: string;
|
||||
videoType?: string;
|
||||
torrentStreamId?: string;
|
||||
groupedEpisodes?: { [seasonNumber: number]: any[] };
|
||||
};
|
||||
Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string };
|
||||
|
|
@ -1315,13 +1317,13 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
component={MetadataScreen}
|
||||
options={{
|
||||
headerShown: false,
|
||||
animation: Platform.OS === 'android' ? 'fade' : 'fade',
|
||||
animationDuration: Platform.OS === 'android' ? 200 : 300,
|
||||
animation: Platform.OS === 'android' ? 'default' : 'fade',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
...(Platform.OS === 'ios' && {
|
||||
cardStyleInterpolator: customFadeInterpolator,
|
||||
animationTypeForReplace: 'push',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
}),
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
|
|
|
|||
|
|
@ -607,7 +607,14 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
const addon = manifests.find(a => a.id === addonId);
|
||||
if (!addon) throw new Error(`Addon ${addonId} not found`);
|
||||
|
||||
const filters = effectiveGenreFilter ? [{ title: 'genre', value: effectiveGenreFilter }] : [];
|
||||
const filters = Object.entries(selectedFilters)
|
||||
.filter(([, value]) => !!value)
|
||||
.map(([name, value]) => ({ title: name, value }));
|
||||
|
||||
if (effectiveGenreFilter) {
|
||||
filters.push({ title: 'genre', value: effectiveGenreFilter });
|
||||
}
|
||||
|
||||
const catalogItems = await stremioService.getCatalog(addon, type, id, pageParam, filters);
|
||||
|
||||
logger.log('[CatalogScreen] Fetched addon catalog page', {
|
||||
|
|
@ -705,7 +712,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
logger.log('[CatalogScreen] loadItems finished');
|
||||
});
|
||||
}
|
||||
}, [addonId, type, id, activeGenreFilter, dataSource]);
|
||||
}, [addonId, type, id, activeGenreFilter, selectedFilters, dataSource]);
|
||||
|
||||
useEffect(() => {
|
||||
loadItems(true, 1);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { NavigationProp } from '@react-navigation/native';
|
|||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { StreamingContent, CatalogContent, catalogService } from '../services/catalogService';
|
||||
import { stremioService } from '../services/stremioService';
|
||||
import { torrentStreamingService } from '../services/torrentStreamingService';
|
||||
import { Stream } from '../types/metadata';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
|
@ -546,6 +547,16 @@ const HomeScreen = () => {
|
|||
if (!featuredContent) return;
|
||||
|
||||
try {
|
||||
let playbackUri = stream.url as any;
|
||||
let torrentStreamId: string | undefined;
|
||||
|
||||
if (Platform.OS === 'android' && torrentStreamingService.isTorrentStream(stream)) {
|
||||
showInfo('Preparing torrent stream...');
|
||||
const prepared = await torrentStreamingService.preparePlayback(stream, featuredContent.name);
|
||||
playbackUri = prepared.playbackUrl;
|
||||
torrentStreamId = prepared.streamId;
|
||||
}
|
||||
|
||||
// Don't clear cache before player - causes broken images on return
|
||||
// FastImage's native libraries handle memory efficiently
|
||||
|
||||
|
|
@ -564,13 +575,14 @@ const HomeScreen = () => {
|
|||
|
||||
// @ts-ignore
|
||||
navigation.navigate(Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid', {
|
||||
uri: stream.url as any,
|
||||
uri: playbackUri,
|
||||
title: featuredContent.name,
|
||||
year: featuredContent.year,
|
||||
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
|
||||
streamProvider: stream.name,
|
||||
id: featuredContent.id,
|
||||
type: featuredContent.type
|
||||
type: featuredContent.type,
|
||||
torrentStreamId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[HomeScreen] Error in handlePlayStream:', error);
|
||||
|
|
@ -588,7 +600,7 @@ const HomeScreen = () => {
|
|||
type: featuredContent.type
|
||||
});
|
||||
}
|
||||
}, [featuredContent, navigation]);
|
||||
}, [featuredContent, navigation, showInfo]);
|
||||
|
||||
const refreshContinueWatching = useCallback(async () => {
|
||||
if (continueWatchingRef.current) {
|
||||
|
|
@ -1482,4 +1494,3 @@ const HomeScreenWithFocusSync = (props: any) => {
|
|||
};
|
||||
|
||||
export default React.memo(HomeScreenWithFocusSync);
|
||||
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ const SectionHeader: React.FC<{ title: string; isDarkMode: boolean; colors: any
|
|||
|
||||
const HomeScreenSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const { settings, updateSetting, isLoaded } = useSettings();
|
||||
const systemColorScheme = useColorScheme();
|
||||
const { currentTheme } = useTheme();
|
||||
const colors = currentTheme.colors;
|
||||
|
|
@ -168,12 +168,16 @@ const HomeScreenSettings: React.FC = () => {
|
|||
|
||||
// Ensure carousel is the default hero layout on tablets for all users
|
||||
useEffect(() => {
|
||||
if (!isLoaded || !isTabletDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isTabletDevice && settings.heroStyle !== 'carousel') {
|
||||
if (settings.heroStyle !== 'carousel') {
|
||||
updateSetting('heroStyle', 'carousel' as any);
|
||||
}
|
||||
} catch { }
|
||||
}, [isTabletDevice, settings.heroStyle, updateSetting]);
|
||||
}, [isLoaded, isTabletDevice, settings.heroStyle, updateSetting]);
|
||||
|
||||
const CustomSwitch = ({ value, onValueChange }: { value: boolean, onValueChange: (value: boolean) => void }) => (
|
||||
<Switch
|
||||
|
|
@ -713,4 +717,4 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
});
|
||||
|
||||
export default HomeScreenSettings;
|
||||
export default HomeScreenSettings;
|
||||
|
|
|
|||
|
|
@ -104,11 +104,21 @@ const TraktItem = React.memo(({
|
|||
currentTheme: any;
|
||||
showTitles: boolean;
|
||||
}) => {
|
||||
const [posterUrl, setPosterUrl] = useState<string | null>(null);
|
||||
const inlinePoster =
|
||||
typeof item.poster === 'string' &&
|
||||
item.poster.length > 0 &&
|
||||
item.poster !== 'placeholder'
|
||||
? item.poster
|
||||
: null;
|
||||
const [posterUrl, setPosterUrl] = useState<string | null>(inlinePoster);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const fetchPoster = async () => {
|
||||
if (isMounted) {
|
||||
setPosterUrl(inlinePoster);
|
||||
}
|
||||
|
||||
if (item.images) {
|
||||
const url = TraktService.getTraktPosterUrl(item.images);
|
||||
if (isMounted && url) {
|
||||
|
|
@ -153,7 +163,7 @@ const TraktItem = React.memo(({
|
|||
};
|
||||
fetchPoster();
|
||||
return () => { isMounted = false; };
|
||||
}, [item.images, item.imdbId, item.traktId, item.type]);
|
||||
}, [item.images, item.imdbId, item.poster, item.traktId, item.type, inlinePoster]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (item.imdbId) {
|
||||
|
|
@ -315,6 +325,10 @@ const LibraryScreen = () => {
|
|||
loadAllCollections: loadSimklCollections
|
||||
} = useSimklContext();
|
||||
|
||||
// Show only one provider tab to reduce clutter: prefer SIMKL when available.
|
||||
const showSimklTab = simklAuthenticated;
|
||||
const showTraktTab = traktAuthenticated && !showSimklTab;
|
||||
|
||||
useEffect(() => {
|
||||
const applyStatusBarConfig = () => {
|
||||
StatusBar.setBarStyle('light-content');
|
||||
|
|
@ -354,6 +368,17 @@ const LibraryScreen = () => {
|
|||
return () => backHandler.remove();
|
||||
}, [showTraktContent, showSimklContent, selectedTraktFolder, selectedSimklFolder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showTraktTab && showTraktContent) {
|
||||
setShowTraktContent(false);
|
||||
setSelectedTraktFolder(null);
|
||||
}
|
||||
if (!showSimklTab && showSimklContent) {
|
||||
setShowSimklContent(false);
|
||||
setSelectedSimklFolder(null);
|
||||
}
|
||||
}, [showTraktTab, showSimklTab, showTraktContent, showSimklContent]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadLibrary = async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -894,232 +919,144 @@ const LibraryScreen = () => {
|
|||
});
|
||||
}, [watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
|
||||
|
||||
const getSimklFolderItems = useCallback((folderId: string): TraktDisplayItem[] => {
|
||||
const items: TraktDisplayItem[] = [];
|
||||
const normalizeImdbId = useCallback((imdbId?: string): string | undefined => {
|
||||
if (!imdbId) return undefined;
|
||||
const trimmed = String(imdbId).trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.startsWith('tt') ? trimmed : `tt${trimmed}`;
|
||||
}, []);
|
||||
|
||||
switch (folderId) {
|
||||
case 'continue-watching':
|
||||
return (simklContinueWatching || []).map(item => {
|
||||
const content = item.show || item.movie;
|
||||
return {
|
||||
id: String(content?.ids?.simkl || Math.random()),
|
||||
name: content?.title || 'Unknown',
|
||||
type: item.show ? 'series' : 'movie',
|
||||
poster: '',
|
||||
year: content?.year,
|
||||
lastWatched: item.paused_at,
|
||||
imdbId: content?.ids?.imdb,
|
||||
traktId: content?.ids?.simkl || 0,
|
||||
};
|
||||
});
|
||||
const getSimklContent = useCallback((item: any) => {
|
||||
return item?.anime || item?.show || item?.movie || item || null;
|
||||
}, []);
|
||||
|
||||
case 'watching-shows':
|
||||
return (watchingShows || []).map(item => ({
|
||||
id: String(item.show?.ids?.simkl || Math.random()),
|
||||
name: item.show?.title || 'Unknown',
|
||||
type: 'series' as const,
|
||||
poster: '',
|
||||
year: item.show?.year,
|
||||
lastWatched: item.last_watched_at,
|
||||
rating: item.user_rating,
|
||||
imdbId: item.show?.ids?.imdb,
|
||||
traktId: item.show?.ids?.simkl || 0,
|
||||
}));
|
||||
const buildSimklDisplayItem = useCallback((
|
||||
item: any,
|
||||
fallbackType: 'series' | 'movie',
|
||||
lastWatched?: string,
|
||||
rating?: number
|
||||
): TraktDisplayItem => {
|
||||
const content = getSimklContent(item);
|
||||
const ids = content?.ids || {};
|
||||
const name = typeof content?.title === 'string' && content.title.trim().length > 0
|
||||
? content.title
|
||||
: 'Unknown';
|
||||
const fallbackId = `simkl-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${content?.year || 'na'}`;
|
||||
const simklId = ids.simkl ?? ids.tmdb ?? ids.imdb ?? fallbackId;
|
||||
const poster = typeof content?.poster === 'string' ? content.poster : '';
|
||||
const type = item?.movie ? 'movie' : fallbackType;
|
||||
|
||||
case 'watching-movies':
|
||||
return (watchingMovies || []).map(item => ({
|
||||
id: String(item.movie?.ids?.simkl || Math.random()),
|
||||
name: item.movie?.title || 'Unknown',
|
||||
type: 'movie' as const,
|
||||
poster: '',
|
||||
year: item.movie?.year,
|
||||
lastWatched: item.last_watched_at,
|
||||
rating: item.user_rating,
|
||||
imdbId: item.movie?.ids?.imdb,
|
||||
traktId: item.movie?.ids?.simkl || 0,
|
||||
}));
|
||||
return {
|
||||
id: String(simklId),
|
||||
name,
|
||||
type,
|
||||
poster,
|
||||
year: content?.year,
|
||||
lastWatched,
|
||||
rating,
|
||||
imdbId: normalizeImdbId(ids.imdb),
|
||||
traktId: typeof ids.simkl === 'number' ? ids.simkl : 0,
|
||||
};
|
||||
}, [getSimklContent, normalizeImdbId]);
|
||||
|
||||
case 'watching-anime':
|
||||
return (watchingAnime || []).map(item => ({
|
||||
id: String(item.anime?.ids?.simkl || Math.random()),
|
||||
name: item.anime?.title || 'Unknown',
|
||||
type: 'series' as const,
|
||||
poster: '',
|
||||
year: item.anime?.year,
|
||||
lastWatched: item.last_watched_at,
|
||||
rating: item.user_rating,
|
||||
imdbId: item.anime?.ids?.imdb,
|
||||
traktId: item.anime?.ids?.simkl || 0,
|
||||
}));
|
||||
|
||||
case 'plantowatch-shows':
|
||||
return (planToWatchShows || []).map(item => ({
|
||||
id: String(item.show?.ids?.simkl || Math.random()),
|
||||
name: item.show?.title || 'Unknown',
|
||||
type: 'series' as const,
|
||||
poster: '',
|
||||
year: item.show?.year,
|
||||
lastWatched: item.added_to_watchlist_at,
|
||||
imdbId: item.show?.ids?.imdb,
|
||||
traktId: item.show?.ids?.simkl || 0,
|
||||
}));
|
||||
|
||||
case 'plantowatch-movies':
|
||||
return (planToWatchMovies || []).map(item => ({
|
||||
id: String(item.movie?.ids?.simkl || Math.random()),
|
||||
name: item.movie?.title || 'Unknown',
|
||||
type: 'movie' as const,
|
||||
poster: '',
|
||||
year: item.movie?.year,
|
||||
lastWatched: item.added_to_watchlist_at,
|
||||
imdbId: item.movie?.ids?.imdb,
|
||||
traktId: item.movie?.ids?.simkl || 0,
|
||||
}));
|
||||
|
||||
case 'plantowatch-anime':
|
||||
return (planToWatchAnime || []).map(item => ({
|
||||
id: String(item.anime?.ids?.simkl || Math.random()),
|
||||
name: item.anime?.title || 'Unknown',
|
||||
type: 'series' as const,
|
||||
poster: '',
|
||||
year: item.anime?.year,
|
||||
lastWatched: item.added_to_watchlist_at,
|
||||
imdbId: item.anime?.ids?.imdb,
|
||||
traktId: item.anime?.ids?.simkl || 0,
|
||||
}));
|
||||
|
||||
case 'completed-shows':
|
||||
return (completedShows || []).map(item => ({
|
||||
id: String(item.show?.ids?.simkl || Math.random()),
|
||||
name: item.show?.title || 'Unknown',
|
||||
type: 'series' as const,
|
||||
poster: '',
|
||||
year: item.show?.year,
|
||||
lastWatched: item.last_watched_at,
|
||||
imdbId: item.show?.ids?.imdb,
|
||||
traktId: item.show?.ids?.simkl || 0,
|
||||
}));
|
||||
|
||||
case 'completed-movies':
|
||||
return (completedMovies || []).map(item => ({
|
||||
id: String(item.movie?.ids?.simkl || Math.random()),
|
||||
name: item.movie?.title || 'Unknown',
|
||||
type: 'movie' as const,
|
||||
poster: '',
|
||||
year: item.movie?.year,
|
||||
lastWatched: item.last_watched_at,
|
||||
imdbId: item.movie?.ids?.imdb,
|
||||
traktId: item.movie?.ids?.simkl || 0,
|
||||
}));
|
||||
|
||||
case 'completed-anime':
|
||||
return (completedAnime || []).map(item => ({
|
||||
id: String(item.anime?.ids?.simkl || Math.random()),
|
||||
name: item.anime?.title || 'Unknown',
|
||||
type: 'series' as const,
|
||||
poster: '',
|
||||
year: item.anime?.year,
|
||||
lastWatched: item.last_watched_at,
|
||||
imdbId: item.anime?.ids?.imdb,
|
||||
traktId: item.anime?.ids?.simkl || 0,
|
||||
}));
|
||||
|
||||
case 'onhold-shows':
|
||||
return (onHoldShows || []).map(item => ({
|
||||
id: String(item.show?.ids?.simkl || Math.random()),
|
||||
name: item.show?.title || 'Unknown',
|
||||
type: 'series' as const,
|
||||
poster: '',
|
||||
year: item.show?.year,
|
||||
lastWatched: item.last_watched_at,
|
||||
imdbId: item.show?.ids?.imdb,
|
||||
traktId: item.show?.ids?.simkl || 0,
|
||||
}));
|
||||
|
||||
case 'onhold-movies':
|
||||
return (onHoldMovies || []).map(item => ({
|
||||
id: String(item.movie?.ids?.simkl || Math.random()),
|
||||
name: item.movie?.title || 'Unknown',
|
||||
type: 'movie' as const,
|
||||
poster: '',
|
||||
year: item.movie?.year,
|
||||
lastWatched: item.last_watched_at,
|
||||
imdbId: item.movie?.ids?.imdb,
|
||||
traktId: item.movie?.ids?.simkl || 0,
|
||||
}));
|
||||
|
||||
case 'onhold-anime':
|
||||
return (onHoldAnime || []).map(item => ({
|
||||
id: String(item.anime?.ids?.simkl || Math.random()),
|
||||
name: item.anime?.title || 'Unknown',
|
||||
type: 'series' as const,
|
||||
poster: '',
|
||||
year: item.anime?.year,
|
||||
lastWatched: item.last_watched_at,
|
||||
imdbId: item.anime?.ids?.imdb,
|
||||
traktId: item.anime?.ids?.simkl || 0,
|
||||
}));
|
||||
|
||||
case 'dropped-shows':
|
||||
return (droppedShows || []).map(item => ({
|
||||
id: String(item.show?.ids?.simkl || Math.random()),
|
||||
name: item.show?.title || 'Unknown',
|
||||
type: 'series' as const,
|
||||
poster: '',
|
||||
year: item.show?.year,
|
||||
lastWatched: item.last_watched_at,
|
||||
imdbId: item.show?.ids?.imdb,
|
||||
traktId: item.show?.ids?.simkl || 0,
|
||||
}));
|
||||
|
||||
case 'dropped-movies':
|
||||
return (droppedMovies || []).map(item => ({
|
||||
id: String(item.movie?.ids?.simkl || Math.random()),
|
||||
name: item.movie?.title || 'Unknown',
|
||||
type: 'movie' as const,
|
||||
poster: '',
|
||||
year: item.movie?.year,
|
||||
lastWatched: item.last_watched_at,
|
||||
imdbId: item.movie?.ids?.imdb,
|
||||
traktId: item.movie?.ids?.simkl || 0,
|
||||
}));
|
||||
|
||||
case 'dropped-anime':
|
||||
return (droppedAnime || []).map(item => ({
|
||||
id: String(item.anime?.ids?.simkl || Math.random()),
|
||||
name: item.anime?.title || 'Unknown',
|
||||
type: 'series' as const,
|
||||
poster: '',
|
||||
year: item.anime?.year,
|
||||
lastWatched: item.last_watched_at,
|
||||
imdbId: item.anime?.ids?.imdb,
|
||||
traktId: item.anime?.ids?.simkl || 0,
|
||||
}));
|
||||
|
||||
case 'ratings':
|
||||
return (simklRatedContent || []).map(item => {
|
||||
const content = item.show || item.movie || item.anime;
|
||||
const type = item.show ? 'series' : item.movie ? 'movie' : 'series';
|
||||
return {
|
||||
id: String(content?.ids?.simkl || Math.random()),
|
||||
name: content?.title || 'Unknown',
|
||||
type,
|
||||
poster: '',
|
||||
year: content?.year,
|
||||
lastWatched: item.rated_at,
|
||||
rating: item.rating,
|
||||
imdbId: content?.ids?.imdb,
|
||||
traktId: content?.ids?.simkl || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return items.sort((a, b) => {
|
||||
const sortDisplayItems = useCallback((items: TraktDisplayItem[]) => {
|
||||
return [...items].sort((a, b) => {
|
||||
const dateA = a.lastWatched ? new Date(a.lastWatched).getTime() : 0;
|
||||
const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
}, [simklContinueWatching, watchingShows, watchingMovies, watchingAnime, planToWatchShows, planToWatchMovies, planToWatchAnime, completedShows, completedMovies, completedAnime, onHoldShows, onHoldMovies, onHoldAnime, droppedShows, droppedMovies, droppedAnime, simklRatedContent]);
|
||||
}, []);
|
||||
|
||||
const getSimklFolderItems = useCallback((folderId: string): TraktDisplayItem[] => {
|
||||
switch (folderId) {
|
||||
case 'continue-watching':
|
||||
return sortDisplayItems((simklContinueWatching || []).map(item => (
|
||||
buildSimklDisplayItem(item, item.show ? 'series' : 'movie', item.paused_at)
|
||||
)));
|
||||
|
||||
case 'watching-shows':
|
||||
return sortDisplayItems((watchingShows || []).map(item => (
|
||||
buildSimklDisplayItem(item, 'series', item.last_watched_at, item.user_rating)
|
||||
)));
|
||||
|
||||
case 'watching-movies':
|
||||
return sortDisplayItems((watchingMovies || []).map(item => (
|
||||
buildSimklDisplayItem(item, 'movie', item.last_watched_at, item.user_rating)
|
||||
)));
|
||||
|
||||
case 'watching-anime':
|
||||
return sortDisplayItems((watchingAnime || []).map(item => (
|
||||
buildSimklDisplayItem(item, 'series', item.last_watched_at, item.user_rating)
|
||||
)));
|
||||
|
||||
case 'plantowatch-shows':
|
||||
return sortDisplayItems((planToWatchShows || []).map(item => (
|
||||
buildSimklDisplayItem(item, 'series', item.added_to_watchlist_at)
|
||||
)));
|
||||
|
||||
case 'plantowatch-movies':
|
||||
return sortDisplayItems((planToWatchMovies || []).map(item => (
|
||||
buildSimklDisplayItem(item, 'movie', item.added_to_watchlist_at)
|
||||
)));
|
||||
|
||||
case 'plantowatch-anime':
|
||||
return sortDisplayItems((planToWatchAnime || []).map(item => (
|
||||
buildSimklDisplayItem(item, 'series', item.added_to_watchlist_at)
|
||||
)));
|
||||
|
||||
case 'completed-shows':
|
||||
return sortDisplayItems((completedShows || []).map(item => (
|
||||
buildSimklDisplayItem(item, 'series', item.last_watched_at)
|
||||
)));
|
||||
|
||||
case 'completed-movies':
|
||||
return sortDisplayItems((completedMovies || []).map(item => (
|
||||
buildSimklDisplayItem(item, 'movie', item.last_watched_at)
|
||||
)));
|
||||
|
||||
case 'completed-anime':
|
||||
return sortDisplayItems((completedAnime || []).map(item => (
|
||||
buildSimklDisplayItem(item, 'series', item.last_watched_at)
|
||||
)));
|
||||
|
||||
case 'onhold-shows':
|
||||
return sortDisplayItems((onHoldShows || []).map(item => (
|
||||
buildSimklDisplayItem(item, 'series', item.last_watched_at)
|
||||
)));
|
||||
|
||||
case 'onhold-movies':
|
||||
return sortDisplayItems((onHoldMovies || []).map(item => (
|
||||
buildSimklDisplayItem(item, 'movie', item.last_watched_at)
|
||||
)));
|
||||
|
||||
case 'onhold-anime':
|
||||
return sortDisplayItems((onHoldAnime || []).map(item => (
|
||||
buildSimklDisplayItem(item, 'series', item.last_watched_at)
|
||||
)));
|
||||
|
||||
case 'dropped-shows':
|
||||
return sortDisplayItems((droppedShows || []).map(item => (
|
||||
buildSimklDisplayItem(item, 'series', item.last_watched_at)
|
||||
)));
|
||||
|
||||
case 'dropped-movies':
|
||||
return sortDisplayItems((droppedMovies || []).map(item => (
|
||||
buildSimklDisplayItem(item, 'movie', item.last_watched_at)
|
||||
)));
|
||||
|
||||
case 'dropped-anime':
|
||||
return sortDisplayItems((droppedAnime || []).map(item => (
|
||||
buildSimklDisplayItem(item, 'series', item.last_watched_at)
|
||||
)));
|
||||
|
||||
case 'ratings':
|
||||
return sortDisplayItems((simklRatedContent || []).map(item => (
|
||||
buildSimklDisplayItem(item, item.movie ? 'movie' : 'series', item.rated_at, item.rating)
|
||||
)));
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [buildSimklDisplayItem, completedAnime, completedMovies, completedShows, droppedAnime, droppedMovies, droppedShows, onHoldAnime, onHoldMovies, onHoldShows, planToWatchAnime, planToWatchMovies, planToWatchShows, simklContinueWatching, simklRatedContent, sortDisplayItems, watchingAnime, watchingMovies, watchingShows]);
|
||||
|
||||
const renderTraktContent = () => {
|
||||
if (traktLoading) {
|
||||
|
|
@ -1476,8 +1413,8 @@ const LibraryScreen = () => {
|
|||
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
{!showTraktContent && !showSimklContent && (
|
||||
<View style={styles.filtersContainer}>
|
||||
{renderFilter('trakt', 'Trakt')}
|
||||
{renderFilter('simkl', 'SIMKL')}
|
||||
{showTraktTab && renderFilter('trakt', 'Trakt')}
|
||||
{showSimklTab && renderFilter('simkl', 'SIMKL')}
|
||||
{renderFilter('movies', t('search.movies'))}
|
||||
{renderFilter('series', t('search.tv_shows'))}
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -314,7 +314,8 @@ const SearchScreen = () => {
|
|||
selectedCatalog.catalogId,
|
||||
selectedCatalog.type,
|
||||
selectedDiscoverGenre || undefined,
|
||||
1
|
||||
1,
|
||||
selectedCatalog.filterKey || 'genre'
|
||||
);
|
||||
if (isMounted.current) {
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -360,7 +361,8 @@ const SearchScreen = () => {
|
|||
selectedCatalog.catalogId,
|
||||
selectedCatalog.type,
|
||||
selectedDiscoverGenre || undefined,
|
||||
nextPage
|
||||
nextPage,
|
||||
selectedCatalog.filterKey || 'genre'
|
||||
);
|
||||
|
||||
if (isMounted.current) {
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ const SimklSettingsScreen: React.FC = () => {
|
|||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
|
||||
{userStats.anime?.completed?.count || 0}
|
||||
{(userStats.anime?.watching?.count || 0) + (userStats.anime?.completed?.count || 0)}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Anime
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|||
import { Dimensions, Platform, Linking } from 'react-native';
|
||||
import { useRoute, useNavigation } from '@react-navigation/native';
|
||||
import { RouteProp } from '@react-navigation/native';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
|
||||
import { RootStackParamList, RootStackNavigationProp } from '../../navigation/AppNavigator';
|
||||
import { useMetadata } from '../../hooks/useMetadata';
|
||||
|
|
@ -17,6 +18,7 @@ import { localScraperService } from '../../services/pluginService';
|
|||
import { VideoPlayerService } from '../../services/videoPlayerService';
|
||||
import { streamCacheService } from '../../services/streamCacheService';
|
||||
import { tmdbService } from '../../services/tmdbService';
|
||||
import { torrentStreamingService } from '../../services/torrentStreamingService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { TABLET_BREAKPOINT } from './constants';
|
||||
import {
|
||||
|
|
@ -24,6 +26,10 @@ import {
|
|||
filterStreamsByLanguage,
|
||||
getQualityNumeric,
|
||||
inferVideoTypeFromUrl,
|
||||
estimateNetworkProfile,
|
||||
getNetworkClassForMbps,
|
||||
getPlaybackViabilityFromStream,
|
||||
rankStreamsByPlaybackViability,
|
||||
sortStreamsByQuality,
|
||||
} from './utils';
|
||||
import {
|
||||
|
|
@ -54,6 +60,7 @@ export const useStreamsScreen = () => {
|
|||
// Dimension tracking
|
||||
const [dimensions, setDimensions] = useState(Dimensions.get('window'));
|
||||
const prevDimensionsRef = useRef({ width: dimensions.width, height: dimensions.height });
|
||||
const [networkProfile, setNetworkProfile] = useState(() => estimateNetworkProfile(null));
|
||||
|
||||
const deviceWidth = dimensions.width;
|
||||
const isTablet = useMemo(() => deviceWidth >= TABLET_BREAKPOINT, [deviceWidth]);
|
||||
|
|
@ -137,6 +144,25 @@ export const useStreamsScreen = () => {
|
|||
return () => subscription?.remove();
|
||||
}, []);
|
||||
|
||||
// Network profile updates used for stream viability ranking.
|
||||
useEffect(() => {
|
||||
const unsubscribe = NetInfo.addEventListener(state => {
|
||||
setNetworkProfile(estimateNetworkProfile(state as any));
|
||||
});
|
||||
|
||||
NetInfo.fetch()
|
||||
.then(state => {
|
||||
setNetworkProfile(estimateNetworkProfile(state as any));
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep default profile on failure.
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Pause trailer on mount
|
||||
useEffect(() => {
|
||||
pauseTrailer();
|
||||
|
|
@ -231,35 +257,68 @@ export const useStreamsScreen = () => {
|
|||
return 0;
|
||||
};
|
||||
|
||||
const allStreams: Array<{ stream: Stream; quality: number; providerPriority: number; originalIndex: number }> = [];
|
||||
const allStreams: Array<{
|
||||
stream: Stream;
|
||||
quality: number;
|
||||
providerPriority: number;
|
||||
originalIndex: number;
|
||||
viabilityScore: number;
|
||||
requiredMbps: number;
|
||||
seeders?: number;
|
||||
}> = [];
|
||||
|
||||
const networkClass = getNetworkClassForMbps(networkProfile.estimatedDownlinkMbps);
|
||||
const prefersLowerBitrate =
|
||||
networkClass === 'very-slow' || networkClass === 'slow' || networkClass === 'medium';
|
||||
|
||||
Object.entries(streamsData).forEach(([addonId, { streams }]) => {
|
||||
const qualityFiltered = filterByQuality(streams);
|
||||
const filteredStreams = filterByLanguage(qualityFiltered);
|
||||
const rankedStreams = rankStreamsByPlaybackViability(
|
||||
filteredStreams,
|
||||
networkProfile.estimatedDownlinkMbps
|
||||
);
|
||||
|
||||
filteredStreams.forEach((stream, index) => {
|
||||
rankedStreams.forEach((stream, index) => {
|
||||
const quality = getQualityNumeric(stream.name || stream.title);
|
||||
const providerPriority = getProviderPriority(addonId);
|
||||
allStreams.push({ stream, quality, providerPriority, originalIndex: index });
|
||||
const viability = getPlaybackViabilityFromStream(stream);
|
||||
allStreams.push({
|
||||
stream,
|
||||
quality,
|
||||
providerPriority,
|
||||
originalIndex: index,
|
||||
viabilityScore: viability?.score ?? 0,
|
||||
requiredMbps: viability?.requiredMbps ?? 0,
|
||||
seeders: viability?.seeders,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (allStreams.length === 0) return null;
|
||||
|
||||
// Sort primarily by provider priority, then respect the addon's internal order (originalIndex)
|
||||
// This ensures if an addon lists 1080p before 4K, we pick 1080p
|
||||
// Prefer streams that are most likely to play smoothly on the current connection.
|
||||
allStreams.sort((a, b) => {
|
||||
if (a.viabilityScore !== b.viabilityScore) return b.viabilityScore - a.viabilityScore;
|
||||
const aSeeders = typeof a.seeders === 'number' ? a.seeders : -1;
|
||||
const bSeeders = typeof b.seeders === 'number' ? b.seeders : -1;
|
||||
if (aSeeders !== bSeeders) return bSeeders - aSeeders;
|
||||
if (a.providerPriority !== b.providerPriority) return b.providerPriority - a.providerPriority;
|
||||
if (prefersLowerBitrate && a.requiredMbps !== b.requiredMbps) {
|
||||
return a.requiredMbps - b.requiredMbps;
|
||||
}
|
||||
if (a.quality !== b.quality) return b.quality - a.quality;
|
||||
return a.originalIndex - b.originalIndex;
|
||||
});
|
||||
|
||||
const bestViability = getPlaybackViabilityFromStream(allStreams[0].stream);
|
||||
logger.log(
|
||||
`🎯 Best stream selected: ${allStreams[0].stream.name || allStreams[0].stream.title} (Quality: ${allStreams[0].quality}p)`
|
||||
`🎯 Best stream selected: ${allStreams[0].stream.name || allStreams[0].stream.title} (Quality: ${allStreams[0].quality}p, Viability: ${bestViability?.label || 'Unknown'})`
|
||||
);
|
||||
|
||||
return allStreams[0].stream;
|
||||
},
|
||||
[filterByQuality, filterByLanguage]
|
||||
[filterByQuality, filterByLanguage, networkProfile.estimatedDownlinkMbps]
|
||||
);
|
||||
|
||||
// Current episode
|
||||
|
|
@ -352,43 +411,59 @@ export const useStreamsScreen = () => {
|
|||
|
||||
// Navigate to player
|
||||
const navigateToPlayer = useCallback(
|
||||
async (stream: Stream, options?: { headers?: Record<string, string> }) => {
|
||||
async (
|
||||
stream: Stream,
|
||||
options?: {
|
||||
headers?: Record<string, string>;
|
||||
overrideUri?: string;
|
||||
overrideHeaders?: Record<string, string>;
|
||||
torrentStreamId?: string;
|
||||
skipCache?: boolean;
|
||||
}
|
||||
) => {
|
||||
const optionHeaders = options?.headers;
|
||||
const streamHeaders = (stream.headers as any) as Record<string, string> | undefined;
|
||||
const proxyHeaders = ((stream as any)?.behaviorHints?.proxyHeaders?.request || undefined) as
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
const targetUri = options?.overrideUri || stream.url;
|
||||
const streamProvider = stream.addonId || (stream as any).addonName || stream.name;
|
||||
const finalHeaders = optionHeaders || streamHeaders || proxyHeaders;
|
||||
const finalHeaders = options?.overrideHeaders ?? optionHeaders ?? streamHeaders ?? proxyHeaders;
|
||||
|
||||
if (!targetUri) {
|
||||
return;
|
||||
}
|
||||
|
||||
const streamsToPass = selectedEpisode ? episodeStreams : groupedStreams;
|
||||
const streamName = stream.name || stream.title || 'Unnamed Stream';
|
||||
const resolvedStreamProvider = streamProvider;
|
||||
|
||||
// Save stream to cache
|
||||
try {
|
||||
const epId = (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined;
|
||||
const season = (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined;
|
||||
const episode = (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined;
|
||||
const episodeTitle = (type === 'series' || type === 'other') ? currentEpisode?.name : undefined;
|
||||
if (!options?.skipCache) {
|
||||
try {
|
||||
const epId = (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined;
|
||||
const season = (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined;
|
||||
const episode = (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined;
|
||||
const episodeTitle = (type === 'series' || type === 'other') ? currentEpisode?.name : undefined;
|
||||
|
||||
await streamCacheService.saveStreamToCache(
|
||||
id,
|
||||
type,
|
||||
stream,
|
||||
metadata,
|
||||
epId,
|
||||
season,
|
||||
episode,
|
||||
episodeTitle,
|
||||
imdbId || undefined,
|
||||
settings.streamCacheTTL
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn('[StreamsScreen] Failed to save stream to cache:', error);
|
||||
await streamCacheService.saveStreamToCache(
|
||||
id,
|
||||
type,
|
||||
stream,
|
||||
metadata,
|
||||
epId,
|
||||
season,
|
||||
episode,
|
||||
episodeTitle,
|
||||
imdbId || undefined,
|
||||
settings.streamCacheTTL
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn('[StreamsScreen] Failed to save stream to cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
let videoType = inferVideoTypeFromUrl(stream.url);
|
||||
let videoType = inferVideoTypeFromUrl(targetUri);
|
||||
try {
|
||||
const providerId = stream.addonId || (stream as any).addon || '';
|
||||
if (!videoType && /xprime/i.test(providerId)) {
|
||||
|
|
@ -400,7 +475,7 @@ export const useStreamsScreen = () => {
|
|||
const finalHeaderKeys = Object.keys(finalHeaders || {});
|
||||
|
||||
logger.log('[StreamsScreen][navigateToPlayer] stream selection', {
|
||||
url: typeof stream.url === 'string' ? stream.url.slice(0, 240) : stream.url,
|
||||
url: typeof targetUri === 'string' ? targetUri.slice(0, 240) : targetUri,
|
||||
addonId: stream.addonId,
|
||||
addonName: (stream as any).addonName,
|
||||
name: stream.name,
|
||||
|
|
@ -415,7 +490,7 @@ export const useStreamsScreen = () => {
|
|||
const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
|
||||
|
||||
navigation.navigate(playerRoute as any, {
|
||||
uri: stream.url as any,
|
||||
uri: targetUri as any,
|
||||
title: metadata?.name || '',
|
||||
episodeTitle: (type === 'series' || type === 'other') ? currentEpisode?.name : undefined,
|
||||
season: (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined,
|
||||
|
|
@ -432,6 +507,7 @@ export const useStreamsScreen = () => {
|
|||
availableStreams: streamsToPass,
|
||||
backdrop: metadata?.banner || bannerImage,
|
||||
videoType,
|
||||
torrentStreamId: options?.torrentStreamId,
|
||||
} as any);
|
||||
},
|
||||
[metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams, bannerImage, settings.streamCacheTTL]
|
||||
|
|
@ -461,9 +537,15 @@ export const useStreamsScreen = () => {
|
|||
});
|
||||
}
|
||||
|
||||
// Block magnet links
|
||||
if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) {
|
||||
openAlert('Not supported', 'Torrent streaming is not supported yet.');
|
||||
const isMagnet = typeof stream.url === 'string' && stream.url.startsWith('magnet:');
|
||||
|
||||
if (
|
||||
isMagnet &&
|
||||
Platform.OS === 'ios' &&
|
||||
settings.preferredPlayer === 'internal' &&
|
||||
!torrentStreamingService.isNativeSupported()
|
||||
) {
|
||||
openAlert('Not supported', 'Native torrent streaming is not available on this iOS build yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -521,7 +603,6 @@ export const useStreamsScreen = () => {
|
|||
// Android external player
|
||||
else if (Platform.OS === 'android' && settings.useExternalPlayer) {
|
||||
try {
|
||||
const isMagnet = typeof stream.url === 'string' && stream.url.startsWith('magnet:');
|
||||
if (isMagnet) {
|
||||
Linking.openURL(stream.url).catch(() => navigateToPlayer(stream));
|
||||
} else {
|
||||
|
|
@ -540,13 +621,43 @@ export const useStreamsScreen = () => {
|
|||
navigateToPlayer(stream);
|
||||
}
|
||||
} else {
|
||||
navigateToPlayer(stream);
|
||||
if (torrentStreamingService.isNativeSupported() && torrentStreamingService.isTorrentStream(stream)) {
|
||||
try {
|
||||
showInfo('Preparing torrent stream...');
|
||||
const prepared = await torrentStreamingService.preparePlayback(
|
||||
stream,
|
||||
metadata?.name || stream.title || stream.name,
|
||||
{ networkMbps: networkProfile.estimatedDownlinkMbps }
|
||||
);
|
||||
|
||||
await navigateToPlayer(stream, {
|
||||
overrideUri: prepared.playbackUrl,
|
||||
overrideHeaders: {},
|
||||
torrentStreamId: prepared.streamId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = error?.message || 'Failed to initialize torrent playback.';
|
||||
openAlert('Playback error', message);
|
||||
}
|
||||
} else {
|
||||
navigateToPlayer(stream);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
navigateToPlayer(stream);
|
||||
}
|
||||
},
|
||||
[settings.preferredPlayer, settings.useExternalPlayer, navigateToPlayer, openAlert, metadata, type, currentEpisode]
|
||||
[
|
||||
settings.preferredPlayer,
|
||||
settings.useExternalPlayer,
|
||||
navigateToPlayer,
|
||||
openAlert,
|
||||
metadata,
|
||||
type,
|
||||
currentEpisode,
|
||||
showInfo,
|
||||
networkProfile.estimatedDownlinkMbps,
|
||||
]
|
||||
);
|
||||
|
||||
// Update providers when streams change
|
||||
|
|
@ -900,11 +1011,19 @@ export const useStreamsScreen = () => {
|
|||
|
||||
sortedEntries.forEach(([key, { streams: providerStreams }]) => {
|
||||
const isInstalledAddon = installedAddons.some(addon => addon.installationId === key || addon.id === key);
|
||||
const providerSortedByQuality =
|
||||
settings.streamSortMode === 'quality-then-scraper'
|
||||
? sortStreamsByQuality(providerStreams)
|
||||
: providerStreams;
|
||||
const providerRankedStreams = rankStreamsByPlaybackViability(
|
||||
providerSortedByQuality,
|
||||
networkProfile.estimatedDownlinkMbps
|
||||
);
|
||||
|
||||
if (isInstalledAddon) {
|
||||
addonStreams.push(...providerStreams);
|
||||
addonStreams.push(...providerRankedStreams);
|
||||
} else {
|
||||
const qualityFiltered = filterByQuality(providerStreams);
|
||||
const qualityFiltered = filterByQuality(providerRankedStreams);
|
||||
const filteredStreams = filterByLanguage(qualityFiltered);
|
||||
if (filteredStreams.length > 0) {
|
||||
pluginStreams.push(...filteredStreams);
|
||||
|
|
@ -913,12 +1032,7 @@ export const useStreamsScreen = () => {
|
|||
});
|
||||
|
||||
let combinedStreams = [...addonStreams];
|
||||
|
||||
if (settings.streamSortMode === 'quality-then-scraper' && pluginStreams.length > 0) {
|
||||
combinedStreams.push(...sortStreamsByQuality(pluginStreams));
|
||||
} else {
|
||||
combinedStreams.push(...pluginStreams);
|
||||
}
|
||||
combinedStreams.push(...pluginStreams);
|
||||
|
||||
let sectionId = 'grouped-all';
|
||||
let sectionTitle = 'Available Streams';
|
||||
|
|
@ -957,10 +1071,14 @@ export const useStreamsScreen = () => {
|
|||
|
||||
if (filteredStreams.length === 0) return null;
|
||||
|
||||
let processedStreams = filteredStreams;
|
||||
if (!isInstalledAddon && settings.streamSortMode === 'quality-then-scraper') {
|
||||
processedStreams = sortStreamsByQuality(filteredStreams);
|
||||
}
|
||||
const sortedByQuality =
|
||||
settings.streamSortMode === 'quality-then-scraper'
|
||||
? sortStreamsByQuality(filteredStreams)
|
||||
: filteredStreams;
|
||||
const processedStreams = rankStreamsByPlaybackViability(
|
||||
sortedByQuality,
|
||||
networkProfile.estimatedDownlinkMbps
|
||||
);
|
||||
|
||||
// For multiple installations of same addon, add # to section title
|
||||
let sectionTitle = addonName;
|
||||
|
|
@ -991,6 +1109,7 @@ export const useStreamsScreen = () => {
|
|||
filterByLanguage,
|
||||
addonResponseOrder,
|
||||
settings.streamSortMode,
|
||||
networkProfile.estimatedDownlinkMbps,
|
||||
selectedEpisode,
|
||||
metadata,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,520 @@ const LANGUAGE_VARIATIONS: Record<string, string[]> = {
|
|||
hindi: ['hin'],
|
||||
};
|
||||
|
||||
const DEFAULT_NETWORK_MBPS = 20;
|
||||
const MIN_NETWORK_MBPS = 0.1;
|
||||
|
||||
export type NetworkClass = 'very-slow' | 'slow' | 'medium' | 'fast' | 'very-fast' | 'ultra';
|
||||
|
||||
type NetworkClassWeights = {
|
||||
targetHeadroomRatio: number;
|
||||
ratioWeight: number;
|
||||
directBonus: number;
|
||||
adaptiveBonus: number;
|
||||
debridBonus: number;
|
||||
torrentBaseBonus: number;
|
||||
seedCritical: number;
|
||||
seedLow: number;
|
||||
seedOkay: number;
|
||||
seedCriticalPenalty: number;
|
||||
seedLowPenalty: number;
|
||||
seedOkayPenalty: number;
|
||||
seedUnknownPenalty: number;
|
||||
seedBoostWeight: number;
|
||||
seedBoostCap: number;
|
||||
peerCongestionRatio: number;
|
||||
peerCongestionPenalty: number;
|
||||
signedUrlPenalty: number;
|
||||
notWebReadyPenalty: number;
|
||||
};
|
||||
|
||||
const NETWORK_CLASS_WEIGHTS: Record<NetworkClass, NetworkClassWeights> = {
|
||||
'very-slow': {
|
||||
targetHeadroomRatio: 2.5,
|
||||
ratioWeight: 25,
|
||||
directBonus: 10,
|
||||
adaptiveBonus: 10,
|
||||
debridBonus: 19,
|
||||
torrentBaseBonus: 4,
|
||||
seedCritical: 8,
|
||||
seedLow: 20,
|
||||
seedOkay: 40,
|
||||
seedCriticalPenalty: 30,
|
||||
seedLowPenalty: 19,
|
||||
seedOkayPenalty: 9,
|
||||
seedUnknownPenalty: 14,
|
||||
seedBoostWeight: 4.4,
|
||||
seedBoostCap: 18,
|
||||
peerCongestionRatio: 2.4,
|
||||
peerCongestionPenalty: 10,
|
||||
signedUrlPenalty: 5,
|
||||
notWebReadyPenalty: 12,
|
||||
},
|
||||
slow: {
|
||||
targetHeadroomRatio: 2.1,
|
||||
ratioWeight: 23,
|
||||
directBonus: 9,
|
||||
adaptiveBonus: 8,
|
||||
debridBonus: 18,
|
||||
torrentBaseBonus: 5,
|
||||
seedCritical: 6,
|
||||
seedLow: 14,
|
||||
seedOkay: 28,
|
||||
seedCriticalPenalty: 26,
|
||||
seedLowPenalty: 15,
|
||||
seedOkayPenalty: 7,
|
||||
seedUnknownPenalty: 11,
|
||||
seedBoostWeight: 4.6,
|
||||
seedBoostCap: 20,
|
||||
peerCongestionRatio: 2.8,
|
||||
peerCongestionPenalty: 9,
|
||||
signedUrlPenalty: 4,
|
||||
notWebReadyPenalty: 10,
|
||||
},
|
||||
medium: {
|
||||
targetHeadroomRatio: 1.7,
|
||||
ratioWeight: 20,
|
||||
directBonus: 8,
|
||||
adaptiveBonus: 7,
|
||||
debridBonus: 16,
|
||||
torrentBaseBonus: 6,
|
||||
seedCritical: 4,
|
||||
seedLow: 10,
|
||||
seedOkay: 20,
|
||||
seedCriticalPenalty: 21,
|
||||
seedLowPenalty: 12,
|
||||
seedOkayPenalty: 5,
|
||||
seedUnknownPenalty: 8,
|
||||
seedBoostWeight: 4.8,
|
||||
seedBoostCap: 22,
|
||||
peerCongestionRatio: 3.2,
|
||||
peerCongestionPenalty: 7,
|
||||
signedUrlPenalty: 3,
|
||||
notWebReadyPenalty: 8,
|
||||
},
|
||||
fast: {
|
||||
targetHeadroomRatio: 1.4,
|
||||
ratioWeight: 17,
|
||||
directBonus: 7,
|
||||
adaptiveBonus: 5,
|
||||
debridBonus: 14,
|
||||
torrentBaseBonus: 6,
|
||||
seedCritical: 3,
|
||||
seedLow: 7,
|
||||
seedOkay: 14,
|
||||
seedCriticalPenalty: 15,
|
||||
seedLowPenalty: 8,
|
||||
seedOkayPenalty: 3,
|
||||
seedUnknownPenalty: 5,
|
||||
seedBoostWeight: 5.1,
|
||||
seedBoostCap: 24,
|
||||
peerCongestionRatio: 3.7,
|
||||
peerCongestionPenalty: 5,
|
||||
signedUrlPenalty: 2,
|
||||
notWebReadyPenalty: 7,
|
||||
},
|
||||
'very-fast': {
|
||||
targetHeadroomRatio: 1.2,
|
||||
ratioWeight: 15,
|
||||
directBonus: 6,
|
||||
adaptiveBonus: 4,
|
||||
debridBonus: 12,
|
||||
torrentBaseBonus: 6,
|
||||
seedCritical: 2,
|
||||
seedLow: 5,
|
||||
seedOkay: 10,
|
||||
seedCriticalPenalty: 11,
|
||||
seedLowPenalty: 6,
|
||||
seedOkayPenalty: 2,
|
||||
seedUnknownPenalty: 4,
|
||||
seedBoostWeight: 5.3,
|
||||
seedBoostCap: 26,
|
||||
peerCongestionRatio: 4.2,
|
||||
peerCongestionPenalty: 4,
|
||||
signedUrlPenalty: 1,
|
||||
notWebReadyPenalty: 6,
|
||||
},
|
||||
ultra: {
|
||||
targetHeadroomRatio: 1.1,
|
||||
ratioWeight: 13,
|
||||
directBonus: 5,
|
||||
adaptiveBonus: 3,
|
||||
debridBonus: 11,
|
||||
torrentBaseBonus: 6,
|
||||
seedCritical: 2,
|
||||
seedLow: 4,
|
||||
seedOkay: 8,
|
||||
seedCriticalPenalty: 9,
|
||||
seedLowPenalty: 5,
|
||||
seedOkayPenalty: 1,
|
||||
seedUnknownPenalty: 3,
|
||||
seedBoostWeight: 5.5,
|
||||
seedBoostCap: 28,
|
||||
peerCongestionRatio: 4.8,
|
||||
peerCongestionPenalty: 3,
|
||||
signedUrlPenalty: 1,
|
||||
notWebReadyPenalty: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export interface NetworkProfile {
|
||||
estimatedDownlinkMbps: number;
|
||||
source:
|
||||
| 'wifi-rx-link-speed'
|
||||
| 'wifi-link-speed'
|
||||
| 'cellular-generation'
|
||||
| 'ethernet'
|
||||
| 'unknown';
|
||||
connectionType?: string;
|
||||
cellularGeneration?: string;
|
||||
isMetered?: boolean;
|
||||
}
|
||||
|
||||
export interface PlaybackViability {
|
||||
score: number;
|
||||
label: 'Excellent' | 'Good' | 'Fair' | 'Risky';
|
||||
reason: string;
|
||||
requiredMbps: number;
|
||||
availableMbps: number;
|
||||
throughputRatio: number;
|
||||
networkClass: NetworkClass;
|
||||
seeders?: number;
|
||||
peers?: number;
|
||||
}
|
||||
|
||||
type MinimalNetInfoState = {
|
||||
type?: string;
|
||||
details?: Record<string, any> | null;
|
||||
isConnectionExpensive?: boolean | null;
|
||||
isInternetReachable?: boolean | null;
|
||||
};
|
||||
|
||||
const clamp = (value: number, minValue: number, maxValue: number): number => {
|
||||
return Math.max(minValue, Math.min(maxValue, value));
|
||||
};
|
||||
|
||||
export const getNetworkClassForMbps = (networkMbps: number): NetworkClass => {
|
||||
const mbps = clamp(networkMbps || DEFAULT_NETWORK_MBPS, MIN_NETWORK_MBPS, 1000);
|
||||
if (mbps <= 1.5) return 'very-slow';
|
||||
if (mbps <= 5) return 'slow';
|
||||
if (mbps <= 20) return 'medium';
|
||||
if (mbps <= 80) return 'fast';
|
||||
if (mbps <= 250) return 'very-fast';
|
||||
return 'ultra';
|
||||
};
|
||||
|
||||
const isTorrentLikeStream = (stream: Stream): boolean => {
|
||||
const url = stream.url || '';
|
||||
const hints = stream.behaviorHints as any;
|
||||
return (
|
||||
url.startsWith('magnet:') ||
|
||||
typeof stream.infoHash === 'string' ||
|
||||
typeof hints?.infoHash === 'string' ||
|
||||
hints?.type === 'torrent'
|
||||
);
|
||||
};
|
||||
|
||||
const isHttpStream = (stream: Stream): boolean => {
|
||||
const url = stream.url || '';
|
||||
return /^https?:\/\//i.test(url);
|
||||
};
|
||||
|
||||
const getSearchText = (stream: Stream): string => {
|
||||
return `${stream.name || ''} ${stream.title || ''} ${stream.description || ''}`.toLowerCase();
|
||||
};
|
||||
|
||||
const parseNumberFromPatterns = (text: string, patterns: RegExp[]): number | undefined => {
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (!match) continue;
|
||||
const value = Number(match[1]);
|
||||
if (Number.isFinite(value) && value >= 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractTorrentStats = (stream: Stream): { seeders?: number; peers?: number } => {
|
||||
const hints = (stream.behaviorHints || {}) as any;
|
||||
const torrentHints = hints?.torrent || {};
|
||||
|
||||
const hintedSeeders =
|
||||
(typeof hints.seeders === 'number' ? hints.seeders : undefined) ??
|
||||
(typeof torrentHints.seeders === 'number' ? torrentHints.seeders : undefined);
|
||||
const hintedPeers =
|
||||
(typeof hints.peers === 'number' ? hints.peers : undefined) ??
|
||||
(typeof torrentHints.peers === 'number' ? torrentHints.peers : undefined);
|
||||
|
||||
if (hintedSeeders !== undefined || hintedPeers !== undefined) {
|
||||
return {
|
||||
seeders: hintedSeeders,
|
||||
peers: hintedPeers,
|
||||
};
|
||||
}
|
||||
|
||||
const text = `${stream.name || ''}\n${stream.title || ''}\n${stream.description || ''}`;
|
||||
|
||||
const seeders = parseNumberFromPatterns(text, [
|
||||
/(?:seeders?|seeds?)\s*[:=]\s*(\d{1,6})/i,
|
||||
/(?:👤|🧲)\s*(\d{1,6})/u,
|
||||
/\bS\s*[:=]\s*(\d{1,6})\b/i,
|
||||
]);
|
||||
|
||||
const peers = parseNumberFromPatterns(text, [
|
||||
/(?:peers?|leechers?)\s*[:=]\s*(\d{1,6})/i,
|
||||
/(?:👥)\s*(\d{1,6})/u,
|
||||
/\bP\s*[:=]\s*(\d{1,6})\b/i,
|
||||
]);
|
||||
|
||||
return { seeders, peers };
|
||||
};
|
||||
|
||||
const estimateRequiredBitrateMbps = (stream: Stream): number => {
|
||||
const text = getSearchText(stream);
|
||||
|
||||
const explicitRateMatch = text.match(/(\d+(?:\.\d+)?)\s*(?:mbps|mb\/s|mib\/s)/i);
|
||||
if (explicitRateMatch) {
|
||||
const explicit = Number(explicitRateMatch[1]);
|
||||
if (Number.isFinite(explicit) && explicit > 0) {
|
||||
return clamp(explicit, 0.5, 120);
|
||||
}
|
||||
}
|
||||
|
||||
const quality = getQualityNumeric(stream.quality || stream.name || stream.title);
|
||||
|
||||
let estimated = 6;
|
||||
if (quality >= 4320) estimated = 80;
|
||||
else if (quality >= 2160) estimated = 28;
|
||||
else if (quality >= 1440) estimated = 16;
|
||||
else if (quality >= 1080) estimated = 9;
|
||||
else if (quality >= 720) estimated = 4.5;
|
||||
else if (quality >= 480) estimated = 2.5;
|
||||
else if (quality > 0) estimated = 1.5;
|
||||
|
||||
// More efficient codecs typically need less bandwidth at similar quality.
|
||||
if (/\b(hevc|x265|h265|av1)\b/i.test(text)) {
|
||||
estimated *= 0.75;
|
||||
}
|
||||
|
||||
if (/\b(remux|blu[\s-]?ray)\b/i.test(text)) {
|
||||
estimated *= 1.35;
|
||||
}
|
||||
|
||||
if (/\b(cam|ts|telesync|screener)\b/i.test(text)) {
|
||||
estimated *= 0.75;
|
||||
}
|
||||
|
||||
return clamp(estimated, 0.8, 120);
|
||||
};
|
||||
|
||||
export const estimateNetworkProfile = (state?: MinimalNetInfoState | null): NetworkProfile => {
|
||||
const connectionType = state?.type || 'unknown';
|
||||
const details = state?.details || {};
|
||||
const isMetered = !!state?.isConnectionExpensive;
|
||||
|
||||
let estimatedDownlinkMbps = DEFAULT_NETWORK_MBPS;
|
||||
let source: NetworkProfile['source'] = 'unknown';
|
||||
|
||||
if (connectionType === 'wifi') {
|
||||
const rx = Number(details.rxLinkSpeed);
|
||||
const link = Number(details.linkSpeed);
|
||||
if (Number.isFinite(rx) && rx > 0) {
|
||||
estimatedDownlinkMbps = rx;
|
||||
source = 'wifi-rx-link-speed';
|
||||
} else if (Number.isFinite(link) && link > 0) {
|
||||
estimatedDownlinkMbps = link;
|
||||
source = 'wifi-link-speed';
|
||||
}
|
||||
} else if (connectionType === 'cellular') {
|
||||
const generation = (details.cellularGeneration || '').toString().toLowerCase();
|
||||
source = 'cellular-generation';
|
||||
if (generation === '5g') estimatedDownlinkMbps = 130;
|
||||
else if (generation === '4g') estimatedDownlinkMbps = 28;
|
||||
else if (generation === '3g') estimatedDownlinkMbps = 4;
|
||||
else if (generation === '2g') estimatedDownlinkMbps = 0.6;
|
||||
else estimatedDownlinkMbps = 12;
|
||||
} else if (connectionType === 'ethernet') {
|
||||
estimatedDownlinkMbps = 350;
|
||||
source = 'ethernet';
|
||||
}
|
||||
|
||||
if (state?.isInternetReachable === false) {
|
||||
estimatedDownlinkMbps = MIN_NETWORK_MBPS;
|
||||
}
|
||||
|
||||
if (isMetered && estimatedDownlinkMbps > 40) {
|
||||
estimatedDownlinkMbps = 40;
|
||||
}
|
||||
|
||||
return {
|
||||
estimatedDownlinkMbps: clamp(estimatedDownlinkMbps, MIN_NETWORK_MBPS, 1000),
|
||||
source,
|
||||
connectionType,
|
||||
cellularGeneration: details.cellularGeneration,
|
||||
isMetered,
|
||||
};
|
||||
};
|
||||
|
||||
export const computeStreamPlaybackViability = (
|
||||
stream: Stream,
|
||||
networkMbps = DEFAULT_NETWORK_MBPS
|
||||
): PlaybackViability => {
|
||||
const availableMbps = clamp(networkMbps || DEFAULT_NETWORK_MBPS, MIN_NETWORK_MBPS, 1000);
|
||||
const networkClass = getNetworkClassForMbps(availableMbps);
|
||||
const weights = NETWORK_CLASS_WEIGHTS[networkClass];
|
||||
const requiredMbps = estimateRequiredBitrateMbps(stream);
|
||||
const throughputRatio = availableMbps / Math.max(requiredMbps, MIN_NETWORK_MBPS);
|
||||
const isTorrent = isTorrentLikeStream(stream);
|
||||
const isDirect = isHttpStream(stream);
|
||||
const text = getSearchText(stream);
|
||||
const lowerUrl = (stream.url || '').toLowerCase();
|
||||
const { seeders, peers } = extractTorrentStats(stream);
|
||||
const isDebrid = !!stream.behaviorHints?.cached;
|
||||
const isAdaptive = /\b(m3u8|hls|adaptive|auto)\b/i.test(text);
|
||||
const isSignedUrl = /\b(token|expires?|signature|sig)=/i.test(lowerUrl);
|
||||
|
||||
let score = 48;
|
||||
score += clamp((throughputRatio - weights.targetHeadroomRatio) * weights.ratioWeight, -42, 35);
|
||||
|
||||
if (isDirect) score += weights.directBonus;
|
||||
if (isAdaptive) score += weights.adaptiveBonus;
|
||||
if (isDebrid) score += weights.debridBonus;
|
||||
if (isSignedUrl && throughputRatio < weights.targetHeadroomRatio) {
|
||||
score -= weights.signedUrlPenalty;
|
||||
}
|
||||
|
||||
if (isTorrent) {
|
||||
score += weights.torrentBaseBonus;
|
||||
if (typeof seeders === 'number') {
|
||||
score += clamp(Math.log2(seeders + 1) * weights.seedBoostWeight, 0, weights.seedBoostCap);
|
||||
if (seeders < weights.seedCritical) {
|
||||
score -= weights.seedCriticalPenalty;
|
||||
} else if (seeders < weights.seedLow) {
|
||||
score -= weights.seedLowPenalty;
|
||||
} else if (seeders < weights.seedOkay) {
|
||||
score -= weights.seedOkayPenalty;
|
||||
}
|
||||
} else {
|
||||
score -= weights.seedUnknownPenalty;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof peers === 'number' &&
|
||||
typeof seeders === 'number' &&
|
||||
peers > seeders * weights.peerCongestionRatio
|
||||
) {
|
||||
score -= weights.peerCongestionPenalty;
|
||||
}
|
||||
}
|
||||
|
||||
if (stream.behaviorHints?.notWebReady && !isTorrent) {
|
||||
score -= weights.notWebReadyPenalty;
|
||||
}
|
||||
|
||||
if (availableMbps < 1) score -= 20;
|
||||
|
||||
score = clamp(score, 1, 99);
|
||||
|
||||
let label: PlaybackViability['label'];
|
||||
if (score >= 85) label = 'Excellent';
|
||||
else if (score >= 70) label = 'Good';
|
||||
else if (score >= 50) label = 'Fair';
|
||||
else label = 'Risky';
|
||||
|
||||
let reason = 'Balanced for current connection';
|
||||
if (isDebrid) {
|
||||
reason = 'Cached/debrid source';
|
||||
} else if (isTorrent && typeof seeders === 'number' && seeders < weights.seedLow) {
|
||||
reason = 'Low seed availability for current network';
|
||||
} else if (throughputRatio < weights.targetHeadroomRatio * 0.8) {
|
||||
reason = 'Bitrate may exceed stable throughput';
|
||||
} else if (isAdaptive && availableMbps <= 25) {
|
||||
reason = 'Adaptive stream favored for current network';
|
||||
} else if (throughputRatio > weights.targetHeadroomRatio * 1.7) {
|
||||
reason = 'Bandwidth headroom available';
|
||||
}
|
||||
|
||||
return {
|
||||
score,
|
||||
label,
|
||||
reason,
|
||||
requiredMbps: Number(requiredMbps.toFixed(1)),
|
||||
availableMbps: Number(availableMbps.toFixed(1)),
|
||||
throughputRatio: Number(throughputRatio.toFixed(2)),
|
||||
networkClass,
|
||||
seeders,
|
||||
peers,
|
||||
};
|
||||
};
|
||||
|
||||
export const annotateStreamWithPlaybackViability = (
|
||||
stream: Stream,
|
||||
networkMbps = DEFAULT_NETWORK_MBPS
|
||||
): Stream => {
|
||||
const viability = computeStreamPlaybackViability(stream, networkMbps);
|
||||
const currentHints = (stream.behaviorHints || {}) as any;
|
||||
stream.behaviorHints = {
|
||||
...currentHints,
|
||||
playbackViability: viability,
|
||||
seeders: currentHints?.seeders ?? viability.seeders ?? undefined,
|
||||
peers: currentHints?.peers ?? viability.peers ?? undefined,
|
||||
};
|
||||
return stream;
|
||||
};
|
||||
|
||||
export const getPlaybackViabilityFromStream = (stream: Stream): PlaybackViability | undefined => {
|
||||
return (stream.behaviorHints as any)?.playbackViability as PlaybackViability | undefined;
|
||||
};
|
||||
|
||||
export const rankStreamsByPlaybackViability = (
|
||||
streams: Stream[],
|
||||
networkMbps = DEFAULT_NETWORK_MBPS
|
||||
): Stream[] => {
|
||||
const networkClass = getNetworkClassForMbps(networkMbps);
|
||||
const prefersLowerBitrate =
|
||||
networkClass === 'very-slow' || networkClass === 'slow' || networkClass === 'medium';
|
||||
|
||||
return streams
|
||||
.map((stream, index) => {
|
||||
const annotated = annotateStreamWithPlaybackViability(stream, networkMbps);
|
||||
const viability = getPlaybackViabilityFromStream(annotated);
|
||||
return {
|
||||
stream: annotated,
|
||||
index,
|
||||
viabilityScore: viability?.score ?? 0,
|
||||
requiredMbps: viability?.requiredMbps ?? estimateRequiredBitrateMbps(stream),
|
||||
seeders: viability?.seeders,
|
||||
quality: getQualityNumeric(stream.quality || stream.name || stream.title),
|
||||
isCached: !!annotated.behaviorHints?.cached,
|
||||
isTorrent: isTorrentLikeStream(annotated),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.viabilityScore !== b.viabilityScore) return b.viabilityScore - a.viabilityScore;
|
||||
if (a.isCached !== b.isCached) return a.isCached ? -1 : 1;
|
||||
if (a.isTorrent || b.isTorrent) {
|
||||
const aSeeders = a.seeders ?? -1;
|
||||
const bSeeders = b.seeders ?? -1;
|
||||
if (aSeeders !== bSeeders) return bSeeders - aSeeders;
|
||||
}
|
||||
if (prefersLowerBitrate && a.requiredMbps !== b.requiredMbps) {
|
||||
return a.requiredMbps - b.requiredMbps;
|
||||
}
|
||||
if (!prefersLowerBitrate && a.quality !== b.quality) {
|
||||
return b.quality - a.quality;
|
||||
}
|
||||
if (!prefersLowerBitrate && a.requiredMbps !== b.requiredMbps) {
|
||||
return b.requiredMbps - a.requiredMbps;
|
||||
}
|
||||
if (prefersLowerBitrate && a.quality !== b.quality) {
|
||||
return b.quality - a.quality;
|
||||
}
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map(item => item.stream);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all variations of a language name
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1062,12 +1062,28 @@ class CatalogService {
|
|||
async getDiscoverFilters(): Promise<{
|
||||
genres: string[];
|
||||
types: string[];
|
||||
catalogsByType: Record<string, { addonId: string; addonName: string; catalogId: string; catalogName: string; genres: string[] }[]>;
|
||||
catalogsByType: Record<string, {
|
||||
addonId: string;
|
||||
addonName: string;
|
||||
catalogId: string;
|
||||
catalogName: string;
|
||||
genres: string[];
|
||||
filterKey: string | null;
|
||||
filterLabel: 'genre' | 'year' | 'filter';
|
||||
}[]>;
|
||||
}> {
|
||||
const addons = await this.getAllAddons();
|
||||
const allGenres = new Set<string>();
|
||||
const allTypes = new Set<string>();
|
||||
const catalogsByType: Record<string, { addonId: string; addonName: string; catalogId: string; catalogName: string; genres: string[] }[]> = {};
|
||||
const catalogsByType: Record<string, {
|
||||
addonId: string;
|
||||
addonName: string;
|
||||
catalogId: string;
|
||||
catalogName: string;
|
||||
genres: string[];
|
||||
filterKey: string | null;
|
||||
filterLabel: 'genre' | 'year' | 'filter';
|
||||
}[]> = {};
|
||||
|
||||
for (const addon of addons) {
|
||||
if (!addon.catalogs) continue;
|
||||
|
|
@ -1078,15 +1094,32 @@ class CatalogService {
|
|||
allTypes.add(catalog.type);
|
||||
}
|
||||
|
||||
// Get genres from catalog extras
|
||||
// Get primary filter options (genre/year/etc.) from catalog extras
|
||||
const catalogGenres: string[] = [];
|
||||
let filterKey: string | null = null;
|
||||
let filterLabel: 'genre' | 'year' | 'filter' = 'genre';
|
||||
|
||||
if (catalog.extra && Array.isArray(catalog.extra)) {
|
||||
for (const extra of catalog.extra) {
|
||||
if (extra.name === 'genre' && extra.options && Array.isArray(extra.options)) {
|
||||
for (const genre of extra.options) {
|
||||
allGenres.add(genre);
|
||||
catalogGenres.push(genre);
|
||||
}
|
||||
const primaryFilter = catalog.extra.find(extra =>
|
||||
!!extra?.name &&
|
||||
Array.isArray(extra.options) &&
|
||||
extra.options.length > 0
|
||||
);
|
||||
|
||||
if (primaryFilter && Array.isArray(primaryFilter.options)) {
|
||||
filterKey = primaryFilter.name;
|
||||
const options = primaryFilter.options
|
||||
.map(option => (typeof option === 'string' ? option : String(option)))
|
||||
.filter(Boolean);
|
||||
|
||||
const looksLikeYears = options.length > 0 && options.every(option => /^\d{4}$/.test(option));
|
||||
filterLabel = looksLikeYears
|
||||
? 'year'
|
||||
: (filterKey === 'genre' ? 'genre' : 'filter');
|
||||
|
||||
for (const option of options) {
|
||||
allGenres.add(option);
|
||||
catalogGenres.push(option);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1101,7 +1134,9 @@ class CatalogService {
|
|||
addonName: addon.name,
|
||||
catalogId: catalog.id,
|
||||
catalogName: catalog.name || catalog.id,
|
||||
genres: catalogGenres
|
||||
genres: catalogGenres,
|
||||
filterKey,
|
||||
filterLabel
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1216,7 +1251,8 @@ class CatalogService {
|
|||
catalogId: string,
|
||||
type: string,
|
||||
genre?: string,
|
||||
page: number = 1
|
||||
page: number = 1,
|
||||
filterKey: string = 'genre'
|
||||
): Promise<StreamingContent[]> {
|
||||
try {
|
||||
const manifests = await stremioService.getInstalledAddonsAsync();
|
||||
|
|
@ -1227,7 +1263,7 @@ class CatalogService {
|
|||
return [];
|
||||
}
|
||||
|
||||
const filters = genre ? [{ title: 'genre', value: genre }] : [];
|
||||
const filters = genre && filterKey ? [{ title: filterKey, value: genre }] : [];
|
||||
const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters);
|
||||
|
||||
if (metas && metas.length > 0) {
|
||||
|
|
@ -1668,4 +1704,4 @@ class CatalogService {
|
|||
}
|
||||
|
||||
export const catalogService = CatalogService.getInstance();
|
||||
export default catalogService;
|
||||
export default catalogService;
|
||||
|
|
|
|||
|
|
@ -1573,7 +1573,9 @@ class LocalScraperService {
|
|||
description: result.size ? `${result.size}` : undefined,
|
||||
size: result.size ? this.parseSize(result.size) : undefined,
|
||||
behaviorHints: {
|
||||
bingeGroup: `local-scraper-${scraper.id}`
|
||||
bingeGroup: `local-scraper-${scraper.id}`,
|
||||
seeders: typeof result.seeders === 'number' ? result.seeders : undefined,
|
||||
peers: typeof result.peers === 'number' ? result.peers : undefined,
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -752,6 +752,16 @@ export class SimklService {
|
|||
}
|
||||
return response.anime || [];
|
||||
}
|
||||
if (type === 'anime' && response.shows && Array.isArray(response.shows) && response.shows.length > 0) {
|
||||
logger.log(`[SimklService] getAllItems: Anime fallback using ${response.shows.length} shows entries`);
|
||||
return response.shows.map((item: any) => {
|
||||
if (item?.anime) return item;
|
||||
if (item?.show) {
|
||||
return { ...item, anime: item.show };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
// If no type specified, return all
|
||||
if (!type) {
|
||||
|
|
@ -937,4 +947,4 @@ export class SimklService {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,6 +272,7 @@ class StremioService {
|
|||
private initialized: boolean = false;
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
private catalogHasMore: Map<string, boolean> = new Map();
|
||||
private catalogPageSize: Map<string, number> = new Map();
|
||||
|
||||
private constructor() {
|
||||
// Start initialization but don't wait for it
|
||||
|
|
@ -889,7 +890,10 @@ class StremioService {
|
|||
// Build URLs per Stremio protocol: /{resource}/{type}/{id}/{extraArgs}.json
|
||||
// Extra args (search, genre, skip) go in path segment, NOT query params
|
||||
const encodedId = encodeURIComponent(id);
|
||||
const pageSkip = (page - 1) * this.DEFAULT_PAGE_SIZE;
|
||||
const catalogKey = `${manifest.id}|${type}|${id}`;
|
||||
const knownPageSize = this.catalogPageSize.get(catalogKey);
|
||||
const pageSize = knownPageSize && knownPageSize > 0 ? knownPageSize : this.DEFAULT_PAGE_SIZE;
|
||||
const pageSkip = (page - 1) * pageSize;
|
||||
|
||||
// For all addons
|
||||
if (!manifest.url) {
|
||||
|
|
@ -931,7 +935,7 @@ class StremioService {
|
|||
.filter(f => f && f.value)
|
||||
.map(f => `&${encodeURIComponent(f.title)}=${encodeURIComponent(f.value!)}`)
|
||||
.join('');
|
||||
let urlQueryStyle = `${baseUrl}/catalog/${type}/${encodedId}.json?skip=${pageSkip}&limit=${this.DEFAULT_PAGE_SIZE}`;
|
||||
let urlQueryStyle = `${baseUrl}/catalog/${type}/${encodedId}.json?skip=${pageSkip}&limit=${pageSize}`;
|
||||
if (queryParams) urlQueryStyle += `&${queryParams}`;
|
||||
urlQueryStyle += legacyFilterQuery;
|
||||
|
||||
|
|
@ -973,10 +977,12 @@ class StremioService {
|
|||
if (response && response.data) {
|
||||
const hasMore = typeof response.data.hasMore === 'boolean' ? response.data.hasMore : undefined;
|
||||
try {
|
||||
const key = `${manifest.id}|${type}|${id}`;
|
||||
if (typeof hasMore === 'boolean') this.catalogHasMore.set(key, hasMore);
|
||||
if (typeof hasMore === 'boolean') this.catalogHasMore.set(catalogKey, hasMore);
|
||||
} catch { }
|
||||
if (response.data.metas && Array.isArray(response.data.metas)) {
|
||||
if (page === 1 && response.data.metas.length > 0) {
|
||||
this.catalogPageSize.set(catalogKey, response.data.metas.length);
|
||||
}
|
||||
return response.data.metas;
|
||||
}
|
||||
}
|
||||
|
|
@ -1752,6 +1758,18 @@ class StremioService {
|
|||
videoHash: stream.behaviorHints?.videoHash || undefined,
|
||||
videoSize: stream.behaviorHints?.videoSize || undefined,
|
||||
filename: stream.behaviorHints?.filename || undefined,
|
||||
seeders:
|
||||
typeof stream.seeders === 'number'
|
||||
? stream.seeders
|
||||
: typeof stream.behaviorHints?.seeders === 'number'
|
||||
? stream.behaviorHints.seeders
|
||||
: undefined,
|
||||
peers:
|
||||
typeof stream.peers === 'number'
|
||||
? stream.peers
|
||||
: typeof stream.behaviorHints?.peers === 'number'
|
||||
? stream.behaviorHints.peers
|
||||
: undefined,
|
||||
// Include essential torrent data for magnet streams
|
||||
...(isMagnetStream ? {
|
||||
infoHash: stream.infoHash || streamUrl?.match(/btih:([a-zA-Z0-9]+)/)?.[1],
|
||||
|
|
|
|||
187
src/services/torrentStreamingService.ts
Normal file
187
src/services/torrentStreamingService.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { NativeModules, Platform } from 'react-native';
|
||||
import { Stream } from '../types/metadata';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
type NativePrepareInput = {
|
||||
magnetUri: string;
|
||||
streamTitle?: string;
|
||||
fileIndex?: number;
|
||||
trackers?: string[];
|
||||
networkMbps?: number;
|
||||
};
|
||||
|
||||
type NativePrepareResult = {
|
||||
streamId: string;
|
||||
playbackUrl: string;
|
||||
infoHash?: string;
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
type NativeTorrentStreamingModule = {
|
||||
prepareStream: (input: NativePrepareInput) => Promise<NativePrepareResult>;
|
||||
stopStream: (streamId: string) => Promise<boolean>;
|
||||
stopAllStreams: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
const getNativeModule = (): NativeTorrentStreamingModule | undefined => {
|
||||
const modules = NativeModules as any;
|
||||
return (
|
||||
(modules.TorrentStreamingModule as NativeTorrentStreamingModule | undefined) ||
|
||||
(modules.NuvioTorrentStreamingModule as NativeTorrentStreamingModule | undefined)
|
||||
);
|
||||
};
|
||||
|
||||
export type PreparedTorrentPlayback = {
|
||||
streamId: string;
|
||||
playbackUrl: string;
|
||||
infoHash?: string;
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
type PreparePlaybackOptions = {
|
||||
networkMbps?: number;
|
||||
};
|
||||
|
||||
class TorrentStreamingService {
|
||||
isNativeSupported(): boolean {
|
||||
if (Platform.OS !== 'android' && Platform.OS !== 'ios') return false;
|
||||
return !!getNativeModule();
|
||||
}
|
||||
|
||||
isTorrentStream(stream: Partial<Stream> | null | undefined): boolean {
|
||||
if (!stream) return false;
|
||||
if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) return true;
|
||||
return typeof stream.infoHash === 'string' && stream.infoHash.length > 0;
|
||||
}
|
||||
|
||||
isLocalTorrentPlaybackUrl(url?: string | null): boolean {
|
||||
if (!url) return false;
|
||||
return /^http:\/\/(?:127\.0\.0\.1|localhost):\d+\/torrent\//.test(url);
|
||||
}
|
||||
|
||||
async preparePlayback(
|
||||
stream: Stream,
|
||||
streamTitle?: string,
|
||||
options?: PreparePlaybackOptions
|
||||
): Promise<PreparedTorrentPlayback> {
|
||||
const nativeModule = getNativeModule();
|
||||
if (!this.isNativeSupported() || !nativeModule) {
|
||||
throw new Error('Native torrent streaming is not available on this device.');
|
||||
}
|
||||
|
||||
const magnetUri = this.buildMagnetUri(stream, streamTitle);
|
||||
if (!magnetUri) {
|
||||
throw new Error('Missing torrent identifier. Expected magnet URL or infoHash.');
|
||||
}
|
||||
|
||||
const trackers = this.extractTrackerUrls(stream);
|
||||
const fileIndex = typeof stream.fileIdx === 'number' && Number.isFinite(stream.fileIdx)
|
||||
? stream.fileIdx
|
||||
: undefined;
|
||||
|
||||
const payload: NativePrepareInput = {
|
||||
magnetUri,
|
||||
streamTitle: streamTitle || stream.title || stream.name,
|
||||
fileIndex,
|
||||
trackers,
|
||||
networkMbps:
|
||||
typeof options?.networkMbps === 'number' && Number.isFinite(options.networkMbps)
|
||||
? Math.max(0.1, options.networkMbps)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const result = await nativeModule.prepareStream(payload);
|
||||
if (!result?.playbackUrl || !result?.streamId) {
|
||||
throw new Error('Native module did not return a playback URL.');
|
||||
}
|
||||
|
||||
logger.log('[TorrentStreamingService] Prepared torrent playback', {
|
||||
streamId: result.streamId,
|
||||
playbackUrl: result.playbackUrl,
|
||||
infoHash: result.infoHash,
|
||||
fileName: result.fileName,
|
||||
fileSize: result.fileSize,
|
||||
});
|
||||
|
||||
return {
|
||||
streamId: result.streamId,
|
||||
playbackUrl: result.playbackUrl,
|
||||
infoHash: result.infoHash,
|
||||
fileName: result.fileName,
|
||||
fileSize: result.fileSize,
|
||||
mimeType: result.mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
async stopStream(streamId?: string): Promise<void> {
|
||||
const nativeModule = getNativeModule();
|
||||
if (!this.isNativeSupported() || !nativeModule || !streamId) return;
|
||||
try {
|
||||
await nativeModule.stopStream(streamId);
|
||||
} catch (error) {
|
||||
logger.warn('[TorrentStreamingService] Failed to stop torrent stream', error);
|
||||
}
|
||||
}
|
||||
|
||||
async stopAll(): Promise<void> {
|
||||
const nativeModule = getNativeModule();
|
||||
if (!this.isNativeSupported() || !nativeModule) return;
|
||||
try {
|
||||
await nativeModule.stopAllStreams();
|
||||
} catch (error) {
|
||||
logger.warn('[TorrentStreamingService] Failed to stop all torrent streams', error);
|
||||
}
|
||||
}
|
||||
|
||||
private buildMagnetUri(stream: Stream, streamTitle?: string): string | null {
|
||||
if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) {
|
||||
return stream.url;
|
||||
}
|
||||
|
||||
if (typeof stream.infoHash === 'string' && stream.infoHash.trim().length > 0) {
|
||||
const trackers = this.extractTrackerUrls(stream);
|
||||
const encodedName = encodeURIComponent(streamTitle || stream.title || stream.name || 'Torrent Stream');
|
||||
const trackersQuery = trackers.map(tr => `&tr=${encodeURIComponent(tr)}`).join('');
|
||||
return `magnet:?xt=urn:btih:${stream.infoHash.trim()}&dn=${encodedName}${trackersQuery}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractTrackerUrls(stream: Stream): string[] {
|
||||
const out = new Set<string>();
|
||||
|
||||
if (Array.isArray(stream.sources)) {
|
||||
stream.sources.forEach(source => {
|
||||
if (typeof source === 'string' && source.startsWith('tracker:')) {
|
||||
const tracker = source.slice('tracker:'.length).trim();
|
||||
if (tracker) out.add(tracker);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) {
|
||||
const query = stream.url.split('?')[1] || '';
|
||||
query.split('&').forEach(param => {
|
||||
const [key, value] = param.split('=');
|
||||
if ((key || '').toLowerCase() === 'tr' && value) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(value);
|
||||
if (decoded) out.add(decoded);
|
||||
} catch {
|
||||
// ignore invalid tracker encoding
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return [...out];
|
||||
}
|
||||
}
|
||||
|
||||
export const torrentStreamingService = new TorrentStreamingService();
|
||||
export default torrentStreamingService;
|
||||
Loading…
Reference in a new issue