fix(player): resolve unrecognized input format during HLS stream playback

This commit is contained in:
halibiram 2026-06-13 19:39:54 +03:00
parent 639a99fd80
commit b694fb3e72
2 changed files with 99 additions and 30 deletions

View file

@ -2,6 +2,8 @@ package com.nuvio.app.features.player
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import java.net.HttpURLConnection
import java.net.URL
import java.util.Locale
internal fun playbackMediaItemFromUrl(
@ -68,7 +70,7 @@ private fun inferMimeTypeFromResponseHeaders(headers: Map<String, String>): Stri
return inferMimeTypeFromPath(filename)
}
private fun normalizeMimeType(contentType: String?): String? {
internal fun normalizeMimeType(contentType: String?): String? {
val normalized = contentType
?.substringBefore(';')
?.trim()
@ -187,12 +189,44 @@ private fun inferMimeTypeFromQuery(query: String): String? {
private fun inferMimeTypeFromDelimitedToken(value: String): String? =
when {
DELIMITED_M3U8_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_M3U8
DELIMITED_HLS_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_M3U8
DELIMITED_MPD_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_MPD
DELIMITED_SS_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_SS
else -> null
}
internal fun probeMimeType(url: String, headers: Map<String, String>): String? {
if (!url.startsWith("http://") && !url.startsWith("https://")) return null
val methods = listOf("HEAD", "GET")
methods.forEach { method ->
runCatching {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = 3_000
readTimeout = 3_000
instanceFollowRedirects = true
setRequestProperty("User-Agent", "Mozilla/5.0")
setRequestProperty("Accept", "*/*")
headers.forEach { (key, value) ->
setRequestProperty(key, value)
}
}
try {
val responseCode = connection.responseCode
if (responseCode in 200..299) {
val contentType = connection.contentType
normalizeMimeType(contentType)
} else null
} finally {
connection.disconnect()
}
}.getOrNull()?.let { return it }
}
return null
}
private const val MIME_VIDEO_QUICK_TIME = "video/quicktime"
private val DELIMITED_M3U8_PATTERN = Regex("(^|[=/_.?&-])m3u8($|[=/_.?&-])")
private val DELIMITED_MPD_PATTERN = Regex("(^|[=/_.?&-])mpd($|[=/_.?&-])")
private val DELIMITED_SS_PATTERN = Regex("(^|[=/_.?&-])(ism|isml)($|[=/_.?&-])")
private val DELIMITED_M3U8_PATTERN = Regex("(^|[=/_.?&%-])m3u8($|[=/_.?&%-])")
private val DELIMITED_HLS_PATTERN = Regex("(^|[=/_.?&%-])hls($|[=/_.?&%-])")
private val DELIMITED_MPD_PATTERN = Regex("(^|[=/_.?&%-])mpd($|[=/_.?&%-])")
private val DELIMITED_SS_PATTERN = Regex("(^|[=/_.?&%-])(ism|isml)($|[=/_.?&%-])")

View file

@ -126,6 +126,17 @@ actual fun PlatformPlayerSurface(
var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf<Long?>(null) }
val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority
val initialMediaItem = remember(playerSourceKey) {
playbackMediaItemFromUrl(
url = sourceUrl,
responseHeaders = sanitizedSourceResponseHeaders,
streamType = normalizedStreamType,
)
}
var resolvedMediaItem by remember(playerSourceKey) { mutableStateOf(initialMediaItem) }
var probeAttempted by remember(playerSourceKey) { mutableStateOf(false) }
val extractorsFactory = remember {
DefaultExtractorsFactory()
.setTsExtractorFlags(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS)
@ -228,18 +239,13 @@ actual fun PlatformPlayerSurface(
.build()
}
player.apply {
setPlaybackMediaItem(
videoMediaItem = playbackMediaItemFromUrl(
url = sourceUrl,
responseHeaders = sanitizedSourceResponseHeaders,
streamType = normalizedStreamType,
),
startPositionMs = fallbackStartPositionMs,
)
prepare()
this.playWhenReady = playWhenReady
}
player
}
LaunchedEffect(exoPlayer, resolvedMediaItem) {
val mediaItem = resolvedMediaItem ?: return@LaunchedEffect
exoPlayer.setPlaybackMediaItem(mediaItem, fallbackStartPositionMs)
exoPlayer.prepare()
}
val pendingSubtitleTrackIndex = remember { mutableListOf<Int>() }
@ -264,25 +270,54 @@ actual fun PlatformPlayerSurface(
exoPlayer.pause()
}
fun reportPlayerError(error: PlaybackException) {
if (
playerSettings.decoderPriority == DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON &&
effectiveDecoderPriority != DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER &&
error.isDecoderFailure()
) {
Log.w(
TAG,
"Decoder failure (${error.errorCodeName}); retrying with app decoders",
error,
)
fallbackStartPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L)
decoderPriorityOverride = DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
latestOnError.value(null)
return
}
latestOnError.value(error.localizedMessage ?: runBlocking { getString(Res.string.player_unable_to_play_stream) })
}
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
syncPlayerViewKeepScreenOn()
if (
playerSettings.decoderPriority == DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON &&
effectiveDecoderPriority != DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER &&
error.isDecoderFailure()
) {
Log.w(
TAG,
"Decoder failure (${error.errorCodeName}); retrying with app decoders",
error,
)
fallbackStartPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L)
decoderPriorityOverride = DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
latestOnError.value(null)
val isSourceError = error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW ||
error.errorCode == PlaybackException.ERROR_CODE_IO_UNSPECIFIED ||
error.cause?.toString()?.contains("UnrecognizedInputFormatException") == true
if (isSourceError && !probeAttempted) {
probeAttempted = true
coroutineScope.launch {
val probedMime = withContext(Dispatchers.IO) {
probeMimeType(sourceUrl, sanitizedSourceHeaders)
}
if (probedMime != null) {
Log.d(TAG, "Playback failed with source error. Probed MIME type: $probedMime. Retrying...")
resolvedMediaItem = MediaItem.Builder()
.setUri(sourceUrl)
.setMimeType(probedMime)
.build()
latestOnError.value(null)
return@launch
}
reportPlayerError(error)
}
return
}
latestOnError.value(error.localizedMessage ?: runBlocking { getString(Res.string.player_unable_to_play_stream) })
reportPlayerError(error)
}
override fun onPlaybackStateChanged(playbackState: Int) {