mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
added header support during playback
This commit is contained in:
parent
fa7c4b3881
commit
9f9775e3d2
14 changed files with 227 additions and 10 deletions
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue