diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index dba64ff..7daaf87 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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> { diff --git a/app/src/main/java/com/antoniegil/astronia/player/Media3Player.kt b/app/src/main/java/com/antoniegil/astronia/player/Media3Player.kt index 625332c..dc81952 100644 --- a/app/src/main/java/com/antoniegil/astronia/player/Media3Player.kt +++ b/app/src/main/java/com/antoniegil/astronia/player/Media3Player.kt @@ -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 - } } diff --git a/app/src/main/java/com/antoniegil/astronia/player/PlayerFactory.kt b/app/src/main/java/com/antoniegil/astronia/player/PlayerFactory.kt new file mode 100644 index 0000000..8712ee5 --- /dev/null +++ b/app/src/main/java/com/antoniegil/astronia/player/PlayerFactory.kt @@ -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) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/antoniegil/astronia/player/PlayerListeners.kt b/app/src/main/java/com/antoniegil/astronia/player/PlayerListeners.kt new file mode 100644 index 0000000..7b320b9 --- /dev/null +++ b/app/src/main/java/com/antoniegil/astronia/player/PlayerListeners.kt @@ -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 = {} +) diff --git a/app/src/main/java/com/antoniegil/astronia/player/UrlInterceptor.kt b/app/src/main/java/com/antoniegil/astronia/player/UrlInterceptor.kt new file mode 100644 index 0000000..864469c --- /dev/null +++ b/app/src/main/java/com/antoniegil/astronia/player/UrlInterceptor.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/antoniegil/astronia/util/NetworkUtils.kt b/app/src/main/java/com/antoniegil/astronia/util/NetworkUtils.kt index 2db3c12..ba0c7ac 100644 --- a/app/src/main/java/com/antoniegil/astronia/util/NetworkUtils.kt +++ b/app/src/main/java/com/antoniegil/astronia/util/NetworkUtils.kt @@ -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 + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 57b69e0..2a2ceeb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" ]