Merge pull request #1313 from skoruppa/cmp-rewrite

Download subtitles locally before passing to external players
This commit is contained in:
Nayif 2026-06-11 01:51:45 +05:30 committed by GitHub
commit ee5c923091
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 245 additions and 9 deletions

View file

@ -28,6 +28,7 @@ import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.player.PlayerTrackPreferenceStorage
import com.nuvio.app.features.player.ExternalPlayerPlatform
import com.nuvio.app.features.player.SubtitleFileCache
import com.nuvio.app.features.player.PlayerPictureInPictureManager
import com.nuvio.app.features.p2p.P2pSettingsStorage
import com.nuvio.app.features.p2p.P2pStreamingEngine
@ -76,6 +77,7 @@ class MainActivity : AppCompatActivity() {
P2pSettingsStorage.initialize(applicationContext)
P2pStreamingEngine.initialize(applicationContext)
ExternalPlayerPlatform.initialize(applicationContext)
SubtitleFileCache.initialize(applicationContext)
ProfileStorage.initialize(applicationContext)
AvatarStorage.initialize(applicationContext)
ProfilePinCacheStorage.initialize(applicationContext)

View file

@ -76,7 +76,7 @@ internal actual object ExternalPlayerPlatform {
}
// Title extras
val displayTitle = request.streamTitle ?: request.title
val displayTitle = request.buildPlayerTitle(includeEpisodeTitle = true)
putExtra(Intent.EXTRA_TITLE, displayTitle)
putExtra("title", displayTitle)
putExtra("forcename", displayTitle) // Vimu Player
@ -85,6 +85,7 @@ internal actual object ExternalPlayerPlatform {
if (request.resumePositionMs > 0L) {
putExtra("position", request.resumePositionMs.toInt()) // MX Player / Just Player / mpv
putExtra("startfrom", request.resumePositionMs.toInt()) // Vimu Player
putExtra("forceresume", true) // Vimu: enable resume for network streams
putExtra("from_start", false) // VLC: don't force start from beginning
}
@ -107,18 +108,33 @@ internal actual object ExternalPlayerPlatform {
val subtitleNames = subtitles.map { it.name }.toTypedArray()
val subtitleFilenames = subtitles.map { "${it.lang}_${it.name}.srt" }.toTypedArray()
// Grant read permission for content:// URIs via ClipData.
// FLAG_GRANT_READ_URI_PERMISSION only covers intent.data, not extras.
// Adding all subtitle URIs to ClipData ensures the receiving player
// gets read access to all of them.
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val clipData = android.content.ClipData(
"subtitles",
arrayOf("application/x-subrip", "text/vtt"),
android.content.ClipData.Item(subtitleUris.first())
)
subtitleUris.drop(1).forEach { subtitleUri ->
clipData.addItem(android.content.ClipData.Item(subtitleUri))
}
setClipData(clipData)
// MX Player / mpv-android / Nova
putExtra("subs", subtitleUris)
putExtra("subs.name", subtitleNames)
putExtra("subs.filename", subtitleFilenames)
putExtra("subs.enable", arrayOf(Uri.parse(subtitles.first().url)))
putExtra("subs.enable", arrayOf(subtitleUris.first()))
// Just Player
putExtra("subtitle_uri", subtitleUris)
putExtra("subtitle_name", subtitleNames)
// VLC (single subtitle — use first one)
putExtra("subtitles_location", Uri.parse(subtitles.first().url))
putExtra("subtitles_location", subtitleUris.first())
// Vimu Player
putExtra("forcedsrt", subtitles.first().url)

View file

@ -0,0 +1,11 @@
package com.nuvio.app.features.player
/**
* Android implementation: downloads subtitles to local cache and returns content:// URIs
* via FileProvider so external players can read them.
*/
actual object SubtitleCacheProvider {
actual suspend fun cacheForExternalPlayer(subtitles: List<SubtitleInput>): List<SubtitleInput>? {
return SubtitleFileCache.cacheSubtitles(subtitles)
}
}

View file

@ -0,0 +1,130 @@
package com.nuvio.app.features.player
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
/**
* Downloads subtitle files from remote URLs to local cache and provides
* content:// URIs via FileProvider so external players can read them.
*/
object SubtitleFileCache {
private const val TAG = "SubtitleFileCache"
private const val SUBTITLE_CACHE_DIR = "subtitles"
private var appContext: Context? = null
private val okHttpClient: OkHttpClient by lazy { OkHttpClient() }
fun initialize(context: Context) {
appContext = context.applicationContext
}
private val cacheDir: File?
get() = appContext?.let { File(it.cacheDir, SUBTITLE_CACHE_DIR).also { dir -> dir.mkdirs() } }
/**
* Downloads subtitle files and returns updated [SubtitleInput] list with
* content:// URIs instead of HTTP URLs.
*
* Subtitles that fail to download are silently skipped (not included in result).
* Returns null if no subtitles could be downloaded.
*/
suspend fun cacheSubtitles(subtitles: List<SubtitleInput>): List<SubtitleInput>? {
if (subtitles.isEmpty()) return null
val context = appContext ?: return null
// Clean old cached subtitles before downloading new ones
clearCache()
val cached = subtitles.mapNotNull { input ->
try {
val localUri = downloadToCache(context, input)
if (localUri != null) {
input.copy(url = localUri.toString())
} else {
Log.w(TAG, "Failed to download subtitle: ${input.name}")
null
}
} catch (e: Exception) {
Log.w(TAG, "Error caching subtitle: ${input.name}", e)
null
}
}
return cached.ifEmpty { null }
}
/**
* Downloads a single subtitle file and returns a content:// URI.
*/
private suspend fun downloadToCache(context: Context, input: SubtitleInput): Uri? =
withContext(Dispatchers.IO) {
val dir = cacheDir ?: return@withContext null
val extension = guessExtension(input.url)
val filename = sanitizeFilename("${input.lang}_${input.name}.$extension")
val file = File(dir, filename)
val request = Request.Builder()
.url(input.url)
.build()
try {
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.w(TAG, "HTTP ${response.code} downloading subtitle: ${input.url}")
return@withContext null
}
val body = response.body ?: return@withContext null
file.outputStream().use { output ->
body.byteStream().copyTo(output)
}
}
// Return content:// URI via FileProvider
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
} catch (e: Exception) {
Log.w(TAG, "Failed to download subtitle file: ${input.url}", e)
file.delete()
null
}
}
/**
* Removes all cached subtitle files.
*/
fun clearCache() {
try {
cacheDir?.listFiles()?.forEach { it.delete() }
} catch (e: Exception) {
Log.w(TAG, "Failed to clear subtitle cache", e)
}
}
private fun guessExtension(url: String): String {
val path = url.substringBefore('?').substringBefore('#').trimEnd('/')
return when {
path.endsWith(".vtt", ignoreCase = true) -> "vtt"
path.endsWith(".ass", ignoreCase = true) -> "ass"
path.endsWith(".ssa", ignoreCase = true) -> "ssa"
path.endsWith(".ttml", ignoreCase = true) -> "ttml"
path.endsWith(".dfxp", ignoreCase = true) -> "dfxp"
else -> "srt"
}
}
private fun sanitizeFilename(name: String): String {
return name.replace(Regex("[^a-zA-Z0-9._\\-]"), "_")
.take(100) // Limit filename length
}
}

View file

@ -3,6 +3,9 @@
<cache-path
name="nuvio_updates"
path="updates/" />
<cache-path
name="nuvio_subtitles"
path="subtitles/" />
<files-path
name="nuvio_downloads"
path="downloads/" />

View file

@ -367,6 +367,9 @@ private fun PlayerLaunch.toExternalPlayerPlaybackRequest(): ExternalPlayerPlayba
streamTitle = streamTitle,
sourceHeaders = sourceHeaders,
resumePositionMs = initialPositionMs,
season = seasonNumber,
episode = episodeNumber,
episodeTitle = episodeTitle,
)
private enum class AppGateScreen {

View file

@ -2,8 +2,8 @@ package com.nuvio.app.features.player
/**
* Orchestrates the full external player launch flow:
* fetches subtitles if forwarding is enabled, then returns an enriched request
* for the caller to dispatch.
* fetches subtitles if forwarding is enabled, downloads them to local cache,
* then returns an enriched request for the caller to dispatch.
*/
suspend fun prepareExternalPlayerLaunch(
request: ExternalPlayerPlaybackRequest,
@ -25,6 +25,12 @@ suspend fun prepareExternalPlayerLaunch(
)
if (subtitles != null) {
onOverlayMessage("Downloading subtitles...")
val cachedSubtitles = SubtitleCacheProvider.cacheForExternalPlayer(subtitles)
if (cachedSubtitles != null) {
return request.copy(subtitles = cachedSubtitles)
}
// Fallback: use original URLs if caching fails
return request.copy(subtitles = subtitles)
}
}

View file

@ -18,7 +18,25 @@ data class ExternalPlayerPlaybackRequest(
val sourceHeaders: Map<String, String> = emptyMap(),
val resumePositionMs: Long = 0L,
val subtitles: List<SubtitleInput>? = null,
)
val season: Int? = null,
val episode: Int? = null,
val episodeTitle: String? = null,
) {
/**
* Builds a display title for external players.
* For series: "Show Name - S02E05" or "Show Name - S02E05 - Episode Title"
* For movies: just the content name (title).
*/
fun buildPlayerTitle(includeEpisodeTitle: Boolean = false): String {
if (season == null || episode == null) return title
val seasonEp = "S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}"
return if (includeEpisodeTitle && !episodeTitle.isNullOrBlank()) {
"$title - $seasonEp - $episodeTitle"
} else {
"$title - $seasonEp"
}
}
}
enum class ExternalPlayerOpenResult {
Opened,

View file

@ -511,6 +511,9 @@ private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long,
sourceHeaders = activeSourceHeaders,
resumePositionMs = playbackSnapshot.positionMs,
subtitles = loadedSubtitles,
season = activeSeasonNumber,
episode = activeEpisodeNumber,
episodeTitle = activeEpisodeTitle,
),
)
}

View file

@ -0,0 +1,21 @@
package com.nuvio.app.features.player
/**
* Platform-specific subtitle caching for external players.
*
* On Android, downloads subtitle files from remote URLs to local cache and returns
* content:// URIs so external players can access them (via intent extras + ClipData).
*
* On iOS, returns subtitles unchanged players like Infuse accept remote URLs
* directly via their URL scheme parameters.
*/
expect object SubtitleCacheProvider {
/**
* Caches subtitle files locally and returns updated [SubtitleInput] list
* with local URIs instead of remote HTTP URLs.
*
* Returns null if caching fails or no subtitles could be downloaded.
* On platforms where caching is not needed, returns the input list unchanged.
*/
suspend fun cacheForExternalPlayer(subtitles: List<SubtitleInput>): List<SubtitleInput>?
}

View file

@ -20,7 +20,11 @@ private val iosExternalPlayerSpecs = listOf(
append("infuse://x-callback-url/play?url=")
append(request.sourceUrl.urlQueryEncode())
append("&filename=")
append((request.streamTitle ?: request.title).urlQueryEncode())
append(request.buildPlayerTitle(includeEpisodeTitle = true).urlQueryEncode())
request.subtitles?.forEach { subtitle ->
append("&sub=")
append(subtitle.url.urlQueryEncode())
}
}
},
),
@ -29,7 +33,14 @@ private val iosExternalPlayerSpecs = listOf(
name = "VLC",
scheme = "vlc-x-callback",
buildUrl = { request ->
"vlc-x-callback://x-callback-url/stream?url=${request.sourceUrl.urlQueryEncode()}"
buildString {
append("vlc-x-callback://x-callback-url/stream?url=")
append(request.sourceUrl.urlQueryEncode())
request.subtitles?.firstOrNull()?.let { subtitle ->
append("&sub=")
append(subtitle.url.urlQueryEncode())
}
}
},
),
IosExternalPlayerSpec(
@ -41,7 +52,7 @@ private val iosExternalPlayerSpecs = listOf(
append("outplayer://x-callback-url/play?url=")
append(request.sourceUrl.urlQueryEncode())
append("&filename=")
append((request.streamTitle ?: request.title).urlQueryEncode())
append(request.buildPlayerTitle(includeEpisodeTitle = true).urlQueryEncode())
}
},
),

View file

@ -0,0 +1,12 @@
package com.nuvio.app.features.player
/**
* iOS implementation: returns subtitles unchanged (no caching needed).
* iOS players like Infuse accept remote subtitle URLs directly via their URL scheme,
* so we just pass through the original HTTP URLs without downloading.
*/
actual object SubtitleCacheProvider {
actual suspend fun cacheForExternalPlayer(subtitles: List<SubtitleInput>): List<SubtitleInput>? {
return subtitles.ifEmpty { null }
}
}