From afb16eaebee706f2cc956e433ebf30b0a4f71de2 Mon Sep 17 00:00:00 2001 From: CrissZollo Date: Fri, 3 Apr 2026 11:25:14 +0200 Subject: [PATCH] Initial version of p2p support --- app/build.gradle.kts | 10 +- app/proguard-rules.pro | 6 + .../com/nuvio/tv/core/di/TorrentModule.kt | 29 + .../nuvio/tv/core/torrent/TorrentEngine.kt | 508 ++++++++++++++++++ .../nuvio/tv/core/torrent/TorrentSettings.kt | 81 +++ .../com/nuvio/tv/core/torrent/TorrentState.kt | 28 + .../tv/core/torrent/TorrentStreamServer.kt | 152 ++++++ .../nuvio/tv/ui/navigation/NuvioNavHost.kt | 16 +- .../java/com/nuvio/tv/ui/navigation/Screen.kt | 9 +- .../ui/screens/player/PlayerNavigationArgs.kt | 8 +- .../screens/player/PlayerRuntimeController.kt | 9 + .../PlayerRuntimeControllerInitialization.kt | 31 +- .../PlayerRuntimeControllerLifecycle.kt | 1 + .../PlayerRuntimeControllerPlaybackEvents.kt | 35 +- .../player/PlayerRuntimeControllerStartup.kt | 30 ++ .../player/PlayerRuntimeControllerStreams.kt | 142 ++++- .../player/PlayerRuntimeControllerTorrent.kt | 156 ++++++ .../tv/ui/screens/player/PlayerScreen.kt | 14 + .../tv/ui/screens/player/PlayerUiState.kt | 12 +- .../tv/ui/screens/player/PlayerViewModel.kt | 3 + .../tv/ui/screens/player/TorrentOverlay.kt | 82 +++ .../screens/stream/StreamScreenViewModel.kt | 6 +- compose_stability_config.conf | 3 + gradle/libs.versions.toml | 6 + 24 files changed, 1348 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/com/nuvio/tv/core/di/TorrentModule.kt create mode 100644 app/src/main/java/com/nuvio/tv/core/torrent/TorrentEngine.kt create mode 100644 app/src/main/java/com/nuvio/tv/core/torrent/TorrentSettings.kt create mode 100644 app/src/main/java/com/nuvio/tv/core/torrent/TorrentState.kt create mode 100644 app/src/main/java/com/nuvio/tv/core/torrent/TorrentStreamServer.kt create mode 100644 app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerTorrent.kt create mode 100644 app/src/main/java/com/nuvio/tv/ui/screens/player/TorrentOverlay.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 04d6496b..dd6aede7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -179,7 +179,8 @@ android { "lib/*/libavcodec.so", "lib/*/libavutil.so", "lib/*/libswscale.so", - "lib/*/libswresample.so" + "lib/*/libswresample.so", + "lib/*/libtorrent4j.so" ) } } @@ -313,6 +314,13 @@ dependencies { implementation(libs.nanohttpd) implementation(libs.zxing.core) + // P2P / Torrent streaming (libtorrent4j) + implementation(libs.libtorrent4j) + implementation(libs.libtorrent4j.android.arm) + implementation(libs.libtorrent4j.android.arm64) + implementation(libs.libtorrent4j.android.x86) + implementation(libs.libtorrent4j.android.x8664) + // Supabase implementation(platform(libs.supabase.bom)) implementation(libs.supabase.auth) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 4a1c9c0a..5940682c 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -53,6 +53,12 @@ # Keep server classes and their inner data classes (serialized with Gson) -keep class com.nuvio.tv.core.server.** { *; } +# ── libtorrent4j (P2P / torrent streaming) ──────────────────────────────────── +-keep class org.libtorrent4j.** { *; } +-keepclassmembers class org.libtorrent4j.swig.** { *; } +-dontwarn org.libtorrent4j.** +-keep class com.nuvio.tv.core.torrent.** { *; } + #── QuickJS ──────────────────────────────────────────────────────────────────── # Keep quickjs-kt library classes for proper type conversion -keep class com.dokar.quickjs.** { *; } diff --git a/app/src/main/java/com/nuvio/tv/core/di/TorrentModule.kt b/app/src/main/java/com/nuvio/tv/core/di/TorrentModule.kt new file mode 100644 index 00000000..2cbfc60f --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/core/di/TorrentModule.kt @@ -0,0 +1,29 @@ +package com.nuvio.tv.core.di + +import android.content.Context +import com.nuvio.tv.core.torrent.TorrentEngine +import com.nuvio.tv.core.torrent.TorrentSettings +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object TorrentModule { + + @Provides + @Singleton + fun provideTorrentSettings( + @ApplicationContext context: Context + ): TorrentSettings = TorrentSettings(context) + + @Provides + @Singleton + fun provideTorrentEngine( + @ApplicationContext context: Context, + torrentSettings: TorrentSettings + ): TorrentEngine = TorrentEngine(context, torrentSettings) +} diff --git a/app/src/main/java/com/nuvio/tv/core/torrent/TorrentEngine.kt b/app/src/main/java/com/nuvio/tv/core/torrent/TorrentEngine.kt new file mode 100644 index 00000000..39014c1a --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/core/torrent/TorrentEngine.kt @@ -0,0 +1,508 @@ +package com.nuvio.tv.core.torrent + +import android.content.Context +import android.util.Log +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import org.libtorrent4j.AlertListener +import org.libtorrent4j.Priority +import org.libtorrent4j.SessionManager +import org.libtorrent4j.SessionParams +import org.libtorrent4j.SettingsPack +import org.libtorrent4j.Sha1Hash +import org.libtorrent4j.TorrentFlags +import org.libtorrent4j.TorrentHandle +import org.libtorrent4j.TorrentInfo +import org.libtorrent4j.alerts.AddTorrentAlert +import org.libtorrent4j.alerts.Alert +import org.libtorrent4j.alerts.AlertType +import org.libtorrent4j.alerts.MetadataReceivedAlert +import org.libtorrent4j.alerts.TorrentErrorAlert +import org.libtorrent4j.swig.torrent_flags_t +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume + +@Singleton +class TorrentEngine @Inject constructor( + @ApplicationContext private val context: Context, + private val torrentSettings: TorrentSettings +) { + companion object { + private const val TAG = "TorrentEngine" + private const val STREAMING_WINDOW_SIZE = 50 + private const val LOOKAHEAD_WINDOW_SIZE = 100 + private val DEFAULT_TRACKERS = listOf( + "udp://tracker.opentrackr.org:1337/announce", + "udp://open.stealth.si:80/announce", + "udp://tracker.openbittorrent.com:6969/announce", + "udp://exodus.desync.com:6969/announce", + "udp://tracker.torrent.eu.org:451/announce", + "udp://open.demonii.com:1337/announce", + "udp://tracker.moeking.me:6969/announce", + "udp://explodie.org:6969/announce", + "udp://tracker.tiny-vps.com:6969/announce" + ) + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val _state = MutableStateFlow(TorrentState.Idle) + val state: StateFlow = _state.asStateFlow() + + private var sessionManager: SessionManager? = null + private var currentHandle: TorrentHandle? = null + private var streamServer: TorrentStreamServer? = null + private var statsJob: Job? = null + private var currentFileIndex: Int = -1 + private var totalPieces: Int = 0 + private var currentSettings: TorrentSettingsData = TorrentSettingsData() + + private val cacheDir: File + get() = File(context.cacheDir, "torrent_cache").also { it.mkdirs() } + + /** + * Start streaming a torrent. Returns the local HTTP URL for ExoPlayer. + */ + suspend fun startStream( + infoHash: String, + fileIdx: Int?, + trackers: List = emptyList() + ): String = withContext(Dispatchers.IO) { + stopCurrentStream() + _state.value = TorrentState.Connecting + + currentSettings = torrentSettings.settings.first() + evictCacheIfNeeded() + + val session = getOrCreateSession() + val magnetUri = buildMagnetUri(infoHash, trackers) + + Log.d(TAG, "Starting torrent stream: $magnetUri") + + val handle = addTorrentAndAwaitMetadata(session, magnetUri) + ?: throw TorrentException("Failed to fetch torrent metadata") + + currentHandle = handle + val torrentInfo = handle.torrentFile() ?: throw TorrentException("No torrent info available") + + currentFileIndex = resolveFileIndex(torrentInfo, fileIdx) + val selectedFile = torrentInfo.files().filePath(currentFileIndex) + val selectedFileSize = torrentInfo.files().fileSize(currentFileIndex) + totalPieces = torrentInfo.numPieces() + + Log.d(TAG, "Selected file[$currentFileIndex]: $selectedFile ($selectedFileSize bytes), $totalPieces pieces") + + configureFilePriorities(handle, torrentInfo, currentFileIndex) + handle.setFlags(TorrentFlags.SEQUENTIAL_DOWNLOAD) + prioritizePiecesForPosition(handle, torrentInfo, currentFileIndex, 0) + + _state.value = TorrentState.Buffering( + progress = 0f, + downloadSpeed = 0, + peers = 0, + seeds = 0 + ) + + val bufferPieces = currentSettings.bufferPiecesBeforePlayback + awaitBufferReady(handle, torrentInfo, currentFileIndex, bufferPieces) + + val server = startStreamServer(handle, torrentInfo, currentFileIndex) + streamServer = server + + server.servingFile = File(cacheDir, selectedFile) + server.fileLength = selectedFileSize + server.pieceLength = torrentInfo.pieceLength().toLong() + server.fileOffset = torrentInfo.files().fileOffset(currentFileIndex) + server.mimeType = inferMimeType(selectedFile) + + val localUrl = "http://127.0.0.1:${server.listeningPort}/stream" + + startStatsPolling(handle) + + _state.value = TorrentState.Streaming( + localUrl = localUrl, + downloadSpeed = 0, + uploadSpeed = 0, + peers = 0, + seeds = 0, + bufferProgress = 0f, + totalProgress = 0f + ) + + Log.d(TAG, "Torrent stream ready at $localUrl") + localUrl + } + + fun stopCurrentStream() { + statsJob?.cancel() + statsJob = null + + streamServer?.stop() + streamServer = null + + currentHandle?.let { handle -> + try { + sessionManager?.remove(handle) + } catch (e: Exception) { + Log.w(TAG, "Error removing torrent handle", e) + } + } + currentHandle = null + currentFileIndex = -1 + totalPieces = 0 + + if (currentSettings.autoClearCacheOnExit) { + clearCache() + } + + _state.value = TorrentState.Idle + } + + fun release() { + stopCurrentStream() + try { + sessionManager?.stop() + } catch (e: Exception) { + Log.w(TAG, "Error stopping session manager", e) + } + sessionManager = null + } + + fun onPlaybackSeek(positionMs: Long, durationMs: Long) { + val handle = currentHandle ?: return + val torrentInfo = handle.torrentFile() ?: return + if (durationMs <= 0) return + + val progress = positionMs.toFloat() / durationMs + val fileSize = torrentInfo.files().fileSize(currentFileIndex) + val bytePosition = (progress * fileSize).toLong() + + prioritizePiecesForPosition(handle, torrentInfo, currentFileIndex, bytePosition) + } + + fun clearCache() { + scope.launch(Dispatchers.IO) { + try { + cacheDir.deleteRecursively() + cacheDir.mkdirs() + Log.d(TAG, "Torrent cache cleared") + } catch (e: Exception) { + Log.w(TAG, "Failed to clear torrent cache", e) + } + } + } + + // ── Private helpers ────────────────────────────────────────────────────────── + + private fun getOrCreateSession(): SessionManager { + sessionManager?.let { return it } + + val settings = SettingsPack().apply { + activeDownloads(1) + activeSeeds(1) + connectionsLimit(currentSettings.maxConnections) + setEnableDht(currentSettings.enableDht) + setEnableLsd(true) + + if (!currentSettings.enableUpload) { + uploadRateLimit(1024) // 1 KB/s minimum + } + } + + val session = SessionManager(false) + session.start(SessionParams(settings)) + + if (currentSettings.enableDht) { + session.startDht() + } + + sessionManager = session + Log.d(TAG, "Session manager started") + return session + } + + private fun buildMagnetUri(infoHash: String, extraTrackers: List): String { + val trackers = (DEFAULT_TRACKERS + extraTrackers).distinct() + val trackerParams = trackers.joinToString("") { "&tr=$it" } + return "magnet:?xt=urn:btih:$infoHash$trackerParams" + } + + private suspend fun addTorrentAndAwaitMetadata( + session: SessionManager, + magnetUri: String + ): TorrentHandle? = suspendCancellableCoroutine { cont -> + var resumed = false + + val listener = object : AlertListener { + override fun types(): IntArray = intArrayOf( + AlertType.ADD_TORRENT.swig(), + AlertType.METADATA_RECEIVED.swig(), + AlertType.TORRENT_ERROR.swig() + ) + + override fun alert(alert: Alert<*>) { + when (alert) { + is AddTorrentAlert -> { + if (alert.error().isError) { + if (!resumed) { + resumed = true + session.removeListener(this) + cont.resume(null) + } + } + } + is MetadataReceivedAlert -> { + if (!resumed) { + resumed = true + session.removeListener(this) + cont.resume(alert.handle()) + } + } + is TorrentErrorAlert -> { + if (!resumed) { + resumed = true + session.removeListener(this) + cont.resume(null) + } + } + } + } + } + + session.addListener(listener) + + cont.invokeOnCancellation { + session.removeListener(listener) + } + + try { + session.download(magnetUri, cacheDir, torrent_flags_t()) + // If torrent info is already cached, handle may already be available + val hash = Sha1Hash.parseHex( + magnetUri.substringAfter("btih:").substringBefore("&") + ) + val handle = session.find(hash) + if (handle != null && handle.torrentFile() != null && !resumed) { + resumed = true + session.removeListener(listener) + cont.resume(handle) + } + } catch (e: Exception) { + if (!resumed) { + resumed = true + session.removeListener(listener) + cont.resume(null) + } + } + } + + private fun resolveFileIndex(torrentInfo: TorrentInfo, requestedIdx: Int?): Int { + val numFiles = torrentInfo.files().numFiles() + + if (requestedIdx != null && requestedIdx in 0 until numFiles) { + return requestedIdx + } + + // Auto-select: pick the largest file (usually the video) + var largestIdx = 0 + var largestSize = 0L + for (i in 0 until numFiles) { + val size = torrentInfo.files().fileSize(i) + if (size > largestSize) { + largestSize = size + largestIdx = i + } + } + return largestIdx + } + + private fun configureFilePriorities( + handle: TorrentHandle, + torrentInfo: TorrentInfo, + selectedFileIndex: Int + ) { + val numFiles = torrentInfo.files().numFiles() + val priorities = Array(numFiles) { i -> + if (i == selectedFileIndex) Priority.DEFAULT else Priority.IGNORE + } + handle.prioritizeFiles(priorities) + } + + private fun prioritizePiecesForPosition( + handle: TorrentHandle, + torrentInfo: TorrentInfo, + fileIndex: Int, + bytePosition: Long + ) { + val pieceLength = torrentInfo.pieceLength().toLong() + val fileOffset = torrentInfo.files().fileOffset(fileIndex) + val fileSize = torrentInfo.files().fileSize(fileIndex) + val numPieces = torrentInfo.numPieces() + + val firstPiece = (fileOffset / pieceLength).toInt() + val lastPiece = ((fileOffset + fileSize - 1) / pieceLength).toInt().coerceAtMost(numPieces - 1) + + val currentPiece = ((fileOffset + bytePosition) / pieceLength).toInt().coerceIn(firstPiece, lastPiece) + + for (i in firstPiece..lastPiece) { + val priority = when { + handle.havePiece(i) -> Priority.IGNORE + // First few pieces (container header) + i in firstPiece..(firstPiece + 4).coerceAtMost(lastPiece) -> Priority.TOP_PRIORITY + // Last piece (container trailer/moov atom) + i == lastPiece -> Priority.TOP_PRIORITY + // Streaming window ahead of playback + i in currentPiece..(currentPiece + STREAMING_WINDOW_SIZE).coerceAtMost(lastPiece) -> Priority.TOP_PRIORITY + // Lookahead window + i in (currentPiece + STREAMING_WINDOW_SIZE + 1)..(currentPiece + STREAMING_WINDOW_SIZE + LOOKAHEAD_WINDOW_SIZE).coerceAtMost(lastPiece) -> Priority.DEFAULT + else -> Priority.LOW + } + handle.piecePriority(i, priority) + } + } + + private suspend fun awaitBufferReady( + handle: TorrentHandle, + torrentInfo: TorrentInfo, + fileIndex: Int, + requiredPieces: Int + ) { + val pieceLength = torrentInfo.pieceLength().toLong() + val fileOffset = torrentInfo.files().fileOffset(fileIndex) + val fileSize = torrentInfo.files().fileSize(fileIndex) + val firstPiece = (fileOffset / pieceLength).toInt() + val lastPiece = ((fileOffset + fileSize - 1) / pieceLength).toInt().coerceAtMost(torrentInfo.numPieces() - 1) + val piecesToCheck = requiredPieces.coerceAtMost(lastPiece - firstPiece + 1) + + while (true) { + var readyCount = 0 + for (i in firstPiece until (firstPiece + piecesToCheck).coerceAtMost(lastPiece + 1)) { + if (handle.havePiece(i)) readyCount++ + } + + // Also require last piece for container metadata + val hasLastPiece = handle.havePiece(lastPiece) + + val progress = readyCount.toFloat() / piecesToCheck + val status = handle.status() + + _state.value = TorrentState.Buffering( + progress = progress, + downloadSpeed = status.downloadPayloadRate().toLong(), + peers = status.numPeers(), + seeds = status.numSeeds() + ) + + if (readyCount >= piecesToCheck && hasLastPiece) { + break + } + + delay(500) + } + } + + private fun startStreamServer( + handle: TorrentHandle, + torrentInfo: TorrentInfo, + fileIndex: Int + ): TorrentStreamServer { + return TorrentStreamServer.startOnAvailablePort( + onPiecesNeeded = { startPiece, endPiece -> + prioritizePiecesForRange(handle, torrentInfo, fileIndex, startPiece, endPiece) + } + ) ?: throw TorrentException("Failed to start stream server - no available port") + } + + private fun prioritizePiecesForRange( + handle: TorrentHandle, + torrentInfo: TorrentInfo, + fileIndex: Int, + startPiece: Int, + endPiece: Int + ) { + val pieceLength = torrentInfo.pieceLength().toLong() + val fileOffset = torrentInfo.files().fileOffset(fileIndex) + val firstFilePiece = (fileOffset / pieceLength).toInt() + val adjustedStart = firstFilePiece + startPiece + val adjustedEnd = firstFilePiece + endPiece + + for (i in adjustedStart..adjustedEnd.coerceAtMost(torrentInfo.numPieces() - 1)) { + if (!handle.havePiece(i)) { + handle.piecePriority(i, Priority.TOP_PRIORITY) + } + } + } + + private fun startStatsPolling(handle: TorrentHandle) { + statsJob?.cancel() + statsJob = scope.launch { + while (isActive) { + try { + val status = handle.status() + val currentState = _state.value + if (currentState is TorrentState.Streaming) { + _state.value = currentState.copy( + downloadSpeed = status.downloadPayloadRate().toLong(), + uploadSpeed = status.uploadPayloadRate().toLong(), + peers = status.numPeers(), + seeds = status.numSeeds(), + totalProgress = status.progress() + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Stats polling error", e) + } + delay(1000) + } + } + } + + private fun evictCacheIfNeeded() { + val maxBytes = currentSettings.maxCacheSizeMb.toLong() * 1024 * 1024 + val dir = cacheDir + if (!dir.exists()) return + + val files = dir.listFiles()?.toMutableList() ?: return + files.sortBy { it.lastModified() } + + var totalSize = files.sumOf { it.length() } + while (totalSize > maxBytes && files.isNotEmpty()) { + val oldest = files.removeAt(0) + totalSize -= oldest.length() + oldest.deleteRecursively() + } + } + + private fun inferMimeType(filename: String): String { + val lower = filename.lowercase() + return when { + lower.endsWith(".mkv") -> "video/x-matroska" + lower.endsWith(".mp4") || lower.endsWith(".m4v") -> "video/mp4" + lower.endsWith(".avi") -> "video/x-msvideo" + lower.endsWith(".webm") -> "video/webm" + lower.endsWith(".ts") -> "video/mp2t" + lower.endsWith(".flv") -> "video/x-flv" + lower.endsWith(".wmv") -> "video/x-ms-wmv" + lower.endsWith(".mov") -> "video/quicktime" + else -> "video/mp4" + } + } +} + +class TorrentException(message: String) : Exception(message) diff --git a/app/src/main/java/com/nuvio/tv/core/torrent/TorrentSettings.kt b/app/src/main/java/com/nuvio/tv/core/torrent/TorrentSettings.kt new file mode 100644 index 00000000..0de59879 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/core/torrent/TorrentSettings.kt @@ -0,0 +1,81 @@ +package com.nuvio.tv.core.torrent + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.torrentDataStore by preferencesDataStore(name = "torrent_settings") + +data class TorrentSettingsData( + val maxCacheSizeMb: Int = 2048, + val maxConnections: Int = 200, + val bufferPiecesBeforePlayback: Int = 15, + val enableDht: Boolean = true, + val enableEncryption: Boolean = true, + val enableUpload: Boolean = true, + val autoClearCacheOnExit: Boolean = true +) + +@Singleton +class TorrentSettings @Inject constructor( + @ApplicationContext private val context: Context +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private object Keys { + val MAX_CACHE_SIZE_MB = intPreferencesKey("max_cache_size_mb") + val MAX_CONNECTIONS = intPreferencesKey("max_connections") + val BUFFER_PIECES = intPreferencesKey("buffer_pieces_before_playback") + val ENABLE_DHT = booleanPreferencesKey("enable_dht") + val ENABLE_ENCRYPTION = booleanPreferencesKey("enable_encryption") + val ENABLE_UPLOAD = booleanPreferencesKey("enable_upload") + val AUTO_CLEAR_CACHE = booleanPreferencesKey("auto_clear_cache_on_exit") + } + + val settings: Flow = context.torrentDataStore.data.map { prefs -> + TorrentSettingsData( + maxCacheSizeMb = prefs[Keys.MAX_CACHE_SIZE_MB] ?: 2048, + maxConnections = prefs[Keys.MAX_CONNECTIONS] ?: 200, + bufferPiecesBeforePlayback = prefs[Keys.BUFFER_PIECES] ?: 15, + enableDht = prefs[Keys.ENABLE_DHT] ?: true, + enableEncryption = prefs[Keys.ENABLE_ENCRYPTION] ?: true, + enableUpload = prefs[Keys.ENABLE_UPLOAD] ?: true, + autoClearCacheOnExit = prefs[Keys.AUTO_CLEAR_CACHE] ?: true + ) + } + + fun setEnableUpload(enabled: Boolean) { + scope.launch { + context.torrentDataStore.edit { it[Keys.ENABLE_UPLOAD] = enabled } + } + } + + fun setAutoClearCache(enabled: Boolean) { + scope.launch { + context.torrentDataStore.edit { it[Keys.AUTO_CLEAR_CACHE] = enabled } + } + } + + fun setBufferPieces(count: Int) { + scope.launch { + context.torrentDataStore.edit { it[Keys.BUFFER_PIECES] = count } + } + } + + fun setMaxCacheSizeMb(sizeMb: Int) { + scope.launch { + context.torrentDataStore.edit { it[Keys.MAX_CACHE_SIZE_MB] = sizeMb } + } + } +} diff --git a/app/src/main/java/com/nuvio/tv/core/torrent/TorrentState.kt b/app/src/main/java/com/nuvio/tv/core/torrent/TorrentState.kt new file mode 100644 index 00000000..cc61f262 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/core/torrent/TorrentState.kt @@ -0,0 +1,28 @@ +package com.nuvio.tv.core.torrent + +import androidx.compose.runtime.Immutable + +@Immutable +sealed class TorrentState { + data object Idle : TorrentState() + data object Connecting : TorrentState() + + data class Buffering( + val progress: Float, + val downloadSpeed: Long, + val peers: Int, + val seeds: Int + ) : TorrentState() + + data class Streaming( + val localUrl: String, + val downloadSpeed: Long, + val uploadSpeed: Long, + val peers: Int, + val seeds: Int, + val bufferProgress: Float, + val totalProgress: Float + ) : TorrentState() + + data class Error(val message: String) : TorrentState() +} diff --git a/app/src/main/java/com/nuvio/tv/core/torrent/TorrentStreamServer.kt b/app/src/main/java/com/nuvio/tv/core/torrent/TorrentStreamServer.kt new file mode 100644 index 00000000..0b43f77c --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/core/torrent/TorrentStreamServer.kt @@ -0,0 +1,152 @@ +package com.nuvio.tv.core.torrent + +import android.util.Log +import fi.iki.elonen.NanoHTTPD +import java.io.File +import java.io.FileInputStream +import java.io.InputStream + +/** + * Local HTTP server that serves torrent file data to ExoPlayer. + * Supports Range requests for seeking within the media file. + */ +class TorrentStreamServer( + port: Int = 9080, + private val onPiecesNeeded: (startPiece: Int, endPiece: Int) -> Unit +) : NanoHTTPD(port) { + + @Volatile + var servingFile: File? = null + + @Volatile + var fileLength: Long = 0L + + @Volatile + var pieceLength: Long = 0L + + @Volatile + var fileOffset: Long = 0L + + @Volatile + var mimeType: String = "video/mp4" + + override fun serve(session: IHTTPSession): Response { + if (session.uri != "/stream") { + return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Not found") + } + + val file = servingFile + if (file == null || !file.exists()) { + return newFixedLengthResponse( + Response.Status.SERVICE_UNAVAILABLE, + MIME_PLAINTEXT, + "File not ready" + ) + } + + val totalLength = fileLength + if (totalLength <= 0) { + return newFixedLengthResponse( + Response.Status.SERVICE_UNAVAILABLE, + MIME_PLAINTEXT, + "File length unknown" + ) + } + + val rangeHeader = session.headers["range"] + return if (rangeHeader != null) { + serveRangeRequest(file, totalLength, rangeHeader) + } else { + serveFullRequest(file, totalLength) + } + } + + private fun serveFullRequest(file: File, totalLength: Long): Response { + requestPiecesForRange(0, totalLength) + + val inputStream: InputStream = FileInputStream(file).also { + if (fileOffset > 0) it.skip(fileOffset) + } + val response = newFixedLengthResponse( + Response.Status.OK, + mimeType, + inputStream, + totalLength + ) + response.addHeader("Accept-Ranges", "bytes") + response.addHeader("Content-Length", totalLength.toString()) + return response + } + + private fun serveRangeRequest(file: File, totalLength: Long, rangeHeader: String): Response { + val rangeValue = rangeHeader.replace("bytes=", "").trim() + val parts = rangeValue.split("-") + + val start = parts[0].toLongOrNull() ?: 0L + val end = if (parts.size > 1 && parts[1].isNotEmpty()) { + parts[1].toLongOrNull() ?: (totalLength - 1) + } else { + totalLength - 1 + } + + if (start >= totalLength || end >= totalLength || start > end) { + val response = newFixedLengthResponse( + Response.Status.RANGE_NOT_SATISFIABLE, + MIME_PLAINTEXT, + "Range not satisfiable" + ) + response.addHeader("Content-Range", "bytes */$totalLength") + return response + } + + requestPiecesForRange(start, end) + + val contentLength = end - start + 1 + val inputStream: InputStream = FileInputStream(file).also { + it.skip(fileOffset + start) + } + + val response = newFixedLengthResponse( + Response.Status.PARTIAL_CONTENT, + mimeType, + inputStream, + contentLength + ) + response.addHeader("Accept-Ranges", "bytes") + response.addHeader("Content-Range", "bytes $start-$end/$totalLength") + response.addHeader("Content-Length", contentLength.toString()) + return response + } + + private fun requestPiecesForRange(start: Long, end: Long) { + if (pieceLength <= 0) return + val startPiece = (start / pieceLength).toInt() + val endPiece = (end / pieceLength).toInt() + try { + onPiecesNeeded(startPiece, endPiece) + } catch (e: Exception) { + Log.w(TAG, "Failed to request pieces $startPiece-$endPiece", e) + } + } + + companion object { + private const val TAG = "TorrentStreamServer" + + fun startOnAvailablePort( + onPiecesNeeded: (startPiece: Int, endPiece: Int) -> Unit, + startPort: Int = 9080, + maxAttempts: Int = 20 + ): TorrentStreamServer? { + for (port in startPort until startPort + maxAttempts) { + try { + val server = TorrentStreamServer(port, onPiecesNeeded) + server.start(SOCKET_READ_TIMEOUT, false) + return server + } catch (e: Exception) { + // Port in use, try next + } + } + return null + } + } +} diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt index 326d6f6e..d22675fb 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt @@ -447,7 +447,9 @@ fun NuvioNavHost( } }, onStreamSelected = { playbackInfo -> - playbackInfo.url?.let { url -> + val streamUrl = playbackInfo.url + ?: if (playbackInfo.isTorrent) "torrent://${playbackInfo.infoHash}" else null + streamUrl?.let { url -> navController.navigate( Screen.Player.createRoute( streamUrl = url, @@ -475,13 +477,17 @@ fun NuvioNavHost( startFromBeginning = startFromBeginning, addonName = playbackInfo.addonName, addonLogo = playbackInfo.addonLogo, - streamDescription = playbackInfo.streamDescription + streamDescription = playbackInfo.streamDescription, + infoHash = playbackInfo.infoHash, + fileIdx = playbackInfo.fileIdx ) ) } }, onAutoPlayResolved = { playbackInfo -> - playbackInfo.url?.let { url -> + val autoPlayUrl = playbackInfo.url + ?: if (playbackInfo.isTorrent) "torrent://${playbackInfo.infoHash}" else null + autoPlayUrl?.let { url -> navController.navigate( Screen.Player.createRoute( streamUrl = url, @@ -509,7 +515,9 @@ fun NuvioNavHost( startFromBeginning = startFromBeginning, addonName = playbackInfo.addonName, addonLogo = playbackInfo.addonLogo, - streamDescription = playbackInfo.streamDescription + streamDescription = playbackInfo.streamDescription, + infoHash = playbackInfo.infoHash, + fileIdx = playbackInfo.fileIdx ) ) { popUpTo(Screen.Stream.route) { inclusive = true } diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt index e85d23a0..a9b04010 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt @@ -62,7 +62,7 @@ sealed class Screen(val route: String) { return "stream/$encodedVideoId/$encodedContentTypePath/$encodedTitle?poster=$encodedPoster&backdrop=$encodedBackdrop&logo=$encodedLogo&season=${season ?: ""}&episode=${episode ?: ""}&episodeName=$encodedEpisodeName&genres=$encodedGenres&year=$encodedYear&contentId=$encodedContentId&contentName=$encodedContentName&runtime=${runtime ?: ""}&manualSelection=$manualSelection&returnToDetailOnBack=$returnToDetailOnBack&returnToHomeOnBack=$returnToHomeOnBack&startFromBeginning=$startFromBeginning" } } - data object Player : Screen("player/{streamUrl}/{title}?streamName={streamName}&year={year}&headers={headers}&contentId={contentId}&contentType={contentType}&contentName={contentName}&poster={poster}&backdrop={backdrop}&logo={logo}&videoId={videoId}&season={season}&episode={episode}&episodeTitle={episodeTitle}&bingeGroup={bingeGroup}&autoPlayNav={autoPlayNav}&returnToDetailOnBack={returnToDetailOnBack}&returnToHomeOnBack={returnToHomeOnBack}&filename={filename}&videoHash={videoHash}&videoSize={videoSize}&startFromBeginning={startFromBeginning}&addonName={addonName}&addonLogo={addonLogo}&streamDescription={streamDescription}") { + data object Player : Screen("player/{streamUrl}/{title}?streamName={streamName}&year={year}&headers={headers}&contentId={contentId}&contentType={contentType}&contentName={contentName}&poster={poster}&backdrop={backdrop}&logo={logo}&videoId={videoId}&season={season}&episode={episode}&episodeTitle={episodeTitle}&bingeGroup={bingeGroup}&autoPlayNav={autoPlayNav}&returnToDetailOnBack={returnToDetailOnBack}&returnToHomeOnBack={returnToHomeOnBack}&filename={filename}&videoHash={videoHash}&videoSize={videoSize}&startFromBeginning={startFromBeginning}&addonName={addonName}&addonLogo={addonLogo}&streamDescription={streamDescription}&infoHash={infoHash}&fileIdx={fileIdx}") { private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8").replace("+", "%20") @@ -92,7 +92,9 @@ sealed class Screen(val route: String) { startFromBeginning: Boolean = false, addonName: String? = null, addonLogo: String? = null, - streamDescription: String? = null + streamDescription: String? = null, + infoHash: String? = null, + fileIdx: Int? = null ): String { val encodedUrl = encode(streamUrl) val encodedTitle = encode(title) @@ -115,7 +117,8 @@ sealed class Screen(val route: String) { val encodedAddonName = addonName?.let { encode(it) } ?: "" val encodedAddonLogo = addonLogo?.let { encode(it) } ?: "" val encodedStreamDescription = streamDescription?.let { encode(it) } ?: "" - return "player/$encodedUrl/$encodedTitle?streamName=$encodedStreamName&year=$encodedYear&headers=$encodedHeaders&contentId=$encodedContentId&contentType=$encodedContentType&contentName=$encodedContentName&poster=$encodedPoster&backdrop=$encodedBackdrop&logo=$encodedLogo&videoId=$encodedVideoId&season=${season ?: ""}&episode=${episode ?: ""}&episodeTitle=$encodedEpisodeTitle&bingeGroup=$encodedBingeGroup&autoPlayNav=$autoPlayNav&returnToDetailOnBack=$returnToDetailOnBack&returnToHomeOnBack=$returnToHomeOnBack&filename=$encodedFilename&videoHash=$encodedVideoHash&videoSize=${videoSize ?: ""}&startFromBeginning=$startFromBeginning&addonName=$encodedAddonName&addonLogo=$encodedAddonLogo&streamDescription=$encodedStreamDescription" + val encodedInfoHash = infoHash ?: "" + return "player/$encodedUrl/$encodedTitle?streamName=$encodedStreamName&year=$encodedYear&headers=$encodedHeaders&contentId=$encodedContentId&contentType=$encodedContentType&contentName=$encodedContentName&poster=$encodedPoster&backdrop=$encodedBackdrop&logo=$encodedLogo&videoId=$encodedVideoId&season=${season ?: ""}&episode=${episode ?: ""}&episodeTitle=$encodedEpisodeTitle&bingeGroup=$encodedBingeGroup&autoPlayNav=$autoPlayNav&returnToDetailOnBack=$returnToDetailOnBack&returnToHomeOnBack=$returnToHomeOnBack&filename=$encodedFilename&videoHash=$encodedVideoHash&videoSize=${videoSize ?: ""}&startFromBeginning=$startFromBeginning&addonName=$encodedAddonName&addonLogo=$encodedAddonLogo&streamDescription=$encodedStreamDescription&infoHash=$encodedInfoHash&fileIdx=${fileIdx ?: ""}" } } data object Search : Screen("search") diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerNavigationArgs.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerNavigationArgs.kt index afa72820..5539607b 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerNavigationArgs.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerNavigationArgs.kt @@ -26,7 +26,9 @@ internal data class PlayerNavigationArgs( val startFromBeginning: Boolean, val addonName: String?, val addonLogo: String?, - val streamDescription: String? + val streamDescription: String?, + val infoHash: String?, + val fileIdx: Int? ) { companion object { fun from(savedStateHandle: SavedStateHandle): PlayerNavigationArgs { @@ -59,7 +61,9 @@ internal data class PlayerNavigationArgs( startFromBeginning = savedStateHandle.get("startFromBeginning")?.toBooleanStrictOrNull() == true, addonName = decodedOrNull("addonName"), addonLogo = decodedOrNull("addonLogo"), - streamDescription = decodedOrNull("streamDescription") + streamDescription = decodedOrNull("streamDescription"), + infoHash = savedStateHandle.get("infoHash")?.takeIf { it.isNotEmpty() }, + fileIdx = savedStateHandle.get("fileIdx")?.toIntOrNull() ) } } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt index 96bbf371..84a683a3 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import com.nuvio.tv.core.plugin.PluginManager +import com.nuvio.tv.core.torrent.TorrentEngine import com.nuvio.tv.data.local.NextEpisodeThresholdMode import com.nuvio.tv.data.local.PlayerSettingsDataStore import com.nuvio.tv.data.local.StreamLinkCacheDataStore @@ -49,6 +50,7 @@ class PlayerRuntimeController( internal val layoutPreferenceDataStore: com.nuvio.tv.data.local.LayoutPreferenceDataStore, internal val watchedItemsPreferences: com.nuvio.tv.data.local.WatchedItemsPreferences, internal val trackPreferenceDataStore: com.nuvio.tv.data.local.TrackPreferenceDataStore, + internal val torrentEngine: TorrentEngine, savedStateHandle: SavedStateHandle, internal val scope: CoroutineScope ) { @@ -250,6 +252,12 @@ class PlayerRuntimeController( internal var libassPipelineSwitchInFlight: Boolean = false internal var hasDetectedAssSsaTrackForCurrentStream: Boolean = false internal var libassPipelineDecisionStreamUrl: String? = null + internal var torrentStreamJob: Job? = null + internal var torrentStateObserverJob: Job? = null + internal var isTorrentStream: Boolean = navigationArgs.infoHash != null + internal var currentInfoHash: String? = navigationArgs.infoHash + internal var currentFileIdx: Int? = navigationArgs.fileIdx + internal var episodeStreamsJob: Job? = null internal var episodeStreamsCacheRequestKey: String? = null internal val streamCacheKey: String? by lazy { @@ -272,6 +280,7 @@ class PlayerRuntimeController( fun onCleared() { releasePlayer() + stopTorrentStream() mediaSourceFactory.shutdown() sourceChipErrorDismissJob?.cancel() } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerInitialization.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerInitialization.kt index 3ab52674..535c0a82 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerInitialization.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerInitialization.kt @@ -114,15 +114,28 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map< requestedLibassRenderType == AssRenderType.OVERLAY_CANVAS -> AssRenderType.EFFECTS_CANVAS else -> requestedLibassRenderType } - val loadControl = DefaultLoadControl.Builder() - .setTargetBufferBytes(100 * 1024 * 1024) - .setBufferDurationsMs( - DefaultLoadControl.DEFAULT_MIN_BUFFER_MS, - 70_000, - DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, - 5_000 - ) - .build() + val loadControl = if (isTorrentStream) { + // Reduced buffer for torrent streams to avoid requesting data far ahead + DefaultLoadControl.Builder() + .setTargetBufferBytes(20 * 1024 * 1024) + .setBufferDurationsMs( + DefaultLoadControl.DEFAULT_MIN_BUFFER_MS, + 30_000, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, + 3_000 + ) + .build() + } else { + DefaultLoadControl.Builder() + .setTargetBufferBytes(100 * 1024 * 1024) + .setBufferDurationsMs( + DefaultLoadControl.DEFAULT_MIN_BUFFER_MS, + 70_000, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, + 5_000 + ) + .build() + } trackSelector = DefaultTrackSelector(context).apply { diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerLifecycle.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerLifecycle.kt index 5f64be74..78222325 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerLifecycle.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerLifecycle.kt @@ -10,6 +10,7 @@ internal fun PlayerRuntimeController.releasePlayer() { internal fun PlayerRuntimeController.releasePlayer(flushPlaybackState: Boolean) { isReleasingPlayer = true if (flushPlaybackState) { + stopTorrentStream() flushPlaybackSnapshotForSwitchOrExit() } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt index 55368756..80704b0f 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt @@ -469,6 +469,9 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) { _uiState.update { it.copy(currentPosition = target) } pendingPreviewSeekPosition = null scheduleProgressSyncAfterSeek() + if (isTorrentStream) { + onTorrentSeek(target, lastKnownDuration) + } if (_uiState.value.showControls) { showControlsTemporarily() } else { @@ -481,6 +484,9 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) { _exoPlayer?.seekTo(event.position) _uiState.update { it.copy(currentPosition = event.position) } scheduleProgressSyncAfterSeek() + if (isTorrentStream) { + onTorrentSeek(event.position, lastKnownDuration) + } if (_uiState.value.showControls) { showControlsTemporarily() } else { @@ -754,12 +760,37 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) { showSubtitleDelayOverlay = false ) } - releasePlayer() - initializePlayer(currentStreamUrl, currentHeaders) + if (isTorrentStream && currentInfoHash != null) { + releasePlayer() + stopTorrentStream() + launchTorrentSourceStream( + stream = com.nuvio.tv.domain.model.Stream( + name = _uiState.value.currentStreamName, + title = null, + description = null, + url = null, + ytId = null, + infoHash = currentInfoHash, + fileIdx = currentFileIdx, + externalUrl = null, + behaviorHints = null, + addonName = currentAddonName ?: "", + addonLogo = currentAddonLogo + ), + infoHash = currentInfoHash!!, + loadSavedProgress = true + ) + } else { + releasePlayer() + initializePlayer(currentStreamUrl, currentHeaders) + } } PlayerEvent.OnParentalGuideHide -> { _uiState.update { it.copy(showParentalGuide = false) } } + PlayerEvent.OnToggleTorrentStats -> { + _uiState.update { it.copy(showTorrentStats = !it.showTorrentStats) } + } is PlayerEvent.OnShowDisplayModeInfo -> { _uiState.update { it.copy( diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerStartup.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerStartup.kt index b5205ddf..2b53af1e 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerStartup.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerStartup.kt @@ -1,6 +1,8 @@ package com.nuvio.tv.ui.screens.player import android.app.Activity +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import java.lang.ref.WeakReference internal fun PlayerRuntimeController.attachHostActivity(activity: Activity?) { @@ -11,6 +13,34 @@ internal fun PlayerRuntimeController.startInitialPlaybackIfNeeded() { if (initialPlaybackStarted) return initialPlaybackStarted = true + + val infoHash = navigationArgs.infoHash + if (infoHash != null) { + torrentStreamJob = scope.launch { + try { + val localUrl = startTorrentStream(infoHash, navigationArgs.fileIdx) + currentStreamUrl = localUrl + currentHeaders = emptyMap() + observeTorrentState() + preparePlaybackBeforeStart( + url = localUrl, + headers = emptyMap(), + loadSavedProgress = false + ) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + _uiState.update { + it.copy( + error = "Failed to start torrent: ${e.message}", + showLoadingOverlay = false + ) + } + } + } + return + } + preparePlaybackBeforeStart( url = currentStreamUrl, headers = currentHeaders, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerStreams.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerStreams.kt index 5edf2915..02cffa3c 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerStreams.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerStreams.kt @@ -316,11 +316,52 @@ private fun PlayerRuntimeController.persistSelectedStreamForReuse( @androidx.annotation.OptIn(UnstableApi::class) internal fun PlayerRuntimeController.switchToSourceStream(stream: Stream) { + // Torrent streams: delegate to torrent-aware path + if (stream.isTorrent()) { + val infoHash = stream.infoHash ?: return + stopTorrentStream() + nextEpisodeAutoPlayJob?.cancel() + nextEpisodeAutoPlayJob = null + flushPlaybackSnapshotForSwitchOrExit() + resetLoadingOverlayForNewStream() + releasePlayer(flushPlaybackState = false) + hasRetriedCurrentStreamAfter416 = false + errorRetryCount = 0 + subtitleDisabledByPersistedPreference = false + subtitleAddonRestoredByPersistedPreference = false + pendingRestoredAddonSubtitle = null + lastSavedPosition = 0L + _uiState.update { + it.copy( + isBuffering = true, + error = null, + currentStreamName = stream.name ?: stream.addonName, + currentStreamUrl = "", + audioTracks = emptyList(), + subtitleTracks = emptyList(), + selectedAudioTrackIndex = -1, + selectedSubtitleTrackIndex = -1, + showSourcesPanel = false, + isLoadingSourceStreams = false, + sourceStreamsError = null, + isTorrentStream = true + ) + } + showStreamSourceIndicator(stream) + resetNextEpisodeCardState(clearEpisode = false) + launchTorrentSourceStream(stream, infoHash, loadSavedProgress = true) + return + } + val url = stream.getStreamUrl() if (url.isNullOrBlank()) { _uiState.update { it.copy(sourceStreamsError = "Invalid stream URL") } return } + + // Stop any active torrent before switching to HTTP stream + stopTorrentStream() + nextEpisodeAutoPlayJob?.cancel() nextEpisodeAutoPlayJob = null @@ -329,7 +370,7 @@ internal fun PlayerRuntimeController.switchToSourceStream(stream: Stream) { val newHeaders = PlayerMediaSourceFactory.sanitizeHeaders( stream.behaviorHints?.proxyHeaders?.request ) - + resetLoadingOverlayForNewStream() releasePlayer(flushPlaybackState = false) @@ -358,7 +399,8 @@ internal fun PlayerRuntimeController.switchToSourceStream(stream: Stream) { selectedSubtitleTrackIndex = -1, showSourcesPanel = false, isLoadingSourceStreams = false, - sourceStreamsError = null + sourceStreamsError = null, + isTorrentStream = false ) } showStreamSourceIndicator(stream) @@ -586,11 +628,24 @@ internal fun PlayerRuntimeController.reloadEpisodeStreams() { } internal fun PlayerRuntimeController.switchToEpisodeStream(stream: Stream, forcedTargetVideo: Video? = null) { + // Torrent streams: delegate to torrent-aware path + if (stream.isTorrent()) { + val infoHash = stream.infoHash ?: return + stopTorrentStream() + switchToEpisodeStreamCommon(stream, forcedTargetVideo) + launchTorrentSourceStream(stream, infoHash, loadSavedProgress = true) + return + } + val url = stream.getStreamUrl() if (url.isNullOrBlank()) { _uiState.update { it.copy(episodeStreamsError = "Invalid stream URL") } return } + + // Stop any active torrent before switching to HTTP stream + stopTorrentStream() + nextEpisodeAutoPlayJob?.cancel() nextEpisodeAutoPlayJob = null @@ -642,11 +697,12 @@ internal fun PlayerRuntimeController.switchToEpisodeStream(stream: Stream, force showEpisodeStreams = false, isLoadingEpisodeStreams = false, episodeStreamsError = null, - + isTorrentStream = false, + parentalWarnings = emptyList(), showParentalGuide = false, parentalGuideHasShown = false, - + activeSkipInterval = null, skipIntervalDismissed = false, showNextEpisodeCard = false, @@ -666,7 +722,7 @@ internal fun PlayerRuntimeController.switchToEpisodeStream(stream: Stream, force skipIntroFetchedKey = null lastActiveSkipType = null - + fetchParentalGuide(contentId, contentType, currentSeason, currentEpisode) fetchSkipIntervals(contentId, currentSeason, currentEpisode) @@ -677,6 +733,82 @@ internal fun PlayerRuntimeController.switchToEpisodeStream(stream: Stream, force ) } +/** + * Shared episode stream setup used by both torrent and HTTP episode switching. + */ +private fun PlayerRuntimeController.switchToEpisodeStreamCommon( + stream: Stream, + forcedTargetVideo: Video? = null +) { + nextEpisodeAutoPlayJob?.cancel() + nextEpisodeAutoPlayJob = null + flushPlaybackSnapshotForSwitchOrExit() + + val targetVideo = forcedTargetVideo + ?: _uiState.value.episodes.firstOrNull { it.id == _uiState.value.episodeStreamsForVideoId } + + resetLoadingOverlayForNewStream() + releasePlayer(flushPlaybackState = false) + + persistedTrackPreference = null + subtitleDisabledByPersistedPreference = false + subtitleAddonRestoredByPersistedPreference = false + pendingRestoredAddonSubtitle = null + hasRetriedCurrentStreamAfter416 = false + errorRetryCount = 0 + currentVideoId = targetVideo?.id ?: _uiState.value.episodeStreamsForVideoId ?: currentVideoId + currentSeason = targetVideo?.season ?: _uiState.value.episodeStreamsSeason ?: currentSeason + currentEpisode = targetVideo?.episode ?: _uiState.value.episodeStreamsEpisode ?: currentEpisode + currentEpisodeTitle = targetVideo?.title ?: _uiState.value.episodeStreamsTitle ?: currentEpisodeTitle + currentTraktEpisodeMapping = null + currentTraktEpisodeMappingKey = null + lastSavedPosition = 0L + + _uiState.update { + it.copy( + isBuffering = true, + error = null, + currentSeason = currentSeason, + currentEpisode = currentEpisode, + currentEpisodeTitle = currentEpisodeTitle, + currentStreamName = stream.name ?: stream.addonName, + currentStreamUrl = "", + audioTracks = emptyList(), + subtitleTracks = emptyList(), + selectedAudioTrackIndex = -1, + selectedSubtitleTrackIndex = -1, + showEpisodesPanel = false, + showEpisodeStreams = false, + isLoadingEpisodeStreams = false, + episodeStreamsError = null, + isTorrentStream = true, + + parentalWarnings = emptyList(), + showParentalGuide = false, + parentalGuideHasShown = false, + + activeSkipInterval = null, + skipIntervalDismissed = false, + showNextEpisodeCard = false, + nextEpisodeCardDismissed = false, + nextEpisodeAutoPlaySearching = false, + nextEpisodeAutoPlaySourceName = null, + nextEpisodeAutoPlayCountdownSec = null + ) + } + showStreamSourceIndicator(stream) + recomputeNextEpisode(resetVisibility = true) + updateEpisodeDescription() + + playbackStartedForParentalGuide = false + skipIntervals = emptyList() + skipIntroFetchedKey = null + lastActiveSkipType = null + + fetchParentalGuide(contentId, contentType, currentSeason, currentEpisode) + fetchSkipIntervals(contentId, currentSeason, currentEpisode) +} + internal fun PlayerRuntimeController.showEpisodeStreamPicker(video: Video, forceRefresh: Boolean = true) { _uiState.update { it.copy( diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerTorrent.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerTorrent.kt new file mode 100644 index 00000000..be9de079 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerTorrent.kt @@ -0,0 +1,156 @@ +package com.nuvio.tv.ui.screens.player + +import android.util.Log +import com.nuvio.tv.core.torrent.TorrentState +import com.nuvio.tv.domain.model.Stream +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +private const val TAG = "PlayerTorrent" + +/** + * Starts a torrent stream: resolves the infoHash via TorrentEngine, waits for + * buffering, and returns the local HTTP URL for ExoPlayer. + */ +internal suspend fun PlayerRuntimeController.startTorrentStream( + infoHash: String, + fileIdx: Int? +): String { + isTorrentStream = true + currentInfoHash = infoHash + currentFileIdx = fileIdx + + _uiState.update { + it.copy( + showLoadingOverlay = true, + loadingMessage = "Connecting to peers...", + isTorrentStream = true + ) + } + + return torrentEngine.startStream(infoHash, fileIdx) +} + +/** + * Stops the current torrent stream and cleans up all related state. + */ +internal fun PlayerRuntimeController.stopTorrentStream() { + torrentStreamJob?.cancel() + torrentStreamJob = null + torrentStateObserverJob?.cancel() + torrentStateObserverJob = null + + if (isTorrentStream) { + torrentEngine.stopCurrentStream() + } + + isTorrentStream = false + currentInfoHash = null + currentFileIdx = null +} + +/** + * Starts collecting TorrentEngine state and maps it to PlayerUiState fields. + */ +internal fun PlayerRuntimeController.observeTorrentState() { + torrentStateObserverJob?.cancel() + torrentStateObserverJob = scope.launch { + torrentEngine.state.collectLatest { torrentState -> + when (torrentState) { + is TorrentState.Idle -> { + // No-op + } + is TorrentState.Connecting -> { + _uiState.update { + it.copy( + showLoadingOverlay = true, + loadingMessage = "Connecting to peers..." + ) + } + } + is TorrentState.Buffering -> { + val pct = (torrentState.progress * 100).toInt() + _uiState.update { + it.copy( + showLoadingOverlay = true, + loadingMessage = "Buffering: $pct% (${torrentState.peers} peers)", + torrentDownloadSpeed = torrentState.downloadSpeed, + torrentPeers = torrentState.peers, + torrentSeeds = torrentState.seeds + ) + } + } + is TorrentState.Streaming -> { + _uiState.update { + it.copy( + torrentDownloadSpeed = torrentState.downloadSpeed, + torrentUploadSpeed = torrentState.uploadSpeed, + torrentPeers = torrentState.peers, + torrentSeeds = torrentState.seeds, + torrentBufferProgress = torrentState.bufferProgress, + torrentTotalProgress = torrentState.totalProgress + ) + } + } + is TorrentState.Error -> { + Log.e(TAG, "Torrent error: ${torrentState.message}") + _uiState.update { + it.copy( + error = "Torrent error: ${torrentState.message}", + showLoadingOverlay = false + ) + } + } + } + } + } +} + +/** + * Called when the user seeks during torrent playback to re-prioritize pieces. + */ +internal fun PlayerRuntimeController.onTorrentSeek(positionMs: Long, durationMs: Long) { + if (!isTorrentStream) return + torrentEngine.onPlaybackSeek(positionMs, durationMs) +} + +/** + * Launches a torrent stream for source stream switching. + * Handles the full flow: start torrent → wait for buffer → play. + */ +internal fun PlayerRuntimeController.launchTorrentSourceStream( + stream: Stream, + infoHash: String, + loadSavedProgress: Boolean +) { + torrentStreamJob?.cancel() + torrentStreamJob = scope.launch { + try { + val localUrl = startTorrentStream(infoHash, stream.fileIdx) + + currentStreamUrl = localUrl + currentHeaders = emptyMap() + currentStreamMimeType = null + + observeTorrentState() + + preparePlaybackBeforeStart( + url = localUrl, + headers = emptyMap(), + loadSavedProgress = loadSavedProgress + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Failed to start torrent stream", e) + _uiState.update { + it.copy( + error = "Failed to start torrent: ${e.message}", + showLoadingOverlay = false + ) + } + } + } +} diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt index 4902ebd0..a16a1701 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt @@ -592,6 +592,20 @@ fun PlayerScreen( .zIndex(2.6f) ) + // Torrent stats overlay (top-right corner) + TorrentOverlay( + visible = uiState.isTorrentStream && uiState.showTorrentStats && uiState.error == null, + downloadSpeed = uiState.torrentDownloadSpeed, + uploadSpeed = uiState.torrentUploadSpeed, + peers = uiState.torrentPeers, + seeds = uiState.torrentSeeds, + totalProgress = uiState.torrentTotalProgress, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 16.dp, end = 16.dp) + .zIndex(2.7f) + ) + // Buffering indicator if (uiState.isBuffering && !uiState.showLoadingOverlay) { Box( diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt index b4393dfa..0add2a42 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt @@ -132,7 +132,16 @@ data class PlayerUiState( val aspectRatioIndicatorText: String = "", // Stream info overlay val showStreamInfoOverlay: Boolean = false, - val streamInfoData: StreamInfoData? = null + val streamInfoData: StreamInfoData? = null, + // Torrent streaming state + val isTorrentStream: Boolean = false, + val torrentDownloadSpeed: Long = 0L, + val torrentUploadSpeed: Long = 0L, + val torrentPeers: Int = 0, + val torrentSeeds: Int = 0, + val torrentBufferProgress: Float = 0f, + val torrentTotalProgress: Float = 0f, + val showTorrentStats: Boolean = false ) data class TrackInfo( @@ -219,6 +228,7 @@ sealed class PlayerEvent { data object OnToggleAspectRatio : PlayerEvent() data object OnShowStreamInfo : PlayerEvent() data object OnDismissStreamInfo : PlayerEvent() + data object OnToggleTorrentStats : PlayerEvent() } data class ParentalWarning( diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt index 8f60dc18..f9586113 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.media3.exoplayer.ExoPlayer import com.nuvio.tv.core.plugin.PluginManager +import com.nuvio.tv.core.torrent.TorrentEngine import com.nuvio.tv.data.local.PlayerSettingsDataStore import com.nuvio.tv.data.local.StreamLinkCacheDataStore import com.nuvio.tv.data.repository.ParentalGuideRepository @@ -39,6 +40,7 @@ class PlayerViewModel @Inject constructor( private val layoutPreferenceDataStore: com.nuvio.tv.data.local.LayoutPreferenceDataStore, private val watchedItemsPreferences: com.nuvio.tv.data.local.WatchedItemsPreferences, private val trackPreferenceDataStore: com.nuvio.tv.data.local.TrackPreferenceDataStore, + private val torrentEngine: TorrentEngine, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -59,6 +61,7 @@ class PlayerViewModel @Inject constructor( layoutPreferenceDataStore = layoutPreferenceDataStore, watchedItemsPreferences = watchedItemsPreferences, trackPreferenceDataStore = trackPreferenceDataStore, + torrentEngine = torrentEngine, savedStateHandle = savedStateHandle, scope = viewModelScope ) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/TorrentOverlay.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/TorrentOverlay.kt new file mode 100644 index 00000000..0a4bea96 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/TorrentOverlay.kt @@ -0,0 +1,82 @@ +package com.nuvio.tv.ui.screens.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Text + +@Composable +fun TorrentOverlay( + visible: Boolean, + downloadSpeed: Long, + uploadSpeed: Long, + peers: Int, + seeds: Int, + totalProgress: Float, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(), + exit = fadeOut(), + modifier = modifier + ) { + Column( + modifier = Modifier + .background( + color = Color.Black.copy(alpha = 0.7f), + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "P2P", + color = Color(0xFF4CAF50), + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = "↓ ${formatSpeed(downloadSpeed)}", + color = Color.White, + fontSize = 11.sp + ) + Text( + text = "↑ ${formatSpeed(uploadSpeed)}", + color = Color.White.copy(alpha = 0.7f), + fontSize = 11.sp + ) + } + Text( + text = "$peers peers · $seeds seeds · ${(totalProgress * 100).toInt()}%", + color = Color.White.copy(alpha = 0.6f), + fontSize = 10.sp + ) + } + } +} + +private fun formatSpeed(bytesPerSec: Long): String { + return when { + bytesPerSec >= 1_048_576 -> String.format("%.1f MB/s", bytesPerSec / 1_048_576.0) + bytesPerSec >= 1_024 -> String.format("%.0f KB/s", bytesPerSec / 1_024.0) + else -> "$bytesPerSec B/s" + } +} diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/stream/StreamScreenViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/stream/StreamScreenViewModel.kt index 054e7f33..a5f77d84 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/stream/StreamScreenViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/stream/StreamScreenViewModel.kt @@ -658,7 +658,8 @@ class StreamScreenViewModel @Inject constructor( videoSize = stream.behaviorHints?.videoSize, addonName = stream.addonName, addonLogo = stream.addonLogo, - streamDescription = stream.description + streamDescription = stream.description, + fileIdx = stream.fileIdx ) val url = playbackInfo.url @@ -714,5 +715,6 @@ data class StreamPlaybackInfo( val videoSize: Long? = null, val addonName: String? = null, val addonLogo: String? = null, - val streamDescription: String? = null + val streamDescription: String? = null, + val fileIdx: Int? = null ) diff --git a/compose_stability_config.conf b/compose_stability_config.conf index e6594d88..49a04e12 100644 --- a/compose_stability_config.conf +++ b/compose_stability_config.conf @@ -5,6 +5,9 @@ // Domain models com.nuvio.tv.domain.model.* +// Torrent state +com.nuvio.tv.core.torrent.TorrentState + // UI state classes com.nuvio.tv.ui.screens.search.SearchUiState com.nuvio.tv.ui.screens.search.DiscoverCatalog diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c00843c..872c8d88 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ quickjsKt = "1.0.1" jsoup = "1.17.2" gson = "2.10.1" markdownRenderer = "0.33.0" +libtorrent4j = "2.1.0-35" nanohttpd = "2.3.1" zxing = "3.5.3" supabase = "3.1.4" @@ -104,6 +105,11 @@ media3-extractor = { group = "androidx.media3", name = "media3-extractor", versi quickjs-kt = { group = "io.github.dokar3", name = "quickjs-kt-android", version.ref = "quickjsKt" } jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +libtorrent4j = { group = "org.libtorrent4j", name = "libtorrent4j", version.ref = "libtorrent4j" } +libtorrent4j-android-arm = { group = "org.libtorrent4j", name = "libtorrent4j-android-arm", version.ref = "libtorrent4j" } +libtorrent4j-android-arm64 = { group = "org.libtorrent4j", name = "libtorrent4j-android-arm64", version.ref = "libtorrent4j" } +libtorrent4j-android-x86 = { group = "org.libtorrent4j", name = "libtorrent4j-android-x86", version.ref = "libtorrent4j" } +libtorrent4j-android-x8664 = { group = "org.libtorrent4j", name = "libtorrent4j-android-x86_64", version.ref = "libtorrent4j" } nanohttpd = { group = "org.nanohttpd", name = "nanohttpd", version.ref = "nanohttpd" } zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" }