diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt index d46463426..fc6d4af64 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt @@ -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): 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? { + 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)($|[=/_.?&%-])") 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 40acdfe63..dcc8de9f7 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 @@ -126,6 +126,17 @@ actual fun PlatformPlayerSurface( var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf(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() } @@ -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) {