From 267f63ecff900d4f9f2a1b6bae22bd82b33b67e8 Mon Sep 17 00:00:00 2001 From: Israel Bill Date: Thu, 19 Feb 2026 17:02:16 +0300 Subject: [PATCH] Implement torrent streaming core, playback buffering, and standalone Android packaging fixes --- android/app/build.gradle | 22 +- android/app/proguard-rules.pro | 3 + .../java/com/nuvio/app/MainApplication.kt | 4 +- .../main/java/com/nuvio/app/mpv/MPVView.kt | 18 +- .../app/torrent/TorrentStreamingModule.kt | 933 ++++++++++++++++++ .../app/torrent/TorrentStreamingPackage.kt | 16 + android/build.gradle | 6 + docs/IOS_TORRENT_ENGINE_PATH.md | 97 ++ docs/TORRENT_ENGINE_RESEARCH_2026-02-19.md | 74 ++ package-lock.json | 66 +- src/components/StreamCard.tsx | 76 +- .../home/ContinueWatchingSection.tsx | 126 ++- src/components/player/AndroidVideoPlayer.tsx | 200 +++- src/components/player/KSPlayerCore.tsx | 109 +- .../android/components/VideoSurface.tsx | 15 +- .../player/modals/EpisodeStreamsModal.tsx | 49 +- .../player/modals/LoadingOverlay.tsx | 124 ++- src/components/player/modals/SourcesModal.tsx | 53 +- .../search/DiscoverBottomSheets.tsx | 7 +- src/components/search/DiscoverSection.tsx | 6 +- src/components/search/searchUtils.ts | 2 + src/contexts/DownloadsContext.tsx | 54 +- src/hooks/useSettings.ts | 30 +- src/navigation/AppNavigator.tsx | 10 +- src/screens/CatalogScreen.tsx | 11 +- src/screens/HomeScreen.tsx | 19 +- src/screens/HomeScreenSettings.tsx | 12 +- src/screens/LibraryScreen.tsx | 381 +++---- src/screens/SearchScreen.tsx | 6 +- src/screens/SimklSettingsScreen.tsx | 2 +- src/screens/streams/useStreamsScreen.ts | 217 +++- src/screens/streams/utils.ts | 514 ++++++++++ src/services/catalogService.ts | 62 +- src/services/pluginService.ts | 4 +- src/services/simklService.ts | 12 +- src/services/stremioService.ts | 26 +- src/services/torrentStreamingService.ts | 187 ++++ 37 files changed, 3146 insertions(+), 407 deletions(-) create mode 100644 android/app/src/main/java/com/nuvio/app/torrent/TorrentStreamingModule.kt create mode 100644 android/app/src/main/java/com/nuvio/app/torrent/TorrentStreamingPackage.kt create mode 100644 docs/IOS_TORRENT_ENGINE_PATH.md create mode 100644 docs/TORRENT_ENGINE_RESEARCH_2026-02-19.md create mode 100644 src/services/torrentStreamingService.ts diff --git a/android/app/build.gradle b/android/app/build.gradle index ce1d3fe4..1e5a378b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -4,6 +4,10 @@ apply plugin: "com.facebook.react" apply plugin: "io.sentry.android.gradle" def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() +def resolveTargetAbis = { + def configured = project.findProperty("reactNativeArchitectures") ?: "armeabi-v7a,arm64-v8a,x86,x86_64" + configured.split(",").collect { it.trim() }.findAll { it } +} /** * This is the configuration block to customize your React Native Android app. @@ -21,6 +25,11 @@ react { cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim()) bundleCommand = "export:embed" + // Optional: build a debug APK with embedded JS so it doesn't require Metro. + if ((findProperty("standaloneDebug") ?: "false").toBoolean()) { + debuggableVariants = [] + } + /* Folders */ // The root of your project, i.e. where "package.json" lives. Default is '../..' // root = file("../../") @@ -106,8 +115,8 @@ android { abi { enable true reset() - include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' - universalApk true + include(*resolveTargetAbis()) + universalApk(resolveTargetAbis().size() > 1) } density { enable false @@ -250,6 +259,15 @@ dependencies { // MPV Player library implementation files("libs/libmpv-release.aar") + // Torrent streaming engine + def jlibtorrentVersion = '2.0.12.7' + implementation("com.frostwire:jlibtorrent:${jlibtorrentVersion}") + implementation("com.frostwire:jlibtorrent-android-arm:${jlibtorrentVersion}") + implementation("com.frostwire:jlibtorrent-android-arm64:${jlibtorrentVersion}") + implementation("com.frostwire:jlibtorrent-android-x86:${jlibtorrentVersion}") + implementation("com.frostwire:jlibtorrent-android-x86_64:${jlibtorrentVersion}") + implementation("org.nanohttpd:nanohttpd:2.3.1") + // Google Cast Framework implementation "com.google.android.gms:play-services-cast-framework:${safeExtGet('castFrameworkVersion', '+')}" } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 4e639728..17ce8854 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -26,3 +26,6 @@ **[] $VALUES; public *; } + +# jlibtorrent JNI +-keep class com.frostwire.jlibtorrent.swig.libtorrent_jni { *; } diff --git a/android/app/src/main/java/com/nuvio/app/MainApplication.kt b/android/app/src/main/java/com/nuvio/app/MainApplication.kt index 497e21a1..69424820 100644 --- a/android/app/src/main/java/com/nuvio/app/MainApplication.kt +++ b/android/app/src/main/java/com/nuvio/app/MainApplication.kt @@ -16,6 +16,7 @@ import com.facebook.react.defaults.DefaultReactNativeHost import expo.modules.ApplicationLifecycleDispatcher import expo.modules.ReactNativeHostWrapper import com.nuvio.app.mpv.MpvPackage +import com.nuvio.app.torrent.TorrentStreamingPackage class MainApplication : Application(), ReactApplication { @@ -25,7 +26,8 @@ class MainApplication : Application(), ReactApplication { override fun getPackages(): List = PackageList(this).packages.apply { // Packages that cannot be autolinked yet can be added manually here, for example: - add(com.nuvio.app.mpv.MpvPackage()) + add(MpvPackage()) + add(TorrentStreamingPackage()) } override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt index f2f9cd4f..659a652f 100644 --- a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt +++ b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt @@ -156,12 +156,14 @@ class MPVView @JvmOverloads constructor( MPVLib.setOptionString("ao", "audiotrack,opensles") - // Limit demuxer cache based on Android version (like mpvKt) - val cacheMegs = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O_MR1) 64 else 32 + // Use a larger demuxer cache window to reduce rebuffering on unstable links. + val cacheMegs = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O_MR1) 192 else 96 MPVLib.setOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}") MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") MPVLib.setOptionString("cache", "yes") - MPVLib.setOptionString("cache-secs", "30") + MPVLib.setOptionString("cache-secs", "120") + MPVLib.setOptionString("demuxer-readahead-secs", "120") + MPVLib.setOptionString("cache-pause-wait", "5") MPVLib.setOptionString("network-timeout", "60") MPVLib.setOptionString("ytdl", "no") @@ -595,14 +597,20 @@ class MPVView @JvmOverloads constructor( MPV_EVENT_END_FILE -> { Log.d(TAG, "MPV_EVENT_END_FILE") - // Heuristic: If duration is effectively 0 at end of file, it's a load error + // Heuristic: certain links don't report duration reliably and still end correctly. + // Treat them as completed if we already played a meaningful amount. val duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0 val timePos = MPVLib.getPropertyDouble("time-pos") ?: 0.0 val eofReached = MPVLib.getPropertyBoolean("eof-reached") ?: false Log.d(TAG, "End stats - Duration: $duration, Time: $timePos, EOF: $eofReached") - if (duration < 1.0 && !eofReached) { + if (eofReached) { + onEndCallback?.invoke() + } else if (timePos > 10.0) { + // Playback advanced enough to consider this a normal end. + onEndCallback?.invoke() + } else if (duration < 1.0 && timePos < 1.0) { val customError = "Unable to play media. Source may be unreachable." Log.e(TAG, "Playback error detected (heuristic): $customError") onErrorCallback?.invoke(customError) diff --git a/android/app/src/main/java/com/nuvio/app/torrent/TorrentStreamingModule.kt b/android/app/src/main/java/com/nuvio/app/torrent/TorrentStreamingModule.kt new file mode 100644 index 00000000..00055b8f --- /dev/null +++ b/android/app/src/main/java/com/nuvio/app/torrent/TorrentStreamingModule.kt @@ -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() + + @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() + 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, + 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 { + if (trackers.isEmpty() || !magnetUri.startsWith("magnet:?")) { + return magnetUri + } + + val existingTrackers = mutableSetOf() + 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 { + val out = mutableListOf() + 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? { + 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 + } + } +} diff --git a/android/app/src/main/java/com/nuvio/app/torrent/TorrentStreamingPackage.kt b/android/app/src/main/java/com/nuvio/app/torrent/TorrentStreamingPackage.kt new file mode 100644 index 00000000..0d500419 --- /dev/null +++ b/android/app/src/main/java/com/nuvio/app/torrent/TorrentStreamingPackage.kt @@ -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 { + return listOf(TorrentStreamingModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +} diff --git a/android/build.gradle b/android/build.gradle index 9d7137b1..6bf77745 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -26,6 +26,12 @@ allprojects { google() mavenCentral() maven { url 'https://www.jitpack.io' } + maven { + url "https://dl.frostwire.com/maven" + content { + includeGroup "com.frostwire" + } + } } } diff --git a/docs/IOS_TORRENT_ENGINE_PATH.md b/docs/IOS_TORRENT_ENGINE_PATH.md new file mode 100644 index 00000000..047ac4f6 --- /dev/null +++ b/docs/IOS_TORRENT_ENGINE_PATH.md @@ -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:/torrent/`. + +## 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 diff --git a/docs/TORRENT_ENGINE_RESEARCH_2026-02-19.md b/docs/TORRENT_ENGINE_RESEARCH_2026-02-19.md new file mode 100644 index 00000000..ae9d4153 --- /dev/null +++ b/docs/TORRENT_ENGINE_RESEARCH_2026-02-19.md @@ -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 diff --git a/package-lock.json b/package-lock.json index e7c7a877..45f9fc43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1540,6 +1540,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -2097,6 +2098,7 @@ "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz", "integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==", "license": "MIT", + "peer": true, "dependencies": { "anser": "^1.4.9", "pretty-format": "^29.7.0", @@ -2585,6 +2587,7 @@ "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz", "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/core": "^0.22.12" } @@ -2770,6 +2773,7 @@ "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.13.5.tgz", "integrity": "sha512-4U5okwjRqDPkjB572hfZtLXJ/LGfCo6vDwUB2KIPEUoSgqbIlw+UrbnaqVp3GS+dRvhMD27F2JObpHpYRlpF0Q==", "license": "MIT", + "peer": true, "dependencies": { "@lottiefiles/dotlottie-web": "0.44.0" }, @@ -3125,7 +3129,7 @@ "version": "0.72.8", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz", "integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "invariant": "^2.2.4", @@ -3246,6 +3250,7 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.25.tgz", "integrity": "sha512-zQeWK9txDePWbYfqTs0C6jeRdJTm/7VhQtW/1IbJNDi9/rFIRzZule8bdQPAnf8QWUsNujRmi1J9OG/hhfbalg==", "license": "MIT", + "peer": true, "dependencies": { "@react-navigation/core": "^7.13.6", "escape-string-regexp": "^4.0.0", @@ -3887,6 +3892,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -4107,6 +4113,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4116,8 +4123,9 @@ "version": "0.72.8", "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz", "integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@react-native/virtualized-lists": "^0.72.4", "@types/react": "*" @@ -4653,6 +4661,7 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -5056,6 +5065,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6285,6 +6295,7 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.29.tgz", "integrity": "sha512-9C90gyOzV83y2S3XzCbRDCuKYNaiyCzuP9ketv46acHCEZn+QTamPK/DobdghoSiofCmlfoaiD6/SzfxDiHMnw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.19", @@ -6488,6 +6499,7 @@ "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz", "integrity": "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==", "license": "MIT", + "peer": true, "dependencies": { "ua-parser-js": "^0.7.33" }, @@ -6515,6 +6527,7 @@ "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", "integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==", "license": "MIT", + "peer": true, "peerDependencies": { "expo": "*", "react-native": "*" @@ -6525,6 +6538,7 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.10.tgz", "integrity": "sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q==", "license": "MIT", + "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -6620,6 +6634,7 @@ "resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz", "integrity": "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==", "license": "MIT", + "peer": true, "dependencies": { "rtl-detect": "^1.0.2" }, @@ -7679,6 +7694,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -10585,6 +10601,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10625,6 +10642,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -10682,6 +10700,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.4.tgz", "integrity": "sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ==", "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.4", @@ -10770,6 +10789,7 @@ "resolved": "https://registry.npmjs.org/react-native-bottom-tabs/-/react-native-bottom-tabs-1.1.0.tgz", "integrity": "sha512-Uu1gvM3i1Hb4DjVvR/38J1QVQEs0RkPc7K6yon99HgvRWWOyLs7kjPDhUswtb8ije4pKW712skIXWJ0lgKzbyQ==", "license": "MIT", + "peer": true, "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", @@ -10800,6 +10820,7 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.29.1.tgz", "integrity": "sha512-du3qmv0e3Sm7qsd9SfmHps+AggLiylcBBQ8ztz7WUtd8ZjKs5V3kekAbi9R2W9bRLSg47Ntp4GGMYZOhikQdZA==", "license": "MIT", + "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -10898,6 +10919,7 @@ "integrity": "sha512-hcvjTu9YJE9fMmnAUvhG8CxvYLpOuMQ/2eyi/S6GyrecezF6Rmk/uRQEL6v09BRFWA/xRVZNQVulQPS+2HS3mQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -10963,6 +10985,7 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.0.tgz", "integrity": "sha512-frhu5b8/m/VvaMWz48V8RxcsXnE3hrlErQ5chr21MzAeDCpY4X14sQjvm+jvu3aOI+7Cz2atdRpyhhIuqxVaXg==", "license": "MIT", + "peer": true, "dependencies": { "react-native-is-edge-to-edge": "1.2.1", "semver": "7.7.3" @@ -11002,6 +11025,7 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -11012,6 +11036,7 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.18.0.tgz", "integrity": "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ==", "license": "MIT", + "peer": true, "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" @@ -11026,6 +11051,7 @@ "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.1.tgz", "integrity": "sha512-ZUD1xwc3Hwo4cOmOLumjJVoc7lEf9oQFlHnLmgccLC19fNm6LVEdtB+Cnip6gEi0PG3wfvVzskViEtrySQP8Fw==", "license": "MIT", + "peer": true, "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", @@ -11254,6 +11280,7 @@ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", @@ -11510,6 +11537,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12976,6 +13004,24 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -13030,6 +13076,19 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -13104,8 +13163,9 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/components/StreamCard.tsx b/src/components/StreamCard.tsx index 1849c9e0..c2d0eecb 100644 --- a/src/components/StreamCard.tsx +++ b/src/components/StreamCard.tsx @@ -12,7 +12,6 @@ import { import { MaterialIcons } from '@expo/vector-icons'; import FastImage from '@d11/react-native-fast-image'; import { Stream } from '../types/metadata'; -import QualityBadge from './metadata/QualityBadge'; import { useSettings } from '../hooks/useSettings'; import { useDownloads } from '../contexts/DownloadsContext'; import { useToast } from '../contexts/ToastContext'; @@ -90,9 +89,28 @@ const StreamCard = memo(({ const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); + const getViabilityColor = useCallback((label?: string): string => { + switch (label) { + case 'Excellent': + return theme.colors.success; + case 'Good': + return theme.colors.primary; + case 'Fair': + return theme.colors.warning || '#C9A227'; + case 'Risky': + return theme.colors.error || '#C23C3C'; + default: + return theme.colors.darkGray; + } + }, [theme.colors]); + const streamInfo = useMemo(() => { const title = stream.title || ''; const name = stream.name || ''; + const hints = (stream.behaviorHints || {}) as any; + const playbackViability = hints.playbackViability as + | { label?: string; availableMbps?: number; requiredMbps?: number; seeders?: number; peers?: number } + | undefined; // Helper function to format size from bytes const formatSize = (bytes: number): string => { @@ -118,10 +136,24 @@ const StreamCard = memo(({ isDolby: title.toLowerCase().includes('dolby') || title.includes('DV'), size: sizeDisplay, isDebrid: stream.behaviorHints?.cached, + isTorrent: + (stream.url || '').startsWith('magnet:') || + !!stream.infoHash || + hints.type === 'torrent' || + !!hints.infoHash, + viabilityLabel: playbackViability?.label, + viabilityAvailableMbps: playbackViability?.availableMbps, + viabilityRequiredMbps: playbackViability?.requiredMbps, + seeders: + typeof hints.seeders === 'number' + ? hints.seeders + : typeof playbackViability?.seeders === 'number' + ? playbackViability.seeders + : undefined, displayName: name || 'Unnamed Stream', subTitle: title && title !== name ? title : null }; - }, [stream.name, stream.title, stream.behaviorHints, stream.size]); + }, [stream.name, stream.title, stream.behaviorHints, stream.size, stream.url, stream.infoHash]); const handleDownload = useCallback(async () => { try { @@ -241,8 +273,46 @@ const StreamCard = memo(({ + {streamInfo.viabilityLabel && ( + + + {streamInfo.viabilityLabel.toUpperCase()} + + + )} + + {streamInfo.isTorrent && ( + + TORRENT + + )} + + {streamInfo.isHDR && ( + + HDR + + )} + {streamInfo.isDolby && ( - + + DOLBY + + )} + + {typeof streamInfo.seeders === 'number' && ( + + + SEEDS {streamInfo.seeders} + + + )} + + {typeof streamInfo.viabilityRequiredMbps === 'number' && typeof streamInfo.viabilityAvailableMbps === 'number' && ( + + + {`${streamInfo.viabilityRequiredMbps.toFixed(1)} / ${streamInfo.viabilityAvailableMbps.toFixed(0)} Mbps`} + + )} {streamInfo.size && ( diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 62bdd020..cb6d3e99 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -829,8 +829,7 @@ const ContinueWatchingSection = React.forwardRef((props, re lastTraktSyncRef.current = now; - // Fetch only playback progress (paused items with actual progress %) - // Removed: history items and watched shows - redundant with local logic + // Fetch playback progress (paused items with actual progress %) const playbackItems = await traktService.getPlaybackProgress(); try { @@ -851,6 +850,61 @@ const ContinueWatchingSection = React.forwardRef((props, re // ignore } + const normalizeImdbId = (imdbId?: string): string | null => { + if (!imdbId) return null; + const trimmed = String(imdbId).trim(); + if (!trimmed) return null; + return trimmed.startsWith('tt') ? trimmed : `tt${trimmed}`; + }; + + const watchedEpisodeTimestampsByShow = new Map>(); + 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(); + watchedEpisodeTimestampsByShow.set(showImdb, showMap); + } + const existing = showMap.get(episodeKey) ?? 0; + if (safeTimestamp >= existing) { + showMap.set(episodeKey, safeTimestamp); + } + }; + + let watchedShows: any[] = []; + try { + watchedShows = await traktService.getWatchedShows(); + for (const watchedShow of watchedShows) { + const normalizedShowId = normalizeImdbId(watchedShow?.show?.ids?.imdb); + if (!normalizedShowId) continue; + + if (Array.isArray(watchedShow?.seasons)) { + for (const season of watchedShow.seasons) { + if (!Array.isArray(season?.episodes)) continue; + for (const episode of season.episodes) { + upsertWatchedEpisodeTimestamp( + normalizedShowId, + Number(season.number), + Number(episode.number), + episode?.last_watched_at + ); + } + } + } + } + } catch (err) { + logger.warn('[TraktSync] Error preloading watched shows:', err); + watchedShows = []; + } + const traktBatch: ContinueWatchingItem[] = []; @@ -901,15 +955,28 @@ const ContinueWatchingSection = React.forwardRef((props, re logger.log(`📺 [TraktPlayback] Adding movie ${item.movie.title} with ${item.progress.toFixed(1)}% progress`); } else if (item.type === 'episode' && item.show?.ids?.imdb && item.episode) { - const showImdb = item.show.ids.imdb.startsWith('tt') - ? item.show.ids.imdb - : `tt${item.show.ids.imdb}`; + const showImdb = normalizeImdbId(item.show.ids.imdb); + if (!showImdb) continue; // Check if recently removed const showKey = `series:${showImdb}`; if (recentlyRemovedRef.current.has(showKey)) continue; const pausedAt = new Date(item.paused_at).getTime(); + const safePausedAt = Number.isFinite(pausedAt) ? pausedAt : 0; + const episodeSeason = Number((item.episode as any).season); + const episodeNumber = Number((item.episode as any).number ?? (item.episode as any).episode); + if (!Number.isFinite(episodeSeason) || !Number.isFinite(episodeNumber)) continue; + + // If Trakt already marks this episode watched at/after this playback timestamp, + // treat this playback row as stale and skip it (prevents old episodes from resurfacing as Up Next). + const watchedEpisodeTs = watchedEpisodeTimestampsByShow + .get(showImdb) + ?.get(`${episodeSeason}:${episodeNumber}`); + if (typeof watchedEpisodeTs === 'number' && watchedEpisodeTs >= (safePausedAt - 5000)) { + logger.log(`[CW][Trakt] Skip stale playback ${showImdb} S${episodeSeason}E${episodeNumber}`); + continue; + } const cachedData = await getCachedMetadata('series', showImdb); if (!cachedData?.basicContent) continue; @@ -919,8 +986,8 @@ const ContinueWatchingSection = React.forwardRef((props, re const metadata = cachedData.metadata; if (metadata?.videos) { const nextEpisode = findNextEpisode( - item.episode.season, - item.episode.number, + episodeSeason, + episodeNumber, metadata.videos, undefined, // No watched set needed, findNextEpisode handles it showImdb @@ -933,7 +1000,7 @@ const ContinueWatchingSection = React.forwardRef((props, re id: showImdb, type: 'series', progress: 0, // Up next - no progress yet - lastUpdated: pausedAt, + lastUpdated: safePausedAt, season: nextEpisode.season, episode: nextEpisode.episode, episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`, @@ -950,39 +1017,35 @@ const ContinueWatchingSection = React.forwardRef((props, re id: showImdb, type: 'series', progress: item.progress, - lastUpdated: pausedAt, - season: item.episode.season, - episode: item.episode.number, - episodeTitle: item.episode.title || `Episode ${item.episode.number}`, + lastUpdated: safePausedAt, + season: episodeSeason, + episode: episodeNumber, + episodeTitle: item.episode.title || `Episode ${episodeNumber}`, addonId: undefined, traktPlaybackId: item.id, // Store playback ID for removal } as ContinueWatchingItem); - logger.log(`📺 [TraktPlayback] Adding ${item.show.title} S${item.episode.season}E${item.episode.number} with ${item.progress.toFixed(1)}% progress`); + logger.log(`📺 [TraktPlayback] Adding ${item.show.title} S${episodeSeason}E${episodeNumber} with ${item.progress.toFixed(1)}% progress`); } } catch (err) { // Continue with other items } } - // STEP 2: Get watched shows and find "Up Next" episodes - // This handles cases where episodes are fully completed and removed from playback progress + // STEP 2: Use watched shows to find "Up Next" episodes + // This handles cases where episodes are fully completed and removed from playback progress. try { - const watchedShows = await traktService.getWatchedShows(); const thirtyDaysAgoForShows = Date.now() - (30 * 24 * 60 * 60 * 1000); for (const watchedShow of watchedShows) { try { - if (!watchedShow.show?.ids?.imdb) continue; + const showImdb = normalizeImdbId(watchedShow?.show?.ids?.imdb); + if (!showImdb) continue; // Skip shows that haven't been watched recently const lastWatchedAt = new Date(watchedShow.last_watched_at).getTime(); if (lastWatchedAt < thirtyDaysAgoForShows) continue; - const showImdb = watchedShow.show.ids.imdb.startsWith('tt') - ? watchedShow.show.ids.imdb - : `tt${watchedShow.show.ids.imdb}`; - // Check if recently removed const showKey = `series:${showImdb}`; if (recentlyRemovedRef.current.has(showKey)) continue; @@ -996,7 +1059,15 @@ const ContinueWatchingSection = React.forwardRef((props, re for (const season of watchedShow.seasons) { for (const episode of season.episodes) { const episodeTimestamp = new Date(episode.last_watched_at).getTime(); - if (episodeTimestamp > latestEpisodeTimestamp) { + const isMoreRecent = episodeTimestamp > latestEpisodeTimestamp; + const isTimestampTieWithLaterEpisode = + episodeTimestamp === latestEpisodeTimestamp && + ( + season.number > lastWatchedSeason || + (season.number === lastWatchedSeason && episode.number > lastWatchedEpisode) + ); + + if (isMoreRecent || isTimestampTieWithLaterEpisode) { latestEpisodeTimestamp = episodeTimestamp; lastWatchedSeason = season.number; lastWatchedEpisode = episode.number; @@ -1013,11 +1084,10 @@ const ContinueWatchingSection = React.forwardRef((props, re // Build a set of watched episodes for this show const watchedEpisodeSet = new Set(); - if (watchedShow.seasons) { - for (const season of watchedShow.seasons) { - for (const episode of season.episodes) { - watchedEpisodeSet.add(`${showImdb}:${season.number}:${episode.number}`); - } + const watchedEpisodeMap = watchedEpisodeTimestampsByShow.get(showImdb); + if (watchedEpisodeMap && watchedEpisodeMap.size > 0) { + for (const episodeKey of watchedEpisodeMap.keys()) { + watchedEpisodeSet.add(`${showImdb}:${episodeKey}`); } } @@ -1310,7 +1380,7 @@ const ContinueWatchingSection = React.forwardRef((props, re // ignore } - setContinueWatchingItems(adjustedItems); + await mergeBatchIntoState(adjustedItems); // Fire-and-forget reconcile (don't block UI) if (reconcilePromises.length > 0) { diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 331937f3..c1eab0f2 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -58,6 +58,7 @@ import { styles } from './utils/playerStyles'; import { formatTime, isHlsStream, getHlsHeaders, defaultAndroidHeaders, parseSRT } from './utils/playerUtils'; import { storageService } from '../../services/storageService'; import stremioService from '../../services/stremioService'; +import { torrentStreamingService } from '../../services/torrentStreamingService'; import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes'; import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils'; import { useTheme } from '../../contexts/ThemeContext'; @@ -74,7 +75,7 @@ const AndroidVideoPlayer: React.FC = () => { const { uri, title = 'Episode Name', season, episode, episodeTitle, quality, year, streamProvider, streamName, headers, id, type, episodeId, imdbId, - availableStreams: passedAvailableStreams, backdrop, groupedEpisodes + availableStreams: passedAvailableStreams, backdrop, groupedEpisodes, torrentStreamId } = route.params; // --- State & Custom Hooks --- @@ -107,6 +108,10 @@ const AndroidVideoPlayer: React.FC = () => { const [useExoPlayer, setUseExoPlayer] = useState(!shouldUseMpvOnly); const hasExoPlayerFailed = useRef(false); const [showMpvSwitchAlert, setShowMpvSwitchAlert] = useState(false); + const [openingBufferProgress, setOpeningBufferProgress] = useState(0); + const openingBufferProgressRef = useRef(0); + const openingOverlayCompletedRef = useRef(false); + const openingFallbackTimeoutRef = useRef(null); // Sync useExoPlayer with settings when videoPlayerEngine is set to 'mpv' @@ -234,6 +239,43 @@ const AndroidVideoPlayer: React.FC = () => { }); const fadeAnim = useRef(new Animated.Value(1)).current; + const initialBufferTargetSec = useMemo(() => { + const isTorrentPlayback = !!torrentStreamId || torrentStreamingService.isLocalTorrentPlaybackUrl(currentStreamUrl); + if (isTorrentPlayback) { + return 28; + } + const looksLikeHls = + isHlsStream(currentStreamUrl) || + currentVideoType === 'm3u8' || + currentVideoType === 'hls'; + return looksLikeHls ? 12 : 20; + }, [currentStreamUrl, currentVideoType, torrentStreamId]); + + const completeOpeningOverlay = useCallback((force = false) => { + if (openingOverlayCompletedRef.current) return; + if (!force && openingBufferProgressRef.current < 0.999) return; + + openingOverlayCompletedRef.current = true; + if (openingFallbackTimeoutRef.current) { + clearTimeout(openingFallbackTimeoutRef.current); + openingFallbackTimeoutRef.current = null; + } + + openingBufferProgressRef.current = 1; + setOpeningBufferProgress(1); + openingAnimation.completeOpeningAnimation(); + }, [openingAnimation]); + + const updateOpeningBufferProgress = useCallback((nextProgress: number, forceComplete = false) => { + const clamped = Math.max(openingBufferProgressRef.current, Math.max(0, Math.min(1, nextProgress))); + if (clamped !== openingBufferProgressRef.current) { + openingBufferProgressRef.current = clamped; + setOpeningBufferProgress(clamped); + } + if (forceComplete || clamped >= 0.999) { + completeOpeningOverlay(true); + } + }, [completeOpeningOverlay]); useEffect(() => { Animated.timing(fadeAnim, { @@ -274,6 +316,26 @@ const AndroidVideoPlayer: React.FC = () => { openingAnimation.startOpeningAnimation(); }, []); + useEffect(() => { + openingOverlayCompletedRef.current = false; + openingBufferProgressRef.current = 0; + setOpeningBufferProgress(0); + + if (openingFallbackTimeoutRef.current) { + clearTimeout(openingFallbackTimeoutRef.current); + openingFallbackTimeoutRef.current = null; + } + }, [currentStreamUrl, torrentStreamId, currentVideoType]); + + useEffect(() => { + return () => { + if (openingFallbackTimeoutRef.current) { + clearTimeout(openingFallbackTimeoutRef.current); + openingFallbackTimeoutRef.current = null; + } + }; + }, []); + // Load subtitle settings on mount useEffect(() => { const loadSubtitleSettings = async () => { @@ -367,7 +429,14 @@ const AndroidVideoPlayer: React.FC = () => { } playerState.setIsVideoLoaded(true); - openingAnimation.completeOpeningAnimation(); + updateOpeningBufferProgress(0.2); + + if (openingFallbackTimeoutRef.current) { + clearTimeout(openingFallbackTimeoutRef.current); + } + openingFallbackTimeoutRef.current = setTimeout(() => { + completeOpeningOverlay(true); + }, 12000); // Auto-select audio track based on preferences if (data.audioTracks && data.audioTracks.length > 0 && settings?.preferredAudioLanguage) { @@ -437,16 +506,48 @@ const AndroidVideoPlayer: React.FC = () => { } }, 300); } - }, [id, type, episodeId, playerState.isMounted, watchProgress.initialPosition, useExoPlayer]); + }, [id, type, episodeId, playerState.isMounted, watchProgress.initialPosition, useExoPlayer, updateOpeningBufferProgress, completeOpeningOverlay]); const handleProgress = useCallback((data: any) => { if (playerState.isDragging.current || playerState.isSeeking.current || !playerState.isMounted.current || setupHook.isAppBackgrounded.current) return; const currentTimeInSeconds = data.currentTime; + const playableDuration = data.playableDuration || currentTimeInSeconds; + const bufferAhead = Math.max(0, playableDuration - currentTimeInSeconds); + + if (!openingOverlayCompletedRef.current) { + const progressFloor = playerState.isVideoLoaded ? 0.22 : 0.08; + const progress = Math.max(progressFloor, Math.min(1, bufferAhead / initialBufferTargetSec)); + const shouldComplete = playerState.isVideoLoaded && progress >= 1; + updateOpeningBufferProgress(progress, shouldComplete); + } + if (Math.abs(currentTimeInSeconds - playerState.currentTime) > 0.5) { playerState.setCurrentTime(currentTimeInSeconds); - playerState.setBuffered(data.playableDuration || currentTimeInSeconds); + playerState.setBuffered(playableDuration); } - }, [playerState.currentTime, playerState.isDragging, playerState.isSeeking, setupHook.isAppBackgrounded]); + }, [playerState.currentTime, playerState.isDragging, playerState.isSeeking, setupHook.isAppBackgrounded, playerState.isVideoLoaded, initialBufferTargetSec, updateOpeningBufferProgress]); + + const isLikelyTailPlaybackError = useCallback((message: string): boolean => { + const lower = (message || '').toLowerCase(); + const isKnownTailIoError = + lower.includes('error_code_io_unspecified') || + lower.includes('22000') || + lower.includes('unable to play media. source may be unreachable') || + lower.includes('source may be unreachable') || + lower.includes('source error'); + + if (!isKnownTailIoError) return false; + + const current = playerState.currentTime || 0; + const duration = playerState.duration || 0; + + if (duration > 0) { + const remaining = duration - current; + return current > 0 && (remaining <= 2.5 || current / duration >= 0.985); + } + + return current > 45; + }, [playerState.currentTime, playerState.duration]); // Auto-select subtitles when both internal tracks and video are loaded // This ensures we wait for internal tracks before falling back to external @@ -514,9 +615,20 @@ const AndroidVideoPlayer: React.FC = () => { const controlsTimeout = useRef(null); const handleClose = useCallback(() => { + if (torrentStreamId) { + void torrentStreamingService.stopStream(torrentStreamId); + } if (navigation.canGoBack()) navigation.goBack(); else navigation.reset({ index: 0, routes: [{ name: 'Home' }] } as any); - }, [navigation]); + }, [navigation, torrentStreamId]); + + useEffect(() => { + return () => { + if (torrentStreamId) { + void torrentStreamingService.stopStream(torrentStreamId); + } + }; + }, [torrentStreamId]); // Handle codec errors from ExoPlayer - silently switch to MPV const handleCodecError = useCallback(() => { @@ -555,9 +667,34 @@ const AndroidVideoPlayer: React.FC = () => { }, 500); }, [playerState.currentTime]); + const getEstimatedNetworkMbpsFromStream = useCallback((stream: any): number | undefined => { + const hinted = stream?.behaviorHints?.playbackViability?.availableMbps; + if (typeof hinted === 'number' && Number.isFinite(hinted) && hinted > 0) { + return hinted; + } + return undefined; + }, []); const handleSelectStream = async (newStream: any) => { - if (newStream.url === currentStreamUrl) { + let resolvedUri = newStream.url; + let nextTorrentStreamId: string | undefined; + + if (torrentStreamingService.isNativeSupported() && torrentStreamingService.isTorrentStream(newStream)) { + try { + const prepared = await torrentStreamingService.preparePlayback( + newStream, + title || newStream?.title || newStream?.name, + { networkMbps: getEstimatedNetworkMbpsFromStream(newStream) } + ); + resolvedUri = prepared.playbackUrl; + nextTorrentStreamId = prepared.streamId; + } catch (error: any) { + toast.error(error?.message || 'Failed to initialize torrent stream'); + return; + } + } + + if (resolvedUri === currentStreamUrl) { modals.setShowSourcesModal(false); return; } @@ -575,18 +712,38 @@ const AndroidVideoPlayer: React.FC = () => { setTimeout(() => { (navigation as any).replace('PlayerAndroid', { ...route.params, - uri: newStream.url, + uri: resolvedUri, quality: newQuality, streamProvider: newProvider, streamName: newStreamName, - headers: newStream.headers, - availableStreams: availableStreams + headers: nextTorrentStreamId ? undefined : newStream.headers, + availableStreams: availableStreams, + torrentStreamId: nextTorrentStreamId, }); }, 300); }; const handleEpisodeStreamSelect = async (stream: any) => { if (!modals.selectedEpisodeForStreams) return; + + let resolvedUri = stream.url; + let nextTorrentStreamId: string | undefined; + + if (torrentStreamingService.isNativeSupported() && torrentStreamingService.isTorrentStream(stream)) { + try { + const prepared = await torrentStreamingService.preparePlayback( + stream, + title || stream?.title || stream?.name, + { networkMbps: getEstimatedNetworkMbpsFromStream(stream) } + ); + resolvedUri = prepared.playbackUrl; + nextTorrentStreamId = prepared.streamId; + } catch (error: any) { + toast.error(error?.message || 'Failed to initialize torrent stream'); + return; + } + } + modals.setShowEpisodeStreamsModal(false); playerState.setPaused(true); @@ -602,7 +759,7 @@ const AndroidVideoPlayer: React.FC = () => { // Wait for unmount to complete, then navigate setTimeout(() => { (navigation as any).replace('PlayerAndroid', { - uri: stream.url, + uri: resolvedUri, title: title, episodeTitle: ep.name, season: ep.season_number, @@ -611,7 +768,7 @@ const AndroidVideoPlayer: React.FC = () => { year: year, streamProvider: newProvider, streamName: newStreamName, - headers: stream.headers || undefined, + headers: nextTorrentStreamId ? undefined : (stream.headers || undefined), id, type: 'series', episodeId: ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`, @@ -619,6 +776,7 @@ const AndroidVideoPlayer: React.FC = () => { backdrop: backdrop || undefined, availableStreams: {}, groupedEpisodes: groupedEpisodes, + torrentStreamId: nextTorrentStreamId, }); }, 300); }; @@ -745,6 +903,8 @@ const AndroidVideoPlayer: React.FC = () => { backdrop={backdrop || null} hasLogo={hasLogo} logo={metadata?.logo} + loadingTitle={episodeTitle || title} + loadingProgress={openingBufferProgress} backgroundFadeAnim={openingAnimation.backgroundFadeAnim} backdropImageOpacityAnim={openingAnimation.backdropImageOpacityAnim} onClose={handleClose} @@ -792,11 +952,27 @@ const AndroidVideoPlayer: React.FC = () => { displayError = JSON.stringify(err); } + if (isLikelyTailPlaybackError(displayError)) { + logger.warn('[AndroidVideoPlayer] Treating tail I/O error as graceful end', { + error: displayError, + currentTime: playerState.currentTime, + duration: playerState.duration, + }); + + if (!modals.showEpisodeStreamsModal) { + playerState.setPaused(true); + } + return; + } + modals.setErrorDetails(displayError); modals.setShowErrorModal(true); }} onBuffer={(buf) => { playerState.setIsBuffering(buf.isBuffering); + if (!buf.isBuffering && playerState.isVideoLoaded && openingBufferProgressRef.current >= 0.8) { + completeOpeningOverlay(true); + } }} onTracksChanged={(data) => { console.log('[AndroidVideoPlayer] onTracksChanged:', data); diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 231d71d8..2527d482 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -48,6 +48,7 @@ import { useTraktAutosync } from '../../hooks/useTraktAutosync'; import { useMetadata } from '../../hooks/useMetadata'; import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls'; import stremioService from '../../services/stremioService'; +import { torrentStreamingService } from '../../services/torrentStreamingService'; import { logger } from '../../utils/logger'; // Utils @@ -78,6 +79,7 @@ interface PlayerRouteParams { availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; headers?: Record; initialPosition?: number; + torrentStreamId?: string; } const KSPlayerCore: React.FC = () => { @@ -92,7 +94,8 @@ const KSPlayerCore: React.FC = () => { uri, title, episodeTitle, season, episode, id, type, quality, year, episodeId, imdbId, backdrop, availableStreams, headers, streamProvider, streamName, - initialPosition: routeInitialPosition + initialPosition: routeInitialPosition, + torrentStreamId } = params; const videoType = (params as any)?.videoType as string | undefined; @@ -115,10 +118,11 @@ const KSPlayerCore: React.FC = () => { streamProvider, streamName, videoType, + torrentStreamId, headersKeys: headerKeys, headersCount: headerKeys.length, }); - }, [uri, episodeId]); + }, [uri, episodeId, torrentStreamId]); useEffect(() => { if (!__DEV__) return; @@ -566,9 +570,20 @@ const KSPlayerCore: React.FC = () => { // The useWatchProgress and useTraktAutosync hooks handle cleanup on unmount traktAutosync.handleProgressUpdate(currentTime, duration, true); traktAutosync.handlePlaybackEnd(currentTime, duration, 'user_close'); + if (torrentStreamId) { + void torrentStreamingService.stopStream(torrentStreamId); + } navigation.goBack(); - }, [navigation, currentTime, duration, traktAutosync]); + }, [navigation, currentTime, duration, traktAutosync, torrentStreamId]); + + useEffect(() => { + return () => { + if (torrentStreamId) { + void torrentStreamingService.stopStream(torrentStreamId); + } + }; + }, [torrentStreamId]); // Track selection handlers - update state, prop change triggers native update const handleSelectTextTrack = useCallback((trackId: number) => { @@ -591,9 +606,44 @@ const KSPlayerCore: React.FC = () => { } }, [tracks, ksPlayerRef]); + const getEstimatedNetworkMbpsFromStream = useCallback((stream: any): number | undefined => { + const hinted = stream?.behaviorHints?.playbackViability?.availableMbps; + if (typeof hinted === 'number' && Number.isFinite(hinted) && hinted > 0) { + return hinted; + } + return undefined; + }, []); + // Stream selection handler const handleSelectStream = async (newStream: any) => { - if (newStream.url === uri) { + let resolvedUri = newStream?.url; + let nextTorrentStreamId: string | undefined; + let resolvedHeaders = newStream?.headers; + + const shouldPrepareNativeTorrent = + torrentStreamingService.isNativeSupported() && + torrentStreamingService.isTorrentStream(newStream); + + if (shouldPrepareNativeTorrent) { + try { + const prepared = await torrentStreamingService.preparePlayback( + newStream, + title || newStream?.title || newStream?.name, + { networkMbps: getEstimatedNetworkMbpsFromStream(newStream) } + ); + resolvedUri = prepared.playbackUrl; + nextTorrentStreamId = prepared.streamId; + resolvedHeaders = undefined; + } catch (error: any) { + modals.setErrorDetails(error?.message || 'Failed to initialize torrent playback.'); + modals.setShowErrorModal(true); + return; + } + } + + if (!resolvedUri) return; + + if (resolvedUri === uri && !nextTorrentStreamId) { modals.setShowSourcesModal(false); return; } @@ -601,10 +651,11 @@ const KSPlayerCore: React.FC = () => { if (__DEV__) { logger.log('[KSPlayerCore] switching stream', { fromUri: typeof uri === 'string' ? uri.slice(0, 240) : uri, - toUri: typeof newStream?.url === 'string' ? newStream.url.slice(0, 240) : newStream?.url, + toUri: typeof resolvedUri === 'string' ? resolvedUri.slice(0, 240) : resolvedUri, newStreamHeadersKeys: Object.keys(newStream?.headers || {}), newProvider: newStream?.addonName || newStream?.name || newStream?.addon || 'Unknown', newName: newStream?.name || newStream?.title || 'Unknown', + nextTorrentStreamId, }); } @@ -616,14 +667,18 @@ const KSPlayerCore: React.FC = () => { const newStreamName = newStream.name || newStream.title || 'Unknown'; setTimeout(() => { + if (torrentStreamId && torrentStreamId !== nextTorrentStreamId) { + void torrentStreamingService.stopStream(torrentStreamId); + } (navigation as any).replace('PlayerIOS', { ...params, - uri: newStream.url, + uri: resolvedUri, quality: newQuality, streamProvider: newProvider, streamName: newStreamName, - headers: newStream.headers, - availableStreams: availableStreams + headers: resolvedHeaders, + availableStreams: availableStreams, + torrentStreamId: nextTorrentStreamId, }); }, 100); }; @@ -638,13 +693,40 @@ const KSPlayerCore: React.FC = () => { // Episode stream selection handler - navigates to new episode with selected stream const handleEpisodeStreamSelect = async (stream: any) => { if (!modals.selectedEpisodeForStreams) return; + let resolvedUri = stream?.url; + let nextTorrentStreamId: string | undefined; + let resolvedHeaders = stream?.headers || undefined; + + const shouldPrepareNativeTorrent = + torrentStreamingService.isNativeSupported() && + torrentStreamingService.isTorrentStream(stream); + + if (shouldPrepareNativeTorrent) { + try { + const prepared = await torrentStreamingService.preparePlayback( + stream, + title || stream?.title || stream?.name, + { networkMbps: getEstimatedNetworkMbpsFromStream(stream) } + ); + resolvedUri = prepared.playbackUrl; + nextTorrentStreamId = prepared.streamId; + resolvedHeaders = undefined; + } catch (error: any) { + modals.setErrorDetails(error?.message || 'Failed to initialize torrent playback.'); + modals.setShowErrorModal(true); + return; + } + } + + if (!resolvedUri) return; + modals.setShowEpisodeStreamsModal(false); setPaused(true); const ep = modals.selectedEpisodeForStreams; if (__DEV__) { logger.log('[KSPlayerCore] switching episode stream', { - toUri: typeof stream?.url === 'string' ? stream.url.slice(0, 240) : stream?.url, + toUri: typeof resolvedUri === 'string' ? resolvedUri.slice(0, 240) : resolvedUri, streamHeadersKeys: Object.keys(stream?.headers || {}), ep: { season: ep?.season_number, @@ -652,6 +734,7 @@ const KSPlayerCore: React.FC = () => { name: ep?.name, stremioId: ep?.stremioId, }, + nextTorrentStreamId, }); } @@ -660,8 +743,11 @@ const KSPlayerCore: React.FC = () => { const newStreamName = stream.name || stream.title || 'Unknown Stream'; setTimeout(() => { + if (torrentStreamId && torrentStreamId !== nextTorrentStreamId) { + void torrentStreamingService.stopStream(torrentStreamId); + } (navigation as any).replace('PlayerIOS', { - uri: stream.url, + uri: resolvedUri, title: title, episodeTitle: ep.name, season: ep.season_number, @@ -670,12 +756,13 @@ const KSPlayerCore: React.FC = () => { year: year, streamProvider: newProvider, streamName: newStreamName, - headers: stream.headers || undefined, + headers: resolvedHeaders, id, type: 'series', episodeId: ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number} `, imdbId: imdbId ?? undefined, backdrop: backdrop || undefined, + torrentStreamId: nextTorrentStreamId, }); }, 100); }; diff --git a/src/components/player/android/components/VideoSurface.tsx b/src/components/player/android/components/VideoSurface.tsx index 57321d56..1ef57ae3 100644 --- a/src/components/player/android/components/VideoSurface.tsx +++ b/src/components/player/android/components/VideoSurface.tsx @@ -338,14 +338,17 @@ export const VideoSurface: React.FC = ({ requestHeaders: exoRequestHeadersArray, ...(resolvedRnVideoType ? { type: resolvedRnVideoType } : null), bufferConfig: { - minBufferMs: 10000, - maxBufferMs: 20000, - bufferForPlaybackMs: 2000, - bufferForPlaybackAfterRebufferMs: 4000, + minBufferMs: 45000, + maxBufferMs: 1800000, + bufferForPlaybackMs: 2500, + bufferForPlaybackAfterRebufferMs: 6000, + backBufferDurationMs: 300000, // @ts-ignore - Extra props supported by patched react-native-video - minBufferMemoryReservePercent: 0.15, + minBufferMemoryReservePercent: 0.05, // @ts-ignore - Extra props supported by patched react-native-video - maxHeapAllocationPercent: 0.25, + maxHeapAllocationPercent: 0.55, + // @ts-ignore - Extra props supported by patched react-native-video + cacheSizeMB: 2048, } } as any} paused={paused} diff --git a/src/components/player/modals/EpisodeStreamsModal.tsx b/src/components/player/modals/EpisodeStreamsModal.tsx index 68a901e4..9e19220a 100644 --- a/src/components/player/modals/EpisodeStreamsModal.tsx +++ b/src/components/player/modals/EpisodeStreamsModal.tsx @@ -1,5 +1,6 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, StyleSheet, Platform, useWindowDimensions } from 'react-native'; +import NetInfo from '@react-native-community/netinfo'; import { MaterialIcons } from '@expo/vector-icons'; import Animated, { FadeIn, @@ -12,6 +13,11 @@ import { Episode } from '../../../types/metadata'; import { Stream } from '../../../types/streams'; import { stremioService } from '../../../services/stremioService'; import { logger } from '../../../utils/logger'; +import { + estimateNetworkProfile, + getPlaybackViabilityFromStream, + rankStreamsByPlaybackViability, +} from '../../../screens/streams/utils'; interface EpisodeStreamsModalProps { visible: boolean; @@ -66,6 +72,7 @@ export const EpisodeStreamsModal: React.FC = ({ const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: Stream[]; addonName: string } }>({}); const [isLoading, setIsLoading] = useState(false); const [hasErrors, setHasErrors] = useState([]); + const [networkMbps, setNetworkMbps] = useState(20); useEffect(() => { if (visible && episode && metadata?.id) { @@ -77,6 +84,20 @@ export const EpisodeStreamsModal: React.FC = ({ } }, [visible, episode, metadata?.id]); + useEffect(() => { + const unsubscribe = NetInfo.addEventListener(state => { + setNetworkMbps(estimateNetworkProfile(state as any).estimatedDownlinkMbps); + }); + + NetInfo.fetch() + .then(state => setNetworkMbps(estimateNetworkProfile(state as any).estimatedDownlinkMbps)) + .catch(() => { + // Keep default profile. + }); + + return () => unsubscribe(); + }, []); + const fetchStreams = async () => { if (!episode || !metadata?.id) return; @@ -137,9 +158,17 @@ export const EpisodeStreamsModal: React.FC = ({ return match ? match[1] : null; }; - if (!visible) return null; + const sortedProviders = useMemo>(() => { + 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 ( @@ -218,6 +247,7 @@ export const EpisodeStreamsModal: React.FC = ({ {providerData.streams.map((stream, index) => { const quality = getQualityFromTitle(stream.title) || stream.quality; + const viability = getPlaybackViabilityFromStream(stream as any); return ( = ({ {stream.name || t('player_ui.unknown_source')} + {viability?.label ? ( + + + {viability.label.toUpperCase()} + + + ) : null} {stream.title && ( diff --git a/src/components/player/modals/LoadingOverlay.tsx b/src/components/player/modals/LoadingOverlay.tsx index 37819a7f..1fb95e71 100644 --- a/src/components/player/modals/LoadingOverlay.tsx +++ b/src/components/player/modals/LoadingOverlay.tsx @@ -1,5 +1,5 @@ -import React, { useEffect } from 'react'; -import { View, TouchableOpacity, Animated, ActivityIndicator, StyleSheet, Image } from 'react-native'; +import React, { useEffect, useState } from 'react'; +import { View, TouchableOpacity, Animated, ActivityIndicator, StyleSheet, Image, Text } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import Reanimated, { @@ -18,6 +18,8 @@ interface LoadingOverlayProps { backdrop: string | null | undefined; hasLogo: boolean; logo: string | null | undefined; + loadingTitle?: string; + loadingProgress?: number; backgroundFadeAnim: Animated.Value; backdropImageOpacityAnim: Animated.Value; onClose: () => void; @@ -30,6 +32,8 @@ const LoadingOverlay: React.FC = ({ backdrop, hasLogo, logo, + loadingTitle, + loadingProgress = 0, backgroundFadeAnim, backdropImageOpacityAnim, onClose, @@ -38,6 +42,9 @@ const LoadingOverlay: React.FC = ({ }) => { const logoOpacity = useSharedValue(0); const logoScale = useSharedValue(1); + const titleScale = useSharedValue(1); + const titleOpacity = useSharedValue(1); + const [titleWidth, setTitleWidth] = useState(0); useEffect(() => { if (visible && hasLogo && logo) { @@ -74,13 +81,53 @@ const LoadingOverlay: React.FC = ({ } }, [visible, hasLogo, logo]); + useEffect(() => { + if (!visible) return; + + titleScale.value = 1; + titleOpacity.value = 0.9; + + titleScale.value = withRepeat( + withSequence( + withTiming(1.03, { + duration: 900, + easing: Easing.inOut(Easing.ease), + }), + withTiming(1, { + duration: 900, + easing: Easing.inOut(Easing.ease), + }) + ), + -1, + false + ); + + titleOpacity.value = withRepeat( + withSequence( + withTiming(0.95, { duration: 900, easing: Easing.inOut(Easing.ease) }), + withTiming(0.75, { duration: 900, easing: Easing.inOut(Easing.ease) }) + ), + -1, + false + ); + }, [visible]); + const logoAnimatedStyle = useAnimatedStyle(() => ({ opacity: logoOpacity.value, transform: [{ scale: logoScale.value }], })); + const titleAnimatedStyle = useAnimatedStyle(() => ({ + opacity: titleOpacity.value, + transform: [{ scale: titleScale.value }], + })); + if (!visible) return null; + const clampedProgress = Math.max(0, Math.min(1, loadingProgress)); + const progressPercent = `${Math.round(clampedProgress * 100)}%` as `${number}%`; + const progressWidth = titleWidth > 0 ? titleWidth * clampedProgress : 0; + return ( = ({ - {hasLogo && logo ? ( + {loadingTitle ? ( + + 0 ? { width: titleWidth } : null]}> + setTitleWidth(event.nativeEvent.layout.width)} + > + {loadingTitle} + + + 0 ? { width: titleWidth } : null]}> + {loadingTitle} + + + + + + + + ) : hasLogo && logo ? ( = ({ ); }; +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; diff --git a/src/components/player/modals/SourcesModal.tsx b/src/components/player/modals/SourcesModal.tsx index c07c6c47..af9ca1ed 100644 --- a/src/components/player/modals/SourcesModal.tsx +++ b/src/components/player/modals/SourcesModal.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, StyleSheet, Platform, useWindowDimensions } from 'react-native'; +import NetInfo from '@react-native-community/netinfo'; import { MaterialIcons } from '@expo/vector-icons'; import Animated, { FadeIn, @@ -9,6 +10,11 @@ import Animated, { } from 'react-native-reanimated'; import { useTranslation } from 'react-i18next'; import { Stream } from '../../../types/streams'; +import { + estimateNetworkProfile, + getPlaybackViabilityFromStream, + rankStreamsByPlaybackViability, +} from '../../../screens/streams/utils'; interface SourcesModalProps { showSourcesModal: boolean; @@ -61,14 +67,35 @@ export const SourcesModal: React.FC = ({ const { t } = useTranslation(); const { width } = useWindowDimensions(); const MENU_WIDTH = Math.min(width * 0.85, 400); + const [networkMbps, setNetworkMbps] = useState(20); const handleClose = () => { setShowSourcesModal(false); }; - if (!showSourcesModal) return null; + useEffect(() => { + const unsubscribe = NetInfo.addEventListener(state => { + setNetworkMbps(estimateNetworkProfile(state as any).estimatedDownlinkMbps); + }); - const sortedProviders = Object.entries(availableStreams); + NetInfo.fetch() + .then(state => setNetworkMbps(estimateNetworkProfile(state as any).estimatedDownlinkMbps)) + .catch(() => { + // Keep default profile. + }); + + return () => unsubscribe(); + }, []); + + const sortedProviders = useMemo>(() => { + return Object.entries(availableStreams).map(([providerId, providerData]) => [ + providerId, + { + ...providerData, + streams: rankStreamsByPlaybackViability((providerData.streams as any) || [], networkMbps) as any as Stream[], + }, + ]); + }, [availableStreams, networkMbps]); const handleStreamSelect = (stream: Stream) => { if (stream.url !== currentStreamUrl && !isChangingSource) { @@ -86,6 +113,8 @@ export const SourcesModal: React.FC = ({ return stream.url === currentStreamUrl; }; + if (!showSourcesModal) return null; + return ( {/* Backdrop */} @@ -168,6 +197,7 @@ export const SourcesModal: React.FC = ({ {providerData.streams.map((stream, index) => { const isSelected = isStreamSelected(stream); const quality = getQualityFromTitle(stream.title) || stream.quality; + const viability = getPlaybackViabilityFromStream(stream as any); return ( = ({ }} numberOfLines={1}> {stream.title || stream.name || t('player_ui.stream', { number: index + 1 })} + {viability?.label ? ( + + + {viability.label.toUpperCase()} + + + ) : null} diff --git a/src/components/search/DiscoverBottomSheets.tsx b/src/components/search/DiscoverBottomSheets.tsx index 87d17d0d..7bf34bee 100644 --- a/src/components/search/DiscoverBottomSheets.tsx +++ b/src/components/search/DiscoverBottomSheets.tsx @@ -37,6 +37,9 @@ export const DiscoverBottomSheets = ({ currentTheme, }: DiscoverBottomSheetsProps) => { const { t } = useTranslation(); + const isYearFilter = selectedCatalog?.filterLabel === 'year'; + const filterTitle = isYearFilter ? 'Select Year' : t('search.select_genre'); + const allFilterLabel = isYearFilter ? 'All Years' : t('search.all_genres'); const typeSnapPoints = useMemo(() => ['25%'], []); const catalogSnapPoints = useMemo(() => ['50%'], []); @@ -140,7 +143,7 @@ export const DiscoverBottomSheets = ({ > - {t('search.select_genre')} + {filterTitle} genreSheetRef.current?.dismiss()}> @@ -160,7 +163,7 @@ export const DiscoverBottomSheets = ({ > - {t('search.all_genres')} + {allFilterLabel} {t('search.show_all_content')} diff --git a/src/components/search/DiscoverSection.tsx b/src/components/search/DiscoverSection.tsx index db83a562..feeac8fe 100644 --- a/src/components/search/DiscoverSection.tsx +++ b/src/components/search/DiscoverSection.tsx @@ -55,6 +55,8 @@ export const DiscoverSection = ({ currentTheme, }: DiscoverSectionProps) => { const { t } = useTranslation(); + const isYearFilter = selectedCatalog?.filterLabel === 'year'; + const allFilterLabel = isYearFilter ? 'All Years' : t('search.all_genres'); return ( @@ -94,14 +96,14 @@ export const DiscoverSection = ({ - {/* Genre Selector Chip - only show if catalog has genres */} + {/* Filter Selector Chip - only show if catalog has options */} {availableGenres.length > 0 && ( genreSheetRef.current?.present()} > - {selectedDiscoverGenre || t('search.all_genres')} + {selectedDiscoverGenre || allFilterLabel} diff --git a/src/components/search/searchUtils.ts b/src/components/search/searchUtils.ts index dd30880d..a6e7538d 100644 --- a/src/components/search/searchUtils.ts +++ b/src/components/search/searchUtils.ts @@ -10,6 +10,8 @@ export interface DiscoverCatalog { catalogName: string; type: string; genres: string[]; + filterKey?: string | null; + filterLabel?: 'genre' | 'year' | 'filter'; } // Enhanced responsive breakpoints diff --git a/src/contexts/DownloadsContext.tsx b/src/contexts/DownloadsContext.tsx index 4f25e2a7..ffacec9b 100644 --- a/src/contexts/DownloadsContext.tsx +++ b/src/contexts/DownloadsContext.tsx @@ -240,6 +240,7 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi // Keep active native background tasks in memory (not persisted) const tasksRef = useRef>(new Map()); const lastBytesRef = useRef>(new Map()); + const lastProgressUiUpdateRef = useRef>(new Map()); // Persist and restore useEffect(() => { @@ -438,18 +439,36 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi lastBytesRef.current.set(taskId, { bytes: bytesDownloaded, time: now }); } - updateDownload(taskId, (d) => ({ - ...d, - downloadedBytes: typeof bytesDownloaded === 'number' ? bytesDownloaded : d.downloadedBytes, - totalBytes: typeof bytesTotal === 'number' && bytesTotal > 0 ? bytesTotal : d.totalBytes, - progress: - typeof bytesDownloaded === 'number' && typeof bytesTotal === 'number' && bytesTotal > 0 - ? Math.floor((bytesDownloaded / bytesTotal) * 100) - : d.progress, - speedBps, - status: 'downloading', - updatedAt: now, - })); + const computedTotalBytes = typeof bytesTotal === 'number' && bytesTotal > 0 ? bytesTotal : undefined; + const computedProgress = + typeof bytesDownloaded === 'number' && computedTotalBytes && computedTotalBytes > 0 + ? Math.floor((bytesDownloaded / computedTotalBytes) * 100) + : undefined; + + // Throttle state updates during active downloads to prevent UI jank. + const prevUi = lastProgressUiUpdateRef.current.get(taskId); + const shouldUpdateUi = !prevUi + || now - prevUi.time >= 500 + || (typeof computedProgress === 'number' && computedProgress !== prevUi.progress) + || (typeof bytesDownloaded === 'number' && Math.abs(bytesDownloaded - prevUi.bytes) >= 2 * 1024 * 1024); + + if (shouldUpdateUi) { + updateDownload(taskId, (d) => ({ + ...d, + downloadedBytes: typeof bytesDownloaded === 'number' ? bytesDownloaded : d.downloadedBytes, + totalBytes: computedTotalBytes ?? d.totalBytes, + progress: typeof computedProgress === 'number' ? computedProgress : d.progress, + speedBps, + status: 'downloading', + updatedAt: now, + })); + + lastProgressUiUpdateRef.current.set(taskId, { + time: now, + progress: computedProgress ?? prevUi?.progress ?? 0, + bytes: typeof bytesDownloaded === 'number' ? bytesDownloaded : prevUi?.bytes ?? 0, + }); + } const current = downloadsRef.current.find(x => x.id === taskId); if (current && typeof bytesDownloaded === 'number') { @@ -489,6 +508,7 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi tasksRef.current.delete(taskId); lastBytesRef.current.delete(taskId); + lastProgressUiUpdateRef.current.delete(taskId); }) .error(({ error }: any) => { updateDownload(taskId, (d) => ({ @@ -501,6 +521,9 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi stopLiveActivityForDownload(taskId, { title: current?.title, subtitle: 'Error', progressPercent: current?.progress }); console.log(`[DownloadsContext] Background download error: ${taskId}`, error); + tasksRef.current.delete(taskId); + lastBytesRef.current.delete(taskId); + lastProgressUiUpdateRef.current.delete(taskId); }); }, [maybeNotifyProgress, maybeUpdateLiveActivity, notifyCompleted, stopLiveActivityForDownload, updateDownload]); @@ -602,6 +625,7 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi } tasksRef.current.delete(d.id); lastBytesRef.current.delete(d.id); + lastProgressUiUpdateRef.current.delete(d.id); } } catch { // Ignore per-item refresh failures @@ -820,6 +844,7 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi } finally { tasksRef.current.delete(id); lastBytesRef.current.delete(id); + lastProgressUiUpdateRef.current.delete(id); } const item = downloadsRef.current.find(d => d.id === id); @@ -832,6 +857,9 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi const removeDownload = useCallback(async (id: string) => { const item = downloadsRef.current.find(d => d.id === id); await stopLiveActivityForDownload(id, { title: item?.title, subtitle: 'Removed', progressPercent: item?.progress }); + tasksRef.current.delete(id); + lastBytesRef.current.delete(id); + lastProgressUiUpdateRef.current.delete(id); if (item?.fileUri && item.status === 'completed') { await FileSystem.deleteAsync(item.fileUri, { idempotent: true }).catch(() => { }); } @@ -862,5 +890,3 @@ export function useDownloads(): DownloadsContextValue { if (!ctx) throw new Error('useDownloads must be used within DownloadsProvider'); return ctx; } - - diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 71093b7e..7f6dd8b9 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -289,10 +289,36 @@ export const useSettings = () => { value: AppSettings[K], emitEvent: boolean = true ) => { - const newSettings = { ...settings, [key]: value }; try { const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`; + + const parseSettings = (json: string | null): Partial | null => { + if (!json) return null; + try { + return JSON.parse(json) as Partial; + } catch { + return null; + } + }; + + // Merge from persisted values first to avoid overwriting unrelated keys with defaults + // when an update occurs before initial settings hydration completes. + const [scopedJson, legacyJson] = await Promise.all([ + mmkvStorage.getItem(scopedKey), + mmkvStorage.getItem(SETTINGS_STORAGE_KEY), + ]); + const persistedScoped = parseSettings(scopedJson) || {}; + const persistedLegacy = parseSettings(legacyJson) || {}; + const memorySettings = isLoaded ? settings : {}; + const newSettings = { + ...DEFAULT_SETTINGS, + ...persistedLegacy, + ...persistedScoped, + ...memorySettings, + [key]: value, + }; + // Write to both scoped key (multi-user aware) and legacy key for backward compatibility await Promise.all([ mmkvStorage.setItem(scopedKey, JSON.stringify(newSettings)), @@ -317,7 +343,7 @@ export const useSettings = () => { } catch (error) { if (__DEV__) console.error('Failed to save settings:', error); } - }, [settings]); + }, [isLoaded, settings]); return { settings, diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index a818fcf2..a6be8b0a 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -152,6 +152,7 @@ export type RootStackParamList = { availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; backdrop?: string; videoType?: string; + torrentStreamId?: string; groupedEpisodes?: { [seasonNumber: number]: any[] }; }; PlayerAndroid: { @@ -172,6 +173,7 @@ export type RootStackParamList = { availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; backdrop?: string; videoType?: string; + torrentStreamId?: string; groupedEpisodes?: { [seasonNumber: number]: any[] }; }; Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string }; @@ -1315,13 +1317,13 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta component={MetadataScreen} options={{ headerShown: false, - animation: Platform.OS === 'android' ? 'fade' : 'fade', - animationDuration: Platform.OS === 'android' ? 200 : 300, + animation: Platform.OS === 'android' ? 'default' : 'fade', + animationDuration: Platform.OS === 'android' ? 250 : 300, + gestureEnabled: true, + gestureDirection: 'horizontal', ...(Platform.OS === 'ios' && { cardStyleInterpolator: customFadeInterpolator, animationTypeForReplace: 'push', - gestureEnabled: true, - gestureDirection: 'horizontal', }), contentStyle: { backgroundColor: currentTheme.colors.darkBackground, diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index 5eedf574..cdafc1bd 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -607,7 +607,14 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { const addon = manifests.find(a => a.id === addonId); if (!addon) throw new Error(`Addon ${addonId} not found`); - const filters = effectiveGenreFilter ? [{ title: 'genre', value: effectiveGenreFilter }] : []; + const filters = Object.entries(selectedFilters) + .filter(([, value]) => !!value) + .map(([name, value]) => ({ title: name, value })); + + if (effectiveGenreFilter) { + filters.push({ title: 'genre', value: effectiveGenreFilter }); + } + const catalogItems = await stremioService.getCatalog(addon, type, id, pageParam, filters); logger.log('[CatalogScreen] Fetched addon catalog page', { @@ -705,7 +712,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { logger.log('[CatalogScreen] loadItems finished'); }); } - }, [addonId, type, id, activeGenreFilter, dataSource]); + }, [addonId, type, id, activeGenreFilter, selectedFilters, dataSource]); useEffect(() => { loadItems(true, 1); diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index e5dd2eb1..0fa12976 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -27,6 +27,7 @@ import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; import { StreamingContent, CatalogContent, catalogService } from '../services/catalogService'; import { stremioService } from '../services/stremioService'; +import { torrentStreamingService } from '../services/torrentStreamingService'; import { Stream } from '../types/metadata'; import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; @@ -546,6 +547,16 @@ const HomeScreen = () => { if (!featuredContent) return; try { + let playbackUri = stream.url as any; + let torrentStreamId: string | undefined; + + if (Platform.OS === 'android' && torrentStreamingService.isTorrentStream(stream)) { + showInfo('Preparing torrent stream...'); + const prepared = await torrentStreamingService.preparePlayback(stream, featuredContent.name); + playbackUri = prepared.playbackUrl; + torrentStreamId = prepared.streamId; + } + // Don't clear cache before player - causes broken images on return // FastImage's native libraries handle memory efficiently @@ -564,13 +575,14 @@ const HomeScreen = () => { // @ts-ignore navigation.navigate(Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid', { - uri: stream.url as any, + uri: playbackUri, title: featuredContent.name, year: featuredContent.year, quality: stream.title?.match(/(\d+)p/)?.[1] || undefined, streamProvider: stream.name, id: featuredContent.id, - type: featuredContent.type + type: featuredContent.type, + torrentStreamId, }); } catch (error) { logger.error('[HomeScreen] Error in handlePlayStream:', error); @@ -588,7 +600,7 @@ const HomeScreen = () => { type: featuredContent.type }); } - }, [featuredContent, navigation]); + }, [featuredContent, navigation, showInfo]); const refreshContinueWatching = useCallback(async () => { if (continueWatchingRef.current) { @@ -1482,4 +1494,3 @@ const HomeScreenWithFocusSync = (props: any) => { }; export default React.memo(HomeScreenWithFocusSync); - diff --git a/src/screens/HomeScreenSettings.tsx b/src/screens/HomeScreenSettings.tsx index 7e563916..f1457cd7 100644 --- a/src/screens/HomeScreenSettings.tsx +++ b/src/screens/HomeScreenSettings.tsx @@ -110,7 +110,7 @@ const SectionHeader: React.FC<{ title: string; isDarkMode: boolean; colors: any const HomeScreenSettings: React.FC = () => { const { t } = useTranslation(); - const { settings, updateSetting } = useSettings(); + const { settings, updateSetting, isLoaded } = useSettings(); const systemColorScheme = useColorScheme(); const { currentTheme } = useTheme(); const colors = currentTheme.colors; @@ -168,12 +168,16 @@ const HomeScreenSettings: React.FC = () => { // Ensure carousel is the default hero layout on tablets for all users useEffect(() => { + if (!isLoaded || !isTabletDevice) { + return; + } + try { - if (isTabletDevice && settings.heroStyle !== 'carousel') { + if (settings.heroStyle !== 'carousel') { updateSetting('heroStyle', 'carousel' as any); } } catch { } - }, [isTabletDevice, settings.heroStyle, updateSetting]); + }, [isLoaded, isTabletDevice, settings.heroStyle, updateSetting]); const CustomSwitch = ({ value, onValueChange }: { value: boolean, onValueChange: (value: boolean) => void }) => ( { - const [posterUrl, setPosterUrl] = useState(null); + const inlinePoster = + typeof item.poster === 'string' && + item.poster.length > 0 && + item.poster !== 'placeholder' + ? item.poster + : null; + const [posterUrl, setPosterUrl] = useState(inlinePoster); useEffect(() => { let isMounted = true; const fetchPoster = async () => { + if (isMounted) { + setPosterUrl(inlinePoster); + } + if (item.images) { const url = TraktService.getTraktPosterUrl(item.images); if (isMounted && url) { @@ -153,7 +163,7 @@ const TraktItem = React.memo(({ }; fetchPoster(); return () => { isMounted = false; }; - }, [item.images, item.imdbId, item.traktId, item.type]); + }, [item.images, item.imdbId, item.poster, item.traktId, item.type, inlinePoster]); const handlePress = useCallback(() => { if (item.imdbId) { @@ -315,6 +325,10 @@ const LibraryScreen = () => { loadAllCollections: loadSimklCollections } = useSimklContext(); + // Show only one provider tab to reduce clutter: prefer SIMKL when available. + const showSimklTab = simklAuthenticated; + const showTraktTab = traktAuthenticated && !showSimklTab; + useEffect(() => { const applyStatusBarConfig = () => { StatusBar.setBarStyle('light-content'); @@ -354,6 +368,17 @@ const LibraryScreen = () => { return () => backHandler.remove(); }, [showTraktContent, showSimklContent, selectedTraktFolder, selectedSimklFolder]); + useEffect(() => { + if (!showTraktTab && showTraktContent) { + setShowTraktContent(false); + setSelectedTraktFolder(null); + } + if (!showSimklTab && showSimklContent) { + setShowSimklContent(false); + setSelectedSimklFolder(null); + } + }, [showTraktTab, showSimklTab, showTraktContent, showSimklContent]); + useEffect(() => { const loadLibrary = async () => { setLoading(true); @@ -894,232 +919,144 @@ const LibraryScreen = () => { }); }, [watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); - const getSimklFolderItems = useCallback((folderId: string): TraktDisplayItem[] => { - const items: TraktDisplayItem[] = []; + const normalizeImdbId = useCallback((imdbId?: string): string | undefined => { + if (!imdbId) return undefined; + const trimmed = String(imdbId).trim(); + if (!trimmed) return undefined; + return trimmed.startsWith('tt') ? trimmed : `tt${trimmed}`; + }, []); - switch (folderId) { - case 'continue-watching': - return (simklContinueWatching || []).map(item => { - const content = item.show || item.movie; - return { - id: String(content?.ids?.simkl || Math.random()), - name: content?.title || 'Unknown', - type: item.show ? 'series' : 'movie', - poster: '', - year: content?.year, - lastWatched: item.paused_at, - imdbId: content?.ids?.imdb, - traktId: content?.ids?.simkl || 0, - }; - }); + const getSimklContent = useCallback((item: any) => { + return item?.anime || item?.show || item?.movie || item || null; + }, []); - case 'watching-shows': - return (watchingShows || []).map(item => ({ - id: String(item.show?.ids?.simkl || Math.random()), - name: item.show?.title || 'Unknown', - type: 'series' as const, - poster: '', - year: item.show?.year, - lastWatched: item.last_watched_at, - rating: item.user_rating, - imdbId: item.show?.ids?.imdb, - traktId: item.show?.ids?.simkl || 0, - })); + const buildSimklDisplayItem = useCallback(( + item: any, + fallbackType: 'series' | 'movie', + lastWatched?: string, + rating?: number + ): TraktDisplayItem => { + const content = getSimklContent(item); + const ids = content?.ids || {}; + const name = typeof content?.title === 'string' && content.title.trim().length > 0 + ? content.title + : 'Unknown'; + const fallbackId = `simkl-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${content?.year || 'na'}`; + const simklId = ids.simkl ?? ids.tmdb ?? ids.imdb ?? fallbackId; + const poster = typeof content?.poster === 'string' ? content.poster : ''; + const type = item?.movie ? 'movie' : fallbackType; - case 'watching-movies': - return (watchingMovies || []).map(item => ({ - id: String(item.movie?.ids?.simkl || Math.random()), - name: item.movie?.title || 'Unknown', - type: 'movie' as const, - poster: '', - year: item.movie?.year, - lastWatched: item.last_watched_at, - rating: item.user_rating, - imdbId: item.movie?.ids?.imdb, - traktId: item.movie?.ids?.simkl || 0, - })); + return { + id: String(simklId), + name, + type, + poster, + year: content?.year, + lastWatched, + rating, + imdbId: normalizeImdbId(ids.imdb), + traktId: typeof ids.simkl === 'number' ? ids.simkl : 0, + }; + }, [getSimklContent, normalizeImdbId]); - case 'watching-anime': - return (watchingAnime || []).map(item => ({ - id: String(item.anime?.ids?.simkl || Math.random()), - name: item.anime?.title || 'Unknown', - type: 'series' as const, - poster: '', - year: item.anime?.year, - lastWatched: item.last_watched_at, - rating: item.user_rating, - imdbId: item.anime?.ids?.imdb, - traktId: item.anime?.ids?.simkl || 0, - })); - - case 'plantowatch-shows': - return (planToWatchShows || []).map(item => ({ - id: String(item.show?.ids?.simkl || Math.random()), - name: item.show?.title || 'Unknown', - type: 'series' as const, - poster: '', - year: item.show?.year, - lastWatched: item.added_to_watchlist_at, - imdbId: item.show?.ids?.imdb, - traktId: item.show?.ids?.simkl || 0, - })); - - case 'plantowatch-movies': - return (planToWatchMovies || []).map(item => ({ - id: String(item.movie?.ids?.simkl || Math.random()), - name: item.movie?.title || 'Unknown', - type: 'movie' as const, - poster: '', - year: item.movie?.year, - lastWatched: item.added_to_watchlist_at, - imdbId: item.movie?.ids?.imdb, - traktId: item.movie?.ids?.simkl || 0, - })); - - case 'plantowatch-anime': - return (planToWatchAnime || []).map(item => ({ - id: String(item.anime?.ids?.simkl || Math.random()), - name: item.anime?.title || 'Unknown', - type: 'series' as const, - poster: '', - year: item.anime?.year, - lastWatched: item.added_to_watchlist_at, - imdbId: item.anime?.ids?.imdb, - traktId: item.anime?.ids?.simkl || 0, - })); - - case 'completed-shows': - return (completedShows || []).map(item => ({ - id: String(item.show?.ids?.simkl || Math.random()), - name: item.show?.title || 'Unknown', - type: 'series' as const, - poster: '', - year: item.show?.year, - lastWatched: item.last_watched_at, - imdbId: item.show?.ids?.imdb, - traktId: item.show?.ids?.simkl || 0, - })); - - case 'completed-movies': - return (completedMovies || []).map(item => ({ - id: String(item.movie?.ids?.simkl || Math.random()), - name: item.movie?.title || 'Unknown', - type: 'movie' as const, - poster: '', - year: item.movie?.year, - lastWatched: item.last_watched_at, - imdbId: item.movie?.ids?.imdb, - traktId: item.movie?.ids?.simkl || 0, - })); - - case 'completed-anime': - return (completedAnime || []).map(item => ({ - id: String(item.anime?.ids?.simkl || Math.random()), - name: item.anime?.title || 'Unknown', - type: 'series' as const, - poster: '', - year: item.anime?.year, - lastWatched: item.last_watched_at, - imdbId: item.anime?.ids?.imdb, - traktId: item.anime?.ids?.simkl || 0, - })); - - case 'onhold-shows': - return (onHoldShows || []).map(item => ({ - id: String(item.show?.ids?.simkl || Math.random()), - name: item.show?.title || 'Unknown', - type: 'series' as const, - poster: '', - year: item.show?.year, - lastWatched: item.last_watched_at, - imdbId: item.show?.ids?.imdb, - traktId: item.show?.ids?.simkl || 0, - })); - - case 'onhold-movies': - return (onHoldMovies || []).map(item => ({ - id: String(item.movie?.ids?.simkl || Math.random()), - name: item.movie?.title || 'Unknown', - type: 'movie' as const, - poster: '', - year: item.movie?.year, - lastWatched: item.last_watched_at, - imdbId: item.movie?.ids?.imdb, - traktId: item.movie?.ids?.simkl || 0, - })); - - case 'onhold-anime': - return (onHoldAnime || []).map(item => ({ - id: String(item.anime?.ids?.simkl || Math.random()), - name: item.anime?.title || 'Unknown', - type: 'series' as const, - poster: '', - year: item.anime?.year, - lastWatched: item.last_watched_at, - imdbId: item.anime?.ids?.imdb, - traktId: item.anime?.ids?.simkl || 0, - })); - - case 'dropped-shows': - return (droppedShows || []).map(item => ({ - id: String(item.show?.ids?.simkl || Math.random()), - name: item.show?.title || 'Unknown', - type: 'series' as const, - poster: '', - year: item.show?.year, - lastWatched: item.last_watched_at, - imdbId: item.show?.ids?.imdb, - traktId: item.show?.ids?.simkl || 0, - })); - - case 'dropped-movies': - return (droppedMovies || []).map(item => ({ - id: String(item.movie?.ids?.simkl || Math.random()), - name: item.movie?.title || 'Unknown', - type: 'movie' as const, - poster: '', - year: item.movie?.year, - lastWatched: item.last_watched_at, - imdbId: item.movie?.ids?.imdb, - traktId: item.movie?.ids?.simkl || 0, - })); - - case 'dropped-anime': - return (droppedAnime || []).map(item => ({ - id: String(item.anime?.ids?.simkl || Math.random()), - name: item.anime?.title || 'Unknown', - type: 'series' as const, - poster: '', - year: item.anime?.year, - lastWatched: item.last_watched_at, - imdbId: item.anime?.ids?.imdb, - traktId: item.anime?.ids?.simkl || 0, - })); - - case 'ratings': - return (simklRatedContent || []).map(item => { - const content = item.show || item.movie || item.anime; - const type = item.show ? 'series' : item.movie ? 'movie' : 'series'; - return { - id: String(content?.ids?.simkl || Math.random()), - name: content?.title || 'Unknown', - type, - poster: '', - year: content?.year, - lastWatched: item.rated_at, - rating: item.rating, - imdbId: content?.ids?.imdb, - traktId: content?.ids?.simkl || 0, - }; - }); - } - - return items.sort((a, b) => { + const sortDisplayItems = useCallback((items: TraktDisplayItem[]) => { + return [...items].sort((a, b) => { const dateA = a.lastWatched ? new Date(a.lastWatched).getTime() : 0; const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0; return dateB - dateA; }); - }, [simklContinueWatching, watchingShows, watchingMovies, watchingAnime, planToWatchShows, planToWatchMovies, planToWatchAnime, completedShows, completedMovies, completedAnime, onHoldShows, onHoldMovies, onHoldAnime, droppedShows, droppedMovies, droppedAnime, simklRatedContent]); + }, []); + + const getSimklFolderItems = useCallback((folderId: string): TraktDisplayItem[] => { + switch (folderId) { + case 'continue-watching': + return sortDisplayItems((simklContinueWatching || []).map(item => ( + buildSimklDisplayItem(item, item.show ? 'series' : 'movie', item.paused_at) + ))); + + case 'watching-shows': + return sortDisplayItems((watchingShows || []).map(item => ( + buildSimklDisplayItem(item, 'series', item.last_watched_at, item.user_rating) + ))); + + case 'watching-movies': + return sortDisplayItems((watchingMovies || []).map(item => ( + buildSimklDisplayItem(item, 'movie', item.last_watched_at, item.user_rating) + ))); + + case 'watching-anime': + return sortDisplayItems((watchingAnime || []).map(item => ( + buildSimklDisplayItem(item, 'series', item.last_watched_at, item.user_rating) + ))); + + case 'plantowatch-shows': + return sortDisplayItems((planToWatchShows || []).map(item => ( + buildSimklDisplayItem(item, 'series', item.added_to_watchlist_at) + ))); + + case 'plantowatch-movies': + return sortDisplayItems((planToWatchMovies || []).map(item => ( + buildSimklDisplayItem(item, 'movie', item.added_to_watchlist_at) + ))); + + case 'plantowatch-anime': + return sortDisplayItems((planToWatchAnime || []).map(item => ( + buildSimklDisplayItem(item, 'series', item.added_to_watchlist_at) + ))); + + case 'completed-shows': + return sortDisplayItems((completedShows || []).map(item => ( + buildSimklDisplayItem(item, 'series', item.last_watched_at) + ))); + + case 'completed-movies': + return sortDisplayItems((completedMovies || []).map(item => ( + buildSimklDisplayItem(item, 'movie', item.last_watched_at) + ))); + + case 'completed-anime': + return sortDisplayItems((completedAnime || []).map(item => ( + buildSimklDisplayItem(item, 'series', item.last_watched_at) + ))); + + case 'onhold-shows': + return sortDisplayItems((onHoldShows || []).map(item => ( + buildSimklDisplayItem(item, 'series', item.last_watched_at) + ))); + + case 'onhold-movies': + return sortDisplayItems((onHoldMovies || []).map(item => ( + buildSimklDisplayItem(item, 'movie', item.last_watched_at) + ))); + + case 'onhold-anime': + return sortDisplayItems((onHoldAnime || []).map(item => ( + buildSimklDisplayItem(item, 'series', item.last_watched_at) + ))); + + case 'dropped-shows': + return sortDisplayItems((droppedShows || []).map(item => ( + buildSimklDisplayItem(item, 'series', item.last_watched_at) + ))); + + case 'dropped-movies': + return sortDisplayItems((droppedMovies || []).map(item => ( + buildSimklDisplayItem(item, 'movie', item.last_watched_at) + ))); + + case 'dropped-anime': + return sortDisplayItems((droppedAnime || []).map(item => ( + buildSimklDisplayItem(item, 'series', item.last_watched_at) + ))); + + case 'ratings': + return sortDisplayItems((simklRatedContent || []).map(item => ( + buildSimklDisplayItem(item, item.movie ? 'movie' : 'series', item.rated_at, item.rating) + ))); + } + + return []; + }, [buildSimklDisplayItem, completedAnime, completedMovies, completedShows, droppedAnime, droppedMovies, droppedShows, onHoldAnime, onHoldMovies, onHoldShows, planToWatchAnime, planToWatchMovies, planToWatchShows, simklContinueWatching, simklRatedContent, sortDisplayItems, watchingAnime, watchingMovies, watchingShows]); const renderTraktContent = () => { if (traktLoading) { @@ -1476,8 +1413,8 @@ const LibraryScreen = () => { {!showTraktContent && !showSimklContent && ( - {renderFilter('trakt', 'Trakt')} - {renderFilter('simkl', 'SIMKL')} + {showTraktTab && renderFilter('trakt', 'Trakt')} + {showSimklTab && renderFilter('simkl', 'SIMKL')} {renderFilter('movies', t('search.movies'))} {renderFilter('series', t('search.tv_shows'))} diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index e70c37df..18826695 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -314,7 +314,8 @@ const SearchScreen = () => { selectedCatalog.catalogId, selectedCatalog.type, selectedDiscoverGenre || undefined, - 1 + 1, + selectedCatalog.filterKey || 'genre' ); if (isMounted.current) { const seen = new Set(); @@ -360,7 +361,8 @@ const SearchScreen = () => { selectedCatalog.catalogId, selectedCatalog.type, selectedDiscoverGenre || undefined, - nextPage + nextPage, + selectedCatalog.filterKey || 'genre' ); if (isMounted.current) { diff --git a/src/screens/SimklSettingsScreen.tsx b/src/screens/SimklSettingsScreen.tsx index 5bcb8087..c3addffb 100644 --- a/src/screens/SimklSettingsScreen.tsx +++ b/src/screens/SimklSettingsScreen.tsx @@ -220,7 +220,7 @@ const SimklSettingsScreen: React.FC = () => { - {userStats.anime?.completed?.count || 0} + {(userStats.anime?.watching?.count || 0) + (userStats.anime?.completed?.count || 0)} Anime diff --git a/src/screens/streams/useStreamsScreen.ts b/src/screens/streams/useStreamsScreen.ts index 62de8cb0..2bb294b0 100644 --- a/src/screens/streams/useStreamsScreen.ts +++ b/src/screens/streams/useStreamsScreen.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { Dimensions, Platform, Linking } from 'react-native'; import { useRoute, useNavigation } from '@react-navigation/native'; import { RouteProp } from '@react-navigation/native'; +import NetInfo from '@react-native-community/netinfo'; import { RootStackParamList, RootStackNavigationProp } from '../../navigation/AppNavigator'; import { useMetadata } from '../../hooks/useMetadata'; @@ -17,6 +18,7 @@ import { localScraperService } from '../../services/pluginService'; import { VideoPlayerService } from '../../services/videoPlayerService'; import { streamCacheService } from '../../services/streamCacheService'; import { tmdbService } from '../../services/tmdbService'; +import { torrentStreamingService } from '../../services/torrentStreamingService'; import { logger } from '../../utils/logger'; import { TABLET_BREAKPOINT } from './constants'; import { @@ -24,6 +26,10 @@ import { filterStreamsByLanguage, getQualityNumeric, inferVideoTypeFromUrl, + estimateNetworkProfile, + getNetworkClassForMbps, + getPlaybackViabilityFromStream, + rankStreamsByPlaybackViability, sortStreamsByQuality, } from './utils'; import { @@ -54,6 +60,7 @@ export const useStreamsScreen = () => { // Dimension tracking const [dimensions, setDimensions] = useState(Dimensions.get('window')); const prevDimensionsRef = useRef({ width: dimensions.width, height: dimensions.height }); + const [networkProfile, setNetworkProfile] = useState(() => estimateNetworkProfile(null)); const deviceWidth = dimensions.width; const isTablet = useMemo(() => deviceWidth >= TABLET_BREAKPOINT, [deviceWidth]); @@ -137,6 +144,25 @@ export const useStreamsScreen = () => { return () => subscription?.remove(); }, []); + // Network profile updates used for stream viability ranking. + useEffect(() => { + const unsubscribe = NetInfo.addEventListener(state => { + setNetworkProfile(estimateNetworkProfile(state as any)); + }); + + NetInfo.fetch() + .then(state => { + setNetworkProfile(estimateNetworkProfile(state as any)); + }) + .catch(() => { + // Keep default profile on failure. + }); + + return () => { + unsubscribe(); + }; + }, []); + // Pause trailer on mount useEffect(() => { pauseTrailer(); @@ -231,35 +257,68 @@ export const useStreamsScreen = () => { return 0; }; - const allStreams: Array<{ stream: Stream; quality: number; providerPriority: number; originalIndex: number }> = []; + const allStreams: Array<{ + stream: Stream; + quality: number; + providerPriority: number; + originalIndex: number; + viabilityScore: number; + requiredMbps: number; + seeders?: number; + }> = []; + + const networkClass = getNetworkClassForMbps(networkProfile.estimatedDownlinkMbps); + const prefersLowerBitrate = + networkClass === 'very-slow' || networkClass === 'slow' || networkClass === 'medium'; Object.entries(streamsData).forEach(([addonId, { streams }]) => { const qualityFiltered = filterByQuality(streams); const filteredStreams = filterByLanguage(qualityFiltered); + const rankedStreams = rankStreamsByPlaybackViability( + filteredStreams, + networkProfile.estimatedDownlinkMbps + ); - filteredStreams.forEach((stream, index) => { + rankedStreams.forEach((stream, index) => { const quality = getQualityNumeric(stream.name || stream.title); const providerPriority = getProviderPriority(addonId); - allStreams.push({ stream, quality, providerPriority, originalIndex: index }); + const viability = getPlaybackViabilityFromStream(stream); + allStreams.push({ + stream, + quality, + providerPriority, + originalIndex: index, + viabilityScore: viability?.score ?? 0, + requiredMbps: viability?.requiredMbps ?? 0, + seeders: viability?.seeders, + }); }); }); if (allStreams.length === 0) return null; - // Sort primarily by provider priority, then respect the addon's internal order (originalIndex) - // This ensures if an addon lists 1080p before 4K, we pick 1080p + // Prefer streams that are most likely to play smoothly on the current connection. allStreams.sort((a, b) => { + if (a.viabilityScore !== b.viabilityScore) return b.viabilityScore - a.viabilityScore; + const aSeeders = typeof a.seeders === 'number' ? a.seeders : -1; + const bSeeders = typeof b.seeders === 'number' ? b.seeders : -1; + if (aSeeders !== bSeeders) return bSeeders - aSeeders; if (a.providerPriority !== b.providerPriority) return b.providerPriority - a.providerPriority; + if (prefersLowerBitrate && a.requiredMbps !== b.requiredMbps) { + return a.requiredMbps - b.requiredMbps; + } + if (a.quality !== b.quality) return b.quality - a.quality; return a.originalIndex - b.originalIndex; }); + const bestViability = getPlaybackViabilityFromStream(allStreams[0].stream); logger.log( - `🎯 Best stream selected: ${allStreams[0].stream.name || allStreams[0].stream.title} (Quality: ${allStreams[0].quality}p)` + `🎯 Best stream selected: ${allStreams[0].stream.name || allStreams[0].stream.title} (Quality: ${allStreams[0].quality}p, Viability: ${bestViability?.label || 'Unknown'})` ); return allStreams[0].stream; }, - [filterByQuality, filterByLanguage] + [filterByQuality, filterByLanguage, networkProfile.estimatedDownlinkMbps] ); // Current episode @@ -352,43 +411,59 @@ export const useStreamsScreen = () => { // Navigate to player const navigateToPlayer = useCallback( - async (stream: Stream, options?: { headers?: Record }) => { + async ( + stream: Stream, + options?: { + headers?: Record; + overrideUri?: string; + overrideHeaders?: Record; + torrentStreamId?: string; + skipCache?: boolean; + } + ) => { const optionHeaders = options?.headers; const streamHeaders = (stream.headers as any) as Record | undefined; const proxyHeaders = ((stream as any)?.behaviorHints?.proxyHeaders?.request || undefined) as | Record | undefined; + const targetUri = options?.overrideUri || stream.url; const streamProvider = stream.addonId || (stream as any).addonName || stream.name; - const finalHeaders = optionHeaders || streamHeaders || proxyHeaders; + const finalHeaders = options?.overrideHeaders ?? optionHeaders ?? streamHeaders ?? proxyHeaders; + + if (!targetUri) { + return; + } const streamsToPass = selectedEpisode ? episodeStreams : groupedStreams; const streamName = stream.name || stream.title || 'Unnamed Stream'; const resolvedStreamProvider = streamProvider; // Save stream to cache - try { - const epId = (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined; - const season = (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined; - const episode = (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined; - const episodeTitle = (type === 'series' || type === 'other') ? currentEpisode?.name : undefined; + if (!options?.skipCache) { + try { + const epId = (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined; + const season = (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined; + const episode = (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined; + const episodeTitle = (type === 'series' || type === 'other') ? currentEpisode?.name : undefined; - await streamCacheService.saveStreamToCache( - id, - type, - stream, - metadata, - epId, - season, - episode, - episodeTitle, - imdbId || undefined, - settings.streamCacheTTL - ); - } catch (error) { - logger.warn('[StreamsScreen] Failed to save stream to cache:', error); + await streamCacheService.saveStreamToCache( + id, + type, + stream, + metadata, + epId, + season, + episode, + episodeTitle, + imdbId || undefined, + settings.streamCacheTTL + ); + } catch (error) { + logger.warn('[StreamsScreen] Failed to save stream to cache:', error); + } } - let videoType = inferVideoTypeFromUrl(stream.url); + let videoType = inferVideoTypeFromUrl(targetUri); try { const providerId = stream.addonId || (stream as any).addon || ''; if (!videoType && /xprime/i.test(providerId)) { @@ -400,7 +475,7 @@ export const useStreamsScreen = () => { const finalHeaderKeys = Object.keys(finalHeaders || {}); logger.log('[StreamsScreen][navigateToPlayer] stream selection', { - url: typeof stream.url === 'string' ? stream.url.slice(0, 240) : stream.url, + url: typeof targetUri === 'string' ? targetUri.slice(0, 240) : targetUri, addonId: stream.addonId, addonName: (stream as any).addonName, name: stream.name, @@ -415,7 +490,7 @@ export const useStreamsScreen = () => { const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; navigation.navigate(playerRoute as any, { - uri: stream.url as any, + uri: targetUri as any, title: metadata?.name || '', episodeTitle: (type === 'series' || type === 'other') ? currentEpisode?.name : undefined, season: (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined, @@ -432,6 +507,7 @@ export const useStreamsScreen = () => { availableStreams: streamsToPass, backdrop: metadata?.banner || bannerImage, videoType, + torrentStreamId: options?.torrentStreamId, } as any); }, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams, bannerImage, settings.streamCacheTTL] @@ -461,9 +537,15 @@ export const useStreamsScreen = () => { }); } - // Block magnet links - if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) { - openAlert('Not supported', 'Torrent streaming is not supported yet.'); + const isMagnet = typeof stream.url === 'string' && stream.url.startsWith('magnet:'); + + if ( + isMagnet && + Platform.OS === 'ios' && + settings.preferredPlayer === 'internal' && + !torrentStreamingService.isNativeSupported() + ) { + openAlert('Not supported', 'Native torrent streaming is not available on this iOS build yet.'); return; } @@ -521,7 +603,6 @@ export const useStreamsScreen = () => { // Android external player else if (Platform.OS === 'android' && settings.useExternalPlayer) { try { - const isMagnet = typeof stream.url === 'string' && stream.url.startsWith('magnet:'); if (isMagnet) { Linking.openURL(stream.url).catch(() => navigateToPlayer(stream)); } else { @@ -540,13 +621,43 @@ export const useStreamsScreen = () => { navigateToPlayer(stream); } } else { - navigateToPlayer(stream); + if (torrentStreamingService.isNativeSupported() && torrentStreamingService.isTorrentStream(stream)) { + try { + showInfo('Preparing torrent stream...'); + const prepared = await torrentStreamingService.preparePlayback( + stream, + metadata?.name || stream.title || stream.name, + { networkMbps: networkProfile.estimatedDownlinkMbps } + ); + + await navigateToPlayer(stream, { + overrideUri: prepared.playbackUrl, + overrideHeaders: {}, + torrentStreamId: prepared.streamId, + }); + } catch (error: any) { + const message = error?.message || 'Failed to initialize torrent playback.'; + openAlert('Playback error', message); + } + } else { + navigateToPlayer(stream); + } } } catch { navigateToPlayer(stream); } }, - [settings.preferredPlayer, settings.useExternalPlayer, navigateToPlayer, openAlert, metadata, type, currentEpisode] + [ + settings.preferredPlayer, + settings.useExternalPlayer, + navigateToPlayer, + openAlert, + metadata, + type, + currentEpisode, + showInfo, + networkProfile.estimatedDownlinkMbps, + ] ); // Update providers when streams change @@ -900,11 +1011,19 @@ export const useStreamsScreen = () => { sortedEntries.forEach(([key, { streams: providerStreams }]) => { const isInstalledAddon = installedAddons.some(addon => addon.installationId === key || addon.id === key); + const providerSortedByQuality = + settings.streamSortMode === 'quality-then-scraper' + ? sortStreamsByQuality(providerStreams) + : providerStreams; + const providerRankedStreams = rankStreamsByPlaybackViability( + providerSortedByQuality, + networkProfile.estimatedDownlinkMbps + ); if (isInstalledAddon) { - addonStreams.push(...providerStreams); + addonStreams.push(...providerRankedStreams); } else { - const qualityFiltered = filterByQuality(providerStreams); + const qualityFiltered = filterByQuality(providerRankedStreams); const filteredStreams = filterByLanguage(qualityFiltered); if (filteredStreams.length > 0) { pluginStreams.push(...filteredStreams); @@ -913,12 +1032,7 @@ export const useStreamsScreen = () => { }); let combinedStreams = [...addonStreams]; - - if (settings.streamSortMode === 'quality-then-scraper' && pluginStreams.length > 0) { - combinedStreams.push(...sortStreamsByQuality(pluginStreams)); - } else { - combinedStreams.push(...pluginStreams); - } + combinedStreams.push(...pluginStreams); let sectionId = 'grouped-all'; let sectionTitle = 'Available Streams'; @@ -957,10 +1071,14 @@ export const useStreamsScreen = () => { if (filteredStreams.length === 0) return null; - let processedStreams = filteredStreams; - if (!isInstalledAddon && settings.streamSortMode === 'quality-then-scraper') { - processedStreams = sortStreamsByQuality(filteredStreams); - } + const sortedByQuality = + settings.streamSortMode === 'quality-then-scraper' + ? sortStreamsByQuality(filteredStreams) + : filteredStreams; + const processedStreams = rankStreamsByPlaybackViability( + sortedByQuality, + networkProfile.estimatedDownlinkMbps + ); // For multiple installations of same addon, add # to section title let sectionTitle = addonName; @@ -991,6 +1109,7 @@ export const useStreamsScreen = () => { filterByLanguage, addonResponseOrder, settings.streamSortMode, + networkProfile.estimatedDownlinkMbps, selectedEpisode, metadata, ]); diff --git a/src/screens/streams/utils.ts b/src/screens/streams/utils.ts index b3836339..06e0e8d3 100644 --- a/src/screens/streams/utils.ts +++ b/src/screens/streams/utils.ts @@ -20,6 +20,520 @@ const LANGUAGE_VARIATIONS: Record = { 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 = { + '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 | 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 */ diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index 09d9fb82..aa29bbee 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -1062,12 +1062,28 @@ class CatalogService { async getDiscoverFilters(): Promise<{ genres: string[]; types: string[]; - catalogsByType: Record; + catalogsByType: Record; }> { const addons = await this.getAllAddons(); const allGenres = new Set(); const allTypes = new Set(); - const catalogsByType: Record = {}; + const catalogsByType: Record = {}; for (const addon of addons) { if (!addon.catalogs) continue; @@ -1078,15 +1094,32 @@ class CatalogService { allTypes.add(catalog.type); } - // Get genres from catalog extras + // Get primary filter options (genre/year/etc.) from catalog extras const catalogGenres: string[] = []; + let filterKey: string | null = null; + let filterLabel: 'genre' | 'year' | 'filter' = 'genre'; + if (catalog.extra && Array.isArray(catalog.extra)) { - for (const extra of catalog.extra) { - if (extra.name === 'genre' && extra.options && Array.isArray(extra.options)) { - for (const genre of extra.options) { - allGenres.add(genre); - catalogGenres.push(genre); - } + const primaryFilter = catalog.extra.find(extra => + !!extra?.name && + Array.isArray(extra.options) && + extra.options.length > 0 + ); + + if (primaryFilter && Array.isArray(primaryFilter.options)) { + filterKey = primaryFilter.name; + const options = primaryFilter.options + .map(option => (typeof option === 'string' ? option : String(option))) + .filter(Boolean); + + const looksLikeYears = options.length > 0 && options.every(option => /^\d{4}$/.test(option)); + filterLabel = looksLikeYears + ? 'year' + : (filterKey === 'genre' ? 'genre' : 'filter'); + + for (const option of options) { + allGenres.add(option); + catalogGenres.push(option); } } } @@ -1101,7 +1134,9 @@ class CatalogService { addonName: addon.name, catalogId: catalog.id, catalogName: catalog.name || catalog.id, - genres: catalogGenres + genres: catalogGenres, + filterKey, + filterLabel }); } } @@ -1216,7 +1251,8 @@ class CatalogService { catalogId: string, type: string, genre?: string, - page: number = 1 + page: number = 1, + filterKey: string = 'genre' ): Promise { try { const manifests = await stremioService.getInstalledAddonsAsync(); @@ -1227,7 +1263,7 @@ class CatalogService { return []; } - const filters = genre ? [{ title: 'genre', value: genre }] : []; + const filters = genre && filterKey ? [{ title: filterKey, value: genre }] : []; const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters); if (metas && metas.length > 0) { @@ -1668,4 +1704,4 @@ class CatalogService { } export const catalogService = CatalogService.getInstance(); -export default catalogService; \ No newline at end of file +export default catalogService; diff --git a/src/services/pluginService.ts b/src/services/pluginService.ts index a84437f4..06928925 100644 --- a/src/services/pluginService.ts +++ b/src/services/pluginService.ts @@ -1573,7 +1573,9 @@ class LocalScraperService { description: result.size ? `${result.size}` : undefined, size: result.size ? this.parseSize(result.size) : undefined, behaviorHints: { - bingeGroup: `local-scraper-${scraper.id}` + bingeGroup: `local-scraper-${scraper.id}`, + seeders: typeof result.seeders === 'number' ? result.seeders : undefined, + peers: typeof result.peers === 'number' ? result.peers : undefined, } }; diff --git a/src/services/simklService.ts b/src/services/simklService.ts index ef027ffe..0b0d4b98 100644 --- a/src/services/simklService.ts +++ b/src/services/simklService.ts @@ -752,6 +752,16 @@ export class SimklService { } return response.anime || []; } + if (type === 'anime' && response.shows && Array.isArray(response.shows) && response.shows.length > 0) { + logger.log(`[SimklService] getAllItems: Anime fallback using ${response.shows.length} shows entries`); + return response.shows.map((item: any) => { + if (item?.anime) return item; + if (item?.show) { + return { ...item, anime: item.show }; + } + return item; + }); + } // If no type specified, return all if (!type) { @@ -937,4 +947,4 @@ export class SimklService { return null; } } -} \ No newline at end of file +} diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index ec886e95..b64275f2 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -272,6 +272,7 @@ class StremioService { private initialized: boolean = false; private initializationPromise: Promise | null = null; private catalogHasMore: Map = new Map(); + private catalogPageSize: Map = new Map(); private constructor() { // Start initialization but don't wait for it @@ -889,7 +890,10 @@ class StremioService { // Build URLs per Stremio protocol: /{resource}/{type}/{id}/{extraArgs}.json // Extra args (search, genre, skip) go in path segment, NOT query params const encodedId = encodeURIComponent(id); - const pageSkip = (page - 1) * this.DEFAULT_PAGE_SIZE; + const catalogKey = `${manifest.id}|${type}|${id}`; + const knownPageSize = this.catalogPageSize.get(catalogKey); + const pageSize = knownPageSize && knownPageSize > 0 ? knownPageSize : this.DEFAULT_PAGE_SIZE; + const pageSkip = (page - 1) * pageSize; // For all addons if (!manifest.url) { @@ -931,7 +935,7 @@ class StremioService { .filter(f => f && f.value) .map(f => `&${encodeURIComponent(f.title)}=${encodeURIComponent(f.value!)}`) .join(''); - let urlQueryStyle = `${baseUrl}/catalog/${type}/${encodedId}.json?skip=${pageSkip}&limit=${this.DEFAULT_PAGE_SIZE}`; + let urlQueryStyle = `${baseUrl}/catalog/${type}/${encodedId}.json?skip=${pageSkip}&limit=${pageSize}`; if (queryParams) urlQueryStyle += `&${queryParams}`; urlQueryStyle += legacyFilterQuery; @@ -973,10 +977,12 @@ class StremioService { if (response && response.data) { const hasMore = typeof response.data.hasMore === 'boolean' ? response.data.hasMore : undefined; try { - const key = `${manifest.id}|${type}|${id}`; - if (typeof hasMore === 'boolean') this.catalogHasMore.set(key, hasMore); + if (typeof hasMore === 'boolean') this.catalogHasMore.set(catalogKey, hasMore); } catch { } if (response.data.metas && Array.isArray(response.data.metas)) { + if (page === 1 && response.data.metas.length > 0) { + this.catalogPageSize.set(catalogKey, response.data.metas.length); + } return response.data.metas; } } @@ -1752,6 +1758,18 @@ class StremioService { videoHash: stream.behaviorHints?.videoHash || undefined, videoSize: stream.behaviorHints?.videoSize || undefined, filename: stream.behaviorHints?.filename || undefined, + seeders: + typeof stream.seeders === 'number' + ? stream.seeders + : typeof stream.behaviorHints?.seeders === 'number' + ? stream.behaviorHints.seeders + : undefined, + peers: + typeof stream.peers === 'number' + ? stream.peers + : typeof stream.behaviorHints?.peers === 'number' + ? stream.behaviorHints.peers + : undefined, // Include essential torrent data for magnet streams ...(isMagnetStream ? { infoHash: stream.infoHash || streamUrl?.match(/btih:([a-zA-Z0-9]+)/)?.[1], diff --git a/src/services/torrentStreamingService.ts b/src/services/torrentStreamingService.ts new file mode 100644 index 00000000..32cc41a8 --- /dev/null +++ b/src/services/torrentStreamingService.ts @@ -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; + stopStream: (streamId: string) => Promise; + stopAllStreams: () => Promise; +}; + +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 | 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 { + 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 { + 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 { + 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(); + + 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;