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( actual fun PlatformPlayerSurface(
sourceUrl: String, sourceUrl: String,
sourceAudioUrl: String?, sourceAudioUrl: String?,
streamType: String?,
sourceHeaders: Map<String, String>, sourceHeaders: Map<String, String>,
sourceResponseHeaders: Map<String, String>, sourceResponseHeaders: Map<String, String>,
useYoutubeChunkedPlayback: Boolean, useYoutubeChunkedPlayback: Boolean,
@ -163,11 +164,11 @@ actual fun PlatformPlayerSurface(
player.apply { player.apply {
if (!sourceAudioUrl.isNullOrBlank()) { if (!sourceAudioUrl.isNullOrBlank()) {
val msf = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory) val msf = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)
val videoSource = msf.createMediaSource(MediaItem.fromUri(sourceUrl)) 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.fromUri(sourceAudioUrl)) 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)) setMediaSource(MergingMediaSource(videoSource, audioSource))
} else { } 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() prepare()
this.playWhenReady = playWhenReady this.playWhenReady = playWhenReady
@ -736,3 +737,10 @@ private fun guessSubtitleMime(url: String): String {
else -> MimeTypes.TEXT_VTT 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, bingeGroup = cached.bingeGroup,
pauseDescription = pauseDescription, pauseDescription = pauseDescription,
providerName = cached.addonName, providerName = cached.addonName,
providerAddonId = cached.addonId, providerAddonId = cached.addonId,
contentType = launch.type, contentType = launch.type,
videoId = effectiveVideoId, videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId ?: effectiveVideoId, parentMetaId = launch.parentMetaId ?: effectiveVideoId,
@ -1544,6 +1544,7 @@ private fun MainAppContent(
pauseDescription = pauseDescription, pauseDescription = pauseDescription,
providerName = stream.addonName, providerName = stream.addonName,
providerAddonId = stream.addonId, providerAddonId = stream.addonId,
streamType = stream.streamType,
contentType = launch.type, contentType = launch.type,
videoId = effectiveVideoId, videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId ?: effectiveVideoId, parentMetaId = launch.parentMetaId ?: effectiveVideoId,
@ -1654,6 +1655,7 @@ private fun MainAppContent(
pauseDescription = pauseDescription, pauseDescription = pauseDescription,
providerName = stream.addonName, providerName = stream.addonName,
providerAddonId = stream.addonId, providerAddonId = stream.addonId,
streamType = stream.streamType,
contentType = launch.type, contentType = launch.type,
videoId = effectiveVideoId, videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId ?: effectiveVideoId, parentMetaId = launch.parentMetaId ?: effectiveVideoId,
@ -1769,6 +1771,7 @@ private fun MainAppContent(
title = launch.title, title = launch.title,
sourceUrl = launch.sourceUrl, sourceUrl = launch.sourceUrl,
sourceAudioUrl = launch.sourceAudioUrl, sourceAudioUrl = launch.sourceAudioUrl,
streamType = launch.streamType,
sourceHeaders = launch.sourceHeaders, sourceHeaders = launch.sourceHeaders,
sourceResponseHeaders = launch.sourceResponseHeaders, sourceResponseHeaders = launch.sourceResponseHeaders,
logo = launch.logo, logo = launch.logo,
@ -1874,7 +1877,7 @@ private fun MainAppContent(
streamTitle = item.streamTitle, streamTitle = item.streamTitle,
streamSubtitle = item.streamSubtitle, streamSubtitle = item.streamSubtitle,
providerName = item.providerName, providerName = item.providerName,
providerAddonId = item.providerAddonId, providerAddonId = item.providerAddonId,
contentType = item.contentType, contentType = item.contentType,
videoId = item.videoId, videoId = item.videoId,
parentMetaId = item.parentMetaId, parentMetaId = item.parentMetaId,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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