Initial version of p2p support
This commit is contained in:
parent
1ac2ec03cf
commit
afb16eaebe
24 changed files with 1348 additions and 29 deletions
|
|
@ -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)
|
||||
|
|
|
|||
6
app/proguard-rules.pro
vendored
6
app/proguard-rules.pro
vendored
|
|
@ -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.** { *; }
|
||||
|
|
|
|||
29
app/src/main/java/com/nuvio/tv/core/di/TorrentModule.kt
Normal file
29
app/src/main/java/com/nuvio/tv/core/di/TorrentModule.kt
Normal 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)
|
||||
}
|
||||
508
app/src/main/java/com/nuvio/tv/core/torrent/TorrentEngine.kt
Normal file
508
app/src/main/java/com/nuvio/tv/core/torrent/TorrentEngine.kt
Normal 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)
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
28
app/src/main/java/com/nuvio/tv/core/torrent/TorrentState.kt
Normal file
28
app/src/main/java/com/nuvio/tv/core/torrent/TorrentState.kt
Normal 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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ internal fun PlayerRuntimeController.releasePlayer() {
|
|||
internal fun PlayerRuntimeController.releasePlayer(flushPlaybackState: Boolean) {
|
||||
isReleasingPlayer = true
|
||||
if (flushPlaybackState) {
|
||||
stopTorrentStream()
|
||||
flushPlaybackSnapshotForSwitchOrExit()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue