fix: ExoPlayer HLS auto-detection for plugin streams (#1057)

This commit is contained in:
VenusIsJaded 2026-05-14 23:06:00 -05:00
parent 70d3eee9d2
commit 16a271ebd9
10 changed files with 27 additions and 5 deletions

View file

@ -66,6 +66,7 @@ private const val TAG = "NuvioPlayer"
actual fun PlatformPlayerSurface(
sourceUrl: String,
sourceAudioUrl: String?,
streamType: String?,
sourceHeaders: Map<String, String>,
sourceResponseHeaders: Map<String, String>,
useYoutubeChunkedPlayback: Boolean,
@ -163,11 +164,11 @@ actual fun PlatformPlayerSurface(
player.apply {
if (!sourceAudioUrl.isNullOrBlank()) {
val msf = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)
val videoSource = msf.createMediaSource(MediaItem.fromUri(sourceUrl))
val audioSource = msf.createMediaSource(MediaItem.fromUri(sourceAudioUrl))
val videoSource = msf.createMediaSource(MediaItem.Builder().setUri(sourceUrl).apply { if ("hls".equals(streamType, ignoreCase = true) || sourceUrl.isHlsUrl()) setMimeType(MimeTypes.APPLICATION_M3U8) }.build())
val audioSource = msf.createMediaSource(MediaItem.Builder().setUri(sourceAudioUrl).apply { if ("hls".equals(streamType, ignoreCase = true) || sourceAudioUrl.isHlsUrl()) setMimeType(MimeTypes.APPLICATION_M3U8) }.build())
setMediaSource(MergingMediaSource(videoSource, audioSource))
} else {
setMediaItem(MediaItem.fromUri(sourceUrl))
setMediaItem(MediaItem.Builder().setUri(sourceUrl).apply { if ("hls".equals(streamType, ignoreCase = true) || sourceUrl.isHlsUrl()) setMimeType(MimeTypes.APPLICATION_M3U8) }.build())
}
prepare()
this.playWhenReady = playWhenReady
@ -736,3 +737,10 @@ private fun guessSubtitleMime(url: String): String {
else -> MimeTypes.TEXT_VTT
}
}
private fun String.isHlsUrl(): Boolean =
endsWith(".m3u8", ignoreCase = true) ||
contains(".m3u8?", ignoreCase = true) ||
contains("/playlist/", ignoreCase = true) ||
contains("/master/", ignoreCase = true) ||
contains("/chunklist/", ignoreCase = true)

View file

@ -1437,7 +1437,7 @@ private fun MainAppContent(
bingeGroup = cached.bingeGroup,
pauseDescription = pauseDescription,
providerName = cached.addonName,
providerAddonId = cached.addonId,
providerAddonId = cached.addonId,
contentType = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
@ -1544,6 +1544,7 @@ private fun MainAppContent(
pauseDescription = pauseDescription,
providerName = stream.addonName,
providerAddonId = stream.addonId,
streamType = stream.streamType,
contentType = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
@ -1654,6 +1655,7 @@ private fun MainAppContent(
pauseDescription = pauseDescription,
providerName = stream.addonName,
providerAddonId = stream.addonId,
streamType = stream.streamType,
contentType = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
@ -1769,6 +1771,7 @@ private fun MainAppContent(
title = launch.title,
sourceUrl = launch.sourceUrl,
sourceAudioUrl = launch.sourceAudioUrl,
streamType = launch.streamType,
sourceHeaders = launch.sourceHeaders,
sourceResponseHeaders = launch.sourceResponseHeaders,
logo = launch.logo,
@ -1874,7 +1877,7 @@ private fun MainAppContent(
streamTitle = item.streamTitle,
streamSubtitle = item.streamSubtitle,
providerName = item.providerName,
providerAddonId = item.providerAddonId,
providerAddonId = item.providerAddonId,
contentType = item.contentType,
videoId = item.videoId,
parentMetaId = item.parentMetaId,

View file

@ -288,6 +288,7 @@ internal object MetaDetailsParser {
externalUrl = externalUrl,
addonName = addonName,
addonId = "embedded",
streamType = obj.string("type"),
behaviorHints = StreamBehaviorHints(
bingeGroup = hintsObj?.string("bingeGroup"),
notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null,

View file

@ -53,6 +53,7 @@ internal fun sanitizePlaybackResponseHeaders(headers: Map<String, String>?): Map
expect fun PlatformPlayerSurface(
sourceUrl: String,
sourceAudioUrl: String? = null,
streamType: String? = null,
sourceHeaders: Map<String, String> = emptyMap(),
sourceResponseHeaders: Map<String, String> = emptyMap(),
useYoutubeChunkedPlayback: Boolean = false,

View file

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

View file

@ -122,6 +122,7 @@ fun PlayerScreen(
title: String,
sourceUrl: String,
sourceAudioUrl: String? = null,
streamType: String? = null,
sourceHeaders: Map<String, String> = emptyMap(),
sourceResponseHeaders: Map<String, String> = emptyMap(),
providerName: String,
@ -187,6 +188,7 @@ fun PlayerScreen(
// Active playback state (mutable to support source/episode switching)
var activeSourceUrl by rememberSaveable { mutableStateOf(sourceUrl) }
var activeSourceAudioUrl by rememberSaveable { mutableStateOf(sourceAudioUrl) }
var activeStreamType by rememberSaveable { mutableStateOf(streamType) }
var activeSourceHeaders by remember(sourceUrl, sourceHeaders) {
mutableStateOf(sanitizePlaybackHeaders(sourceHeaders))
}
@ -1633,6 +1635,7 @@ fun PlayerScreen(
PlatformPlayerSurface(
sourceUrl = activeSourceUrl,
sourceAudioUrl = activeSourceAudioUrl,
streamType = activeStreamType,
sourceHeaders = activeSourceHeaders,
sourceResponseHeaders = activeSourceResponseHeaders,
modifier = Modifier.fillMaxSize(),

View file

@ -380,6 +380,7 @@ private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem
infoHash = infoHash,
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
streamType = type,
behaviorHints = if (requestHeaders.isEmpty()) {
com.nuvio.app.features.streams.StreamBehaviorHints()
} else {

View file

@ -16,6 +16,7 @@ data class StreamItem(
val sourceName: String? = null,
val addonName: String,
val addonId: String,
val streamType: String? = null,
val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(),
val clientResolve: StreamClientResolve? = null,
) {

View file

@ -27,6 +27,7 @@ object StreamParser {
val infoHash = obj.string("infoHash")
val externalUrl = obj.string("externalUrl")
val clientResolve = obj.objectValue("clientResolve")?.toClientResolve()
val streamType = obj.string("type")
// Must have at least one playable source
if (url == null && infoHash == null && externalUrl == null && clientResolve == null) return@mapNotNull null
@ -44,6 +45,7 @@ object StreamParser {
fileIdx = obj.int("fileIdx"),
externalUrl = externalUrl,
sources = obj.stringList("sources"),
streamType = streamType,
addonName = addonName,
addonId = addonId,
clientResolve = clientResolve,

View file

@ -649,6 +649,7 @@ private fun PluginRuntimeResult.toStreamItem(
sourceName = scraper.name,
addonName = addonName,
addonId = addonId,
streamType = type,
behaviorHints = if (requestHeaders.isEmpty()) {
StreamBehaviorHints()
} else {