From 16a271ebd9e52abb88b5bd53334e110b2660d32b Mon Sep 17 00:00:00 2001 From: VenusIsJaded Date: Thu, 14 May 2026 23:06:00 -0500 Subject: [PATCH] fix: ExoPlayer HLS auto-detection for plugin streams (#1057) --- .../app/features/player/PlayerEngine.android.kt | 14 +++++++++++--- .../src/commonMain/kotlin/com/nuvio/app/App.kt | 7 +++++-- .../app/features/details/MetaDetailsParser.kt | 1 + .../com/nuvio/app/features/player/PlayerEngine.kt | 1 + .../com/nuvio/app/features/player/PlayerModels.kt | 1 + .../com/nuvio/app/features/player/PlayerScreen.kt | 3 +++ .../app/features/player/PlayerStreamsRepository.kt | 1 + .../com/nuvio/app/features/streams/StreamModels.kt | 1 + .../com/nuvio/app/features/streams/StreamParser.kt | 2 ++ .../app/features/streams/StreamsRepository.kt | 1 + 10 files changed, 27 insertions(+), 5 deletions(-) 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 ebdcfd92..1b0f12e5 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 @@ -66,6 +66,7 @@ private const val TAG = "NuvioPlayer" actual fun PlatformPlayerSurface( sourceUrl: String, sourceAudioUrl: String?, + streamType: String?, sourceHeaders: Map, sourceResponseHeaders: Map, 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) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index ae2f3728..dfab5d13 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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, 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 adcf6811..031ae310 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 @@ -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, 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 8a5b6730..d3ac1115 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 @@ -53,6 +53,7 @@ internal fun sanitizePlaybackResponseHeaders(headers: Map?): Map expect fun PlatformPlayerSurface( sourceUrl: String, sourceAudioUrl: String? = null, + streamType: String? = null, sourceHeaders: Map = emptyMap(), sourceResponseHeaders: Map = emptyMap(), useYoutubeChunkedPlayback: Boolean = false, 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 f659084a..ad92f12f 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 streamType: String? = null, val sourceHeaders: Map = emptyMap(), val sourceResponseHeaders: Map = emptyMap(), val logo: 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 279aae9f..f828e1bd 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 @@ -122,6 +122,7 @@ fun PlayerScreen( title: String, sourceUrl: String, sourceAudioUrl: String? = null, + streamType: String? = null, sourceHeaders: Map = emptyMap(), sourceResponseHeaders: Map = 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(), 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 6e9487ed..008c8a5a 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 @@ -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 { 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 0b3d8b24..d4ba5eaa 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 @@ -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, ) { 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 9a6aa866..99579a72 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 @@ -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, 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 1f7d42e1..0b08281f 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 @@ -649,6 +649,7 @@ private fun PluginRuntimeResult.toStreamItem( sourceName = scraper.name, addonName = addonName, addonId = addonId, + streamType = type, behaviorHints = if (requestHeaders.isEmpty()) { StreamBehaviorHints() } else {