refactor: ​enhance Media3Player error-handling mechanisms to resolve playback failures.

This commit is contained in:
tunbmoon
2026-02-12 00:12:58 +08:00
parent 2a1cf234c6
commit 5765817ee5
7 changed files with 453 additions and 303 deletions
+2
View File
@@ -13,6 +13,8 @@
-keep class androidx.media3.common.VideoSize { *; }
-keep class androidx.media3.common.Format { *; }
-keep class androidx.media3.exoplayer.DecoderCounters { *; }
-keep class androidx.media3.exoplayer.dash.** { *; }
-keep class androidx.media3.exoplayer.hls.** { *; }
-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
@@ -1,24 +1,15 @@
package com.antoniegil.astronia.player
import android.content.Context
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.view.Surface
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.VideoSize
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import com.antoniegil.astronia.util.ErrorHandler
import com.antoniegil.astronia.util.NetworkUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class Media3Player(private val context: Context) {
@@ -26,15 +17,11 @@ class Media3Player(private val context: Context) {
private set
internal var surface: Surface? = null
private var currentHardwareAcceleration: Boolean = true
private var shouldPlayWhenReady: Boolean = false
private var parserErrorRetryCount: Int = 0
private var currentMediaUrl: String? = null
private val maxParserRetries = 2
private var isInitialLoad: Boolean = false
private var initialM3uUrl: String? = null
private var actualPlayingUrl: String? = null
private val state = PlayerState(context = context)
var onPreparedListener: (() -> Unit)? = null
var onInfoListener: ((what: Int, extra: Int) -> Boolean)? = null
var onBufferingListener: ((Boolean) -> Unit)? = null
var onPlaybackStateChanged: ((isPlaying: Boolean, position: Long, bufferedPosition: Long, duration: Long) -> Unit)? = null
var onErrorListener: ((error: String, isRetriable: Boolean) -> Unit)? = null
@@ -45,199 +32,26 @@ class Media3Player(private val context: Context) {
}
private fun createPlayer(hardwareAcceleration: Boolean) {
val trackSelector = DefaultTrackSelector(context).apply {
setParameters(
buildUponParameters()
.setTunnelingEnabled(hardwareAcceleration && isTunnelingSupported())
)
}
val callbacks = PlayerCallbacks(
onPrepared = { onPreparedListener?.invoke() },
onBuffering = { onBufferingListener?.invoke(it) },
onStateChanged = { playing, pos, buffered, dur -> onPlaybackStateChanged?.invoke(playing, pos, buffered, dur) },
onError = { msg, retriable -> onErrorListener?.invoke(msg, retriable) },
onRetryWithFix = { retryWithFixedM3u8() },
onReloadOriginal = { reloadOriginalUrl() }
)
val renderersFactory = DefaultRenderersFactory(context).apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(
if (hardwareAcceleration) DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
else DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
)
}
val loadControl = DefaultLoadControl.Builder()
.setBufferDurationsMs(3000, 15000, 2000, 2000)
.setPrioritizeTimeOverSizeThresholds(true)
.build()
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setConnectTimeoutMs(15000)
.setReadTimeoutMs(30000)
val mediaSourceFactory = DefaultMediaSourceFactory(context)
.setDataSourceFactory(dataSourceFactory)
exoPlayer = ExoPlayer.Builder(context)
.setRenderersFactory(renderersFactory)
.setLoadControl(loadControl)
.setTrackSelector(trackSelector)
.setMediaSourceFactory(mediaSourceFactory)
.setWakeMode(C.WAKE_MODE_NETWORK)
.build()
.apply {
setHandleAudioBecomingNoisy(true)
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build(),
true
)
addAnalyticsListener(object : AnalyticsListener {
override fun onLoadStarted(
eventTime: AnalyticsListener.EventTime,
loadEventInfo: androidx.media3.exoplayer.source.LoadEventInfo,
mediaLoadData: androidx.media3.exoplayer.source.MediaLoadData
) {
val loadUrl = loadEventInfo.dataSpec.uri.toString()
if (isInitialLoad) {
actualPlayingUrl = loadUrl
}
}
})
addAnalyticsListener(createLatencyMonitor())
addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_READY -> {
parserErrorRetryCount = 0
if (isInitialLoad) {
isInitialLoad = false
onPreparedListener?.invoke()
}
onBufferingListener?.invoke(false)
if (shouldPlayWhenReady && !isPlaying) {
play()
}
}
Player.STATE_ENDED -> {
shouldPlayWhenReady = false
onBufferingListener?.invoke(false)
}
Player.STATE_BUFFERING -> {
onBufferingListener?.invoke(true)
}
Player.STATE_IDLE -> {
onBufferingListener?.invoke(false)
if (shouldPlayWhenReady && exoPlayer?.currentMediaItem != null) {
exoPlayer?.prepare()
}
}
}
notifyPlaybackState()
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (playbackState != Player.STATE_IDLE && playbackState != Player.STATE_BUFFERING) {
shouldPlayWhenReady = isPlaying
}
if (isPlaying && playbackState == Player.STATE_READY) {
onBufferingListener?.invoke(false)
}
notifyPlaybackState()
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
notifyPlaybackState()
}
override fun onPlayerError(error: PlaybackException) {
ErrorHandler.logError("Media3Player", "Playback error occurred", error)
val wasPlaying = shouldPlayWhenReady
val httpError = error.cause as? androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException
val httpCode = httpError?.responseCode ?: 0
val isParserError = error.errorCode in listOf(
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED
)
val isRetriableNetworkError = error.errorCode in listOf(
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT
)
val isBehindLiveWindow = error.cause is androidx.media3.exoplayer.source.BehindLiveWindowException
when {
isBehindLiveWindow -> {
exoPlayer?.let { player ->
player.seekToDefaultPosition()
player.prepare()
if (wasPlaying) player.play()
}
}
(isParserError || NetworkUtils.isRetriableHttpError(httpCode)) && parserErrorRetryCount < maxParserRetries -> {
parserErrorRetryCount++
ErrorHandler.logError("Media3Player", "Retriable error, retrying ($parserErrorRetryCount/$maxParserRetries)", null)
currentMediaUrl?.let { url ->
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
exoPlayer?.let { player ->
player.stop()
player.clearMediaItems()
player.setMediaItem(MediaItem.fromUri(url))
player.prepare()
if (wasPlaying) player.play()
}
}, 500)
}
}
isRetriableNetworkError && parserErrorRetryCount < maxParserRetries -> {
parserErrorRetryCount++
ErrorHandler.logError("Media3Player", "Network error, retrying ($parserErrorRetryCount/$maxParserRetries)", null)
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
exoPlayer?.let { player ->
player.prepare()
if (wasPlaying) player.play()
}
}, 1000)
}
else -> {
ErrorHandler.logError("Media3Player", "Unrecoverable error: ${error.errorCodeName}", error)
shouldPlayWhenReady = false
exoPlayer?.stop()
onBufferingListener?.invoke(false)
val errorMsg = when {
NetworkUtils.isPermanentHttpError(httpCode) -> "Failed to load source: $httpCode ${httpError?.responseMessage ?: "Error"}"
error.errorCode == PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS ->
"Failed to load source: $httpCode ${httpError?.responseMessage ?: "Error"}"
isRetriableNetworkError -> "Failed to load source: Network connection failed"
isParserError -> "Failed to load source: Format not supported"
else -> "Failed to load source: ${error.errorCodeName}"
}
onErrorListener?.invoke(errorMsg, false)
}
}
}
override fun onVideoSizeChanged(videoSize: VideoSize) {
onInfoListener?.invoke(MEDIA_INFO_VIDEO_RENDERING_START, 0)
}
})
}
}
private fun notifyPlaybackState() {
exoPlayer?.let { player ->
val pos = player.currentPosition.coerceAtLeast(0L)
val buffered = player.bufferedPosition.coerceAtLeast(0L)
val dur = if (player.duration == C.TIME_UNSET || player.duration < 0) 0L else player.duration
onPlaybackStateChanged?.invoke(player.isPlaying, pos, buffered, dur)
}
exoPlayer = PlayerFactory.createExoPlayer(
context = context,
hardwareAcceleration = hardwareAcceleration,
urlUpgradeListener = PlayerListeners.createUrlUpgradeListener(
isInitialLoad = { state.isInitialLoad },
initialM3uUrl = { initialM3uUrl },
onUrlResolved = { actualPlayingUrl = it }
),
latencyMonitor = PlayerFactory.createLatencyMonitor { exoPlayer },
combinedListener = PlayerListeners.createCombinedListener({ exoPlayer }, state, callbacks)
)
}
fun attachSurface(surface: Surface?) {
@@ -246,65 +60,78 @@ class Media3Player(private val context: Context) {
if (surface == null || surface.isValid) {
exoPlayer?.setVideoSurface(surface)
}
} catch (e: Exception) {
ErrorHandler.logError("Media3Player", "Failed to attach surface", e)
} catch (_: Exception) {
}
}
fun setDataSource(url: String) {
val finalUrl = if (url.startsWith("http://", ignoreCase = true)) {
url.replaceFirst("http://", "https://", ignoreCase = true)
} else {
url
}
currentMediaUrl = finalUrl
parserErrorRetryCount = 0
isInitialLoad = true
initialM3uUrl = url
state.currentMediaUrl = url
state.isInitialLoad = true
state.hasTriedM3u8Fix = false
state.isFixingM3u8 = false
actualPlayingUrl = null
val mediaItem = MediaItem.fromUri(finalUrl)
exoPlayer?.apply {
stop()
clearMediaItems()
setMediaItem(mediaItem)
setMediaItem(createMediaItem(url))
prepare()
}
}
fun prepareAsync() {
onPreparedListener?.invoke()
private fun createMediaItem(url: String): MediaItem =
MediaItem.Builder().setUri(url).build()
internal fun retryWithFixedM3u8() {
val url = initialM3uUrl ?: return
CoroutineScope(Dispatchers.Main).launch {
try {
val fixedUrl = UrlInterceptor.fixMalformedM3u8(context, url)
state.currentMediaUrl = fixedUrl
exoPlayer?.apply {
setMediaItem(createMediaItem(fixedUrl))
prepare()
if (state.shouldPlayWhenReady) play()
}
state.isFixingM3u8 = false
} catch (_: Exception) {
onBufferingListener?.invoke(false)
state.isFixingM3u8 = false
}
}
}
internal fun reloadOriginalUrl() {
val url = initialM3uUrl ?: return
state.hasTriedM3u8Fix = false
exoPlayer?.apply {
stop()
clearMediaItems()
setMediaItem(createMediaItem(url))
prepare()
if (state.shouldPlayWhenReady) play()
}
}
fun start() {
shouldPlayWhenReady = true
exoPlayer?.let { player ->
if (player.playbackState == Player.STATE_IDLE) {
player.prepare()
}
player.play()
notifyPlaybackState()
state.shouldPlayWhenReady = true
exoPlayer?.let {
if (it.playbackState == androidx.media3.common.Player.STATE_IDLE) it.prepare()
it.play()
}
}
fun pause() {
shouldPlayWhenReady = false
exoPlayer?.let { player ->
player.pause()
notifyPlaybackState()
}
}
fun stop() {
exoPlayer?.stop()
notifyPlaybackState()
}
fun seekTo(msec: Long) {
exoPlayer?.seekTo(msec)
notifyPlaybackState()
state.shouldPlayWhenReady = false
exoPlayer?.pause()
}
fun stop() = exoPlayer?.stop()
fun setHardwareAcceleration(enabled: Boolean) {
if (currentHardwareAcceleration != enabled) {
currentHardwareAcceleration = enabled
@@ -318,9 +145,7 @@ class Media3Player(private val context: Context) {
currentUrl?.let {
setDataSource(it)
exoPlayer?.seekTo(currentPos)
if (wasPlaying) {
start()
}
if (wasPlaying) start()
}
}
}
@@ -330,58 +155,10 @@ class Media3Player(private val context: Context) {
exoPlayer = null
}
val isPlaying: Boolean
get() = exoPlayer?.isPlaying ?: false
val currentPosition: Long
get() = exoPlayer?.currentPosition ?: 0L
val bufferedPosition: Long
get() = exoPlayer?.bufferedPosition ?: 0L
val duration: Long
get() = exoPlayer?.duration?.takeIf { it != C.TIME_UNSET } ?: 0L
val isPlaying: Boolean get() = exoPlayer?.isPlaying ?: false
val currentPosition: Long get() = exoPlayer?.currentPosition ?: 0L
val bufferedPosition: Long get() = exoPlayer?.bufferedPosition ?: 0L
val duration: Long get() = exoPlayer?.duration?.takeIf { it != C.TIME_UNSET } ?: 0L
fun getActualPlayingUrl(): String? = actualPlayingUrl
private fun isTunnelingSupported(): Boolean {
return try {
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
codecList.codecInfos.any { codecInfo ->
codecInfo.supportedTypes.any { type ->
type.startsWith("video/") &&
codecInfo.getCapabilitiesForType(type)
.isFeatureSupported(MediaCodecInfo.CodecCapabilities.FEATURE_TunneledPlayback)
}
}
} catch (e: Exception) {
false
}
}
private fun createLatencyMonitor() = object : AnalyticsListener {
private var lastCheck = 0L
override fun onPlaybackStateChanged(
eventTime: AnalyticsListener.EventTime,
state: Int
) {
if (state == Player.STATE_READY && System.currentTimeMillis() - lastCheck > 1000) {
lastCheck = System.currentTimeMillis()
exoPlayer?.let {
val latency = it.bufferedPosition - it.currentPosition
if (latency > 5000 && it.duration != C.TIME_UNSET && it.duration > 0) {
val targetPosition = it.currentPosition + 1000
if (targetPosition < it.duration) {
it.seekTo(targetPosition)
}
}
}
}
}
}
companion object {
const val MEDIA_INFO_VIDEO_RENDERING_START = 3
}
}
@@ -0,0 +1,103 @@
package com.antoniegil.astronia.player
import android.content.Context
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
internal object PlayerFactory {
fun createExoPlayer(
context: Context,
hardwareAcceleration: Boolean,
urlUpgradeListener: AnalyticsListener,
latencyMonitor: AnalyticsListener,
combinedListener: androidx.media3.common.Player.Listener
): ExoPlayer {
val tunnelingSupported = try {
MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos.any { codecInfo ->
codecInfo.supportedTypes.any { type ->
type.startsWith("video/") && codecInfo.getCapabilitiesForType(type)
.isFeatureSupported(MediaCodecInfo.CodecCapabilities.FEATURE_TunneledPlayback)
}
}
} catch (e: Exception) { false }
val trackSelector = DefaultTrackSelector(context).apply {
setParameters(buildUponParameters().setTunnelingEnabled(hardwareAcceleration && tunnelingSupported))
}
val renderersFactory = DefaultRenderersFactory(context).apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(
if (hardwareAcceleration) DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
else DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
)
}
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setConnectTimeoutMs(15000)
.setReadTimeoutMs(30000)
.setAllowCrossProtocolRedirects(true)
val compositeDataSourceFactory = androidx.media3.datasource.DefaultDataSource.Factory(
context,
dataSourceFactory
)
val mediaSourceFactory = DefaultMediaSourceFactory(context)
.setDataSourceFactory(compositeDataSourceFactory)
return ExoPlayer.Builder(context)
.setRenderersFactory(renderersFactory)
.setLoadControl(DefaultLoadControl.Builder()
.setBufferDurationsMs(3000, 15000, 2000, 2000)
.setPrioritizeTimeOverSizeThresholds(true)
.build())
.setTrackSelector(trackSelector)
.setMediaSourceFactory(mediaSourceFactory)
.setWakeMode(C.WAKE_MODE_NETWORK)
.build()
.apply {
setHandleAudioBecomingNoisy(true)
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build(),
true
)
addAnalyticsListener(urlUpgradeListener)
addAnalyticsListener(latencyMonitor)
addListener(combinedListener)
}
}
fun createLatencyMonitor(getPlayer: () -> ExoPlayer?): AnalyticsListener {
return object : AnalyticsListener {
private var lastCheck = 0L
override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, state: Int) {
if (state == androidx.media3.common.Player.STATE_READY && System.currentTimeMillis() - lastCheck > 1000) {
lastCheck = System.currentTimeMillis()
getPlayer()?.let {
val latency = it.bufferedPosition - it.currentPosition
if (latency > 5000 && it.duration != C.TIME_UNSET && it.duration > 0) {
val targetPosition = it.currentPosition + 1000
if (targetPosition < it.duration) it.seekTo(targetPosition)
}
}
}
}
}
}
}
@@ -0,0 +1,177 @@
package com.antoniegil.astronia.player
import android.content.Context
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.analytics.AnalyticsListener
import com.antoniegil.astronia.util.ErrorHandler
import com.antoniegil.astronia.util.NetworkUtils
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
internal object PlayerListeners {
fun createUrlUpgradeListener(
isInitialLoad: () -> Boolean,
initialM3uUrl: () -> String?,
onUrlResolved: (String) -> Unit
) = object : AnalyticsListener {
private var lastUrl: String? = null
@Deprecated("Deprecated in Java")
override fun onLoadStarted(
eventTime: AnalyticsListener.EventTime,
loadEventInfo: androidx.media3.exoplayer.source.LoadEventInfo,
mediaLoadData: androidx.media3.exoplayer.source.MediaLoadData
) {
val loadUrl = loadEventInfo.dataSpec.uri.toString()
if (isInitialLoad() && loadUrl != lastUrl) {
lastUrl = loadUrl
onUrlResolved(NetworkUtils.upgradeToHttps(initialM3uUrl(), loadUrl))
}
}
}
fun createCombinedListener(
getPlayer: () -> androidx.media3.exoplayer.ExoPlayer?,
state: PlayerState,
callbacks: PlayerCallbacks
) = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
val player = getPlayer()
when (playbackState) {
Player.STATE_READY -> {
if (state.isInitialLoad) {
state.isInitialLoad = false
callbacks.onPrepared()
}
callbacks.onBuffering(false)
if (state.shouldPlayWhenReady && player?.isPlaying == false) player.play()
}
Player.STATE_ENDED -> {
state.shouldPlayWhenReady = false
callbacks.onBuffering(false)
}
Player.STATE_BUFFERING -> callbacks.onBuffering(true)
Player.STATE_IDLE -> {
callbacks.onBuffering(false)
if (state.shouldPlayWhenReady && player?.currentMediaItem != null) player.prepare()
}
}
notifyState(player, callbacks.onStateChanged)
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
val player = getPlayer()
val playbackState = player?.playbackState ?: Player.STATE_IDLE
if (playbackState != Player.STATE_IDLE && playbackState != Player.STATE_BUFFERING) {
state.shouldPlayWhenReady = isPlaying
}
if (isPlaying && playbackState == Player.STATE_READY) callbacks.onBuffering(false)
notifyState(player, callbacks.onStateChanged)
}
override fun onPositionDiscontinuity(oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int) {
notifyState(getPlayer(), callbacks.onStateChanged)
}
override fun onPlayerError(error: PlaybackException) {
if (state.isFixingM3u8) {
return
}
val player = getPlayer()
val httpError = error.cause as? androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException
val httpCode = httpError?.responseCode ?: 0
val isRetriableNetworkError = error.errorCode in listOf(
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT
)
val isManifestParsingError = error.errorCode in listOf(
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED
)
val isPlaylistStuck = error.cause?.javaClass?.simpleName == "PlaylistStuckException"
when {
error.cause is androidx.media3.exoplayer.source.BehindLiveWindowException -> {
player?.seekToDefaultPosition()
player?.prepare()
if (state.shouldPlayWhenReady) player?.play()
}
isPlaylistStuck && state.hasTriedM3u8Fix -> {
state.context?.cacheDir?.listFiles()?.filter { it.name.startsWith("m3u8_") }?.forEach { it.delete() }
callbacks.onReloadOriginal()
}
isManifestParsingError && !state.hasTriedM3u8Fix -> {
state.isFixingM3u8 = true
state.hasTriedM3u8Fix = true
player?.stop()
player?.clearMediaItems()
callbacks.onBuffering(true)
callbacks.onRetryWithFix()
}
isManifestParsingError && state.hasTriedM3u8Fix -> {
state.shouldPlayWhenReady = false
player?.stop()
callbacks.onBuffering(false)
val errorMsg = when {
error.message?.contains("None of the available extractors") == true ->
"Unsupported video format"
error.cause?.message?.contains("sniff failures") == true ->
"Unable to detect video format"
else -> "Format not supported"
}
callbacks.onError(errorMsg, false)
}
else -> {
val isExtractorError = error.errorCode == PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED ||
error.message?.contains("None of the available extractors") == true
if (!isExtractorError) {
state.shouldPlayWhenReady = false
player?.stop()
callbacks.onBuffering(false)
val errorMsg = when {
NetworkUtils.isPermanentHttpError(httpCode) -> "HTTP $httpCode: ${httpError?.responseMessage ?: "Error"}"
error.errorCode == PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS ->
"HTTP $httpCode: ${httpError?.responseMessage ?: "Error"}"
isRetriableNetworkError -> "Network connection failed"
isManifestParsingError -> "Format error"
else -> "Playback error: ${error.errorCodeName}"
}
callbacks.onError(errorMsg, false)
}
}
}
}
}
private fun notifyState(player: androidx.media3.exoplayer.ExoPlayer?, callback: (Boolean, Long, Long, Long) -> Unit) {
player?.let {
val dur = if (it.duration == androidx.media3.common.C.TIME_UNSET || it.duration < 0) 0L else it.duration
callback(it.isPlaying, it.currentPosition.coerceAtLeast(0L), it.bufferedPosition.coerceAtLeast(0L), dur)
}
}
}
internal data class PlayerState(
var shouldPlayWhenReady: Boolean = false,
var isInitialLoad: Boolean = false,
var currentMediaUrl: String? = null,
var context: Context? = null,
var hasTriedM3u8Fix: Boolean = false,
var isFixingM3u8: Boolean = false
)
internal data class PlayerCallbacks(
val onPrepared: () -> Unit,
val onBuffering: (Boolean) -> Unit,
val onStateChanged: (Boolean, Long, Long, Long) -> Unit,
val onError: (String, Boolean) -> Unit,
val onRetryWithFix: () -> Unit = {},
val onReloadOriginal: () -> Unit = {}
)
@@ -0,0 +1,66 @@
package com.antoniegil.astronia.player
import android.content.Context
import android.net.Uri
import com.antoniegil.astronia.util.ErrorHandler
import com.antoniegil.astronia.util.NetworkUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
object UrlInterceptor {
suspend fun fixMalformedM3u8(context: Context, url: String): String = withContext(Dispatchers.IO) {
try {
val client = NetworkUtils.createHttpClient(3000, 5000, true)
var currentUrl = url
var depth = 0
val maxDepth = 5
while (depth < maxDepth) {
val request = Request.Builder().url(currentUrl).build()
val response = client.newCall(request).execute()
val finalUrl = response.request.url.toString()
val content = response.body.string()
if (!content.trim().startsWith("#EXTM3U")) {
return@withContext finalUrl
}
val isMasterPlaylist = content.contains("#EXT-X-STREAM-INF")
val isMediaPlaylist = content.contains("#EXTINF")
if (isMediaPlaylist) {
return@withContext finalUrl
}
if (isMasterPlaylist) {
val lines = content.lines()
val variantUrl = lines.firstOrNull { line ->
line.isNotBlank() && !line.startsWith("#")
}
if (variantUrl != null) {
val uri = Uri.parse(finalUrl)
val baseUrl = "${uri.scheme}://${uri.host}${if (uri.port == -1) "" else ":${uri.port}"}${uri.path?.substringBeforeLast("/") ?: ""}"
currentUrl = if (variantUrl.startsWith("http")) {
variantUrl
} else {
"$baseUrl/${variantUrl.trimStart('/')}"
}
depth++
continue
}
}
return@withContext finalUrl
}
url
} catch (_: Exception) {
url
}
}
}
@@ -64,5 +64,28 @@ object NetworkUtils {
fun isPermanentHttpError(code: Int): Boolean = code in listOf(400, 403, 404, 410, 451)
fun isRetriableHttpError(code: Int): Boolean = code in listOf(500, 502, 503, 504)
fun isIpAddress(url: String): Boolean = url.matches(Regex("https?://\\d+\\.\\d+\\.\\d+\\.\\d+.*"))
fun extractDomain(url: String): String? = url.substringAfter("://", "").substringBefore("/").takeIf { it.isNotEmpty() }
fun upgradeToHttps(m3uUrl: String?, targetUrl: String): String {
val m3uIsHttps = m3uUrl?.startsWith("https://", ignoreCase = true) == true
val targetIsHttp = targetUrl.startsWith("http://", ignoreCase = true)
val targetIsHttps = targetUrl.startsWith("https://", ignoreCase = true)
return when {
isIpAddress(targetUrl) -> targetUrl
targetIsHttps -> targetUrl
targetIsHttp && (m3uIsHttps || m3uUrl?.startsWith("http://", ignoreCase = true) == true) -> {
val targetDomain = extractDomain(targetUrl)
val m3uDomain = extractDomain(m3uUrl)
if (targetDomain == m3uDomain) {
targetUrl.replaceFirst("http://", "https://", ignoreCase = true)
} else {
targetUrl
}
}
else -> targetUrl
}
}
}
+2
View File
@@ -84,6 +84,7 @@ compose-markdown = { group = "com.github.jeziellago", name = "compose-markdown",
# Media3 for m3u8 playback
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" }
androidx-media3-exoplayer-dash = { group = "androidx.media3", name = "media3-exoplayer-dash", version.ref = "media3" }
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
androidx-media3-exoplayer-ffmpeg = { group = "org.jellyfin.media3", name = "media3-ffmpeg-decoder", version = "1.9.0+1" }
@@ -124,6 +125,7 @@ core = [
media3 = [
"androidx-media3-exoplayer",
"androidx-media3-exoplayer-hls",
"androidx-media3-exoplayer-dash",
"androidx-media3-exoplayer-ffmpeg",
"androidx-media3-ui"
]