Initial version of p2p support

This commit is contained in:
CrissZollo 2026-04-03 11:25:14 +02:00
parent 1ac2ec03cf
commit afb16eaebe
No known key found for this signature in database
24 changed files with 1348 additions and 29 deletions

View file

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

View file

@ -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.** { *; }

View file

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

View file

@ -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>(TorrentState.Idle)
val state: StateFlow<TorrentState> = _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<String> = 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>): 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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String>("startFromBeginning")?.toBooleanStrictOrNull() == true,
addonName = decodedOrNull("addonName"),
addonLogo = decodedOrNull("addonLogo"),
streamDescription = decodedOrNull("streamDescription")
streamDescription = decodedOrNull("streamDescription"),
infoHash = savedStateHandle.get<String>("infoHash")?.takeIf { it.isNotEmpty() },
fileIdx = savedStateHandle.get<String>("fileIdx")?.toIntOrNull()
)
}
}

View file

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

View file

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

View file

@ -10,6 +10,7 @@ internal fun PlayerRuntimeController.releasePlayer() {
internal fun PlayerRuntimeController.releasePlayer(flushPlaybackState: Boolean) {
isReleasingPlayer = true
if (flushPlaybackState) {
stopTorrentStream()
flushPlaybackSnapshotForSwitchOrExit()
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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