Implement torrent streaming core, playback buffering, and standalone Android packaging fixes

This commit is contained in:
Israel Bill 2026-02-19 17:02:16 +03:00
parent 9b330b8226
commit 267f63ecff
37 changed files with 3146 additions and 407 deletions

View file

@ -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', '+')}"
}

View file

@ -26,3 +26,6 @@
**[] $VALUES;
public *;
}
# jlibtorrent JNI
-keep class com.frostwire.jlibtorrent.swig.libtorrent_jni { *; }

View file

@ -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"

View file

@ -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)

View file

@ -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
}
}
}

View file

@ -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()
}
}

View file

@ -26,6 +26,12 @@ allprojects {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
maven {
url "https://dl.frostwire.com/maven"
content {
includeGroup "com.frostwire"
}
}
}
}

View 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 Nuvios React Native architecture.
## Decision
Use:
1. `libtorrent-rasterbar` as the iOS/tvOS torrent core.
2. A thin Swift bridge layer (`TorrentStreamingModule`) exposing Nuvios 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

View 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
View file

@ -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"

View file

@ -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 && (

View file

@ -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) {

View file

@ -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);

View file

@ -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);
};

View file

@ -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}

View file

@ -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 && (

View file

@ -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;

View file

@ -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>

View file

@ -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')}

View file

@ -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>

View file

@ -10,6 +10,8 @@ export interface DiscoverCatalog {
catalogName: string;
type: string;
genres: string[];
filterKey?: string | null;
filterLabel?: 'genre' | 'year' | 'filter';
}
// Enhanced responsive breakpoints

View file

@ -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;
}

View file

@ -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,

View file

@ -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,

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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>

View file

@ -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) {

View file

@ -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

View file

@ -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,
]);

View file

@ -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
*/

View file

@ -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;

View file

@ -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,
}
};

View file

@ -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;
}
}
}
}

View file

@ -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],

View 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;