refactor: enhance Media3Player error-handling mechanisms to resolve playback failures.
This commit is contained in:
Vendored
+2
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user