added header support during playback

This commit is contained in:
tapframe 2026-04-02 13:10:27 +05:30
parent fa7c4b3881
commit 9f9775e3d2
14 changed files with 227 additions and 10 deletions

View file

@ -58,6 +58,7 @@ private const val TAG = "NuvioPlayer"
actual fun PlatformPlayerSurface(
sourceUrl: String,
sourceAudioUrl: String?,
sourceHeaders: Map<String, String>,
modifier: Modifier,
playWhenReady: Boolean,
resizeMode: PlayerResizeMode,
@ -77,7 +78,11 @@ actual fun PlatformPlayerSurface(
PlayerSettingsRepository.uiState.value
}
val exoPlayer = remember(sourceUrl, sourceAudioUrl) {
val sanitizedSourceHeaders = remember(sourceHeaders) {
sanitizePlaybackHeaders(sourceHeaders)
}
val exoPlayer = remember(sourceUrl, sourceAudioUrl, sanitizedSourceHeaders) {
val renderersFactory = DefaultRenderersFactory(context)
.setExtensionRendererMode(playerSettings.decoderPriority)
.setMapDV7ToHevc(playerSettings.mapDV7ToHevc)
@ -107,7 +112,7 @@ actual fun PlatformPlayerSurface(
.setTsExtractorTimestampSearchBytes(1500 * TsExtractor.TS_PACKET_SIZE)
val mediaSourceFactory = DefaultMediaSourceFactory(
YoutubeChunkedDataSourceFactory(),
YoutubeChunkedDataSourceFactory(defaultRequestHeaders = sanitizedSourceHeaders),
extractorsFactory,
)

View file

@ -19,6 +19,7 @@ import androidx.media3.datasource.TransferListener
*/
@UnstableApi
class YoutubeChunkedDataSourceFactory(
private val defaultRequestHeaders: Map<String, String> = emptyMap(),
private val chunkSizeBytes: Long = CHUNK_SIZE
) : DataSource.Factory {
@ -29,11 +30,14 @@ class YoutubeChunkedDataSourceFactory(
}
override fun createDataSource(): DataSource {
val upstream = DefaultHttpDataSource.Factory()
val upstreamFactory = DefaultHttpDataSource.Factory()
.setConnectTimeoutMs(15_000)
.setReadTimeoutMs(15_000)
.setAllowCrossProtocolRedirects(true)
.createDataSource()
if (defaultRequestHeaders.isNotEmpty()) {
upstreamFactory.setDefaultRequestProperties(defaultRequestHeaders)
}
val upstream = upstreamFactory.createDataSource()
return YoutubeChunkedDataSource(upstream, chunkSizeBytes)
}

View file

@ -91,6 +91,7 @@ import com.nuvio.app.features.player.PlayerLaunch
import com.nuvio.app.features.player.PlayerLaunchStore
import com.nuvio.app.features.player.PlayerRoute
import com.nuvio.app.features.player.PlayerScreen
import com.nuvio.app.features.player.sanitizePlaybackHeaders
import com.nuvio.app.features.profiles.NuvioProfile
import com.nuvio.app.features.profiles.ProfileEditScreen
import com.nuvio.app.features.profiles.ProfileRepository
@ -713,6 +714,7 @@ private fun MainAppContent(
PlayerLaunch(
title = route.title,
sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
logo = route.logo,
poster = route.poster,
background = route.background,
@ -761,6 +763,7 @@ private fun MainAppContent(
title = launch.title,
sourceUrl = launch.sourceUrl,
sourceAudioUrl = launch.sourceAudioUrl,
sourceHeaders = launch.sourceHeaders,
logo = launch.logo,
poster = launch.poster,
background = launch.background,

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.details
import com.nuvio.app.features.streams.StreamBehaviorHints
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamProxyHeaders
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
@ -268,6 +269,9 @@ internal object MetaDetailsParser {
if (url == null && infoHash == null && externalUrl == null) return@mapNotNull null
val hintsObj = obj["behaviorHints"] as? JsonObject
val proxyHeaders = hintsObj
?.objectValue("proxyHeaders")
?.toProxyHeaders()
val streamData = obj["streamData"] as? JsonObject
val addonName = streamData?.string("addon") ?: obj.string("name") ?: "Embedded"
StreamItem(
@ -284,11 +288,34 @@ internal object MetaDetailsParser {
notWebReady = hintsObj?.boolean("notWebReady") ?: false,
videoSize = hintsObj?.long("videoSize"),
filename = hintsObj?.string("filename"),
proxyHeaders = proxyHeaders,
),
)
}
}
private fun JsonObject.objectValue(name: String): JsonObject? =
this[name] as? JsonObject
private fun JsonObject.stringMap(): Map<String, String> =
entries.mapNotNull { (key, value) ->
(value as? JsonPrimitive)?.contentOrNull
?.takeIf { it.isNotBlank() }
?.let { key to it }
}.toMap()
private fun JsonObject.toProxyHeaders(): StreamProxyHeaders? {
val requestHeaders = objectValue("request")?.stringMap().orEmpty().takeIf { it.isNotEmpty() }
val responseHeaders = objectValue("response")?.stringMap().orEmpty().takeIf { it.isNotEmpty() }
if (requestHeaders == null && responseHeaders == null) {
return null
}
return StreamProxyHeaders(
request = requestHeaders,
response = responseHeaders,
)
}
private fun JsonObject.long(name: String): Long? =
this[name]?.jsonPrimitive?.longOrNull
}

View file

@ -20,10 +20,26 @@ interface PlayerEngineController {
fun applySubtitleStyle(style: SubtitleStyleState) {}
}
internal fun sanitizePlaybackHeaders(headers: Map<String, String>?): Map<String, String> {
val rawHeaders = headers ?: return emptyMap()
if (rawHeaders.isEmpty()) return emptyMap()
val sanitized = LinkedHashMap<String, String>(rawHeaders.size)
rawHeaders.forEach { (rawKey, rawValue) ->
val key = rawKey.trim()
val value = rawValue.trim()
if (key.isEmpty() || value.isEmpty()) return@forEach
if (key.equals("Range", ignoreCase = true)) return@forEach
sanitized[key] = value
}
return sanitized
}
@Composable
expect fun PlatformPlayerSurface(
sourceUrl: String,
sourceAudioUrl: String? = null,
sourceHeaders: Map<String, String> = emptyMap(),
modifier: Modifier = Modifier,
playWhenReady: Boolean = true,
resizeMode: PlayerResizeMode = PlayerResizeMode.Fit,

View file

@ -11,6 +11,7 @@ data class PlayerLaunch(
val title: String,
val sourceUrl: String,
val sourceAudioUrl: String? = null,
val sourceHeaders: Map<String, String> = emptyMap(),
val logo: String? = null,
val poster: String? = null,
val background: String? = null,

View file

@ -64,6 +64,7 @@ fun PlayerScreen(
title: String,
sourceUrl: String,
sourceAudioUrl: String? = null,
sourceHeaders: Map<String, String> = emptyMap(),
providerName: String,
streamTitle: String,
streamSubtitle: String?,
@ -105,6 +106,9 @@ fun PlayerScreen(
// Active playback state (mutable to support source/episode switching)
var activeSourceUrl by rememberSaveable { mutableStateOf(sourceUrl) }
var activeSourceAudioUrl by rememberSaveable { mutableStateOf(sourceAudioUrl) }
var activeSourceHeaders by remember(sourceUrl, sourceHeaders) {
mutableStateOf(sanitizePlaybackHeaders(sourceHeaders))
}
var activeStreamTitle by rememberSaveable { mutableStateOf(streamTitle) }
var activeStreamSubtitle by rememberSaveable { mutableStateOf(streamSubtitle) }
var activeProviderName by rememberSaveable { mutableStateOf(providerName) }
@ -468,6 +472,7 @@ fun PlayerScreen(
}
activeSourceUrl = url
activeSourceAudioUrl = null
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
activeProviderName = stream.addonName
@ -500,6 +505,7 @@ fun PlayerScreen(
}
activeSourceUrl = url
activeSourceAudioUrl = null
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
activeProviderName = stream.addonName
@ -541,7 +547,7 @@ fun PlayerScreen(
controlsVisible = false
}
LaunchedEffect(activeSourceUrl, activeSourceAudioUrl) {
LaunchedEffect(activeSourceUrl, activeSourceAudioUrl, activeSourceHeaders) {
errorMessage = null
scrubbingPositionMs = null
initialLoadCompleted = false
@ -777,6 +783,7 @@ fun PlayerScreen(
PlatformPlayerSurface(
sourceUrl = activeSourceUrl,
sourceAudioUrl = activeSourceAudioUrl,
sourceHeaders = activeSourceHeaders,
modifier = Modifier.fillMaxSize(),
playWhenReady = shouldPlay,
resizeMode = resizeMode,

View file

@ -285,6 +285,19 @@ private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem
size?.takeIf { it.isNotBlank() },
language?.takeIf { it.isNotBlank() },
)
val requestHeaders = headers
.orEmpty()
.mapNotNull { (key, value) ->
val headerName = key.trim()
val headerValue = value.trim()
if (headerName.isBlank() || headerValue.isBlank() || headerName.equals("Range", ignoreCase = true)) {
null
} else {
headerName to headerValue
}
}
.toMap()
return StreamItem(
name = name ?: title,
description = subtitleParts.joinToString("").ifBlank { null },
@ -292,6 +305,13 @@ private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem
infoHash = infoHash,
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
behaviorHints = if (requestHeaders.isEmpty()) {
com.nuvio.app.features.streams.StreamBehaviorHints()
} else {
com.nuvio.app.features.streams.StreamBehaviorHints(
proxyHeaders = com.nuvio.app.features.streams.StreamProxyHeaders(request = requestHeaders),
)
},
)
}

View file

@ -29,6 +29,12 @@ data class StreamBehaviorHints(
val notWebReady: Boolean = false,
val videoSize: Long? = null,
val filename: String? = null,
val proxyHeaders: StreamProxyHeaders? = null,
)
data class StreamProxyHeaders(
val request: Map<String, String>? = null,
val response: Map<String, String>? = null,
)
data class AddonStreamGroup(

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.streams
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
@ -30,6 +31,9 @@ object StreamParser {
if (url == null && infoHash == null && externalUrl == null) return@mapNotNull null
val hintsObj = obj["behaviorHints"] as? JsonObject
val proxyHeaders = hintsObj
?.objectValue("proxyHeaders")
?.toProxyHeaders()
StreamItem(
name = obj.string("name"),
description = obj.string("description") ?: obj.string("title"),
@ -44,6 +48,7 @@ object StreamParser {
notWebReady = hintsObj?.boolean("notWebReady") ?: false,
videoSize = hintsObj?.long("videoSize"),
filename = hintsObj?.string("filename"),
proxyHeaders = proxyHeaders,
),
)
}
@ -60,4 +65,26 @@ object StreamParser {
private fun JsonObject.boolean(name: String): Boolean? =
this[name]?.jsonPrimitive?.booleanOrNull
private fun JsonObject.objectValue(name: String): JsonObject? =
this[name] as? JsonObject
private fun JsonObject.stringMap(): Map<String, String> =
entries.mapNotNull { (key, value) ->
(value as? JsonPrimitive)?.contentOrNull
?.takeIf { it.isNotBlank() }
?.let { key to it }
}.toMap()
private fun JsonObject.toProxyHeaders(): StreamProxyHeaders? {
val requestHeaders = objectValue("request")?.stringMap().orEmpty().takeIf { it.isNotEmpty() }
val responseHeaders = objectValue("response")?.stringMap().orEmpty().takeIf { it.isNotEmpty() }
if (requestHeaders == null && responseHeaders == null) {
return null
}
return StreamProxyHeaders(
request = requestHeaders,
response = responseHeaders,
)
}
}

View file

@ -257,6 +257,19 @@ private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem
size?.takeIf { it.isNotBlank() },
language?.takeIf { it.isNotBlank() },
)
val requestHeaders = headers
.orEmpty()
.mapNotNull { (key, value) ->
val headerName = key.trim()
val headerValue = value.trim()
if (headerName.isBlank() || headerValue.isBlank() || headerName.equals("Range", ignoreCase = true)) {
null
} else {
headerName to headerValue
}
}
.toMap()
return StreamItem(
name = name ?: title,
description = subtitleParts.joinToString("").ifBlank { null },
@ -264,5 +277,12 @@ private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem
infoHash = infoHash,
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
behaviorHints = if (requestHeaders.isEmpty()) {
StreamBehaviorHints()
} else {
StreamBehaviorHints(
proxyHeaders = StreamProxyHeaders(request = requestHeaders),
)
},
)
}

View file

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

View file

@ -12,6 +12,8 @@ import co.touchlab.kermit.Logger
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private const val TAG = "NuvioiOSPlayer"
@ -20,6 +22,7 @@ private const val TAG = "NuvioiOSPlayer"
actual fun PlatformPlayerSurface(
sourceUrl: String,
sourceAudioUrl: String?,
sourceHeaders: Map<String, String>,
modifier: Modifier,
playWhenReady: Boolean,
resizeMode: PlayerResizeMode,
@ -205,8 +208,12 @@ actual fun PlatformPlayerSurface(
}
// Load file and set initial state
LaunchedEffect(bridge, sourceUrl, sourceAudioUrl) {
bridge.loadFileWithAudio(sourceUrl, sourceAudioUrl)
LaunchedEffect(bridge, sourceUrl, sourceAudioUrl, sourceHeaders) {
bridge.loadFileWithAudio(
sourceUrl,
sourceAudioUrl,
encodePlaybackHeadersForBridge(sourceHeaders),
)
if (playWhenReady) {
bridge.play()
} else {
@ -294,3 +301,13 @@ private fun Int.toHexByte(): String {
append(digits[value % 16])
}
}
private fun encodePlaybackHeadersForBridge(headers: Map<String, String>): String? {
val sanitized = sanitizePlaybackHeaders(headers)
if (sanitized.isEmpty()) {
return null
}
return runCatching {
Json.encodeToString(sanitized)
}.getOrNull()
}

View file

@ -16,7 +16,13 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
}
func loadFile(url: String) { playerVC?.loadFile(url) }
func loadFileWithAudio(videoUrl: String, audioUrl: String?) { playerVC?.loadFile(videoUrl, audioUrl: audioUrl) }
func loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?) {
playerVC?.loadFile(
videoUrl,
audioUrl: audioUrl,
requestHeaders: parseRequestHeaders(headersJson)
)
}
func play() { playerVC?.playPlayback() }
func pause() { playerVC?.pausePlayback() }
func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) }
@ -99,6 +105,25 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
playerVC?.destroyPlayer()
playerVC = nil
}
private func parseRequestHeaders(_ headersJson: String?) -> [String: String] {
guard
let headersJson,
!headersJson.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let data = headersJson.data(using: .utf8),
let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
return [:]
}
var headers: [String: String] = [:]
headers.reserveCapacity(raw.count)
raw.forEach { key, value in
guard let headerValue = value as? String else { return }
headers[key] = headerValue
}
return headers
}
}
// MARK: - Track Info
@ -121,6 +146,7 @@ final class MPVPlayerViewController: UIViewController {
private var mpv: OpaquePointer?
private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated)
private var recentPlaybackLogs: [String] = []
private var activeRequestHeaders: [String: String] = [:]
// Cached track lists
var audioTracks: [TrackInfo] = []
@ -221,9 +247,12 @@ final class MPVPlayerViewController: UIViewController {
// MARK: - Playback API
func loadFile(_ urlString: String, audioUrl: String? = nil) {
func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:]) {
guard mpv != nil else { return }
clearPlaybackError()
let sanitizedHeaders = sanitizeRequestHeaders(requestHeaders)
activeRequestHeaders = sanitizedHeaders
applyRequestHeaders(sanitizedHeaders)
isPlayerLoading = true
isPlayerEnded = false
command("loadfile", args: [urlString, "replace"])
@ -260,6 +289,7 @@ final class MPVPlayerViewController: UIViewController {
guard mpv != nil else { return }
if let path = getString("path") {
clearPlaybackError()
applyRequestHeaders(activeRequestHeaders)
let pos = getDouble("time-pos")
command("loadfile", args: [path, "replace"])
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
@ -562,6 +592,40 @@ final class MPVPlayerViewController: UIViewController {
print("[MPV] API error: \(String(cString: mpv_error_string(status)))")
}
}
private func sanitizeRequestHeaders(_ headers: [String: String]) -> [String: String] {
guard !headers.isEmpty else { return [:] }
var sanitized: [String: String] = [:]
sanitized.reserveCapacity(headers.count)
headers.forEach { rawKey, rawValue in
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty, !value.isEmpty else { return }
guard key.caseInsensitiveCompare("Range") != .orderedSame else { return }
sanitized[key] = value
}
return sanitized
}
private func applyRequestHeaders(_ headers: [String: String]) {
guard mpv != nil else { return }
if headers.isEmpty {
checkError(mpv_set_property_string(mpv, "http-header-fields", ""))
return
}
let serialized = headers
.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending }
.map { key, value in
let escapedValue = value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: ",", with: "\\,")
return "\(key): \(escapedValue)"
}
.joined(separator: ",")
checkError(mpv_set_property_string(mpv, "http-header-fields", serialized))
}
}
// MARK: - Bridge Creator (implements Kotlin protocol)