feat(plugins): add support for plugin subtitles with header injection and format detection

This commit is contained in:
paregi12 2026-05-20 18:25:31 +05:30
parent 75dc501c60
commit 9b372ea118
14 changed files with 224 additions and 19 deletions

View file

@ -35,6 +35,9 @@ import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
@ -68,6 +71,7 @@ actual fun PlatformPlayerSurface(
sourceAudioUrl: String?,
sourceHeaders: Map<String, String>,
sourceResponseHeaders: Map<String, String>,
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
useYoutubeChunkedPlayback: Boolean,
modifier: Modifier,
playWhenReady: Boolean,
@ -146,12 +150,26 @@ actual fun PlatformPlayerSurface(
.setTsExtractorFlags(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS)
.setTsExtractorTimestampSearchBytes(1500 * TsExtractor.TS_PACKET_SIZE)
val dataSourceFactory = PlatformPlaybackDataSourceFactory.create(
context = context,
defaultRequestHeaders = sanitizedSourceHeaders,
val baseNetworkFactory = if (useYoutubeChunkedPlayback) {
YoutubeChunkedDataSourceFactory(defaultRequestHeaders = sanitizedSourceHeaders)
} else {
PlayerPlaybackNetworking.createHttpDataSourceFactory(sanitizedSourceHeaders)
}
val subtitleHeaderFactory = SubtitleRequestHeaderDataSourceFactory(
upstreamFactory = baseNetworkFactory,
externalSubtitles = externalSubtitles
)
val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, subtitleHeaderFactory)
val dataSourceFactory = if (sanitizedSourceResponseHeaders.isEmpty()) {
baseFactory
} else {
ResponseHeaderOverridingDataSourceFactory(
upstreamFactory = baseFactory,
defaultResponseHeaders = sanitizedSourceResponseHeaders,
useYoutubeChunkedPlayback = useYoutubeChunkedPlayback,
)
}
val player = if (useLibass) {
ExoPlayer.Builder(context)
@ -179,13 +197,33 @@ actual fun PlatformPlayerSurface(
}
player.apply {
val mediaItemBuilder = MediaItem.Builder()
.setUri(Uri.parse(sourceUrl))
.setMediaId(sourceUrl)
val subtitleConfigs = externalSubtitles.mapNotNull { subtitle ->
val mimeType = resolveSubtitleMimeType(subtitle.url, subtitle.headers)
MediaItem.SubtitleConfiguration.Builder(Uri.parse(subtitle.url))
.setMimeType(mimeType)
.setLanguage(subtitle.language)
.setLabel(subtitle.name ?: subtitle.language)
.setRoleFlags(C.ROLE_FLAG_SUBTITLE)
.build()
}
if (subtitleConfigs.isNotEmpty()) {
mediaItemBuilder.setSubtitleConfigurations(subtitleConfigs)
}
val mediaItem = mediaItemBuilder.build()
if (!sourceAudioUrl.isNullOrBlank()) {
val msf = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)
val videoSource = msf.createMediaSource(MediaItem.fromUri(sourceUrl))
val videoSource = msf.createMediaSource(mediaItem)
val audioSource = msf.createMediaSource(MediaItem.fromUri(sourceAudioUrl))
setMediaSource(MergingMediaSource(videoSource, audioSource))
} else {
setMediaItem(MediaItem.fromUri(sourceUrl))
setMediaItem(mediaItem)
}
fallbackStartPositionMs?.let { seekTo(it.coerceAtLeast(0L)) }
prepare()
@ -709,15 +747,15 @@ private fun ExoPlayer.logCurrentTracks(context: String) {
Log.d(TAG, "--- end logCurrentTracks ---")
}
private fun resolveSubtitleMimeType(url: String): String {
probeSubtitleHeaders(url)?.let { (contentType, contentDisposition) ->
private fun resolveSubtitleMimeType(url: String, headers: Map<String, String>? = null): String {
probeSubtitleHeaders(url, headers)?.let { (contentType, contentDisposition) ->
mapSubtitleMime(contentType)?.let { return it }
filenameFromContentDisposition(contentDisposition)?.let(::guessSubtitleMime)?.let { return it }
}
return guessSubtitleMime(url)
}
private fun probeSubtitleHeaders(url: String): Pair<String?, String?>? {
private fun probeSubtitleHeaders(url: String, headers: Map<String, String>? = null): Pair<String?, String?>? {
val methods = listOf("HEAD", "GET")
methods.forEach { method ->
runCatching {
@ -727,6 +765,9 @@ private fun probeSubtitleHeaders(url: String): Pair<String?, String?>? {
readTimeout = 5_000
instanceFollowRedirects = true
setRequestProperty("Accept", "*/*")
headers?.forEach { (key, value) ->
setRequestProperty(key, value)
}
}
try {
connection.responseCode
@ -781,3 +822,50 @@ private fun guessSubtitleMime(url: String): String {
else -> MimeTypes.TEXT_VTT
}
}
private class SubtitleRequestHeaderDataSourceFactory(
private val upstreamFactory: DataSource.Factory,
private val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
) : DataSource.Factory {
override fun createDataSource(): DataSource =
SubtitleRequestHeaderDataSource(
upstream = upstreamFactory.createDataSource(),
externalSubtitles = externalSubtitles,
)
}
private class SubtitleRequestHeaderDataSource(
private val upstream: DataSource,
private val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
) : DataSource {
override fun addTransferListener(transferListener: TransferListener) {
upstream.addTransferListener(transferListener)
}
override fun open(dataSpec: DataSpec): Long {
val url = dataSpec.uri.toString()
val subtitle = externalSubtitles.find { it.url == url }
val headers = subtitle?.headers
return if (headers.isNullOrEmpty()) {
upstream.open(dataSpec)
} else {
val mergedHeaders = dataSpec.httpRequestHeaders.toMutableMap()
headers.forEach { (key, value) ->
mergedHeaders[key] = value
}
upstream.open(dataSpec.buildUpon().setHttpRequestHeaders(mergedHeaders).build())
}
}
override fun read(buffer: ByteArray, offset: Int, length: Int): Int =
upstream.read(buffer, offset, length)
override fun getUri(): Uri? = upstream.uri
override fun getResponseHeaders(): Map<String, List<String>> = upstream.responseHeaders
override fun close() {
upstream.close()
}
}

View file

@ -872,6 +872,7 @@ private fun MainAppContent(
sourceUrl = localSourceUrl,
sourceHeaders = emptyMap(),
sourceResponseHeaders = emptyMap(),
externalSubtitles = emptyList(),
logo = logo,
poster = poster,
background = background,
@ -1457,6 +1458,7 @@ private fun MainAppContent(
sourceUrl = cached.url,
sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders),
externalSubtitles = emptyList(),
logo = launch.logo,
poster = launch.poster,
background = launch.background,
@ -1563,6 +1565,7 @@ private fun MainAppContent(
sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
externalSubtitles = stream.externalSubtitles,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
@ -1582,8 +1585,7 @@ private fun MainAppContent(
parentMetaType = launch.parentMetaType ?: launch.type,
initialPositionMs = launch.resumePositionMs ?: 0L,
initialProgressFraction = launch.resumeProgressFraction,
)
StreamsRepository.consumeAutoPlay()
) StreamsRepository.consumeAutoPlay()
StreamsRepository.cancelLoading()
if (playerSettings.externalPlayerEnabled) {
openExternalPlayback(playerLaunch)
@ -1673,6 +1675,7 @@ private fun MainAppContent(
sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
externalSubtitles = stream.externalSubtitles,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
@ -1803,6 +1806,7 @@ private fun MainAppContent(
sourceAudioUrl = launch.sourceAudioUrl,
sourceHeaders = launch.sourceHeaders,
sourceResponseHeaders = launch.sourceResponseHeaders,
externalSubtitles = launch.externalSubtitles,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
@ -1908,6 +1912,7 @@ private fun MainAppContent(
sourceUrl = sourceUrl,
sourceHeaders = emptyMap(),
sourceResponseHeaders = emptyMap(),
externalSubtitles = emptyList(),
logo = item.logo,
poster = item.poster,
background = item.background,

View file

@ -56,6 +56,7 @@ expect fun PlatformPlayerSurface(
sourceAudioUrl: String? = null,
sourceHeaders: Map<String, String> = emptyMap(),
sourceResponseHeaders: Map<String, String> = emptyMap(),
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
useYoutubeChunkedPlayback: Boolean = false,
modifier: Modifier = Modifier,
playWhenReady: Boolean = true,

View file

@ -13,6 +13,7 @@ data class PlayerLaunch(
val sourceAudioUrl: String? = null,
val sourceHeaders: Map<String, String> = emptyMap(),
val sourceResponseHeaders: Map<String, String> = emptyMap(),
val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
val logo: String? = null,
val poster: String? = null,
val background: String? = null,

View file

@ -126,6 +126,7 @@ fun PlayerScreen(
sourceAudioUrl: String? = null,
sourceHeaders: Map<String, String> = emptyMap(),
sourceResponseHeaders: Map<String, String> = emptyMap(),
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
providerName: String,
streamTitle: String,
streamSubtitle: String?,
@ -144,6 +145,8 @@ fun PlayerScreen(
videoId: String? = null,
parentMetaId: String,
parentMetaType: String,
parentMetaLogo: String? = null,
parentMetaPoster: String? = null,
providerAddonId: String? = null,
initialPositionMs: Long = 0L,
initialProgressFraction: Float? = null,
@ -1713,6 +1716,7 @@ fun PlayerScreen(
sourceAudioUrl = activeSourceAudioUrl,
sourceHeaders = activeSourceHeaders,
sourceResponseHeaders = activeSourceResponseHeaders,
externalSubtitles = externalSubtitles,
modifier = Modifier.fillMaxSize(),
playWhenReady = shouldPlay,
resizeMode = resizeMode,

View file

@ -401,5 +401,13 @@ private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem
proxyHeaders = com.nuvio.app.features.streams.StreamProxyHeaders(request = requestHeaders),
)
},
externalSubtitles = subtitles?.map {
com.nuvio.app.features.streams.StreamSubtitle(
url = it.url,
language = it.language,
name = it.name,
headers = it.headers
)
} ?: emptyList()
)
}

View file

@ -78,6 +78,15 @@ data class PluginRuntimeResult(
val peers: Int? = null,
val infoHash: String? = null,
val headers: Map<String, String>? = null,
val subtitles: List<PluginSubtitleResult>? = null,
)
@Serializable
data class PluginSubtitleResult(
val url: String,
val language: String,
val name: String? = null,
val headers: Map<String, String>? = null
)
data class PluginsUiState(

View file

@ -4,6 +4,14 @@ import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
@Serializable
data class StreamSubtitle(
val url: String,
val language: String,
val name: String? = null,
val headers: Map<String, String>? = null
)
data class StreamItem(
val name: String? = null,
val title: String? = null,
@ -18,6 +26,7 @@ data class StreamItem(
val addonId: String,
val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(),
val clientResolve: StreamClientResolve? = null,
val externalSubtitles: List<StreamSubtitle> = emptyList(),
) {
val streamLabel: String
get() = name ?: runBlocking { getString(Res.string.stream_default_name) }

View file

@ -665,6 +665,14 @@ private fun PluginRuntimeResult.toStreamItem(
proxyHeaders = StreamProxyHeaders(request = requestHeaders),
)
},
externalSubtitles = subtitles?.map {
StreamSubtitle(
url = it.url,
language = it.language,
name = it.name,
headers = it.headers
)
} ?: emptyList()
)
}

View file

@ -11,6 +11,7 @@ class PlayerLaunchStoreTest {
val launch = PlayerLaunch(
title = "Title",
sourceUrl = "https://example.com/video.m3u8?token=a/b:c",
externalSubtitles = emptyList(),
streamTitle = "Source",
providerName = "Provider",
parentMetaId = "tt1234567",

View file

@ -206,6 +206,25 @@ internal object PluginRuntime {
?.toMap()
?.takeIf { it.isNotEmpty() }
val subtitles = (item["subtitles"] as? JsonArray)?.mapNotNull { subElement ->
val subObj = subElement as? JsonObject ?: return@mapNotNull null
val subUrl = subObj["url"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
val subLang = subObj["language"]?.jsonPrimitive?.contentOrNull ?: "Unknown"
val subName = subObj["name"]?.jsonPrimitive?.contentOrNull
val subHeaders = (subObj["headers"] as? JsonObject)
?.mapNotNull { (key, value) ->
value.jsonPrimitive.contentOrNull?.let { key to it }
}
?.toMap()
?.takeIf { it.isNotEmpty() }
com.nuvio.app.features.plugins.PluginSubtitleResult(
url = subUrl,
language = subLang,
name = subName,
headers = subHeaders
)
}?.takeIf { it.isNotEmpty() }
PluginRuntimeResult(
title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: "Unknown",
name = item.stringOrNull("name"),
@ -219,6 +238,7 @@ internal object PluginRuntime {
peers = item["peers"]?.jsonPrimitive?.intOrNull,
infoHash = item.stringOrNull("infoHash"),
headers = headers,
subtitles = subtitles,
)
}.filter { it.url.isNotBlank() }
}.getOrElse { emptyList() }

View file

@ -9,7 +9,12 @@ import platform.UIKit.UIViewController
interface NuvioPlayerBridge {
fun createPlayerViewController(): UIViewController
fun loadFile(url: String)
fun loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?)
fun loadFileWithAudio(
videoUrl: String,
audioUrl: String?,
headersJson: String?,
subtitlesJson: String? = null
)
fun play()
fun pause()
fun seekTo(positionMs: Long)

View file

@ -26,6 +26,7 @@ actual fun PlatformPlayerSurface(
sourceAudioUrl: String?,
sourceHeaders: Map<String, String>,
sourceResponseHeaders: Map<String, String>,
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
useYoutubeChunkedPlayback: Boolean,
modifier: Modifier,
playWhenReady: Boolean,
@ -222,12 +223,13 @@ actual fun PlatformPlayerSurface(
}
// Load file and set initial state
LaunchedEffect(bridge, sourceUrl, sourceAudioUrl, sourceHeaders) {
LaunchedEffect(bridge, sourceUrl, sourceAudioUrl, sourceHeaders, externalSubtitles) {
bridge.applyIosVideoOutputSettings(latestPlayerSettings.value)
bridge.loadFileWithAudio(
sourceUrl,
sourceAudioUrl,
encodePlaybackHeadersForBridge(sourceHeaders),
videoUrl = sourceUrl,
audioUrl = sourceAudioUrl,
headersJson = encodePlaybackHeadersForBridge(sourceHeaders),
subtitlesJson = encodeExternalSubtitlesForBridge(externalSubtitles),
)
if (playWhenReady) {
bridge.play()
@ -339,6 +341,13 @@ private fun Int.toHexByte(): String {
}
}
private fun encodeExternalSubtitlesForBridge(subtitles: List<com.nuvio.app.features.streams.StreamSubtitle>): String? {
if (subtitles.isEmpty()) return null
return runCatching {
Json.encodeToString(subtitles)
}.getOrNull()
}
private fun encodePlaybackHeadersForBridge(headers: Map<String, String>): String? {
val sanitized = sanitizePlaybackHeaders(headers)
if (sanitized.isEmpty()) {

View file

@ -16,13 +16,41 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
}
func loadFile(url: String) { playerVC?.loadFile(url) }
func loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?) {
func loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?, subtitlesJson: String?) {
playerVC?.loadFile(
videoUrl,
audioUrl: audioUrl,
requestHeaders: parseRequestHeaders(headersJson)
requestHeaders: parseRequestHeaders(headersJson),
subtitles: parseSubtitles(subtitlesJson)
)
}
private func parseSubtitles(_ json: String?) -> [PluginSubtitle] {
guard
let json,
let data = json.data(using: .utf8),
let raw = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else {
return []
}
return raw.compactMap { dict in
guard let url = dict["url"] as? String else { return nil }
return PluginSubtitle(
url: url,
language: dict["language"] as? String ?? "Unknown",
name: dict["name"] as? String,
headers: dict["headers"] as? [String: String]
)
}
}
}
struct PluginSubtitle {
val url: String
val language: String
val name: String?
val headers: [String: String]?
}
func play() { playerVC?.playPlayback() }
func pause() { playerVC?.pausePlayback() }
func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) }
@ -172,6 +200,7 @@ private struct PendingLoadRequest {
let urlString: String
let audioUrl: String?
let requestHeaders: [String: String]
let subtitles: [PluginSubtitle]
let queuedAtUptime: TimeInterval
}
@ -357,11 +386,12 @@ final class MPVPlayerViewController: UIViewController {
// MARK: - Playback API
func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:]) {
func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:], subtitles: [PluginSubtitle] = []) {
let request = PendingLoadRequest(
urlString: urlString,
audioUrl: audioUrl,
requestHeaders: requestHeaders,
subtitles: subtitles,
queuedAtUptime: ProcessInfo.processInfo.systemUptime
)
@ -409,6 +439,13 @@ final class MPVPlayerViewController: UIViewController {
self?.command("audio-add", args: [audioUrl, "select"], checkForErrors: false)
}
}
// Add external subtitles
for subtitle in request.subtitles {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.command("sub-add", args: [subtitle.url, "auto", subtitle.name ?? subtitle.language, subtitle.language], checkForErrors: false)
}
}
}
private func isViewportReadyForPlayback(queuedAtUptime: TimeInterval) -> Bool {