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" apply plugin: "io.sentry.android.gradle"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() 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. * 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()) cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed" bundleCommand = "export:embed"
// Optional: build a debug APK with embedded JS so it doesn't require Metro.
if ((findProperty("standaloneDebug") ?: "false").toBoolean()) {
debuggableVariants = []
}
/* Folders */ /* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..' // The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../") // root = file("../../")
@ -106,8 +115,8 @@ android {
abi { abi {
enable true enable true
reset() reset()
include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' include(*resolveTargetAbis())
universalApk true universalApk(resolveTargetAbis().size() > 1)
} }
density { density {
enable false enable false
@ -250,6 +259,15 @@ dependencies {
// MPV Player library // MPV Player library
implementation files("libs/libmpv-release.aar") 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 // Google Cast Framework
implementation "com.google.android.gms:play-services-cast-framework:${safeExtGet('castFrameworkVersion', '+')}" implementation "com.google.android.gms:play-services-cast-framework:${safeExtGet('castFrameworkVersion', '+')}"
} }

View file

@ -26,3 +26,6 @@
**[] $VALUES; **[] $VALUES;
public *; 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.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper import expo.modules.ReactNativeHostWrapper
import com.nuvio.app.mpv.MpvPackage import com.nuvio.app.mpv.MpvPackage
import com.nuvio.app.torrent.TorrentStreamingPackage
class MainApplication : Application(), ReactApplication { class MainApplication : Application(), ReactApplication {
@ -25,7 +26,8 @@ class MainApplication : Application(), ReactApplication {
override fun getPackages(): List<ReactPackage> = override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply { PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example: // 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" override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"

View file

@ -156,12 +156,14 @@ class MPVView @JvmOverloads constructor(
MPVLib.setOptionString("ao", "audiotrack,opensles") MPVLib.setOptionString("ao", "audiotrack,opensles")
// Limit demuxer cache based on Android version (like mpvKt) // 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) 64 else 32 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-bytes", "${cacheMegs * 1024 * 1024}")
MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}")
MPVLib.setOptionString("cache", "yes") 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("network-timeout", "60")
MPVLib.setOptionString("ytdl", "no") MPVLib.setOptionString("ytdl", "no")
@ -595,14 +597,20 @@ class MPVView @JvmOverloads constructor(
MPV_EVENT_END_FILE -> { MPV_EVENT_END_FILE -> {
Log.d(TAG, "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 duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0
val timePos = MPVLib.getPropertyDouble("time-pos") ?: 0.0 val timePos = MPVLib.getPropertyDouble("time-pos") ?: 0.0
val eofReached = MPVLib.getPropertyBoolean("eof-reached") ?: false val eofReached = MPVLib.getPropertyBoolean("eof-reached") ?: false
Log.d(TAG, "End stats - Duration: $duration, Time: $timePos, EOF: $eofReached") 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." val customError = "Unable to play media. Source may be unreachable."
Log.e(TAG, "Playback error detected (heuristic): $customError") Log.e(TAG, "Playback error detected (heuristic): $customError")
onErrorCallback?.invoke(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() google()
mavenCentral() mavenCentral()
maven { url 'https://www.jitpack.io' } 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", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@ -2097,6 +2098,7 @@
"resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz", "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz",
"integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==", "integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"anser": "^1.4.9", "anser": "^1.4.9",
"pretty-format": "^29.7.0", "pretty-format": "^29.7.0",
@ -2585,6 +2587,7 @@
"resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz", "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz",
"integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jimp/core": "^0.22.12" "@jimp/core": "^0.22.12"
} }
@ -2770,6 +2773,7 @@
"resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.13.5.tgz", "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.13.5.tgz",
"integrity": "sha512-4U5okwjRqDPkjB572hfZtLXJ/LGfCo6vDwUB2KIPEUoSgqbIlw+UrbnaqVp3GS+dRvhMD27F2JObpHpYRlpF0Q==", "integrity": "sha512-4U5okwjRqDPkjB572hfZtLXJ/LGfCo6vDwUB2KIPEUoSgqbIlw+UrbnaqVp3GS+dRvhMD27F2JObpHpYRlpF0Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@lottiefiles/dotlottie-web": "0.44.0" "@lottiefiles/dotlottie-web": "0.44.0"
}, },
@ -3125,7 +3129,7 @@
"version": "0.72.8", "version": "0.72.8",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz",
"integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==", "integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"invariant": "^2.2.4", "invariant": "^2.2.4",
@ -3246,6 +3250,7 @@
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.25.tgz", "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.25.tgz",
"integrity": "sha512-zQeWK9txDePWbYfqTs0C6jeRdJTm/7VhQtW/1IbJNDi9/rFIRzZule8bdQPAnf8QWUsNujRmi1J9OG/hhfbalg==", "integrity": "sha512-zQeWK9txDePWbYfqTs0C6jeRdJTm/7VhQtW/1IbJNDi9/rFIRzZule8bdQPAnf8QWUsNujRmi1J9OG/hhfbalg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@react-navigation/core": "^7.13.6", "@react-navigation/core": "^7.13.6",
"escape-string-regexp": "^4.0.0", "escape-string-regexp": "^4.0.0",
@ -3887,6 +3892,7 @@
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/core": "^7.21.3", "@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0", "@svgr/babel-preset": "8.1.0",
@ -4107,6 +4113,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -4116,8 +4123,9 @@
"version": "0.72.8", "version": "0.72.8",
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz", "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz",
"integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==", "integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@react-native/virtualized-lists": "^0.72.4", "@react-native/virtualized-lists": "^0.72.4",
"@types/react": "*" "@types/react": "*"
@ -4653,6 +4661,7 @@
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.4", "form-data": "^4.0.4",
@ -5056,6 +5065,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -6285,6 +6295,7 @@
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.29.tgz", "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.29.tgz",
"integrity": "sha512-9C90gyOzV83y2S3XzCbRDCuKYNaiyCzuP9ketv46acHCEZn+QTamPK/DobdghoSiofCmlfoaiD6/SzfxDiHMnw==", "integrity": "sha512-9C90gyOzV83y2S3XzCbRDCuKYNaiyCzuP9ketv46acHCEZn+QTamPK/DobdghoSiofCmlfoaiD6/SzfxDiHMnw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.20.0", "@babel/runtime": "^7.20.0",
"@expo/cli": "54.0.19", "@expo/cli": "54.0.19",
@ -6488,6 +6499,7 @@
"resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz", "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz",
"integrity": "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==", "integrity": "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ua-parser-js": "^0.7.33" "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", "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==", "integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==",
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"expo": "*", "expo": "*",
"react-native": "*" "react-native": "*"
@ -6525,6 +6538,7 @@
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.10.tgz", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.10.tgz",
"integrity": "sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q==", "integrity": "sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fontfaceobserver": "^2.1.0" "fontfaceobserver": "^2.1.0"
}, },
@ -6620,6 +6634,7 @@
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz", "resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz",
"integrity": "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==", "integrity": "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"rtl-detect": "^1.0.2" "rtl-detect": "^1.0.2"
}, },
@ -7679,6 +7694,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4" "@babel/runtime": "^7.28.4"
}, },
@ -10585,6 +10601,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -10625,6 +10642,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
}, },
@ -10682,6 +10700,7 @@
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.4.tgz", "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.4.tgz",
"integrity": "sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ==", "integrity": "sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/create-cache-key-function": "^29.7.0", "@jest/create-cache-key-function": "^29.7.0",
"@react-native/assets-registry": "0.81.4", "@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", "resolved": "https://registry.npmjs.org/react-native-bottom-tabs/-/react-native-bottom-tabs-1.1.0.tgz",
"integrity": "sha512-Uu1gvM3i1Hb4DjVvR/38J1QVQEs0RkPc7K6yon99HgvRWWOyLs7kjPDhUswtb8ije4pKW712skIXWJ0lgKzbyQ==", "integrity": "sha512-Uu1gvM3i1Hb4DjVvR/38J1QVQEs0RkPc7K6yon99HgvRWWOyLs7kjPDhUswtb8ije4pKW712skIXWJ0lgKzbyQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"react-freeze": "^1.0.0", "react-freeze": "^1.0.0",
"sf-symbols-typescript": "^2.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", "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.29.1.tgz",
"integrity": "sha512-du3qmv0e3Sm7qsd9SfmHps+AggLiylcBBQ8ztz7WUtd8ZjKs5V3kekAbi9R2W9bRLSg47Ntp4GGMYZOhikQdZA==", "integrity": "sha512-du3qmv0e3Sm7qsd9SfmHps+AggLiylcBBQ8ztz7WUtd8ZjKs5V3kekAbi9R2W9bRLSg47Ntp4GGMYZOhikQdZA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@egjs/hammerjs": "^2.0.17", "@egjs/hammerjs": "^2.0.17",
"hoist-non-react-statics": "^3.3.0", "hoist-non-react-statics": "^3.3.0",
@ -10898,6 +10919,7 @@
"integrity": "sha512-hcvjTu9YJE9fMmnAUvhG8CxvYLpOuMQ/2eyi/S6GyrecezF6Rmk/uRQEL6v09BRFWA/xRVZNQVulQPS+2HS3mQ==", "integrity": "sha512-hcvjTu9YJE9fMmnAUvhG8CxvYLpOuMQ/2eyi/S6GyrecezF6Rmk/uRQEL6v09BRFWA/xRVZNQVulQPS+2HS3mQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"react": "*", "react": "*",
"react-native": "*" "react-native": "*"
@ -10963,6 +10985,7 @@
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.0.tgz", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.0.tgz",
"integrity": "sha512-frhu5b8/m/VvaMWz48V8RxcsXnE3hrlErQ5chr21MzAeDCpY4X14sQjvm+jvu3aOI+7Cz2atdRpyhhIuqxVaXg==", "integrity": "sha512-frhu5b8/m/VvaMWz48V8RxcsXnE3hrlErQ5chr21MzAeDCpY4X14sQjvm+jvu3aOI+7Cz2atdRpyhhIuqxVaXg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"react-native-is-edge-to-edge": "1.2.1", "react-native-is-edge-to-edge": "1.2.1",
"semver": "7.7.3" "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", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
"integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==",
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"react": "*", "react": "*",
"react-native": "*" "react-native": "*"
@ -11012,6 +11036,7 @@
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.18.0.tgz", "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.18.0.tgz",
"integrity": "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ==", "integrity": "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"react-freeze": "^1.0.0", "react-freeze": "^1.0.0",
"warn-once": "^0.1.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", "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.1.tgz",
"integrity": "sha512-ZUD1xwc3Hwo4cOmOLumjJVoc7lEf9oQFlHnLmgccLC19fNm6LVEdtB+Cnip6gEi0PG3wfvVzskViEtrySQP8Fw==", "integrity": "sha512-ZUD1xwc3Hwo4cOmOLumjJVoc7lEf9oQFlHnLmgccLC19fNm6LVEdtB+Cnip6gEi0PG3wfvVzskViEtrySQP8Fw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"css-select": "^5.1.0", "css-select": "^5.1.0",
"css-tree": "^1.1.3", "css-tree": "^1.1.3",
@ -11254,6 +11280,7 @@
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.6", "@babel/runtime": "^7.18.6",
"@react-native/normalize-colors": "^0.74.1", "@react-native/normalize-colors": "^0.74.1",
@ -11510,6 +11537,7 @@
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -12976,6 +13004,24 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/tmp": {
"version": "0.2.5", "version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
@ -13030,6 +13076,19 @@
"url": "https://github.com/sponsors/Borewit" "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": { "node_modules/tr46": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@ -13104,8 +13163,9 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View file

@ -12,7 +12,6 @@ import {
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
import { Stream } from '../types/metadata'; import { Stream } from '../types/metadata';
import QualityBadge from './metadata/QualityBadge';
import { useSettings } from '../hooks/useSettings'; import { useSettings } from '../hooks/useSettings';
import { useDownloads } from '../contexts/DownloadsContext'; import { useDownloads } from '../contexts/DownloadsContext';
import { useToast } from '../contexts/ToastContext'; import { useToast } from '../contexts/ToastContext';
@ -90,9 +89,28 @@ const StreamCard = memo(({
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); 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 streamInfo = useMemo(() => {
const title = stream.title || ''; const title = stream.title || '';
const name = stream.name || ''; 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 // Helper function to format size from bytes
const formatSize = (bytes: number): string => { const formatSize = (bytes: number): string => {
@ -118,10 +136,24 @@ const StreamCard = memo(({
isDolby: title.toLowerCase().includes('dolby') || title.includes('DV'), isDolby: title.toLowerCase().includes('dolby') || title.includes('DV'),
size: sizeDisplay, size: sizeDisplay,
isDebrid: stream.behaviorHints?.cached, 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', displayName: name || 'Unnamed Stream',
subTitle: title && title !== name ? title : null 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 () => { const handleDownload = useCallback(async () => {
try { try {
@ -241,8 +273,46 @@ const StreamCard = memo(({
</View> </View>
<View style={styles.streamMetaRow}> <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 && ( {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 && ( {streamInfo.size && (

View file

@ -829,8 +829,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
lastTraktSyncRef.current = now; lastTraktSyncRef.current = now;
// Fetch only playback progress (paused items with actual progress %) // Fetch playback progress (paused items with actual progress %)
// Removed: history items and watched shows - redundant with local logic
const playbackItems = await traktService.getPlaybackProgress(); const playbackItems = await traktService.getPlaybackProgress();
try { try {
@ -851,6 +850,61 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// ignore // 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[] = []; 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`); 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) { } else if (item.type === 'episode' && item.show?.ids?.imdb && item.episode) {
const showImdb = item.show.ids.imdb.startsWith('tt') const showImdb = normalizeImdbId(item.show.ids.imdb);
? item.show.ids.imdb if (!showImdb) continue;
: `tt${item.show.ids.imdb}`;
// Check if recently removed // Check if recently removed
const showKey = `series:${showImdb}`; const showKey = `series:${showImdb}`;
if (recentlyRemovedRef.current.has(showKey)) continue; if (recentlyRemovedRef.current.has(showKey)) continue;
const pausedAt = new Date(item.paused_at).getTime(); 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); const cachedData = await getCachedMetadata('series', showImdb);
if (!cachedData?.basicContent) continue; if (!cachedData?.basicContent) continue;
@ -919,8 +986,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const metadata = cachedData.metadata; const metadata = cachedData.metadata;
if (metadata?.videos) { if (metadata?.videos) {
const nextEpisode = findNextEpisode( const nextEpisode = findNextEpisode(
item.episode.season, episodeSeason,
item.episode.number, episodeNumber,
metadata.videos, metadata.videos,
undefined, // No watched set needed, findNextEpisode handles it undefined, // No watched set needed, findNextEpisode handles it
showImdb showImdb
@ -933,7 +1000,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
id: showImdb, id: showImdb,
type: 'series', type: 'series',
progress: 0, // Up next - no progress yet progress: 0, // Up next - no progress yet
lastUpdated: pausedAt, lastUpdated: safePausedAt,
season: nextEpisode.season, season: nextEpisode.season,
episode: nextEpisode.episode, episode: nextEpisode.episode,
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`, episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
@ -950,39 +1017,35 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
id: showImdb, id: showImdb,
type: 'series', type: 'series',
progress: item.progress, progress: item.progress,
lastUpdated: pausedAt, lastUpdated: safePausedAt,
season: item.episode.season, season: episodeSeason,
episode: item.episode.number, episode: episodeNumber,
episodeTitle: item.episode.title || `Episode ${item.episode.number}`, episodeTitle: item.episode.title || `Episode ${episodeNumber}`,
addonId: undefined, addonId: undefined,
traktPlaybackId: item.id, // Store playback ID for removal traktPlaybackId: item.id, // Store playback ID for removal
} as ContinueWatchingItem); } 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) { } catch (err) {
// Continue with other items // Continue with other items
} }
} }
// STEP 2: Get watched shows and find "Up Next" episodes // STEP 2: Use watched shows to find "Up Next" episodes
// This handles cases where episodes are fully completed and removed from playback progress // This handles cases where episodes are fully completed and removed from playback progress.
try { try {
const watchedShows = await traktService.getWatchedShows();
const thirtyDaysAgoForShows = Date.now() - (30 * 24 * 60 * 60 * 1000); const thirtyDaysAgoForShows = Date.now() - (30 * 24 * 60 * 60 * 1000);
for (const watchedShow of watchedShows) { for (const watchedShow of watchedShows) {
try { 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 // Skip shows that haven't been watched recently
const lastWatchedAt = new Date(watchedShow.last_watched_at).getTime(); const lastWatchedAt = new Date(watchedShow.last_watched_at).getTime();
if (lastWatchedAt < thirtyDaysAgoForShows) continue; 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 // Check if recently removed
const showKey = `series:${showImdb}`; const showKey = `series:${showImdb}`;
if (recentlyRemovedRef.current.has(showKey)) continue; 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 season of watchedShow.seasons) {
for (const episode of season.episodes) { for (const episode of season.episodes) {
const episodeTimestamp = new Date(episode.last_watched_at).getTime(); 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; latestEpisodeTimestamp = episodeTimestamp;
lastWatchedSeason = season.number; lastWatchedSeason = season.number;
lastWatchedEpisode = episode.number; lastWatchedEpisode = episode.number;
@ -1013,11 +1084,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// Build a set of watched episodes for this show // Build a set of watched episodes for this show
const watchedEpisodeSet = new Set<string>(); const watchedEpisodeSet = new Set<string>();
if (watchedShow.seasons) { const watchedEpisodeMap = watchedEpisodeTimestampsByShow.get(showImdb);
for (const season of watchedShow.seasons) { if (watchedEpisodeMap && watchedEpisodeMap.size > 0) {
for (const episode of season.episodes) { for (const episodeKey of watchedEpisodeMap.keys()) {
watchedEpisodeSet.add(`${showImdb}:${season.number}:${episode.number}`); watchedEpisodeSet.add(`${showImdb}:${episodeKey}`);
}
} }
} }
@ -1310,7 +1380,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// ignore // ignore
} }
setContinueWatchingItems(adjustedItems); await mergeBatchIntoState(adjustedItems);
// Fire-and-forget reconcile (don't block UI) // Fire-and-forget reconcile (don't block UI)
if (reconcilePromises.length > 0) { 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 { formatTime, isHlsStream, getHlsHeaders, defaultAndroidHeaders, parseSRT } from './utils/playerUtils';
import { storageService } from '../../services/storageService'; import { storageService } from '../../services/storageService';
import stremioService from '../../services/stremioService'; import stremioService from '../../services/stremioService';
import { torrentStreamingService } from '../../services/torrentStreamingService';
import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes'; import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes';
import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils'; import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
@ -74,7 +75,7 @@ const AndroidVideoPlayer: React.FC = () => {
const { const {
uri, title = 'Episode Name', season, episode, episodeTitle, quality, year, uri, title = 'Episode Name', season, episode, episodeTitle, quality, year,
streamProvider, streamName, headers, id, type, episodeId, imdbId, streamProvider, streamName, headers, id, type, episodeId, imdbId,
availableStreams: passedAvailableStreams, backdrop, groupedEpisodes availableStreams: passedAvailableStreams, backdrop, groupedEpisodes, torrentStreamId
} = route.params; } = route.params;
// --- State & Custom Hooks --- // --- State & Custom Hooks ---
@ -107,6 +108,10 @@ const AndroidVideoPlayer: React.FC = () => {
const [useExoPlayer, setUseExoPlayer] = useState(!shouldUseMpvOnly); const [useExoPlayer, setUseExoPlayer] = useState(!shouldUseMpvOnly);
const hasExoPlayerFailed = useRef(false); const hasExoPlayerFailed = useRef(false);
const [showMpvSwitchAlert, setShowMpvSwitchAlert] = useState(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' // 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 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(() => { useEffect(() => {
Animated.timing(fadeAnim, { Animated.timing(fadeAnim, {
@ -274,6 +316,26 @@ const AndroidVideoPlayer: React.FC = () => {
openingAnimation.startOpeningAnimation(); 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 // Load subtitle settings on mount
useEffect(() => { useEffect(() => {
const loadSubtitleSettings = async () => { const loadSubtitleSettings = async () => {
@ -367,7 +429,14 @@ const AndroidVideoPlayer: React.FC = () => {
} }
playerState.setIsVideoLoaded(true); 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 // Auto-select audio track based on preferences
if (data.audioTracks && data.audioTracks.length > 0 && settings?.preferredAudioLanguage) { if (data.audioTracks && data.audioTracks.length > 0 && settings?.preferredAudioLanguage) {
@ -437,16 +506,48 @@ const AndroidVideoPlayer: React.FC = () => {
} }
}, 300); }, 300);
} }
}, [id, type, episodeId, playerState.isMounted, watchProgress.initialPosition, useExoPlayer]); }, [id, type, episodeId, playerState.isMounted, watchProgress.initialPosition, useExoPlayer, updateOpeningBufferProgress, completeOpeningOverlay]);
const handleProgress = useCallback((data: any) => { const handleProgress = useCallback((data: any) => {
if (playerState.isDragging.current || playerState.isSeeking.current || !playerState.isMounted.current || setupHook.isAppBackgrounded.current) return; if (playerState.isDragging.current || playerState.isSeeking.current || !playerState.isMounted.current || setupHook.isAppBackgrounded.current) return;
const currentTimeInSeconds = data.currentTime; 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) { if (Math.abs(currentTimeInSeconds - playerState.currentTime) > 0.5) {
playerState.setCurrentTime(currentTimeInSeconds); 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 // Auto-select subtitles when both internal tracks and video are loaded
// This ensures we wait for internal tracks before falling back to external // 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 controlsTimeout = useRef<NodeJS.Timeout | null>(null);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
if (torrentStreamId) {
void torrentStreamingService.stopStream(torrentStreamId);
}
if (navigation.canGoBack()) navigation.goBack(); if (navigation.canGoBack()) navigation.goBack();
else navigation.reset({ index: 0, routes: [{ name: 'Home' }] } as any); 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 // Handle codec errors from ExoPlayer - silently switch to MPV
const handleCodecError = useCallback(() => { const handleCodecError = useCallback(() => {
@ -555,9 +667,34 @@ const AndroidVideoPlayer: React.FC = () => {
}, 500); }, 500);
}, [playerState.currentTime]); }, [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) => { 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); modals.setShowSourcesModal(false);
return; return;
} }
@ -575,18 +712,38 @@ const AndroidVideoPlayer: React.FC = () => {
setTimeout(() => { setTimeout(() => {
(navigation as any).replace('PlayerAndroid', { (navigation as any).replace('PlayerAndroid', {
...route.params, ...route.params,
uri: newStream.url, uri: resolvedUri,
quality: newQuality, quality: newQuality,
streamProvider: newProvider, streamProvider: newProvider,
streamName: newStreamName, streamName: newStreamName,
headers: newStream.headers, headers: nextTorrentStreamId ? undefined : newStream.headers,
availableStreams: availableStreams availableStreams: availableStreams,
torrentStreamId: nextTorrentStreamId,
}); });
}, 300); }, 300);
}; };
const handleEpisodeStreamSelect = async (stream: any) => { const handleEpisodeStreamSelect = async (stream: any) => {
if (!modals.selectedEpisodeForStreams) return; 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); modals.setShowEpisodeStreamsModal(false);
playerState.setPaused(true); playerState.setPaused(true);
@ -602,7 +759,7 @@ const AndroidVideoPlayer: React.FC = () => {
// Wait for unmount to complete, then navigate // Wait for unmount to complete, then navigate
setTimeout(() => { setTimeout(() => {
(navigation as any).replace('PlayerAndroid', { (navigation as any).replace('PlayerAndroid', {
uri: stream.url, uri: resolvedUri,
title: title, title: title,
episodeTitle: ep.name, episodeTitle: ep.name,
season: ep.season_number, season: ep.season_number,
@ -611,7 +768,7 @@ const AndroidVideoPlayer: React.FC = () => {
year: year, year: year,
streamProvider: newProvider, streamProvider: newProvider,
streamName: newStreamName, streamName: newStreamName,
headers: stream.headers || undefined, headers: nextTorrentStreamId ? undefined : (stream.headers || undefined),
id, id,
type: 'series', type: 'series',
episodeId: ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`, episodeId: ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`,
@ -619,6 +776,7 @@ const AndroidVideoPlayer: React.FC = () => {
backdrop: backdrop || undefined, backdrop: backdrop || undefined,
availableStreams: {}, availableStreams: {},
groupedEpisodes: groupedEpisodes, groupedEpisodes: groupedEpisodes,
torrentStreamId: nextTorrentStreamId,
}); });
}, 300); }, 300);
}; };
@ -745,6 +903,8 @@ const AndroidVideoPlayer: React.FC = () => {
backdrop={backdrop || null} backdrop={backdrop || null}
hasLogo={hasLogo} hasLogo={hasLogo}
logo={metadata?.logo} logo={metadata?.logo}
loadingTitle={episodeTitle || title}
loadingProgress={openingBufferProgress}
backgroundFadeAnim={openingAnimation.backgroundFadeAnim} backgroundFadeAnim={openingAnimation.backgroundFadeAnim}
backdropImageOpacityAnim={openingAnimation.backdropImageOpacityAnim} backdropImageOpacityAnim={openingAnimation.backdropImageOpacityAnim}
onClose={handleClose} onClose={handleClose}
@ -792,11 +952,27 @@ const AndroidVideoPlayer: React.FC = () => {
displayError = JSON.stringify(err); 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.setErrorDetails(displayError);
modals.setShowErrorModal(true); modals.setShowErrorModal(true);
}} }}
onBuffer={(buf) => { onBuffer={(buf) => {
playerState.setIsBuffering(buf.isBuffering); playerState.setIsBuffering(buf.isBuffering);
if (!buf.isBuffering && playerState.isVideoLoaded && openingBufferProgressRef.current >= 0.8) {
completeOpeningOverlay(true);
}
}} }}
onTracksChanged={(data) => { onTracksChanged={(data) => {
console.log('[AndroidVideoPlayer] onTracksChanged:', data); console.log('[AndroidVideoPlayer] onTracksChanged:', data);

View file

@ -48,6 +48,7 @@ import { useTraktAutosync } from '../../hooks/useTraktAutosync';
import { useMetadata } from '../../hooks/useMetadata'; import { useMetadata } from '../../hooks/useMetadata';
import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls'; import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls';
import stremioService from '../../services/stremioService'; import stremioService from '../../services/stremioService';
import { torrentStreamingService } from '../../services/torrentStreamingService';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
// Utils // Utils
@ -78,6 +79,7 @@ interface PlayerRouteParams {
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
headers?: Record<string, string>; headers?: Record<string, string>;
initialPosition?: number; initialPosition?: number;
torrentStreamId?: string;
} }
const KSPlayerCore: React.FC = () => { const KSPlayerCore: React.FC = () => {
@ -92,7 +94,8 @@ const KSPlayerCore: React.FC = () => {
uri, title, episodeTitle, season, episode, id, type, quality, year, uri, title, episodeTitle, season, episode, id, type, quality, year,
episodeId, imdbId, backdrop, availableStreams, episodeId, imdbId, backdrop, availableStreams,
headers, streamProvider, streamName, headers, streamProvider, streamName,
initialPosition: routeInitialPosition initialPosition: routeInitialPosition,
torrentStreamId
} = params; } = params;
const videoType = (params as any)?.videoType as string | undefined; const videoType = (params as any)?.videoType as string | undefined;
@ -115,10 +118,11 @@ const KSPlayerCore: React.FC = () => {
streamProvider, streamProvider,
streamName, streamName,
videoType, videoType,
torrentStreamId,
headersKeys: headerKeys, headersKeys: headerKeys,
headersCount: headerKeys.length, headersCount: headerKeys.length,
}); });
}, [uri, episodeId]); }, [uri, episodeId, torrentStreamId]);
useEffect(() => { useEffect(() => {
if (!__DEV__) return; if (!__DEV__) return;
@ -566,9 +570,20 @@ const KSPlayerCore: React.FC = () => {
// The useWatchProgress and useTraktAutosync hooks handle cleanup on unmount // The useWatchProgress and useTraktAutosync hooks handle cleanup on unmount
traktAutosync.handleProgressUpdate(currentTime, duration, true); traktAutosync.handleProgressUpdate(currentTime, duration, true);
traktAutosync.handlePlaybackEnd(currentTime, duration, 'user_close'); traktAutosync.handlePlaybackEnd(currentTime, duration, 'user_close');
if (torrentStreamId) {
void torrentStreamingService.stopStream(torrentStreamId);
}
navigation.goBack(); 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 // Track selection handlers - update state, prop change triggers native update
const handleSelectTextTrack = useCallback((trackId: number) => { const handleSelectTextTrack = useCallback((trackId: number) => {
@ -591,9 +606,44 @@ const KSPlayerCore: React.FC = () => {
} }
}, [tracks, ksPlayerRef]); }, [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 // Stream selection handler
const handleSelectStream = async (newStream: any) => { 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); modals.setShowSourcesModal(false);
return; return;
} }
@ -601,10 +651,11 @@ const KSPlayerCore: React.FC = () => {
if (__DEV__) { if (__DEV__) {
logger.log('[KSPlayerCore] switching stream', { logger.log('[KSPlayerCore] switching stream', {
fromUri: typeof uri === 'string' ? uri.slice(0, 240) : uri, 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 || {}), newStreamHeadersKeys: Object.keys(newStream?.headers || {}),
newProvider: newStream?.addonName || newStream?.name || newStream?.addon || 'Unknown', newProvider: newStream?.addonName || newStream?.name || newStream?.addon || 'Unknown',
newName: newStream?.name || newStream?.title || 'Unknown', newName: newStream?.name || newStream?.title || 'Unknown',
nextTorrentStreamId,
}); });
} }
@ -616,14 +667,18 @@ const KSPlayerCore: React.FC = () => {
const newStreamName = newStream.name || newStream.title || 'Unknown'; const newStreamName = newStream.name || newStream.title || 'Unknown';
setTimeout(() => { setTimeout(() => {
if (torrentStreamId && torrentStreamId !== nextTorrentStreamId) {
void torrentStreamingService.stopStream(torrentStreamId);
}
(navigation as any).replace('PlayerIOS', { (navigation as any).replace('PlayerIOS', {
...params, ...params,
uri: newStream.url, uri: resolvedUri,
quality: newQuality, quality: newQuality,
streamProvider: newProvider, streamProvider: newProvider,
streamName: newStreamName, streamName: newStreamName,
headers: newStream.headers, headers: resolvedHeaders,
availableStreams: availableStreams availableStreams: availableStreams,
torrentStreamId: nextTorrentStreamId,
}); });
}, 100); }, 100);
}; };
@ -638,13 +693,40 @@ const KSPlayerCore: React.FC = () => {
// Episode stream selection handler - navigates to new episode with selected stream // Episode stream selection handler - navigates to new episode with selected stream
const handleEpisodeStreamSelect = async (stream: any) => { const handleEpisodeStreamSelect = async (stream: any) => {
if (!modals.selectedEpisodeForStreams) return; 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); modals.setShowEpisodeStreamsModal(false);
setPaused(true); setPaused(true);
const ep = modals.selectedEpisodeForStreams; const ep = modals.selectedEpisodeForStreams;
if (__DEV__) { if (__DEV__) {
logger.log('[KSPlayerCore] switching episode stream', { 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 || {}), streamHeadersKeys: Object.keys(stream?.headers || {}),
ep: { ep: {
season: ep?.season_number, season: ep?.season_number,
@ -652,6 +734,7 @@ const KSPlayerCore: React.FC = () => {
name: ep?.name, name: ep?.name,
stremioId: ep?.stremioId, stremioId: ep?.stremioId,
}, },
nextTorrentStreamId,
}); });
} }
@ -660,8 +743,11 @@ const KSPlayerCore: React.FC = () => {
const newStreamName = stream.name || stream.title || 'Unknown Stream'; const newStreamName = stream.name || stream.title || 'Unknown Stream';
setTimeout(() => { setTimeout(() => {
if (torrentStreamId && torrentStreamId !== nextTorrentStreamId) {
void torrentStreamingService.stopStream(torrentStreamId);
}
(navigation as any).replace('PlayerIOS', { (navigation as any).replace('PlayerIOS', {
uri: stream.url, uri: resolvedUri,
title: title, title: title,
episodeTitle: ep.name, episodeTitle: ep.name,
season: ep.season_number, season: ep.season_number,
@ -670,12 +756,13 @@ const KSPlayerCore: React.FC = () => {
year: year, year: year,
streamProvider: newProvider, streamProvider: newProvider,
streamName: newStreamName, streamName: newStreamName,
headers: stream.headers || undefined, headers: resolvedHeaders,
id, id,
type: 'series', type: 'series',
episodeId: ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number} `, episodeId: ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number} `,
imdbId: imdbId ?? undefined, imdbId: imdbId ?? undefined,
backdrop: backdrop || undefined, backdrop: backdrop || undefined,
torrentStreamId: nextTorrentStreamId,
}); });
}, 100); }, 100);
}; };

View file

@ -338,14 +338,17 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
requestHeaders: exoRequestHeadersArray, requestHeaders: exoRequestHeadersArray,
...(resolvedRnVideoType ? { type: resolvedRnVideoType } : null), ...(resolvedRnVideoType ? { type: resolvedRnVideoType } : null),
bufferConfig: { bufferConfig: {
minBufferMs: 10000, minBufferMs: 45000,
maxBufferMs: 20000, maxBufferMs: 1800000,
bufferForPlaybackMs: 2000, bufferForPlaybackMs: 2500,
bufferForPlaybackAfterRebufferMs: 4000, bufferForPlaybackAfterRebufferMs: 6000,
backBufferDurationMs: 300000,
// @ts-ignore - Extra props supported by patched react-native-video // @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 // @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} } as any}
paused={paused} 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 { 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 { MaterialIcons } from '@expo/vector-icons';
import Animated, { import Animated, {
FadeIn, FadeIn,
@ -12,6 +13,11 @@ import { Episode } from '../../../types/metadata';
import { Stream } from '../../../types/streams'; import { Stream } from '../../../types/streams';
import { stremioService } from '../../../services/stremioService'; import { stremioService } from '../../../services/stremioService';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import {
estimateNetworkProfile,
getPlaybackViabilityFromStream,
rankStreamsByPlaybackViability,
} from '../../../screens/streams/utils';
interface EpisodeStreamsModalProps { interface EpisodeStreamsModalProps {
visible: boolean; visible: boolean;
@ -66,6 +72,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: Stream[]; addonName: string } }>({}); const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: Stream[]; addonName: string } }>({});
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [hasErrors, setHasErrors] = useState<string[]>([]); const [hasErrors, setHasErrors] = useState<string[]>([]);
const [networkMbps, setNetworkMbps] = useState(20);
useEffect(() => { useEffect(() => {
if (visible && episode && metadata?.id) { if (visible && episode && metadata?.id) {
@ -77,6 +84,20 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
} }
}, [visible, episode, metadata?.id]); }, [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 () => { const fetchStreams = async () => {
if (!episode || !metadata?.id) return; if (!episode || !metadata?.id) return;
@ -137,9 +158,17 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
return match ? match[1] : null; 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 ( return (
<View style={[StyleSheet.absoluteFill, { zIndex: 10000 }]}> <View style={[StyleSheet.absoluteFill, { zIndex: 10000 }]}>
@ -218,6 +247,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
<View style={{ gap: 8 }}> <View style={{ gap: 8 }}>
{providerData.streams.map((stream, index) => { {providerData.streams.map((stream, index) => {
const quality = getQualityFromTitle(stream.title) || stream.quality; const quality = getQualityFromTitle(stream.title) || stream.quality;
const viability = getPlaybackViabilityFromStream(stream as any);
return ( return (
<TouchableOpacity <TouchableOpacity
@ -241,6 +271,19 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
<Text style={{ color: 'white', fontWeight: '700', fontSize: 14, flex: 1 }} numberOfLines={1}> <Text style={{ color: 'white', fontWeight: '700', fontSize: 14, flex: 1 }} numberOfLines={1}>
{stream.name || t('player_ui.unknown_source')} {stream.name || t('player_ui.unknown_source')}
</Text> </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} /> <QualityBadge quality={quality} />
</View> </View>
{stream.title && ( {stream.title && (

View file

@ -1,5 +1,5 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { View, TouchableOpacity, Animated, ActivityIndicator, StyleSheet, Image } from 'react-native'; import { View, TouchableOpacity, Animated, ActivityIndicator, StyleSheet, Image, Text } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import Reanimated, { import Reanimated, {
@ -18,6 +18,8 @@ interface LoadingOverlayProps {
backdrop: string | null | undefined; backdrop: string | null | undefined;
hasLogo: boolean; hasLogo: boolean;
logo: string | null | undefined; logo: string | null | undefined;
loadingTitle?: string;
loadingProgress?: number;
backgroundFadeAnim: Animated.Value; backgroundFadeAnim: Animated.Value;
backdropImageOpacityAnim: Animated.Value; backdropImageOpacityAnim: Animated.Value;
onClose: () => void; onClose: () => void;
@ -30,6 +32,8 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
backdrop, backdrop,
hasLogo, hasLogo,
logo, logo,
loadingTitle,
loadingProgress = 0,
backgroundFadeAnim, backgroundFadeAnim,
backdropImageOpacityAnim, backdropImageOpacityAnim,
onClose, onClose,
@ -38,6 +42,9 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
}) => { }) => {
const logoOpacity = useSharedValue(0); const logoOpacity = useSharedValue(0);
const logoScale = useSharedValue(1); const logoScale = useSharedValue(1);
const titleScale = useSharedValue(1);
const titleOpacity = useSharedValue(1);
const [titleWidth, setTitleWidth] = useState(0);
useEffect(() => { useEffect(() => {
if (visible && hasLogo && logo) { if (visible && hasLogo && logo) {
@ -74,13 +81,53 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
} }
}, [visible, hasLogo, logo]); }, [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(() => ({ const logoAnimatedStyle = useAnimatedStyle(() => ({
opacity: logoOpacity.value, opacity: logoOpacity.value,
transform: [{ scale: logoScale.value }], transform: [{ scale: logoScale.value }],
})); }));
const titleAnimatedStyle = useAnimatedStyle(() => ({
opacity: titleOpacity.value,
transform: [{ scale: titleScale.value }],
}));
if (!visible) return null; 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 ( return (
<Animated.View <Animated.View
style={[ style={[
@ -126,7 +173,27 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
</TouchableOpacity> </TouchableOpacity>
<View style={styles.openingContent}> <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={[ <Reanimated.View style={[
{ {
alignItems: 'center', 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; 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 { 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 { MaterialIcons } from '@expo/vector-icons';
import Animated, { import Animated, {
FadeIn, FadeIn,
@ -9,6 +10,11 @@ import Animated, {
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Stream } from '../../../types/streams'; import { Stream } from '../../../types/streams';
import {
estimateNetworkProfile,
getPlaybackViabilityFromStream,
rankStreamsByPlaybackViability,
} from '../../../screens/streams/utils';
interface SourcesModalProps { interface SourcesModalProps {
showSourcesModal: boolean; showSourcesModal: boolean;
@ -61,14 +67,35 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const MENU_WIDTH = Math.min(width * 0.85, 400); const MENU_WIDTH = Math.min(width * 0.85, 400);
const [networkMbps, setNetworkMbps] = useState(20);
const handleClose = () => { const handleClose = () => {
setShowSourcesModal(false); 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) => { const handleStreamSelect = (stream: Stream) => {
if (stream.url !== currentStreamUrl && !isChangingSource) { if (stream.url !== currentStreamUrl && !isChangingSource) {
@ -86,6 +113,8 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
return stream.url === currentStreamUrl; return stream.url === currentStreamUrl;
}; };
if (!showSourcesModal) return null;
return ( return (
<View style={[StyleSheet.absoluteFill, { zIndex: 10000 }]}> <View style={[StyleSheet.absoluteFill, { zIndex: 10000 }]}>
{/* Backdrop */} {/* Backdrop */}
@ -168,6 +197,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
{providerData.streams.map((stream, index) => { {providerData.streams.map((stream, index) => {
const isSelected = isStreamSelected(stream); const isSelected = isStreamSelected(stream);
const quality = getQualityFromTitle(stream.title) || stream.quality; const quality = getQualityFromTitle(stream.title) || stream.quality;
const viability = getPlaybackViabilityFromStream(stream as any);
return ( return (
<TouchableOpacity <TouchableOpacity
@ -195,6 +225,23 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
}} numberOfLines={1}> }} numberOfLines={1}>
{stream.title || stream.name || t('player_ui.stream', { number: index + 1 })} {stream.title || stream.name || t('player_ui.stream', { number: index + 1 })}
</Text> </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} /> <QualityBadge quality={quality} />
</View> </View>

View file

@ -37,6 +37,9 @@ export const DiscoverBottomSheets = ({
currentTheme, currentTheme,
}: DiscoverBottomSheetsProps) => { }: DiscoverBottomSheetsProps) => {
const { t } = useTranslation(); 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 typeSnapPoints = useMemo(() => ['25%'], []);
const catalogSnapPoints = useMemo(() => ['50%'], []); const catalogSnapPoints = useMemo(() => ['50%'], []);
@ -140,7 +143,7 @@ export const DiscoverBottomSheets = ({
> >
<View style={[styles.bottomSheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}> <View style={[styles.bottomSheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}>
<Text style={[styles.bottomSheetTitle, { color: currentTheme.colors.white }]}> <Text style={[styles.bottomSheetTitle, { color: currentTheme.colors.white }]}>
{t('search.select_genre')} {filterTitle}
</Text> </Text>
<TouchableOpacity onPress={() => genreSheetRef.current?.dismiss()}> <TouchableOpacity onPress={() => genreSheetRef.current?.dismiss()}>
<MaterialIcons name="close" size={24} color={currentTheme.colors.lightGray} /> <MaterialIcons name="close" size={24} color={currentTheme.colors.lightGray} />
@ -160,7 +163,7 @@ export const DiscoverBottomSheets = ({
> >
<View style={styles.bottomSheetItemContent}> <View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}> <Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{t('search.all_genres')} {allFilterLabel}
</Text> </Text>
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}> <Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
{t('search.show_all_content')} {t('search.show_all_content')}

View file

@ -55,6 +55,8 @@ export const DiscoverSection = ({
currentTheme, currentTheme,
}: DiscoverSectionProps) => { }: DiscoverSectionProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const isYearFilter = selectedCatalog?.filterLabel === 'year';
const allFilterLabel = isYearFilter ? 'All Years' : t('search.all_genres');
return ( return (
<View style={styles.discoverContainer}> <View style={styles.discoverContainer}>
@ -94,14 +96,14 @@ export const DiscoverSection = ({
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} /> <MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
</TouchableOpacity> </TouchableOpacity>
{/* Genre Selector Chip - only show if catalog has genres */} {/* Filter Selector Chip - only show if catalog has options */}
{availableGenres.length > 0 && ( {availableGenres.length > 0 && (
<TouchableOpacity <TouchableOpacity
style={[styles.discoverSelectorChip, { backgroundColor: currentTheme.colors.elevation2 }]} style={[styles.discoverSelectorChip, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={() => genreSheetRef.current?.present()} onPress={() => genreSheetRef.current?.present()}
> >
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}> <Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
{selectedDiscoverGenre || t('search.all_genres')} {selectedDiscoverGenre || allFilterLabel}
</Text> </Text>
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} /> <MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
</TouchableOpacity> </TouchableOpacity>

View file

@ -10,6 +10,8 @@ export interface DiscoverCatalog {
catalogName: string; catalogName: string;
type: string; type: string;
genres: string[]; genres: string[];
filterKey?: string | null;
filterLabel?: 'genre' | 'year' | 'filter';
} }
// Enhanced responsive breakpoints // 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) // Keep active native background tasks in memory (not persisted)
const tasksRef = useRef<Map<string, any>>(new Map()); const tasksRef = useRef<Map<string, any>>(new Map());
const lastBytesRef = useRef<Map<string, { bytes: number; time: number }>>(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 // Persist and restore
useEffect(() => { useEffect(() => {
@ -438,18 +439,36 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
lastBytesRef.current.set(taskId, { bytes: bytesDownloaded, time: now }); lastBytesRef.current.set(taskId, { bytes: bytesDownloaded, time: now });
} }
updateDownload(taskId, (d) => ({ const computedTotalBytes = typeof bytesTotal === 'number' && bytesTotal > 0 ? bytesTotal : undefined;
...d, const computedProgress =
downloadedBytes: typeof bytesDownloaded === 'number' ? bytesDownloaded : d.downloadedBytes, typeof bytesDownloaded === 'number' && computedTotalBytes && computedTotalBytes > 0
totalBytes: typeof bytesTotal === 'number' && bytesTotal > 0 ? bytesTotal : d.totalBytes, ? Math.floor((bytesDownloaded / computedTotalBytes) * 100)
progress: : undefined;
typeof bytesDownloaded === 'number' && typeof bytesTotal === 'number' && bytesTotal > 0
? Math.floor((bytesDownloaded / bytesTotal) * 100) // Throttle state updates during active downloads to prevent UI jank.
: d.progress, const prevUi = lastProgressUiUpdateRef.current.get(taskId);
speedBps, const shouldUpdateUi = !prevUi
status: 'downloading', || now - prevUi.time >= 500
updatedAt: now, || (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); const current = downloadsRef.current.find(x => x.id === taskId);
if (current && typeof bytesDownloaded === 'number') { if (current && typeof bytesDownloaded === 'number') {
@ -489,6 +508,7 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
tasksRef.current.delete(taskId); tasksRef.current.delete(taskId);
lastBytesRef.current.delete(taskId); lastBytesRef.current.delete(taskId);
lastProgressUiUpdateRef.current.delete(taskId);
}) })
.error(({ error }: any) => { .error(({ error }: any) => {
updateDownload(taskId, (d) => ({ 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 }); stopLiveActivityForDownload(taskId, { title: current?.title, subtitle: 'Error', progressPercent: current?.progress });
console.log(`[DownloadsContext] Background download error: ${taskId}`, error); 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]); }, [maybeNotifyProgress, maybeUpdateLiveActivity, notifyCompleted, stopLiveActivityForDownload, updateDownload]);
@ -602,6 +625,7 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
} }
tasksRef.current.delete(d.id); tasksRef.current.delete(d.id);
lastBytesRef.current.delete(d.id); lastBytesRef.current.delete(d.id);
lastProgressUiUpdateRef.current.delete(d.id);
} }
} catch { } catch {
// Ignore per-item refresh failures // Ignore per-item refresh failures
@ -820,6 +844,7 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
} finally { } finally {
tasksRef.current.delete(id); tasksRef.current.delete(id);
lastBytesRef.current.delete(id); lastBytesRef.current.delete(id);
lastProgressUiUpdateRef.current.delete(id);
} }
const item = downloadsRef.current.find(d => d.id === 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 removeDownload = useCallback(async (id: string) => {
const item = downloadsRef.current.find(d => d.id === id); const item = downloadsRef.current.find(d => d.id === id);
await stopLiveActivityForDownload(id, { title: item?.title, subtitle: 'Removed', progressPercent: item?.progress }); 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') { if (item?.fileUri && item.status === 'completed') {
await FileSystem.deleteAsync(item.fileUri, { idempotent: true }).catch(() => { }); 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'); if (!ctx) throw new Error('useDownloads must be used within DownloadsProvider');
return ctx; return ctx;
} }

View file

@ -289,10 +289,36 @@ export const useSettings = () => {
value: AppSettings[K], value: AppSettings[K],
emitEvent: boolean = true emitEvent: boolean = true
) => { ) => {
const newSettings = { ...settings, [key]: value };
try { try {
const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`; 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 // Write to both scoped key (multi-user aware) and legacy key for backward compatibility
await Promise.all([ await Promise.all([
mmkvStorage.setItem(scopedKey, JSON.stringify(newSettings)), mmkvStorage.setItem(scopedKey, JSON.stringify(newSettings)),
@ -317,7 +343,7 @@ export const useSettings = () => {
} catch (error) { } catch (error) {
if (__DEV__) console.error('Failed to save settings:', error); if (__DEV__) console.error('Failed to save settings:', error);
} }
}, [settings]); }, [isLoaded, settings]);
return { return {
settings, settings,

View file

@ -152,6 +152,7 @@ export type RootStackParamList = {
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
backdrop?: string; backdrop?: string;
videoType?: string; videoType?: string;
torrentStreamId?: string;
groupedEpisodes?: { [seasonNumber: number]: any[] }; groupedEpisodes?: { [seasonNumber: number]: any[] };
}; };
PlayerAndroid: { PlayerAndroid: {
@ -172,6 +173,7 @@ export type RootStackParamList = {
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
backdrop?: string; backdrop?: string;
videoType?: string; videoType?: string;
torrentStreamId?: string;
groupedEpisodes?: { [seasonNumber: number]: any[] }; groupedEpisodes?: { [seasonNumber: number]: any[] };
}; };
Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string }; Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string };
@ -1315,13 +1317,13 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
component={MetadataScreen} component={MetadataScreen}
options={{ options={{
headerShown: false, headerShown: false,
animation: Platform.OS === 'android' ? 'fade' : 'fade', animation: Platform.OS === 'android' ? 'default' : 'fade',
animationDuration: Platform.OS === 'android' ? 200 : 300, animationDuration: Platform.OS === 'android' ? 250 : 300,
gestureEnabled: true,
gestureDirection: 'horizontal',
...(Platform.OS === 'ios' && { ...(Platform.OS === 'ios' && {
cardStyleInterpolator: customFadeInterpolator, cardStyleInterpolator: customFadeInterpolator,
animationTypeForReplace: 'push', animationTypeForReplace: 'push',
gestureEnabled: true,
gestureDirection: 'horizontal',
}), }),
contentStyle: { contentStyle: {
backgroundColor: currentTheme.colors.darkBackground, 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); const addon = manifests.find(a => a.id === addonId);
if (!addon) throw new Error(`Addon ${addonId} not found`); 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); const catalogItems = await stremioService.getCatalog(addon, type, id, pageParam, filters);
logger.log('[CatalogScreen] Fetched addon catalog page', { logger.log('[CatalogScreen] Fetched addon catalog page', {
@ -705,7 +712,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
logger.log('[CatalogScreen] loadItems finished'); logger.log('[CatalogScreen] loadItems finished');
}); });
} }
}, [addonId, type, id, activeGenreFilter, dataSource]); }, [addonId, type, id, activeGenreFilter, selectedFilters, dataSource]);
useEffect(() => { useEffect(() => {
loadItems(true, 1); loadItems(true, 1);

View file

@ -27,6 +27,7 @@ import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator'; import { RootStackParamList } from '../navigation/AppNavigator';
import { StreamingContent, CatalogContent, catalogService } from '../services/catalogService'; import { StreamingContent, CatalogContent, catalogService } from '../services/catalogService';
import { stremioService } from '../services/stremioService'; import { stremioService } from '../services/stremioService';
import { torrentStreamingService } from '../services/torrentStreamingService';
import { Stream } from '../types/metadata'; import { Stream } from '../types/metadata';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
@ -546,6 +547,16 @@ const HomeScreen = () => {
if (!featuredContent) return; if (!featuredContent) return;
try { 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 // Don't clear cache before player - causes broken images on return
// FastImage's native libraries handle memory efficiently // FastImage's native libraries handle memory efficiently
@ -564,13 +575,14 @@ const HomeScreen = () => {
// @ts-ignore // @ts-ignore
navigation.navigate(Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid', { navigation.navigate(Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid', {
uri: stream.url as any, uri: playbackUri,
title: featuredContent.name, title: featuredContent.name,
year: featuredContent.year, year: featuredContent.year,
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined, quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
streamProvider: stream.name, streamProvider: stream.name,
id: featuredContent.id, id: featuredContent.id,
type: featuredContent.type type: featuredContent.type,
torrentStreamId,
}); });
} catch (error) { } catch (error) {
logger.error('[HomeScreen] Error in handlePlayStream:', error); logger.error('[HomeScreen] Error in handlePlayStream:', error);
@ -588,7 +600,7 @@ const HomeScreen = () => {
type: featuredContent.type type: featuredContent.type
}); });
} }
}, [featuredContent, navigation]); }, [featuredContent, navigation, showInfo]);
const refreshContinueWatching = useCallback(async () => { const refreshContinueWatching = useCallback(async () => {
if (continueWatchingRef.current) { if (continueWatchingRef.current) {
@ -1482,4 +1494,3 @@ const HomeScreenWithFocusSync = (props: any) => {
}; };
export default React.memo(HomeScreenWithFocusSync); 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 HomeScreenSettings: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { settings, updateSetting } = useSettings(); const { settings, updateSetting, isLoaded } = useSettings();
const systemColorScheme = useColorScheme(); const systemColorScheme = useColorScheme();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const colors = currentTheme.colors; const colors = currentTheme.colors;
@ -168,12 +168,16 @@ const HomeScreenSettings: React.FC = () => {
// Ensure carousel is the default hero layout on tablets for all users // Ensure carousel is the default hero layout on tablets for all users
useEffect(() => { useEffect(() => {
if (!isLoaded || !isTabletDevice) {
return;
}
try { try {
if (isTabletDevice && settings.heroStyle !== 'carousel') { if (settings.heroStyle !== 'carousel') {
updateSetting('heroStyle', 'carousel' as any); updateSetting('heroStyle', 'carousel' as any);
} }
} catch { } } catch { }
}, [isTabletDevice, settings.heroStyle, updateSetting]); }, [isLoaded, isTabletDevice, settings.heroStyle, updateSetting]);
const CustomSwitch = ({ value, onValueChange }: { value: boolean, onValueChange: (value: boolean) => void }) => ( const CustomSwitch = ({ value, onValueChange }: { value: boolean, onValueChange: (value: boolean) => void }) => (
<Switch <Switch

View file

@ -104,11 +104,21 @@ const TraktItem = React.memo(({
currentTheme: any; currentTheme: any;
showTitles: boolean; 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(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
const fetchPoster = async () => { const fetchPoster = async () => {
if (isMounted) {
setPosterUrl(inlinePoster);
}
if (item.images) { if (item.images) {
const url = TraktService.getTraktPosterUrl(item.images); const url = TraktService.getTraktPosterUrl(item.images);
if (isMounted && url) { if (isMounted && url) {
@ -153,7 +163,7 @@ const TraktItem = React.memo(({
}; };
fetchPoster(); fetchPoster();
return () => { isMounted = false; }; 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(() => { const handlePress = useCallback(() => {
if (item.imdbId) { if (item.imdbId) {
@ -315,6 +325,10 @@ const LibraryScreen = () => {
loadAllCollections: loadSimklCollections loadAllCollections: loadSimklCollections
} = useSimklContext(); } = useSimklContext();
// Show only one provider tab to reduce clutter: prefer SIMKL when available.
const showSimklTab = simklAuthenticated;
const showTraktTab = traktAuthenticated && !showSimklTab;
useEffect(() => { useEffect(() => {
const applyStatusBarConfig = () => { const applyStatusBarConfig = () => {
StatusBar.setBarStyle('light-content'); StatusBar.setBarStyle('light-content');
@ -354,6 +368,17 @@ const LibraryScreen = () => {
return () => backHandler.remove(); return () => backHandler.remove();
}, [showTraktContent, showSimklContent, selectedTraktFolder, selectedSimklFolder]); }, [showTraktContent, showSimklContent, selectedTraktFolder, selectedSimklFolder]);
useEffect(() => {
if (!showTraktTab && showTraktContent) {
setShowTraktContent(false);
setSelectedTraktFolder(null);
}
if (!showSimklTab && showSimklContent) {
setShowSimklContent(false);
setSelectedSimklFolder(null);
}
}, [showTraktTab, showSimklTab, showTraktContent, showSimklContent]);
useEffect(() => { useEffect(() => {
const loadLibrary = async () => { const loadLibrary = async () => {
setLoading(true); setLoading(true);
@ -894,232 +919,144 @@ const LibraryScreen = () => {
}); });
}, [watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); }, [watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
const getSimklFolderItems = useCallback((folderId: string): TraktDisplayItem[] => { const normalizeImdbId = useCallback((imdbId?: string): string | undefined => {
const items: TraktDisplayItem[] = []; if (!imdbId) return undefined;
const trimmed = String(imdbId).trim();
if (!trimmed) return undefined;
return trimmed.startsWith('tt') ? trimmed : `tt${trimmed}`;
}, []);
switch (folderId) { const getSimklContent = useCallback((item: any) => {
case 'continue-watching': return item?.anime || item?.show || item?.movie || item || null;
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,
};
});
case 'watching-shows': const buildSimklDisplayItem = useCallback((
return (watchingShows || []).map(item => ({ item: any,
id: String(item.show?.ids?.simkl || Math.random()), fallbackType: 'series' | 'movie',
name: item.show?.title || 'Unknown', lastWatched?: string,
type: 'series' as const, rating?: number
poster: '', ): TraktDisplayItem => {
year: item.show?.year, const content = getSimklContent(item);
lastWatched: item.last_watched_at, const ids = content?.ids || {};
rating: item.user_rating, const name = typeof content?.title === 'string' && content.title.trim().length > 0
imdbId: item.show?.ids?.imdb, ? content.title
traktId: item.show?.ids?.simkl || 0, : '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 {
return (watchingMovies || []).map(item => ({ id: String(simklId),
id: String(item.movie?.ids?.simkl || Math.random()), name,
name: item.movie?.title || 'Unknown', type,
type: 'movie' as const, poster,
poster: '', year: content?.year,
year: item.movie?.year, lastWatched,
lastWatched: item.last_watched_at, rating,
rating: item.user_rating, imdbId: normalizeImdbId(ids.imdb),
imdbId: item.movie?.ids?.imdb, traktId: typeof ids.simkl === 'number' ? ids.simkl : 0,
traktId: item.movie?.ids?.simkl || 0, };
})); }, [getSimklContent, normalizeImdbId]);
case 'watching-anime': const sortDisplayItems = useCallback((items: TraktDisplayItem[]) => {
return (watchingAnime || []).map(item => ({ return [...items].sort((a, b) => {
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 dateA = a.lastWatched ? new Date(a.lastWatched).getTime() : 0; const dateA = a.lastWatched ? new Date(a.lastWatched).getTime() : 0;
const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0; const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0;
return dateB - dateA; 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 = () => { const renderTraktContent = () => {
if (traktLoading) { if (traktLoading) {
@ -1476,8 +1413,8 @@ const LibraryScreen = () => {
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}> <View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
{!showTraktContent && !showSimklContent && ( {!showTraktContent && !showSimklContent && (
<View style={styles.filtersContainer}> <View style={styles.filtersContainer}>
{renderFilter('trakt', 'Trakt')} {showTraktTab && renderFilter('trakt', 'Trakt')}
{renderFilter('simkl', 'SIMKL')} {showSimklTab && renderFilter('simkl', 'SIMKL')}
{renderFilter('movies', t('search.movies'))} {renderFilter('movies', t('search.movies'))}
{renderFilter('series', t('search.tv_shows'))} {renderFilter('series', t('search.tv_shows'))}
</View> </View>

View file

@ -314,7 +314,8 @@ const SearchScreen = () => {
selectedCatalog.catalogId, selectedCatalog.catalogId,
selectedCatalog.type, selectedCatalog.type,
selectedDiscoverGenre || undefined, selectedDiscoverGenre || undefined,
1 1,
selectedCatalog.filterKey || 'genre'
); );
if (isMounted.current) { if (isMounted.current) {
const seen = new Set<string>(); const seen = new Set<string>();
@ -360,7 +361,8 @@ const SearchScreen = () => {
selectedCatalog.catalogId, selectedCatalog.catalogId,
selectedCatalog.type, selectedCatalog.type,
selectedDiscoverGenre || undefined, selectedDiscoverGenre || undefined,
nextPage nextPage,
selectedCatalog.filterKey || 'genre'
); );
if (isMounted.current) { if (isMounted.current) {

View file

@ -220,7 +220,7 @@ const SimklSettingsScreen: React.FC = () => {
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}> <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>
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>
Anime Anime

View file

@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { Dimensions, Platform, Linking } from 'react-native'; import { Dimensions, Platform, Linking } from 'react-native';
import { useRoute, useNavigation } from '@react-navigation/native'; import { useRoute, useNavigation } from '@react-navigation/native';
import { RouteProp } from '@react-navigation/native'; import { RouteProp } from '@react-navigation/native';
import NetInfo from '@react-native-community/netinfo';
import { RootStackParamList, RootStackNavigationProp } from '../../navigation/AppNavigator'; import { RootStackParamList, RootStackNavigationProp } from '../../navigation/AppNavigator';
import { useMetadata } from '../../hooks/useMetadata'; import { useMetadata } from '../../hooks/useMetadata';
@ -17,6 +18,7 @@ import { localScraperService } from '../../services/pluginService';
import { VideoPlayerService } from '../../services/videoPlayerService'; import { VideoPlayerService } from '../../services/videoPlayerService';
import { streamCacheService } from '../../services/streamCacheService'; import { streamCacheService } from '../../services/streamCacheService';
import { tmdbService } from '../../services/tmdbService'; import { tmdbService } from '../../services/tmdbService';
import { torrentStreamingService } from '../../services/torrentStreamingService';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { TABLET_BREAKPOINT } from './constants'; import { TABLET_BREAKPOINT } from './constants';
import { import {
@ -24,6 +26,10 @@ import {
filterStreamsByLanguage, filterStreamsByLanguage,
getQualityNumeric, getQualityNumeric,
inferVideoTypeFromUrl, inferVideoTypeFromUrl,
estimateNetworkProfile,
getNetworkClassForMbps,
getPlaybackViabilityFromStream,
rankStreamsByPlaybackViability,
sortStreamsByQuality, sortStreamsByQuality,
} from './utils'; } from './utils';
import { import {
@ -54,6 +60,7 @@ export const useStreamsScreen = () => {
// Dimension tracking // Dimension tracking
const [dimensions, setDimensions] = useState(Dimensions.get('window')); const [dimensions, setDimensions] = useState(Dimensions.get('window'));
const prevDimensionsRef = useRef({ width: dimensions.width, height: dimensions.height }); const prevDimensionsRef = useRef({ width: dimensions.width, height: dimensions.height });
const [networkProfile, setNetworkProfile] = useState(() => estimateNetworkProfile(null));
const deviceWidth = dimensions.width; const deviceWidth = dimensions.width;
const isTablet = useMemo(() => deviceWidth >= TABLET_BREAKPOINT, [deviceWidth]); const isTablet = useMemo(() => deviceWidth >= TABLET_BREAKPOINT, [deviceWidth]);
@ -137,6 +144,25 @@ export const useStreamsScreen = () => {
return () => subscription?.remove(); 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 // Pause trailer on mount
useEffect(() => { useEffect(() => {
pauseTrailer(); pauseTrailer();
@ -231,35 +257,68 @@ export const useStreamsScreen = () => {
return 0; 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 }]) => { Object.entries(streamsData).forEach(([addonId, { streams }]) => {
const qualityFiltered = filterByQuality(streams); const qualityFiltered = filterByQuality(streams);
const filteredStreams = filterByLanguage(qualityFiltered); 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 quality = getQualityNumeric(stream.name || stream.title);
const providerPriority = getProviderPriority(addonId); 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; if (allStreams.length === 0) return null;
// Sort primarily by provider priority, then respect the addon's internal order (originalIndex) // Prefer streams that are most likely to play smoothly on the current connection.
// This ensures if an addon lists 1080p before 4K, we pick 1080p
allStreams.sort((a, b) => { 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 (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; return a.originalIndex - b.originalIndex;
}); });
const bestViability = getPlaybackViabilityFromStream(allStreams[0].stream);
logger.log( 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; return allStreams[0].stream;
}, },
[filterByQuality, filterByLanguage] [filterByQuality, filterByLanguage, networkProfile.estimatedDownlinkMbps]
); );
// Current episode // Current episode
@ -352,43 +411,59 @@ export const useStreamsScreen = () => {
// Navigate to player // Navigate to player
const navigateToPlayer = useCallback( 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 optionHeaders = options?.headers;
const streamHeaders = (stream.headers as any) as Record<string, string> | undefined; const streamHeaders = (stream.headers as any) as Record<string, string> | undefined;
const proxyHeaders = ((stream as any)?.behaviorHints?.proxyHeaders?.request || undefined) as const proxyHeaders = ((stream as any)?.behaviorHints?.proxyHeaders?.request || undefined) as
| Record<string, string> | Record<string, string>
| undefined; | undefined;
const targetUri = options?.overrideUri || stream.url;
const streamProvider = stream.addonId || (stream as any).addonName || stream.name; 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 streamsToPass = selectedEpisode ? episodeStreams : groupedStreams;
const streamName = stream.name || stream.title || 'Unnamed Stream'; const streamName = stream.name || stream.title || 'Unnamed Stream';
const resolvedStreamProvider = streamProvider; const resolvedStreamProvider = streamProvider;
// Save stream to cache // Save stream to cache
try { if (!options?.skipCache) {
const epId = (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined; try {
const season = (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined; const epId = (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined;
const episode = (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined; const season = (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined;
const episodeTitle = (type === 'series' || type === 'other') ? currentEpisode?.name : undefined; const episode = (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined;
const episodeTitle = (type === 'series' || type === 'other') ? currentEpisode?.name : undefined;
await streamCacheService.saveStreamToCache( await streamCacheService.saveStreamToCache(
id, id,
type, type,
stream, stream,
metadata, metadata,
epId, epId,
season, season,
episode, episode,
episodeTitle, episodeTitle,
imdbId || undefined, imdbId || undefined,
settings.streamCacheTTL settings.streamCacheTTL
); );
} catch (error) { } catch (error) {
logger.warn('[StreamsScreen] Failed to save stream to cache:', error); logger.warn('[StreamsScreen] Failed to save stream to cache:', error);
}
} }
let videoType = inferVideoTypeFromUrl(stream.url); let videoType = inferVideoTypeFromUrl(targetUri);
try { try {
const providerId = stream.addonId || (stream as any).addon || ''; const providerId = stream.addonId || (stream as any).addon || '';
if (!videoType && /xprime/i.test(providerId)) { if (!videoType && /xprime/i.test(providerId)) {
@ -400,7 +475,7 @@ export const useStreamsScreen = () => {
const finalHeaderKeys = Object.keys(finalHeaders || {}); const finalHeaderKeys = Object.keys(finalHeaders || {});
logger.log('[StreamsScreen][navigateToPlayer] stream selection', { 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, addonId: stream.addonId,
addonName: (stream as any).addonName, addonName: (stream as any).addonName,
name: stream.name, name: stream.name,
@ -415,7 +490,7 @@ export const useStreamsScreen = () => {
const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
navigation.navigate(playerRoute as any, { navigation.navigate(playerRoute as any, {
uri: stream.url as any, uri: targetUri as any,
title: metadata?.name || '', title: metadata?.name || '',
episodeTitle: (type === 'series' || type === 'other') ? currentEpisode?.name : undefined, episodeTitle: (type === 'series' || type === 'other') ? currentEpisode?.name : undefined,
season: (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined, season: (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined,
@ -432,6 +507,7 @@ export const useStreamsScreen = () => {
availableStreams: streamsToPass, availableStreams: streamsToPass,
backdrop: metadata?.banner || bannerImage, backdrop: metadata?.banner || bannerImage,
videoType, videoType,
torrentStreamId: options?.torrentStreamId,
} as any); } as any);
}, },
[metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams, bannerImage, settings.streamCacheTTL] [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams, bannerImage, settings.streamCacheTTL]
@ -461,9 +537,15 @@ export const useStreamsScreen = () => {
}); });
} }
// Block magnet links const isMagnet = typeof stream.url === 'string' && stream.url.startsWith('magnet:');
if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) {
openAlert('Not supported', 'Torrent streaming is not supported yet.'); 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; return;
} }
@ -521,7 +603,6 @@ export const useStreamsScreen = () => {
// Android external player // Android external player
else if (Platform.OS === 'android' && settings.useExternalPlayer) { else if (Platform.OS === 'android' && settings.useExternalPlayer) {
try { try {
const isMagnet = typeof stream.url === 'string' && stream.url.startsWith('magnet:');
if (isMagnet) { if (isMagnet) {
Linking.openURL(stream.url).catch(() => navigateToPlayer(stream)); Linking.openURL(stream.url).catch(() => navigateToPlayer(stream));
} else { } else {
@ -540,13 +621,43 @@ export const useStreamsScreen = () => {
navigateToPlayer(stream); navigateToPlayer(stream);
} }
} else { } 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 { } catch {
navigateToPlayer(stream); 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 // Update providers when streams change
@ -900,11 +1011,19 @@ export const useStreamsScreen = () => {
sortedEntries.forEach(([key, { streams: providerStreams }]) => { sortedEntries.forEach(([key, { streams: providerStreams }]) => {
const isInstalledAddon = installedAddons.some(addon => addon.installationId === key || addon.id === key); 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) { if (isInstalledAddon) {
addonStreams.push(...providerStreams); addonStreams.push(...providerRankedStreams);
} else { } else {
const qualityFiltered = filterByQuality(providerStreams); const qualityFiltered = filterByQuality(providerRankedStreams);
const filteredStreams = filterByLanguage(qualityFiltered); const filteredStreams = filterByLanguage(qualityFiltered);
if (filteredStreams.length > 0) { if (filteredStreams.length > 0) {
pluginStreams.push(...filteredStreams); pluginStreams.push(...filteredStreams);
@ -913,12 +1032,7 @@ export const useStreamsScreen = () => {
}); });
let combinedStreams = [...addonStreams]; let combinedStreams = [...addonStreams];
combinedStreams.push(...pluginStreams);
if (settings.streamSortMode === 'quality-then-scraper' && pluginStreams.length > 0) {
combinedStreams.push(...sortStreamsByQuality(pluginStreams));
} else {
combinedStreams.push(...pluginStreams);
}
let sectionId = 'grouped-all'; let sectionId = 'grouped-all';
let sectionTitle = 'Available Streams'; let sectionTitle = 'Available Streams';
@ -957,10 +1071,14 @@ export const useStreamsScreen = () => {
if (filteredStreams.length === 0) return null; if (filteredStreams.length === 0) return null;
let processedStreams = filteredStreams; const sortedByQuality =
if (!isInstalledAddon && settings.streamSortMode === 'quality-then-scraper') { settings.streamSortMode === 'quality-then-scraper'
processedStreams = sortStreamsByQuality(filteredStreams); ? sortStreamsByQuality(filteredStreams)
} : filteredStreams;
const processedStreams = rankStreamsByPlaybackViability(
sortedByQuality,
networkProfile.estimatedDownlinkMbps
);
// For multiple installations of same addon, add # to section title // For multiple installations of same addon, add # to section title
let sectionTitle = addonName; let sectionTitle = addonName;
@ -991,6 +1109,7 @@ export const useStreamsScreen = () => {
filterByLanguage, filterByLanguage,
addonResponseOrder, addonResponseOrder,
settings.streamSortMode, settings.streamSortMode,
networkProfile.estimatedDownlinkMbps,
selectedEpisode, selectedEpisode,
metadata, metadata,
]); ]);

View file

@ -20,6 +20,520 @@ const LANGUAGE_VARIATIONS: Record<string, string[]> = {
hindi: ['hin'], 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 * Get all variations of a language name
*/ */

View file

@ -1062,12 +1062,28 @@ class CatalogService {
async getDiscoverFilters(): Promise<{ async getDiscoverFilters(): Promise<{
genres: string[]; genres: string[];
types: 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 addons = await this.getAllAddons();
const allGenres = new Set<string>(); const allGenres = new Set<string>();
const allTypes = 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) { for (const addon of addons) {
if (!addon.catalogs) continue; if (!addon.catalogs) continue;
@ -1078,15 +1094,32 @@ class CatalogService {
allTypes.add(catalog.type); allTypes.add(catalog.type);
} }
// Get genres from catalog extras // Get primary filter options (genre/year/etc.) from catalog extras
const catalogGenres: string[] = []; const catalogGenres: string[] = [];
let filterKey: string | null = null;
let filterLabel: 'genre' | 'year' | 'filter' = 'genre';
if (catalog.extra && Array.isArray(catalog.extra)) { if (catalog.extra && Array.isArray(catalog.extra)) {
for (const extra of catalog.extra) { const primaryFilter = catalog.extra.find(extra =>
if (extra.name === 'genre' && extra.options && Array.isArray(extra.options)) { !!extra?.name &&
for (const genre of extra.options) { Array.isArray(extra.options) &&
allGenres.add(genre); extra.options.length > 0
catalogGenres.push(genre); );
}
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, addonName: addon.name,
catalogId: catalog.id, catalogId: catalog.id,
catalogName: catalog.name || catalog.id, catalogName: catalog.name || catalog.id,
genres: catalogGenres genres: catalogGenres,
filterKey,
filterLabel
}); });
} }
} }
@ -1216,7 +1251,8 @@ class CatalogService {
catalogId: string, catalogId: string,
type: string, type: string,
genre?: string, genre?: string,
page: number = 1 page: number = 1,
filterKey: string = 'genre'
): Promise<StreamingContent[]> { ): Promise<StreamingContent[]> {
try { try {
const manifests = await stremioService.getInstalledAddonsAsync(); const manifests = await stremioService.getInstalledAddonsAsync();
@ -1227,7 +1263,7 @@ class CatalogService {
return []; 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); const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters);
if (metas && metas.length > 0) { if (metas && metas.length > 0) {

View file

@ -1573,7 +1573,9 @@ class LocalScraperService {
description: result.size ? `${result.size}` : undefined, description: result.size ? `${result.size}` : undefined,
size: result.size ? this.parseSize(result.size) : undefined, size: result.size ? this.parseSize(result.size) : undefined,
behaviorHints: { 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 || []; 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 no type specified, return all
if (!type) { if (!type) {

View file

@ -272,6 +272,7 @@ class StremioService {
private initialized: boolean = false; private initialized: boolean = false;
private initializationPromise: Promise<void> | null = null; private initializationPromise: Promise<void> | null = null;
private catalogHasMore: Map<string, boolean> = new Map(); private catalogHasMore: Map<string, boolean> = new Map();
private catalogPageSize: Map<string, number> = new Map();
private constructor() { private constructor() {
// Start initialization but don't wait for it // Start initialization but don't wait for it
@ -889,7 +890,10 @@ class StremioService {
// Build URLs per Stremio protocol: /{resource}/{type}/{id}/{extraArgs}.json // Build URLs per Stremio protocol: /{resource}/{type}/{id}/{extraArgs}.json
// Extra args (search, genre, skip) go in path segment, NOT query params // Extra args (search, genre, skip) go in path segment, NOT query params
const encodedId = encodeURIComponent(id); 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 // For all addons
if (!manifest.url) { if (!manifest.url) {
@ -931,7 +935,7 @@ class StremioService {
.filter(f => f && f.value) .filter(f => f && f.value)
.map(f => `&${encodeURIComponent(f.title)}=${encodeURIComponent(f.value!)}`) .map(f => `&${encodeURIComponent(f.title)}=${encodeURIComponent(f.value!)}`)
.join(''); .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}`; if (queryParams) urlQueryStyle += `&${queryParams}`;
urlQueryStyle += legacyFilterQuery; urlQueryStyle += legacyFilterQuery;
@ -973,10 +977,12 @@ class StremioService {
if (response && response.data) { if (response && response.data) {
const hasMore = typeof response.data.hasMore === 'boolean' ? response.data.hasMore : undefined; const hasMore = typeof response.data.hasMore === 'boolean' ? response.data.hasMore : undefined;
try { try {
const key = `${manifest.id}|${type}|${id}`; if (typeof hasMore === 'boolean') this.catalogHasMore.set(catalogKey, hasMore);
if (typeof hasMore === 'boolean') this.catalogHasMore.set(key, hasMore);
} catch { } } catch { }
if (response.data.metas && Array.isArray(response.data.metas)) { 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; return response.data.metas;
} }
} }
@ -1752,6 +1758,18 @@ class StremioService {
videoHash: stream.behaviorHints?.videoHash || undefined, videoHash: stream.behaviorHints?.videoHash || undefined,
videoSize: stream.behaviorHints?.videoSize || undefined, videoSize: stream.behaviorHints?.videoSize || undefined,
filename: stream.behaviorHints?.filename || 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 // Include essential torrent data for magnet streams
...(isMagnetStream ? { ...(isMagnetStream ? {
infoHash: stream.infoHash || streamUrl?.match(/btih:([a-zA-Z0-9]+)/)?.[1], 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;