diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 86fa6704..ada92b37 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -58,6 +58,7 @@ private const val TAG = "NuvioPlayer" actual fun PlatformPlayerSurface( sourceUrl: String, sourceAudioUrl: String?, + sourceHeaders: Map, 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, ) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trailer/YoutubeChunkedDataSourceFactory.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trailer/YoutubeChunkedDataSourceFactory.kt index 66a3f5c9..5d93ab92 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trailer/YoutubeChunkedDataSourceFactory.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trailer/YoutubeChunkedDataSourceFactory.kt @@ -19,6 +19,7 @@ import androidx.media3.datasource.TransferListener */ @UnstableApi class YoutubeChunkedDataSourceFactory( + private val defaultRequestHeaders: Map = 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) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 9c6c6cd0..9f24b597 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index f21a2ee4..0e115b04 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -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 = + 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 } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index 1596c432..6db3fd3c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -20,10 +20,26 @@ interface PlayerEngineController { fun applySubtitleStyle(style: SubtitleStyleState) {} } +internal fun sanitizePlaybackHeaders(headers: Map?): Map { + val rawHeaders = headers ?: return emptyMap() + if (rawHeaders.isEmpty()) return emptyMap() + + val sanitized = LinkedHashMap(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 = emptyMap(), modifier: Modifier = Modifier, playWhenReady: Boolean = true, resizeMode: PlayerResizeMode = PlayerResizeMode.Fit, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index d06edc5f..9c737b72 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -11,6 +11,7 @@ data class PlayerLaunch( val title: String, val sourceUrl: String, val sourceAudioUrl: String? = null, + val sourceHeaders: Map = emptyMap(), val logo: String? = null, val poster: String? = null, val background: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index d85fa11b..030c645e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -64,6 +64,7 @@ fun PlayerScreen( title: String, sourceUrl: String, sourceAudioUrl: String? = null, + sourceHeaders: Map = 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index cbb77552..efaed602 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -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), + ) + }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index a1df0e2f..98c17973 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -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? = null, + val response: Map? = null, ) data class AddonStreamGroup( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt index 26d0d6dd..10d87635 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt @@ -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 = + 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, + ) + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index a1777539..96cd6256 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -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), + ) + }, ) } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt index 4423a715..4c627dc2 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt @@ -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) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 6b2805a6..0046d15e 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -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, 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? { + val sanitized = sanitizePlaybackHeaders(headers) + if (sanitized.isEmpty()) { + return null + } + return runCatching { + Json.encodeToString(sanitized) + }.getOrNull() +} diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 195d86cd..01a71e08 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -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)