mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-18 21:36:42 +00:00
Merge pull request #1336 from halibiram/fix/hls-playback-unrecognized-format
fix(player): resolve unrecognized input format during HLS stream playback
This commit is contained in:
commit
f5a675d864
2 changed files with 99 additions and 30 deletions
|
|
@ -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)($|[=/_.?&%-])")
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue