feat: add support for response headers in playback data source handling

This commit is contained in:
tapframe 2026-04-08 14:58:35 +05:30
parent c4d4f59db0
commit 42a4ee2d66
11 changed files with 158 additions and 6 deletions

View file

@ -7,11 +7,21 @@ import com.nuvio.app.features.trailer.YoutubeChunkedDataSourceFactory
internal object PlatformPlaybackDataSourceFactory {
fun create(
defaultRequestHeaders: Map<String, String>,
defaultResponseHeaders: Map<String, String>,
useYoutubeChunkedPlayback: Boolean,
): DataSource.Factory =
if (useYoutubeChunkedPlayback) {
): DataSource.Factory {
val baseFactory: DataSource.Factory = if (useYoutubeChunkedPlayback) {
YoutubeChunkedDataSourceFactory(defaultRequestHeaders = defaultRequestHeaders)
} else {
DefaultHttpDataSource.Factory().setDefaultRequestProperties(defaultRequestHeaders)
}
return if (defaultResponseHeaders.isEmpty()) {
baseFactory
} else {
ResponseHeaderOverridingDataSourceFactory(
upstreamFactory = baseFactory,
defaultResponseHeaders = defaultResponseHeaders,
)
}
}
}

View file

@ -63,6 +63,7 @@ actual fun PlatformPlayerSurface(
sourceUrl: String,
sourceAudioUrl: String?,
sourceHeaders: Map<String, String>,
sourceResponseHeaders: Map<String, String>,
useYoutubeChunkedPlayback: Boolean,
modifier: Modifier,
playWhenReady: Boolean,
@ -86,8 +87,11 @@ actual fun PlatformPlayerSurface(
val sanitizedSourceHeaders = remember(sourceHeaders) {
sanitizePlaybackHeaders(sourceHeaders)
}
val sanitizedSourceResponseHeaders = remember(sourceResponseHeaders) {
sanitizePlaybackResponseHeaders(sourceResponseHeaders)
}
val exoPlayer = remember(sourceUrl, sourceAudioUrl, sanitizedSourceHeaders) {
val exoPlayer = remember(sourceUrl, sourceAudioUrl, sanitizedSourceHeaders, sanitizedSourceResponseHeaders) {
val renderersFactory = DefaultRenderersFactory(context)
.setExtensionRendererMode(playerSettings.decoderPriority)
.setMapDV7ToHevc(playerSettings.mapDV7ToHevc)
@ -119,6 +123,7 @@ actual fun PlatformPlayerSurface(
val mediaSourceFactory = DefaultMediaSourceFactory(
PlatformPlaybackDataSourceFactory.create(
defaultRequestHeaders = sanitizedSourceHeaders,
defaultResponseHeaders = sanitizedSourceResponseHeaders,
useYoutubeChunkedPlayback = useYoutubeChunkedPlayback,
),
extractorsFactory,

View file

@ -0,0 +1,50 @@
package com.nuvio.app.features.player
import android.net.Uri
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener
internal class ResponseHeaderOverridingDataSourceFactory(
private val upstreamFactory: DataSource.Factory,
private val defaultResponseHeaders: Map<String, String>,
) : DataSource.Factory {
override fun createDataSource(): DataSource =
ResponseHeaderOverridingDataSource(
upstream = upstreamFactory.createDataSource(),
defaultResponseHeaders = defaultResponseHeaders,
)
}
private class ResponseHeaderOverridingDataSource(
private val upstream: DataSource,
private val defaultResponseHeaders: Map<String, String>,
) : DataSource {
override fun addTransferListener(transferListener: TransferListener) {
upstream.addTransferListener(transferListener)
}
override fun open(dataSpec: DataSpec): Long = upstream.open(dataSpec)
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>> {
val upstreamHeaders = upstream.responseHeaders
if (defaultResponseHeaders.isEmpty()) return upstreamHeaders
val merged = LinkedHashMap<String, List<String>>(upstreamHeaders.size + defaultResponseHeaders.size)
merged.putAll(upstreamHeaders)
defaultResponseHeaders.forEach { (key, value) ->
merged[key] = listOf(value)
}
return merged
}
override fun close() {
upstream.close()
}
}

View file

@ -6,7 +6,18 @@ import androidx.media3.datasource.DefaultHttpDataSource
internal object PlatformPlaybackDataSourceFactory {
fun create(
defaultRequestHeaders: Map<String, String>,
defaultResponseHeaders: Map<String, String>,
useYoutubeChunkedPlayback: Boolean,
): DataSource.Factory =
DefaultHttpDataSource.Factory().setDefaultRequestProperties(defaultRequestHeaders)
): DataSource.Factory {
val baseFactory = DefaultHttpDataSource.Factory()
.setDefaultRequestProperties(defaultRequestHeaders)
return if (defaultResponseHeaders.isEmpty()) {
baseFactory
} else {
ResponseHeaderOverridingDataSourceFactory(
upstreamFactory = baseFactory,
defaultResponseHeaders = defaultResponseHeaders,
)
}
}
}

View file

@ -104,6 +104,7 @@ 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.player.sanitizePlaybackResponseHeaders
import com.nuvio.app.features.profiles.NuvioProfile
import com.nuvio.app.features.profiles.ProfileEditScreen
import com.nuvio.app.features.profiles.ProfileRepository
@ -901,6 +902,8 @@ private fun MainAppContent(
PlayerLaunch(
title = route.title,
sourceUrl = cached.url,
sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders),
logo = route.logo,
poster = route.poster,
background = route.background,
@ -947,6 +950,8 @@ private fun MainAppContent(
streamName = stream.streamLabel,
addonName = stream.addonName,
addonId = stream.addonId,
requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
filename = stream.behaviorHints.filename,
videoSize = stream.behaviorHints.videoSize,
bingeGroup = stream.behaviorHints.bingeGroup,
@ -957,6 +962,7 @@ private fun MainAppContent(
title = route.title,
sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
logo = route.logo,
poster = route.poster,
background = route.background,
@ -1024,6 +1030,8 @@ private fun MainAppContent(
streamName = stream.streamLabel,
addonName = stream.addonName,
addonId = stream.addonId,
requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
filename = stream.behaviorHints.filename,
videoSize = stream.behaviorHints.videoSize,
bingeGroup = stream.behaviorHints.bingeGroup,
@ -1034,6 +1042,7 @@ private fun MainAppContent(
title = route.title,
sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
logo = route.logo,
poster = route.poster,
background = route.background,
@ -1097,6 +1106,7 @@ private fun MainAppContent(
sourceUrl = launch.sourceUrl,
sourceAudioUrl = launch.sourceAudioUrl,
sourceHeaders = launch.sourceHeaders,
sourceResponseHeaders = launch.sourceResponseHeaders,
logo = launch.logo,
poster = launch.poster,
background = launch.background,

View file

@ -35,11 +35,26 @@ internal fun sanitizePlaybackHeaders(headers: Map<String, String>?): Map<String,
return sanitized
}
internal fun sanitizePlaybackResponseHeaders(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
sanitized[key] = value
}
return sanitized
}
@Composable
expect fun PlatformPlayerSurface(
sourceUrl: String,
sourceAudioUrl: String? = null,
sourceHeaders: Map<String, String> = emptyMap(),
sourceResponseHeaders: Map<String, String> = emptyMap(),
useYoutubeChunkedPlayback: Boolean = false,
modifier: Modifier = Modifier,
playWhenReady: Boolean = true,

View file

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

View file

@ -85,6 +85,7 @@ fun PlayerScreen(
sourceUrl: String,
sourceAudioUrl: String? = null,
sourceHeaders: Map<String, String> = emptyMap(),
sourceResponseHeaders: Map<String, String> = emptyMap(),
providerName: String,
streamTitle: String,
streamSubtitle: String?,
@ -132,6 +133,9 @@ fun PlayerScreen(
var activeSourceHeaders by remember(sourceUrl, sourceHeaders) {
mutableStateOf(sanitizePlaybackHeaders(sourceHeaders))
}
var activeSourceResponseHeaders by remember(sourceUrl, sourceResponseHeaders) {
mutableStateOf(sanitizePlaybackResponseHeaders(sourceResponseHeaders))
}
var activeStreamTitle by rememberSaveable { mutableStateOf(streamTitle) }
var activeStreamSubtitle by rememberSaveable { mutableStateOf(streamSubtitle) }
var activeProviderName by rememberSaveable { mutableStateOf(providerName) }
@ -522,6 +526,8 @@ fun PlayerScreen(
streamName = stream.streamLabel,
addonName = stream.addonName,
addonId = stream.addonId,
requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
filename = stream.behaviorHints.filename,
videoSize = stream.behaviorHints.videoSize,
bingeGroup = stream.behaviorHints.bingeGroup,
@ -530,6 +536,7 @@ fun PlayerScreen(
activeSourceUrl = url
activeSourceAudioUrl = null
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
activeProviderName = stream.addonName
@ -577,6 +584,8 @@ fun PlayerScreen(
streamName = stream.streamLabel,
addonName = stream.addonName,
addonId = stream.addonId,
requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
filename = stream.behaviorHints.filename,
videoSize = stream.behaviorHints.videoSize,
bingeGroup = stream.behaviorHints.bingeGroup,
@ -585,6 +594,7 @@ fun PlayerScreen(
activeSourceUrl = url
activeSourceAudioUrl = null
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
activeProviderName = stream.addonName
@ -740,7 +750,7 @@ fun PlayerScreen(
controlsVisible = false
}
LaunchedEffect(activeSourceUrl, activeSourceAudioUrl, activeSourceHeaders) {
LaunchedEffect(activeSourceUrl, activeSourceAudioUrl, activeSourceHeaders, activeSourceResponseHeaders) {
errorMessage = null
playerController = null
playerControllerSourceUrl = null
@ -1103,6 +1113,7 @@ fun PlayerScreen(
sourceUrl = activeSourceUrl,
sourceAudioUrl = activeSourceAudioUrl,
sourceHeaders = activeSourceHeaders,
sourceResponseHeaders = activeSourceResponseHeaders,
modifier = Modifier.fillMaxSize(),
playWhenReady = shouldPlay,
resizeMode = resizeMode,

View file

@ -10,6 +10,8 @@ data class CachedStreamLink(
val addonName: String,
val addonId: String,
val cachedAtMs: Long,
val requestHeaders: Map<String, String> = emptyMap(),
val responseHeaders: Map<String, String> = emptyMap(),
val filename: String? = null,
val videoSize: Long? = null,
val bingeGroup: String? = null,
@ -29,6 +31,8 @@ object StreamLinkCacheRepository {
streamName: String,
addonName: String,
addonId: String,
requestHeaders: Map<String, String> = emptyMap(),
responseHeaders: Map<String, String> = emptyMap(),
filename: String? = null,
videoSize: Long? = null,
bingeGroup: String? = null,
@ -39,6 +43,8 @@ object StreamLinkCacheRepository {
addonName = addonName,
addonId = addonId,
cachedAtMs = epochMs(),
requestHeaders = requestHeaders,
responseHeaders = responseHeaders,
filename = filename,
videoSize = videoSize,
bingeGroup = bingeGroup,

View file

@ -88,4 +88,35 @@ class StreamParserTest {
val stream = streams.single()
assertFalse(stream.behaviorHints.notWebReady)
}
@Test
fun `parse keeps proxy response headers`() {
val streams = StreamParser.parse(
payload =
"""
{
"streams": [
{
"url": "https://example.com/video.mp4",
"behaviorHints": {
"proxyHeaders": {
"response": {
"content-type": "video/mp4",
"x-test": "ok"
}
}
}
}
]
}
""".trimIndent(),
addonName = "Addon",
addonId = "addon.id",
)
val responseHeaders = streams.single().behaviorHints.proxyHeaders?.response
assertNotNull(responseHeaders)
assertEquals("video/mp4", responseHeaders["content-type"])
assertEquals("ok", responseHeaders["x-test"])
}
}

View file

@ -23,6 +23,7 @@ actual fun PlatformPlayerSurface(
sourceUrl: String,
sourceAudioUrl: String?,
sourceHeaders: Map<String, String>,
sourceResponseHeaders: Map<String, String>,
useYoutubeChunkedPlayback: Boolean,
modifier: Modifier,
playWhenReady: Boolean,
@ -32,6 +33,7 @@ actual fun PlatformPlayerSurface(
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
onError: (String?) -> Unit,
) {
sanitizePlaybackResponseHeaders(sourceResponseHeaders)
val latestOnSnapshot = rememberUpdatedState(onSnapshot)
val latestOnError = rememberUpdatedState(onError)