mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-19 08:22:00 +00:00
Implement torrent streaming core, playback buffering, and standalone Android packaging fixes
This commit is contained in:
parent
9b330b8226
commit
267f63ecff
37 changed files with 3146 additions and 407 deletions
|
|
@ -4,6 +4,10 @@ apply plugin: "com.facebook.react"
|
||||||
apply plugin: "io.sentry.android.gradle"
|
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', '+')}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
android/app/proguard-rules.pro
vendored
3
android/app/proguard-rules.pro
vendored
|
|
@ -26,3 +26,6 @@
|
||||||
**[] $VALUES;
|
**[] $VALUES;
|
||||||
public *;
|
public *;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# jlibtorrent JNI
|
||||||
|
-keep class com.frostwire.jlibtorrent.swig.libtorrent_jni { *; }
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import com.facebook.react.defaults.DefaultReactNativeHost
|
||||||
import expo.modules.ApplicationLifecycleDispatcher
|
import expo.modules.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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,933 @@
|
||||||
|
package com.nuvio.app.torrent
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.facebook.react.bridge.Arguments
|
||||||
|
import com.facebook.react.bridge.Promise
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
|
import com.facebook.react.bridge.ReactMethod
|
||||||
|
import com.facebook.react.bridge.ReadableArray
|
||||||
|
import com.facebook.react.bridge.ReadableMap
|
||||||
|
import com.frostwire.jlibtorrent.AddTorrentParams
|
||||||
|
import com.frostwire.jlibtorrent.Priority
|
||||||
|
import com.frostwire.jlibtorrent.SessionManager
|
||||||
|
import com.frostwire.jlibtorrent.SessionParams
|
||||||
|
import com.frostwire.jlibtorrent.SettingsPack
|
||||||
|
import com.frostwire.jlibtorrent.Sha1Hash
|
||||||
|
import com.frostwire.jlibtorrent.TorrentFlags
|
||||||
|
import com.frostwire.jlibtorrent.TorrentHandle
|
||||||
|
import com.frostwire.jlibtorrent.TorrentInfo
|
||||||
|
import fi.iki.elonen.NanoHTTPD
|
||||||
|
import java.io.EOFException
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.RandomAccessFile
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
class TorrentStreamingModule(private val context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "TorrentStreamingModule"
|
||||||
|
private const val HANDLE_TIMEOUT_MS = 30_000L
|
||||||
|
private const val METADATA_TIMEOUT_MS = 120_000L
|
||||||
|
private const val PIECE_WAIT_MS = 45_000L
|
||||||
|
private const val INITIAL_PIECE_WAIT_MS = 15_000L
|
||||||
|
private const val STREAMING_WINDOW_PIECES = 112
|
||||||
|
private const val STREAMING_BOOST_MIN_STEP = 8
|
||||||
|
private const val PREFETCH_WINDOW_PIECES = 256
|
||||||
|
private const val PREFETCH_ADVANCE_STEP = 24
|
||||||
|
private const val PREFETCH_POLL_INTERVAL_MS = 550L
|
||||||
|
private const val HTTP_HOST = "127.0.0.1"
|
||||||
|
private const val IO_CHUNK_SIZE = 64 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class StreamingTuning(
|
||||||
|
val streamingWindowPieces: Int,
|
||||||
|
val prefetchWindowPieces: Int,
|
||||||
|
val prefetchAdvanceStep: Int,
|
||||||
|
val pieceDeadlineStepMs: Int,
|
||||||
|
val boostNearPieces: Int,
|
||||||
|
val boostMidPieces: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ActiveTorrentStream(
|
||||||
|
val streamId: String,
|
||||||
|
val infoHash: String,
|
||||||
|
val saveDir: File,
|
||||||
|
val handle: TorrentHandle,
|
||||||
|
val fileIndex: Int,
|
||||||
|
val fileName: String,
|
||||||
|
val filePath: File,
|
||||||
|
val fileSize: Long,
|
||||||
|
val fileOffset: Long,
|
||||||
|
val pieceLength: Int,
|
||||||
|
val totalPieces: Int,
|
||||||
|
val fileStartPiece: Int,
|
||||||
|
val fileEndPiece: Int,
|
||||||
|
val streamingWindowPieces: Int,
|
||||||
|
val prefetchWindowPieces: Int,
|
||||||
|
val prefetchAdvanceStep: Int,
|
||||||
|
val pieceDeadlineStepMs: Int,
|
||||||
|
val boostNearPieces: Int,
|
||||||
|
val boostMidPieces: Int,
|
||||||
|
@Volatile var nextPrefetchPiece: Int = 0,
|
||||||
|
@Volatile var lastBoostedPiece: Int = -1
|
||||||
|
)
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
private val ioExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||||
|
private val prefetchExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||||
|
private val activeStreams = ConcurrentHashMap<String, ActiveTorrentStream>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var sessionManager: SessionManager? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var httpServer: TorrentHttpServer? = null
|
||||||
|
|
||||||
|
override fun getName(): String = "TorrentStreamingModule"
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun prepareStream(options: ReadableMap, promise: Promise) {
|
||||||
|
val magnetUri = options.getString("magnetUri")
|
||||||
|
if (magnetUri.isNullOrBlank()) {
|
||||||
|
promise.reject("TORRENT_INVALID_INPUT", "Missing magnetUri")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val streamTitle = if (options.hasKey("streamTitle") && !options.isNull("streamTitle")) {
|
||||||
|
options.getString("streamTitle")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val preferredFileIndex = if (options.hasKey("fileIndex") && !options.isNull("fileIndex")) {
|
||||||
|
options.getDouble("fileIndex").toInt()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val trackers = mutableListOf<String>()
|
||||||
|
if (options.hasKey("trackers") && !options.isNull("trackers")) {
|
||||||
|
val arr = options.getArray("trackers")
|
||||||
|
if (arr != null) {
|
||||||
|
trackers.addAll(readableArrayToStringList(arr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val networkMbps = if (options.hasKey("networkMbps") && !options.isNull("networkMbps")) {
|
||||||
|
options.getDouble("networkMbps")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
ioExecutor.execute {
|
||||||
|
try {
|
||||||
|
val result = prepareStreamInternal(
|
||||||
|
magnetUri = magnetUri,
|
||||||
|
streamTitle = streamTitle,
|
||||||
|
preferredFileIndex = preferredFileIndex,
|
||||||
|
trackers = trackers,
|
||||||
|
networkMbps = networkMbps
|
||||||
|
)
|
||||||
|
promise.resolve(result)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "prepareStream failed", e)
|
||||||
|
promise.reject("TORRENT_PREPARE_FAILED", e.message, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun stopStream(streamId: String, promise: Promise) {
|
||||||
|
ioExecutor.execute {
|
||||||
|
try {
|
||||||
|
synchronized(lock) {
|
||||||
|
val stream = activeStreams.remove(streamId)
|
||||||
|
if (stream != null) {
|
||||||
|
stopStreamLocked(stream, cleanupFiles = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
promise.resolve(true)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "stopStream failed", e)
|
||||||
|
promise.reject("TORRENT_STOP_FAILED", e.message, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun stopAllStreams(promise: Promise) {
|
||||||
|
ioExecutor.execute {
|
||||||
|
try {
|
||||||
|
synchronized(lock) {
|
||||||
|
stopAllStreamsLocked(cleanupFiles = true)
|
||||||
|
}
|
||||||
|
promise.resolve(true)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "stopAllStreams failed", e)
|
||||||
|
promise.reject("TORRENT_STOP_ALL_FAILED", e.message, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun invalidate() {
|
||||||
|
super.invalidate()
|
||||||
|
try {
|
||||||
|
ioExecutor.execute {
|
||||||
|
synchronized(lock) {
|
||||||
|
shutdownLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
} finally {
|
||||||
|
ioExecutor.shutdown()
|
||||||
|
prefetchExecutor.shutdownNow()
|
||||||
|
try {
|
||||||
|
ioExecutor.awaitTermination(1500, TimeUnit.MILLISECONDS)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
prefetchExecutor.awaitTermination(1500, TimeUnit.MILLISECONDS)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prepareStreamInternal(
|
||||||
|
magnetUri: String,
|
||||||
|
streamTitle: String?,
|
||||||
|
preferredFileIndex: Int?,
|
||||||
|
trackers: List<String>,
|
||||||
|
networkMbps: Double?
|
||||||
|
): com.facebook.react.bridge.WritableMap {
|
||||||
|
synchronized(lock) {
|
||||||
|
ensureSessionStartedLocked()
|
||||||
|
ensureHttpServerStartedLocked()
|
||||||
|
stopAllStreamsLocked(cleanupFiles = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val finalMagnet = mergeTrackersIntoMagnet(magnetUri, trackers)
|
||||||
|
val parsedParams = AddTorrentParams.parseMagnetUri(finalMagnet)
|
||||||
|
val infoHash = parsedParams.getInfoHashes().getBest()
|
||||||
|
val infoHashHex = infoHash.toHex().lowercase(Locale.US)
|
||||||
|
|
||||||
|
val saveDir = File(getBaseTorrentDir(), infoHashHex)
|
||||||
|
if (!saveDir.exists()) {
|
||||||
|
saveDir.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
val sm = sessionManager ?: throw IllegalStateException("Torrent session is not initialized")
|
||||||
|
sm.download(finalMagnet, saveDir, TorrentFlags.SEQUENTIAL_DOWNLOAD)
|
||||||
|
|
||||||
|
val handle = waitForHandle(infoHash, HANDLE_TIMEOUT_MS)
|
||||||
|
?: throw IllegalStateException("Timed out while waiting for torrent handle")
|
||||||
|
|
||||||
|
handle.resume()
|
||||||
|
|
||||||
|
val torrentInfo = waitForMetadata(handle, METADATA_TIMEOUT_MS)
|
||||||
|
?: throw IllegalStateException("Timed out while waiting for torrent metadata")
|
||||||
|
|
||||||
|
val selectedFileIndex = selectPlayableFileIndex(torrentInfo, preferredFileIndex)
|
||||||
|
if (selectedFileIndex < 0) {
|
||||||
|
throw IllegalStateException("No playable video file found in torrent")
|
||||||
|
}
|
||||||
|
|
||||||
|
prioritizeSelectedFile(handle, torrentInfo, selectedFileIndex)
|
||||||
|
|
||||||
|
val fs = torrentInfo.files()
|
||||||
|
val relativePath = fs.filePath(selectedFileIndex).ifBlank { "stream.bin" }
|
||||||
|
val absolutePath = fs.filePath(selectedFileIndex, saveDir.absolutePath)
|
||||||
|
val filePath = File(absolutePath)
|
||||||
|
val fileSize = fs.fileSize(selectedFileIndex)
|
||||||
|
val fileOffset = fs.fileOffset(selectedFileIndex)
|
||||||
|
val pieceLength = max(1, torrentInfo.pieceLength())
|
||||||
|
val totalPieces = max(1, torrentInfo.numPieces())
|
||||||
|
val firstPiece = (fileOffset / pieceLength.toLong()).toInt().coerceIn(0, totalPieces - 1)
|
||||||
|
val lastPiece = ((fileOffset + max(1L, fileSize) - 1L) / pieceLength.toLong())
|
||||||
|
.toInt()
|
||||||
|
.coerceIn(firstPiece, totalPieces - 1)
|
||||||
|
val tuning = deriveStreamingTuning(networkMbps)
|
||||||
|
|
||||||
|
val streamId = UUID.randomUUID().toString()
|
||||||
|
val stream = ActiveTorrentStream(
|
||||||
|
streamId = streamId,
|
||||||
|
infoHash = infoHashHex,
|
||||||
|
saveDir = saveDir,
|
||||||
|
handle = handle,
|
||||||
|
fileIndex = selectedFileIndex,
|
||||||
|
fileName = relativePath,
|
||||||
|
filePath = filePath,
|
||||||
|
fileSize = fileSize,
|
||||||
|
fileOffset = fileOffset,
|
||||||
|
pieceLength = pieceLength,
|
||||||
|
totalPieces = totalPieces,
|
||||||
|
fileStartPiece = firstPiece,
|
||||||
|
fileEndPiece = lastPiece,
|
||||||
|
streamingWindowPieces = tuning.streamingWindowPieces,
|
||||||
|
prefetchWindowPieces = tuning.prefetchWindowPieces,
|
||||||
|
prefetchAdvanceStep = tuning.prefetchAdvanceStep,
|
||||||
|
pieceDeadlineStepMs = tuning.pieceDeadlineStepMs,
|
||||||
|
boostNearPieces = tuning.boostNearPieces,
|
||||||
|
boostMidPieces = tuning.boostMidPieces,
|
||||||
|
nextPrefetchPiece = firstPiece
|
||||||
|
)
|
||||||
|
|
||||||
|
synchronized(lock) {
|
||||||
|
activeStreams[streamId] = stream
|
||||||
|
}
|
||||||
|
|
||||||
|
boostPieceWindow(stream, firstPiece, stream.prefetchWindowPieces)
|
||||||
|
startPrefetchLoop(stream)
|
||||||
|
waitForPiece(stream, firstPiece, INITIAL_PIECE_WAIT_MS)
|
||||||
|
|
||||||
|
val serverPort = httpServer?.listeningPort
|
||||||
|
?: throw IllegalStateException("Torrent HTTP bridge is not running")
|
||||||
|
val playbackUrl = "http://$HTTP_HOST:$serverPort/torrent/$streamId"
|
||||||
|
|
||||||
|
val result = Arguments.createMap()
|
||||||
|
result.putString("streamId", streamId)
|
||||||
|
result.putString("playbackUrl", playbackUrl)
|
||||||
|
result.putString("infoHash", infoHashHex)
|
||||||
|
result.putString("fileName", stream.fileName)
|
||||||
|
result.putDouble("fileSize", stream.fileSize.toDouble())
|
||||||
|
result.putString("mimeType", guessMimeType(stream.fileName))
|
||||||
|
result.putString("streamTitle", streamTitle)
|
||||||
|
result.putDouble("networkMbps", networkMbps ?: 0.0)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deriveStreamingTuning(networkMbps: Double?): StreamingTuning {
|
||||||
|
val mbps = if (networkMbps != null && networkMbps.isFinite() && networkMbps > 0.0) {
|
||||||
|
networkMbps
|
||||||
|
} else {
|
||||||
|
20.0
|
||||||
|
}
|
||||||
|
|
||||||
|
return when {
|
||||||
|
mbps <= 1.5 -> StreamingTuning(
|
||||||
|
streamingWindowPieces = 64,
|
||||||
|
prefetchWindowPieces = 128,
|
||||||
|
prefetchAdvanceStep = 12,
|
||||||
|
pieceDeadlineStepMs = 160,
|
||||||
|
boostNearPieces = 14,
|
||||||
|
boostMidPieces = 28
|
||||||
|
)
|
||||||
|
mbps <= 5.0 -> StreamingTuning(
|
||||||
|
streamingWindowPieces = 88,
|
||||||
|
prefetchWindowPieces = 192,
|
||||||
|
prefetchAdvanceStep = 18,
|
||||||
|
pieceDeadlineStepMs = 145,
|
||||||
|
boostNearPieces = 18,
|
||||||
|
boostMidPieces = 36
|
||||||
|
)
|
||||||
|
mbps <= 20.0 -> StreamingTuning(
|
||||||
|
streamingWindowPieces = STREAMING_WINDOW_PIECES,
|
||||||
|
prefetchWindowPieces = PREFETCH_WINDOW_PIECES,
|
||||||
|
prefetchAdvanceStep = PREFETCH_ADVANCE_STEP,
|
||||||
|
pieceDeadlineStepMs = 120,
|
||||||
|
boostNearPieces = 24,
|
||||||
|
boostMidPieces = 48
|
||||||
|
)
|
||||||
|
mbps <= 80.0 -> StreamingTuning(
|
||||||
|
streamingWindowPieces = 144,
|
||||||
|
prefetchWindowPieces = 320,
|
||||||
|
prefetchAdvanceStep = 32,
|
||||||
|
pieceDeadlineStepMs = 100,
|
||||||
|
boostNearPieces = 28,
|
||||||
|
boostMidPieces = 56
|
||||||
|
)
|
||||||
|
mbps <= 250.0 -> StreamingTuning(
|
||||||
|
streamingWindowPieces = 176,
|
||||||
|
prefetchWindowPieces = 384,
|
||||||
|
prefetchAdvanceStep = 40,
|
||||||
|
pieceDeadlineStepMs = 85,
|
||||||
|
boostNearPieces = 32,
|
||||||
|
boostMidPieces = 64
|
||||||
|
)
|
||||||
|
else -> StreamingTuning(
|
||||||
|
streamingWindowPieces = 224,
|
||||||
|
prefetchWindowPieces = 448,
|
||||||
|
prefetchAdvanceStep = 48,
|
||||||
|
pieceDeadlineStepMs = 70,
|
||||||
|
boostNearPieces = 36,
|
||||||
|
boostMidPieces = 72
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureSessionStartedLocked() {
|
||||||
|
if (sessionManager?.isRunning == true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val settings = SettingsPack()
|
||||||
|
.downloadRateLimit(0)
|
||||||
|
.uploadRateLimit(0)
|
||||||
|
.activeDownloads(8)
|
||||||
|
.activeSeeds(2)
|
||||||
|
.connectionsLimit(200)
|
||||||
|
.alertQueueSize(20000)
|
||||||
|
|
||||||
|
val params = SessionParams(settings)
|
||||||
|
val sm = SessionManager(false)
|
||||||
|
sm.start(params)
|
||||||
|
sessionManager = sm
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureHttpServerStartedLocked() {
|
||||||
|
if (httpServer != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val server = TorrentHttpServer(0)
|
||||||
|
server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false)
|
||||||
|
httpServer = server
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shutdownLocked() {
|
||||||
|
stopAllStreamsLocked(cleanupFiles = true)
|
||||||
|
try {
|
||||||
|
httpServer?.stop()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
httpServer = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
sessionManager?.stop()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
sessionManager = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopAllStreamsLocked(cleanupFiles: Boolean) {
|
||||||
|
val current = activeStreams.values.toList()
|
||||||
|
activeStreams.clear()
|
||||||
|
current.forEach { stream ->
|
||||||
|
stopStreamLocked(stream, cleanupFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopStreamLocked(stream: ActiveTorrentStream, cleanupFiles: Boolean) {
|
||||||
|
try {
|
||||||
|
sessionManager?.remove(stream.handle)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanupFiles) {
|
||||||
|
deleteRecursively(stream.saveDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForHandle(infoHash: Sha1Hash, timeoutMs: Long): TorrentHandle? {
|
||||||
|
val startedAt = System.currentTimeMillis()
|
||||||
|
while (System.currentTimeMillis() - startedAt < timeoutMs) {
|
||||||
|
val handle = sessionManager?.find(infoHash)
|
||||||
|
if (handle != null && handle.isValid) {
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(200)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val fallback = sessionManager?.find(infoHash)
|
||||||
|
return if (fallback != null && fallback.isValid) fallback else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForMetadata(handle: TorrentHandle, timeoutMs: Long): TorrentInfo? {
|
||||||
|
val startedAt = System.currentTimeMillis()
|
||||||
|
while (System.currentTimeMillis() - startedAt < timeoutMs) {
|
||||||
|
try {
|
||||||
|
val ti = handle.torrentFile()
|
||||||
|
if (ti != null && ti.isValid && ti.numFiles() > 0) {
|
||||||
|
return ti
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Thread.sleep(250)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectPlayableFileIndex(torrentInfo: TorrentInfo, preferredFileIndex: Int?): Int {
|
||||||
|
val fs = torrentInfo.files()
|
||||||
|
val totalFiles = fs.numFiles()
|
||||||
|
if (totalFiles <= 0) return -1
|
||||||
|
|
||||||
|
if (preferredFileIndex != null && preferredFileIndex in 0 until totalFiles) {
|
||||||
|
val preferredPath = fs.filePath(preferredFileIndex).lowercase(Locale.US)
|
||||||
|
if (isLikelyVideoFile(preferredPath)) {
|
||||||
|
return preferredFileIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bestIndex = -1
|
||||||
|
var bestScore = Long.MIN_VALUE
|
||||||
|
var fallbackLargestIndex = 0
|
||||||
|
var fallbackLargestSize = Long.MIN_VALUE
|
||||||
|
|
||||||
|
for (i in 0 until totalFiles) {
|
||||||
|
val size = fs.fileSize(i)
|
||||||
|
val path = fs.filePath(i)
|
||||||
|
val lowerPath = path.lowercase(Locale.US)
|
||||||
|
|
||||||
|
if (size > fallbackLargestSize) {
|
||||||
|
fallbackLargestSize = size
|
||||||
|
fallbackLargestIndex = i
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLikelyVideoFile(lowerPath)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var score = size
|
||||||
|
if (lowerPath.contains("/sample") || lowerPath.contains("sample.")) score -= 500_000_000L
|
||||||
|
if (lowerPath.contains("trailer")) score -= 300_000_000L
|
||||||
|
if (lowerPath.contains("extras")) score -= 200_000_000L
|
||||||
|
if (lowerPath.contains("featurette")) score -= 200_000_000L
|
||||||
|
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (bestIndex >= 0) bestIndex else fallbackLargestIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prioritizeSelectedFile(handle: TorrentHandle, torrentInfo: TorrentInfo, selectedFileIndex: Int) {
|
||||||
|
val filesCount = max(1, torrentInfo.numFiles())
|
||||||
|
val priorities = Priority.array(Priority.IGNORE, filesCount)
|
||||||
|
priorities[selectedFileIndex] = Priority.SEVEN
|
||||||
|
handle.prioritizeFiles(priorities)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForPiece(stream: ActiveTorrentStream, pieceIndex: Int, timeoutMs: Long): Boolean {
|
||||||
|
if (!stream.handle.isValid) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val safePiece = pieceIndex.coerceIn(stream.fileStartPiece, stream.fileEndPiece)
|
||||||
|
val startedAt = System.currentTimeMillis()
|
||||||
|
|
||||||
|
while (System.currentTimeMillis() - startedAt < timeoutMs) {
|
||||||
|
try {
|
||||||
|
if (stream.handle.havePiece(safePiece)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
boostPieceWindow(stream, safePiece)
|
||||||
|
|
||||||
|
try {
|
||||||
|
Thread.sleep(120)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
stream.handle.havePiece(safePiece)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun boostPieceWindow(
|
||||||
|
stream: ActiveTorrentStream,
|
||||||
|
fromPiece: Int,
|
||||||
|
windowPieces: Int? = null
|
||||||
|
) {
|
||||||
|
if (!stream.handle.isValid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val startPiece = fromPiece.coerceIn(stream.fileStartPiece, stream.fileEndPiece)
|
||||||
|
if (stream.lastBoostedPiece >= 0 && startPiece <= stream.lastBoostedPiece + STREAMING_BOOST_MIN_STEP) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val effectiveWindowPieces = max(1, windowPieces ?: stream.streamingWindowPieces)
|
||||||
|
val endPiece = min(stream.fileEndPiece, startPiece + effectiveWindowPieces)
|
||||||
|
for (piece in startPiece..endPiece) {
|
||||||
|
try {
|
||||||
|
val pieceOffset = piece - startPiece
|
||||||
|
val priority = when {
|
||||||
|
pieceOffset < stream.boostNearPieces -> Priority.SEVEN
|
||||||
|
pieceOffset < stream.boostMidPieces -> Priority.SIX
|
||||||
|
else -> Priority.FOUR
|
||||||
|
}
|
||||||
|
stream.handle.piecePriority(piece, priority)
|
||||||
|
stream.handle.setPieceDeadline(piece, pieceOffset * stream.pieceDeadlineStepMs)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream.lastBoostedPiece = startPiece
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPrefetchLoop(stream: ActiveTorrentStream) {
|
||||||
|
prefetchExecutor.execute {
|
||||||
|
while (true) {
|
||||||
|
val isStillActive = synchronized(lock) {
|
||||||
|
activeStreams[stream.streamId] === stream
|
||||||
|
}
|
||||||
|
if (!isStillActive) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stream.handle.isValid) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
val fromPiece = stream.nextPrefetchPiece.coerceIn(stream.fileStartPiece, stream.fileEndPiece)
|
||||||
|
val nextMissing = findNextMissingPiece(stream, fromPiece)
|
||||||
|
if (nextMissing < 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
boostPieceWindow(stream, nextMissing, stream.prefetchWindowPieces)
|
||||||
|
stream.nextPrefetchPiece = min(stream.fileEndPiece, nextMissing + stream.prefetchAdvanceStep)
|
||||||
|
|
||||||
|
try {
|
||||||
|
Thread.sleep(PREFETCH_POLL_INTERVAL_MS)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findNextMissingPiece(stream: ActiveTorrentStream, fromPiece: Int): Int {
|
||||||
|
if (!stream.handle.isValid) return -1
|
||||||
|
|
||||||
|
val start = stream.fileStartPiece
|
||||||
|
val end = stream.fileEndPiece
|
||||||
|
val pivot = fromPiece.coerceIn(start, end)
|
||||||
|
|
||||||
|
for (piece in pivot..end) {
|
||||||
|
try {
|
||||||
|
if (!stream.handle.havePiece(piece)) return piece
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (piece in start until pivot) {
|
||||||
|
try {
|
||||||
|
if (!stream.handle.havePiece(piece)) return piece
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mergeTrackersIntoMagnet(magnetUri: String, trackers: List<String>): String {
|
||||||
|
if (trackers.isEmpty() || !magnetUri.startsWith("magnet:?")) {
|
||||||
|
return magnetUri
|
||||||
|
}
|
||||||
|
|
||||||
|
val existingTrackers = mutableSetOf<String>()
|
||||||
|
val query = magnetUri.substringAfter('?', "")
|
||||||
|
if (query.isNotBlank()) {
|
||||||
|
query.split('&').forEach { part ->
|
||||||
|
if (part.startsWith("tr=", ignoreCase = true)) {
|
||||||
|
val encoded = part.substringAfter("tr=", "")
|
||||||
|
val decoded = URLDecoder.decode(encoded, Charsets.UTF_8.name())
|
||||||
|
if (decoded.isNotBlank()) {
|
||||||
|
existingTrackers.add(decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = StringBuilder(magnetUri)
|
||||||
|
trackers.forEach { tracker ->
|
||||||
|
val normalized = tracker.trim()
|
||||||
|
if (normalized.isNotBlank() && existingTrackers.add(normalized)) {
|
||||||
|
builder.append("&tr=").append(URLEncoder.encode(normalized, Charsets.UTF_8.name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBaseTorrentDir(): File {
|
||||||
|
val dir = File(context.cacheDir, "torrent-stream")
|
||||||
|
if (!dir.exists()) {
|
||||||
|
dir.mkdirs()
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteRecursively(file: File) {
|
||||||
|
if (!file.exists()) return
|
||||||
|
if (file.isDirectory) {
|
||||||
|
file.listFiles()?.forEach { child ->
|
||||||
|
deleteRecursively(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readableArrayToStringList(arr: ReadableArray): List<String> {
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
for (i in 0 until arr.size()) {
|
||||||
|
if (!arr.isNull(i)) {
|
||||||
|
val value = arr.getString(i)
|
||||||
|
if (!value.isNullOrBlank()) {
|
||||||
|
out.add(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun guessMimeType(path: String): String {
|
||||||
|
val lower = path.lowercase(Locale.US)
|
||||||
|
return when {
|
||||||
|
lower.endsWith(".mkv") -> "video/x-matroska"
|
||||||
|
lower.endsWith(".mp4") || lower.endsWith(".m4v") -> "video/mp4"
|
||||||
|
lower.endsWith(".webm") -> "video/webm"
|
||||||
|
lower.endsWith(".avi") -> "video/x-msvideo"
|
||||||
|
lower.endsWith(".mov") -> "video/quicktime"
|
||||||
|
lower.endsWith(".ts") || lower.endsWith(".m2ts") -> "video/mp2t"
|
||||||
|
lower.endsWith(".mpg") || lower.endsWith(".mpeg") -> "video/mpeg"
|
||||||
|
else -> "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isLikelyVideoFile(path: String): Boolean {
|
||||||
|
return path.endsWith(".mkv") ||
|
||||||
|
path.endsWith(".mp4") ||
|
||||||
|
path.endsWith(".m4v") ||
|
||||||
|
path.endsWith(".avi") ||
|
||||||
|
path.endsWith(".webm") ||
|
||||||
|
path.endsWith(".mov") ||
|
||||||
|
path.endsWith(".ts") ||
|
||||||
|
path.endsWith(".m2ts") ||
|
||||||
|
path.endsWith(".mpg") ||
|
||||||
|
path.endsWith(".mpeg")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRangeHeader(rangeHeader: String?, fileSize: Long): Pair<Long, Long>? {
|
||||||
|
if (rangeHeader.isNullOrBlank() || !rangeHeader.startsWith("bytes=") || fileSize <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val range = rangeHeader.removePrefix("bytes=").trim()
|
||||||
|
val parts = range.split("-", limit = 2)
|
||||||
|
if (parts.size != 2) return null
|
||||||
|
|
||||||
|
val startText = parts[0].trim()
|
||||||
|
val endText = parts[1].trim()
|
||||||
|
|
||||||
|
val start: Long
|
||||||
|
val end: Long
|
||||||
|
|
||||||
|
when {
|
||||||
|
startText.isBlank() -> {
|
||||||
|
val suffixLength = endText.toLongOrNull() ?: return null
|
||||||
|
if (suffixLength <= 0) return null
|
||||||
|
start = max(0L, fileSize - suffixLength)
|
||||||
|
end = fileSize - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
endText.isBlank() -> {
|
||||||
|
start = startText.toLongOrNull() ?: return null
|
||||||
|
end = fileSize - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
start = startText.toLongOrNull() ?: return null
|
||||||
|
end = endText.toLongOrNull() ?: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start < 0 || start >= fileSize) return null
|
||||||
|
if (end < start) return null
|
||||||
|
|
||||||
|
return start to min(end, fileSize - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class TorrentInputStream(
|
||||||
|
private val stream: ActiveTorrentStream,
|
||||||
|
startOffset: Long,
|
||||||
|
private val length: Long
|
||||||
|
) : InputStream() {
|
||||||
|
private var offsetInFile = startOffset
|
||||||
|
private var remaining = length
|
||||||
|
private var raf: RandomAccessFile? = null
|
||||||
|
|
||||||
|
override fun read(): Int {
|
||||||
|
val single = ByteArray(1)
|
||||||
|
val read = read(single, 0, 1)
|
||||||
|
return if (read <= 0) -1 else single[0].toInt() and 0xFF
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun read(buffer: ByteArray, off: Int, len: Int): Int {
|
||||||
|
if (remaining <= 0) return -1
|
||||||
|
if (len <= 0) return 0
|
||||||
|
|
||||||
|
val requested = min(len.toLong(), remaining).toInt()
|
||||||
|
val globalOffset = stream.fileOffset + offsetInFile
|
||||||
|
val pieceIndex = (globalOffset / stream.pieceLength.toLong()).toInt().coerceIn(0, stream.totalPieces - 1)
|
||||||
|
|
||||||
|
if (!waitForPiece(stream, pieceIndex, PIECE_WAIT_MS)) {
|
||||||
|
throw EOFException("Timed out waiting for piece $pieceIndex")
|
||||||
|
}
|
||||||
|
boostPieceWindow(stream, pieceIndex)
|
||||||
|
|
||||||
|
val pieceEndOffsetGlobal = (pieceIndex + 1L) * stream.pieceLength.toLong()
|
||||||
|
val maxInPiece = max(1L, pieceEndOffsetGlobal - globalOffset)
|
||||||
|
val chunkSize = min(requested.toLong(), min(maxInPiece, IO_CHUNK_SIZE.toLong())).toInt()
|
||||||
|
|
||||||
|
val randomAccessFile = ensureFileOpen()
|
||||||
|
|
||||||
|
var bytesRead = -1
|
||||||
|
var attempts = 0
|
||||||
|
while (bytesRead <= 0 && attempts < 20) {
|
||||||
|
randomAccessFile.seek(offsetInFile)
|
||||||
|
bytesRead = randomAccessFile.read(buffer, off, chunkSize)
|
||||||
|
if (bytesRead <= 0) {
|
||||||
|
attempts++
|
||||||
|
try {
|
||||||
|
Thread.sleep(80)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytesRead <= 0) {
|
||||||
|
throw EOFException("Unable to read torrent bytes from disk")
|
||||||
|
}
|
||||||
|
|
||||||
|
offsetInFile += bytesRead.toLong()
|
||||||
|
remaining -= bytesRead.toLong()
|
||||||
|
return bytesRead
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
try {
|
||||||
|
raf?.close()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
raf = null
|
||||||
|
super.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun ensureFileOpen(): RandomAccessFile {
|
||||||
|
raf?.let { return it }
|
||||||
|
|
||||||
|
var attempts = 0
|
||||||
|
while (attempts < 120) {
|
||||||
|
if (stream.filePath.exists()) {
|
||||||
|
val open = RandomAccessFile(stream.filePath, "r")
|
||||||
|
raf = open
|
||||||
|
return open
|
||||||
|
}
|
||||||
|
attempts++
|
||||||
|
try {
|
||||||
|
Thread.sleep(100)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw IOException("Torrent file does not exist yet: ${stream.filePath.absolutePath}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class TorrentHttpServer(port: Int) : NanoHTTPD(HTTP_HOST, port) {
|
||||||
|
override fun serve(session: IHTTPSession): Response {
|
||||||
|
return try {
|
||||||
|
val uri = session.uri ?: "/"
|
||||||
|
if (!uri.startsWith("/torrent/")) {
|
||||||
|
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found")
|
||||||
|
} else {
|
||||||
|
serveTorrentRequest(session)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "HTTP serve failed", e)
|
||||||
|
newFixedLengthResponse(Response.Status.INTERNAL_ERROR, "text/plain", "Internal error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serveTorrentRequest(session: IHTTPSession): Response {
|
||||||
|
if (session.method != Method.GET && session.method != Method.HEAD) {
|
||||||
|
return newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "text/plain", "Method not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
val streamId = session.uri.removePrefix("/torrent/").substringBefore('/')
|
||||||
|
val stream = activeStreams[streamId]
|
||||||
|
?: return newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Stream not found")
|
||||||
|
|
||||||
|
val rangeHeader = session.headers["range"]
|
||||||
|
val parsedRange = parseRangeHeader(rangeHeader, stream.fileSize)
|
||||||
|
|
||||||
|
val start = parsedRange?.first ?: 0L
|
||||||
|
val end = parsedRange?.second ?: (stream.fileSize - 1L)
|
||||||
|
|
||||||
|
if (start < 0 || end < start || start >= stream.fileSize) {
|
||||||
|
val invalid = newFixedLengthResponse(
|
||||||
|
Response.Status.RANGE_NOT_SATISFIABLE,
|
||||||
|
"text/plain",
|
||||||
|
"Requested range not satisfiable"
|
||||||
|
)
|
||||||
|
invalid.addHeader("Accept-Ranges", "bytes")
|
||||||
|
invalid.addHeader("Content-Range", "bytes */${stream.fileSize}")
|
||||||
|
return invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
val length = end - start + 1L
|
||||||
|
val status = if (parsedRange != null) Response.Status.PARTIAL_CONTENT else Response.Status.OK
|
||||||
|
val mimeType = guessMimeType(stream.fileName)
|
||||||
|
|
||||||
|
val response = if (session.method == Method.HEAD) {
|
||||||
|
newFixedLengthResponse(status, mimeType, "")
|
||||||
|
} else {
|
||||||
|
val input = TorrentInputStream(stream, start, length)
|
||||||
|
newFixedLengthResponse(status, mimeType, input, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
response.addHeader("Accept-Ranges", "bytes")
|
||||||
|
response.addHeader("Content-Length", length.toString())
|
||||||
|
response.addHeader("Connection", "keep-alive")
|
||||||
|
if (parsedRange != null) {
|
||||||
|
response.addHeader("Content-Range", "bytes $start-$end/${stream.fileSize}")
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.nuvio.app.torrent
|
||||||
|
|
||||||
|
import com.facebook.react.ReactPackage
|
||||||
|
import com.facebook.react.bridge.NativeModule
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.uimanager.ViewManager
|
||||||
|
|
||||||
|
class TorrentStreamingPackage : ReactPackage {
|
||||||
|
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||||
|
return listOf(TorrentStreamingModule(reactContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,12 @@ allprojects {
|
||||||
google()
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
97
docs/IOS_TORRENT_ENGINE_PATH.md
Normal file
97
docs/IOS_TORRENT_ENGINE_PATH.md
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
# iOS Torrent Engine Path (Native, Not Desktop Service Port)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Enable native torrent playback on iOS/iPadOS/tvOS with the same JS contract already used on Android:
|
||||||
|
- `prepareStream({ magnetUri, streamTitle, fileIndex, trackers, networkMbps })`
|
||||||
|
- `stopStream(streamId)`
|
||||||
|
- `stopAllStreams()`
|
||||||
|
|
||||||
|
The player receives a local playback URL such as `http://127.0.0.1:<port>/torrent/<streamId>`.
|
||||||
|
|
||||||
|
## Why Not Port Desktop Stremio Service
|
||||||
|
- `stremio-shell` is a desktop shell that starts a separate streaming server process in production.
|
||||||
|
- `stremio-service` packaging targets desktop distributions (macOS dmg, Windows exe, Linux deb/rpm/flatpak).
|
||||||
|
- iOS needs an embedded native engine lifecycle (memory/background constraints), not a desktop-style sidecar service process.
|
||||||
|
|
||||||
|
## Research Snapshot (2026-02-19)
|
||||||
|
### Torrent Core Candidates
|
||||||
|
- `arvidn/libtorrent`: mature BitTorrent core, highly active, strongest long-term choice.
|
||||||
|
- `frostwire/frostwire-jlibtorrent`: excellent for Android/JVM, but not a native Swift/iOS API surface.
|
||||||
|
|
||||||
|
### iOS Client/Engine References
|
||||||
|
- `XITRIX/iTorrent`: active iOS torrent app; uses `libtorrent-rasterbar` and local HTTP playback bridging.
|
||||||
|
- `XITRIX/LibTorrent-Swift`: active Swift wrapper project used by iTorrent.
|
||||||
|
- `danylokos/SwiftyTorrent`: useful reference implementation, but older push cadence and Carthage-era stack.
|
||||||
|
- `siuying/peerflix-ios`: archived and stale for modern production use.
|
||||||
|
|
||||||
|
### Local HTTP Bridge Candidates
|
||||||
|
- `swisspol/GCDWebServer`: historically common, but archived.
|
||||||
|
- `swhitty/FlyingFox`: active, modern Swift Concurrency HTTP server with range request support.
|
||||||
|
|
||||||
|
### Requested Check: VLC BitTorrent
|
||||||
|
- `johang/vlc-bittorrent` is a VLC plugin (C/C++ plugin target), useful as concept/reference, but not a direct iOS-native app integration path for Nuvio’s React Native architecture.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Use:
|
||||||
|
1. `libtorrent-rasterbar` as the iOS/tvOS torrent core.
|
||||||
|
2. A thin Swift bridge layer (`TorrentStreamingModule`) exposing Nuvio’s existing JS API.
|
||||||
|
3. A local HTTP range bridge based on `FlyingFox` (preferred) or a custom equivalent if tighter control is needed.
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
- Desktop `stremio-service` process porting.
|
||||||
|
- Archived HTTP server dependencies as primary infrastructure.
|
||||||
|
- iOS torrent clients as direct code imports; use them as implementation references only.
|
||||||
|
|
||||||
|
## Target iOS Architecture
|
||||||
|
1. `TorrentSessionActor`
|
||||||
|
- Owns `libtorrent` session lifecycle, torrent handles, and shutdown.
|
||||||
|
|
||||||
|
2. `TorrentStreamCoordinator`
|
||||||
|
- File selection, piece windowing, and seek-aware reprioritization.
|
||||||
|
- Network-aware prefetch profile (slow/medium/fast/ultra), matching Android strategy.
|
||||||
|
|
||||||
|
3. `LocalPlaybackServer`
|
||||||
|
- Serves `/torrent/{streamId}` with full `Range` support (`206`, `Content-Range`, `Accept-Ranges`).
|
||||||
|
- Keeps buffered reads aggressive during play/pause up to cache policy limits.
|
||||||
|
|
||||||
|
4. `TorrentStreamingModule` (RN bridge)
|
||||||
|
- `prepareStream`, `stopStream`, `stopAllStreams`.
|
||||||
|
- Returns `{ streamId, playbackUrl, infoHash, fileName, fileSize, mimeType }`.
|
||||||
|
|
||||||
|
## Buffering/Robustness Rules
|
||||||
|
- Keep forward prefetch active during pause (subject to thermal/memory policy).
|
||||||
|
- Maintain high-priority piece window around playback pointer and seek target.
|
||||||
|
- Keep a wider low-priority prefetch window for smoother long-form playback.
|
||||||
|
- Resume quickly after app foreground/background transitions.
|
||||||
|
- Treat near-tail transient I/O failures as graceful completion when safe.
|
||||||
|
|
||||||
|
## Disk/Cache Policy
|
||||||
|
- Configurable base cache directory (default app container cache).
|
||||||
|
- Per-infohash subfolders.
|
||||||
|
- LRU cleanup and max cache budget.
|
||||||
|
- Optional “keep completed file” mode off by default.
|
||||||
|
|
||||||
|
## Integration Status In This Branch
|
||||||
|
- JS service already supports native torrent module discovery on Android/iOS.
|
||||||
|
- Stream selection now uses capability checks instead of Android-only assumptions.
|
||||||
|
- `KSPlayerCore` carries `torrentStreamId` and performs stop on close/unmount/switch.
|
||||||
|
- Android torrent prefetch is now network-tuned (very-slow to ultra-fast profiles).
|
||||||
|
|
||||||
|
## Remaining iOS Work
|
||||||
|
1. Implement and register `TorrentStreamingModule` on iOS.
|
||||||
|
2. Integrate `libtorrent-rasterbar` (XCFramework/static linkage path).
|
||||||
|
3. Implement local HTTP range bridge and seek-aware scheduler.
|
||||||
|
4. Add settings UI for cache path + cache budget.
|
||||||
|
5. Validate on real devices: pause buffering, seek storms, tokenized links, EOS behavior.
|
||||||
|
|
||||||
|
## Primary References
|
||||||
|
- https://github.com/Stremio/stremio-shell
|
||||||
|
- https://github.com/Stremio/stremio-service
|
||||||
|
- https://github.com/arvidn/libtorrent
|
||||||
|
- https://github.com/XITRIX/iTorrent
|
||||||
|
- https://github.com/XITRIX/LibTorrent-Swift
|
||||||
|
- https://github.com/danylokos/SwiftyTorrent
|
||||||
|
- https://github.com/siuying/peerflix-ios
|
||||||
|
- https://github.com/swhitty/FlyingFox
|
||||||
|
- https://github.com/swisspol/GCDWebServer
|
||||||
|
- https://github.com/johang/vlc-bittorrent
|
||||||
74
docs/TORRENT_ENGINE_RESEARCH_2026-02-19.md
Normal file
74
docs/TORRENT_ENGINE_RESEARCH_2026-02-19.md
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
# Torrent Engine Research (2026-02-19)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Android torrent streaming engine choice and viability.
|
||||||
|
- iOS/tvOS torrent engine choice and integration strategy.
|
||||||
|
- Check requested references (`Stremio`, `vlc-bittorrent`) and pick lowest-risk modern path.
|
||||||
|
|
||||||
|
## Android Findings
|
||||||
|
### Selected
|
||||||
|
- `frostwire/frostwire-jlibtorrent`
|
||||||
|
- GitHub: 498 stars, recently updated.
|
||||||
|
- Works directly from Android/Kotlin, stable JVM binding around libtorrent.
|
||||||
|
- Current branch now uses `2.0.12.7` artifacts from FrostWire Maven.
|
||||||
|
|
||||||
|
### Why
|
||||||
|
- Native Android integration is straightforward compared to desktop-plugin or Node-sidecar designs.
|
||||||
|
- Mature dependency chain and direct access to session/piece priority APIs.
|
||||||
|
|
||||||
|
## iOS Findings
|
||||||
|
### Core Engine
|
||||||
|
- `arvidn/libtorrent` remains the strongest base core.
|
||||||
|
|
||||||
|
### Real-World iOS References
|
||||||
|
- `XITRIX/iTorrent`
|
||||||
|
- 2,953 stars, actively maintained.
|
||||||
|
- Uses `libtorrent-rasterbar` and local HTTP serving for playback.
|
||||||
|
- `XITRIX/LibTorrent-Swift`
|
||||||
|
- Active Swift wrapper used by iTorrent.
|
||||||
|
- `danylokos/SwiftyTorrent`
|
||||||
|
- Useful reference, but older push cadence and older toolchain approach.
|
||||||
|
- `siuying/peerflix-ios`
|
||||||
|
- Archived and stale; not suitable for modern production path.
|
||||||
|
|
||||||
|
### Local HTTP Layer
|
||||||
|
- `swisspol/GCDWebServer` is archived.
|
||||||
|
- `swhitty/FlyingFox` is active and supports range-friendly file serving patterns.
|
||||||
|
|
||||||
|
### Requested Check: `johang/vlc-bittorrent`
|
||||||
|
- Valuable as VLC plugin reference, but not a direct RN iOS integration path.
|
||||||
|
- Architecture is plugin-for-VLC, not embedded app-native bridge for Nuvio.
|
||||||
|
|
||||||
|
## Stremio Research Summary
|
||||||
|
- `stremio-shell` desktop runtime expects a separate streaming server flow.
|
||||||
|
- `stremio-service` packaging is desktop-oriented.
|
||||||
|
- This confirms Nuvio mobile should use embedded native torrent engine modules per platform, not desktop service porting.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
1. Android: stay on jlibtorrent-based native module (already integrated).
|
||||||
|
2. iOS/tvOS: use libtorrent core + Swift bridge + local HTTP range bridge (prefer active server stack like FlyingFox or equivalent custom).
|
||||||
|
3. Keep one JS contract across platforms (`prepareStream/stopStream/stopAllStreams`) with platform-native implementations.
|
||||||
|
|
||||||
|
## Data Points (GitHub API, 2026-02-19)
|
||||||
|
- `frostwire/frostwire-jlibtorrent`: stars 498, pushed 2026-02-14
|
||||||
|
- `arvidn/libtorrent`: stars 5,847, pushed 2026-02-18
|
||||||
|
- `XITRIX/iTorrent`: stars 2,953, pushed 2026-01-29
|
||||||
|
- `XITRIX/LibTorrent-Swift`: stars 8, pushed 2026-02-03
|
||||||
|
- `danylokos/SwiftyTorrent`: stars 128, pushed 2024-04-20
|
||||||
|
- `siuying/peerflix-ios`: stars 84, archived, pushed 2017-03-18
|
||||||
|
- `swisspol/GCDWebServer`: stars 6,615, archived, pushed 2022-10-05
|
||||||
|
- `swhitty/FlyingFox`: stars 626, pushed 2026-01-23
|
||||||
|
- `johang/vlc-bittorrent`: stars 472, pushed 2026-02-04
|
||||||
|
|
||||||
|
## Links
|
||||||
|
- https://github.com/frostwire/frostwire-jlibtorrent
|
||||||
|
- https://github.com/arvidn/libtorrent
|
||||||
|
- https://github.com/XITRIX/iTorrent
|
||||||
|
- https://github.com/XITRIX/LibTorrent-Swift
|
||||||
|
- https://github.com/danylokos/SwiftyTorrent
|
||||||
|
- https://github.com/siuying/peerflix-ios
|
||||||
|
- https://github.com/swhitty/FlyingFox
|
||||||
|
- https://github.com/swisspol/GCDWebServer
|
||||||
|
- https://github.com/johang/vlc-bittorrent
|
||||||
|
- https://github.com/Stremio/stremio-shell
|
||||||
|
- https://github.com/Stremio/stremio-service
|
||||||
66
package-lock.json
generated
66
package-lock.json
generated
|
|
@ -1540,6 +1540,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
"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"
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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')}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -713,4 +717,4 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default HomeScreenSettings;
|
export default HomeScreenSettings;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
@ -1668,4 +1704,4 @@ class CatalogService {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const catalogService = CatalogService.getInstance();
|
export const catalogService = CatalogService.getInstance();
|
||||||
export default catalogService;
|
export default catalogService;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
@ -937,4 +947,4 @@ export class SimklService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
187
src/services/torrentStreamingService.ts
Normal file
187
src/services/torrentStreamingService.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import { NativeModules, Platform } from 'react-native';
|
||||||
|
import { Stream } from '../types/metadata';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
type NativePrepareInput = {
|
||||||
|
magnetUri: string;
|
||||||
|
streamTitle?: string;
|
||||||
|
fileIndex?: number;
|
||||||
|
trackers?: string[];
|
||||||
|
networkMbps?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NativePrepareResult = {
|
||||||
|
streamId: string;
|
||||||
|
playbackUrl: string;
|
||||||
|
infoHash?: string;
|
||||||
|
fileName?: string;
|
||||||
|
fileSize?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NativeTorrentStreamingModule = {
|
||||||
|
prepareStream: (input: NativePrepareInput) => Promise<NativePrepareResult>;
|
||||||
|
stopStream: (streamId: string) => Promise<boolean>;
|
||||||
|
stopAllStreams: () => Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNativeModule = (): NativeTorrentStreamingModule | undefined => {
|
||||||
|
const modules = NativeModules as any;
|
||||||
|
return (
|
||||||
|
(modules.TorrentStreamingModule as NativeTorrentStreamingModule | undefined) ||
|
||||||
|
(modules.NuvioTorrentStreamingModule as NativeTorrentStreamingModule | undefined)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PreparedTorrentPlayback = {
|
||||||
|
streamId: string;
|
||||||
|
playbackUrl: string;
|
||||||
|
infoHash?: string;
|
||||||
|
fileName?: string;
|
||||||
|
fileSize?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PreparePlaybackOptions = {
|
||||||
|
networkMbps?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
class TorrentStreamingService {
|
||||||
|
isNativeSupported(): boolean {
|
||||||
|
if (Platform.OS !== 'android' && Platform.OS !== 'ios') return false;
|
||||||
|
return !!getNativeModule();
|
||||||
|
}
|
||||||
|
|
||||||
|
isTorrentStream(stream: Partial<Stream> | null | undefined): boolean {
|
||||||
|
if (!stream) return false;
|
||||||
|
if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) return true;
|
||||||
|
return typeof stream.infoHash === 'string' && stream.infoHash.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLocalTorrentPlaybackUrl(url?: string | null): boolean {
|
||||||
|
if (!url) return false;
|
||||||
|
return /^http:\/\/(?:127\.0\.0\.1|localhost):\d+\/torrent\//.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async preparePlayback(
|
||||||
|
stream: Stream,
|
||||||
|
streamTitle?: string,
|
||||||
|
options?: PreparePlaybackOptions
|
||||||
|
): Promise<PreparedTorrentPlayback> {
|
||||||
|
const nativeModule = getNativeModule();
|
||||||
|
if (!this.isNativeSupported() || !nativeModule) {
|
||||||
|
throw new Error('Native torrent streaming is not available on this device.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const magnetUri = this.buildMagnetUri(stream, streamTitle);
|
||||||
|
if (!magnetUri) {
|
||||||
|
throw new Error('Missing torrent identifier. Expected magnet URL or infoHash.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackers = this.extractTrackerUrls(stream);
|
||||||
|
const fileIndex = typeof stream.fileIdx === 'number' && Number.isFinite(stream.fileIdx)
|
||||||
|
? stream.fileIdx
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const payload: NativePrepareInput = {
|
||||||
|
magnetUri,
|
||||||
|
streamTitle: streamTitle || stream.title || stream.name,
|
||||||
|
fileIndex,
|
||||||
|
trackers,
|
||||||
|
networkMbps:
|
||||||
|
typeof options?.networkMbps === 'number' && Number.isFinite(options.networkMbps)
|
||||||
|
? Math.max(0.1, options.networkMbps)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await nativeModule.prepareStream(payload);
|
||||||
|
if (!result?.playbackUrl || !result?.streamId) {
|
||||||
|
throw new Error('Native module did not return a playback URL.');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('[TorrentStreamingService] Prepared torrent playback', {
|
||||||
|
streamId: result.streamId,
|
||||||
|
playbackUrl: result.playbackUrl,
|
||||||
|
infoHash: result.infoHash,
|
||||||
|
fileName: result.fileName,
|
||||||
|
fileSize: result.fileSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
streamId: result.streamId,
|
||||||
|
playbackUrl: result.playbackUrl,
|
||||||
|
infoHash: result.infoHash,
|
||||||
|
fileName: result.fileName,
|
||||||
|
fileSize: result.fileSize,
|
||||||
|
mimeType: result.mimeType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopStream(streamId?: string): Promise<void> {
|
||||||
|
const nativeModule = getNativeModule();
|
||||||
|
if (!this.isNativeSupported() || !nativeModule || !streamId) return;
|
||||||
|
try {
|
||||||
|
await nativeModule.stopStream(streamId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('[TorrentStreamingService] Failed to stop torrent stream', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopAll(): Promise<void> {
|
||||||
|
const nativeModule = getNativeModule();
|
||||||
|
if (!this.isNativeSupported() || !nativeModule) return;
|
||||||
|
try {
|
||||||
|
await nativeModule.stopAllStreams();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('[TorrentStreamingService] Failed to stop all torrent streams', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMagnetUri(stream: Stream, streamTitle?: string): string | null {
|
||||||
|
if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) {
|
||||||
|
return stream.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof stream.infoHash === 'string' && stream.infoHash.trim().length > 0) {
|
||||||
|
const trackers = this.extractTrackerUrls(stream);
|
||||||
|
const encodedName = encodeURIComponent(streamTitle || stream.title || stream.name || 'Torrent Stream');
|
||||||
|
const trackersQuery = trackers.map(tr => `&tr=${encodeURIComponent(tr)}`).join('');
|
||||||
|
return `magnet:?xt=urn:btih:${stream.infoHash.trim()}&dn=${encodedName}${trackersQuery}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTrackerUrls(stream: Stream): string[] {
|
||||||
|
const out = new Set<string>();
|
||||||
|
|
||||||
|
if (Array.isArray(stream.sources)) {
|
||||||
|
stream.sources.forEach(source => {
|
||||||
|
if (typeof source === 'string' && source.startsWith('tracker:')) {
|
||||||
|
const tracker = source.slice('tracker:'.length).trim();
|
||||||
|
if (tracker) out.add(tracker);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) {
|
||||||
|
const query = stream.url.split('?')[1] || '';
|
||||||
|
query.split('&').forEach(param => {
|
||||||
|
const [key, value] = param.split('=');
|
||||||
|
if ((key || '').toLowerCase() === 'tr' && value) {
|
||||||
|
try {
|
||||||
|
const decoded = decodeURIComponent(value);
|
||||||
|
if (decoded) out.add(decoded);
|
||||||
|
} catch {
|
||||||
|
// ignore invalid tracker encoding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...out];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const torrentStreamingService = new TorrentStreamingService();
|
||||||
|
export default torrentStreamingService;
|
||||||
Loading…
Reference in a new issue