diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d0c86bab..9e71b0be 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -191,15 +191,15 @@ dependencies { implementation("dev.zacsweers.autoservice:auto-service-ksp:1.2.0") // Media 3 (ExoPlayer) - implementation("androidx.media3:media3-ui:1.1.1") - implementation("androidx.media3:media3-cast:1.1.1") - implementation("androidx.media3:media3-common:1.1.1") - implementation("androidx.media3:media3-session:1.1.1") - implementation("androidx.media3:media3-exoplayer:1.1.1") + implementation("androidx.media3:media3-ui:1.4.0") + implementation("androidx.media3:media3-cast:1.4.0") + implementation("androidx.media3:media3-common:1.4.0") + implementation("androidx.media3:media3-session:1.4.0") + implementation("androidx.media3:media3-exoplayer:1.4.0") implementation("com.google.android.mediahome:video:1.0.0") - implementation("androidx.media3:media3-exoplayer-hls:1.1.1") - implementation("androidx.media3:media3-exoplayer-dash:1.1.1") - implementation("androidx.media3:media3-datasource-okhttp:1.1.1") + implementation("androidx.media3:media3-exoplayer-hls:1.4.0") + implementation("androidx.media3:media3-exoplayer-dash:1.4.0") + implementation("androidx.media3:media3-datasource-okhttp:1.4.0") // PlayBack implementation("com.jaredrummler:colorpicker:1.1.0") // Subtitle Color Picker diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index 88c34c87..d3995aec 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -25,7 +25,6 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.media3.common.PlaybackException -import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession import androidx.media3.ui.* diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 86d67b28..46368585 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -35,7 +35,6 @@ import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory -import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.SeekParameters import androidx.media3.exoplayer.dash.DashMediaSource @@ -88,6 +87,7 @@ const val toleranceBeforeUs = 300_000L * seek position, in microseconds. Must be non-negative. */ const val toleranceAfterUs = 300_000L + @OptIn(UnstableApi::class) class CS3IPlayer : IPlayer { private var isPlaying = false @@ -147,7 +147,7 @@ class CS3IPlayer : IPlayer { /** * Tracks reported to be used by exoplayer, since sometimes it has a mind of it's own when selecting subs. - * String = id + * String = id (without exoplayer track number) * Boolean = if it's active * */ private var playerSelectedSubtitleTracks = listOf>() @@ -188,6 +188,10 @@ class CS3IPlayer : IPlayer { } } + fun String.stripTrackId(): String { + return this.replace(Regex("""^\d+:"""), "") + } + fun initSubtitles(subView: SubtitleView?, subHolder: FrameLayout?, style: SaveCaptionStyle?) { subtitleHelper.initSubtitles(subView, subHolder, style) } @@ -272,7 +276,11 @@ class CS3IPlayer : IPlayer { return this.firstNotNullOfOrNull { group -> (0 until group.mediaTrackGroup.length).map { group.getTrackFormat(it) to it - }.firstOrNull { it.first.id == id } + }.firstOrNull { + // The format id system is "trackNumber:trackID" + // The track number is not generated by us so we filter it out + it.first.id?.stripTrackId() == id + } ?.let { group.mediaTrackGroup to it.second } } } @@ -355,21 +363,28 @@ class CS3IPlayer : IPlayer { private fun Format.toAudioTrack(): AudioTrack { return AudioTrack( - this.id, + this.id?.stripTrackId(), this.label, -// isPlaying, this.language ) } + private fun Format.toSubtitleTrack(): TextTrack { + return TextTrack( + this.id?.stripTrackId(), + this.label, + this.language, + this.sampleMimeType + ) + } + private fun Format.toVideoTrack(): VideoTrack { return VideoTrack( - this.id, + this.id?.stripTrackId(), this.label, -// isPlaying, this.language, this.width, - this.height + this.height, ) } @@ -381,11 +396,20 @@ class CS3IPlayer : IPlayer { val audioTracks = allTracks.filter { it.type == TRACK_TYPE_AUDIO }.getFormats() .map { it.first.toAudioTrack() } + val textTracks = allTracks.filter { it.type == TRACK_TYPE_TEXT }.getFormats() + .map { it.first.toSubtitleTrack() } + + val currentTextTracks = textTracks.filter { track -> + playerSelectedSubtitleTracks.any { it.second && it.first == track.id } + } + return CurrentTracks( exoPlayer?.videoFormat?.toVideoTrack(), exoPlayer?.audioFormat?.toAudioTrack(), + currentTextTracks, videoTracks, - audioTracks + audioTracks, + textTracks ) } @@ -455,7 +479,8 @@ class CS3IPlayer : IPlayer { override fun setSubtitleOffset(offset: Long) { currentSubtitleOffset = offset - currentTextRenderer?.setRenderOffsetMs(offset) + CustomDecoder.subtitleOffset = offset + currentTextRenderer?.reset() } override fun getSubtitleOffset(): Long { @@ -503,7 +528,6 @@ class CS3IPlayer : IPlayer { release() } //simpleCache?.release() - currentTextRenderer = null exoPlayer = null //simpleCache = null @@ -601,7 +625,10 @@ class CS3IPlayer : IPlayer { } private fun Context.createOfflineSource(): DataSource.Factory { - return DefaultDataSource.Factory(this, DefaultHttpDataSource.Factory().setUserAgent(USER_AGENT)) + return DefaultDataSource.Factory( + this, + DefaultHttpDataSource.Factory().setUserAgent(USER_AGENT) + ) } private fun getCache(context: Context, cacheSize: Long): SimpleCache? { @@ -646,7 +673,8 @@ class CS3IPlayer : IPlayer { return trackSelector } - var currentTextRenderer: CustomTextRenderer? = null + private var currentSubtitleDecoder: CustomSubtitleDecoderFactory? = null + private var currentTextRenderer: TextRenderer? = null private fun buildExoPlayer( context: Context, @@ -672,8 +700,8 @@ class CS3IPlayer : IPlayer { .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput -> DefaultRenderersFactory(context).apply { setEnableDecoderFallback(true) - // Enable Ffmpeg extension - setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON) + // Enable Ffmpeg extension. + setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER) }.createRenderers( eventHandler, videoRendererEventListener, @@ -682,14 +710,23 @@ class CS3IPlayer : IPlayer { metadataRendererOutput ).map { if (it is TextRenderer) { - val currentTextRenderer = CustomTextRenderer( - subtitleOffset, + CustomDecoder.subtitleOffset = subtitleOffset + val decoder = CustomSubtitleDecoderFactory() + val currentTextRenderer = TextRenderer( textRendererOutput, eventHandler.looper, - CustomSubtitleDecoderFactory() - ).also { renderer -> this.currentTextRenderer = renderer } + decoder + ).apply { + // Required to make the decoder work with old subtitles + // Upgrade CustomSubtitleDecoderFactory when media3 supports it + experimentalSetLegacyDecodingEnabled(true) + }.also { renderer -> + this.currentTextRenderer = renderer + this.currentSubtitleDecoder = decoder + } currentTextRenderer - } else it + } else + it }.toTypedArray() } .setTrackSelector( @@ -952,7 +989,8 @@ class CS3IPlayer : IPlayer { playerSelectedSubtitleTracks = textTracks.map { group -> group.getFormats().mapNotNull { (format, _) -> - (format.id ?: return@mapNotNull null) to group.isSelected + (format.id?.stripTrackId() + ?: return@mapNotNull null) to group.isSelected } }.flatten() @@ -970,7 +1008,7 @@ class CS3IPlayer : IPlayer { fromTwoLettersToLanguage(format.language!!) ?: format.language!!, // See setPreferredTextLanguage - format.id!!, + format.id!!.stripTrackId(), SubtitleOrigin.EMBEDDED_IN_VIDEO, format.sampleMimeType ?: MimeTypes.APPLICATION_SUBRIP, emptyMap(), @@ -1210,7 +1248,7 @@ class CS3IPlayer : IPlayer { ): Pair, List> { val activeSubtitles = ArrayList() val subSources = subHelper.getAllSubtitles().mapNotNull { sub -> - val subConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(sub.url)) + val subConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(sub.getFixedUrl())) .setMimeType(sub.mimeType) .setLanguage("_${sub.name}") .setId(sub.getId()) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt index 07ce413e..54dc69a9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt @@ -3,37 +3,36 @@ package com.lagradost.cloudstream3.ui.player import android.content.Context import android.util.Log import androidx.annotation.OptIn -import androidx.preference.PreferenceManager import androidx.media3.common.Format import androidx.media3.common.MimeTypes +import androidx.media3.common.util.Consumer import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.text.ExoplayerCuesDecoder import androidx.media3.exoplayer.text.SubtitleDecoderFactory +import androidx.media3.extractor.text.CuesWithTiming +import androidx.media3.extractor.text.SimpleSubtitleDecoder +import androidx.media3.extractor.text.Subtitle import androidx.media3.extractor.text.SubtitleDecoder -import androidx.media3.extractor.text.SubtitleInputBuffer -import androidx.media3.extractor.text.SubtitleOutputBuffer -import androidx.media3.extractor.text.cea.Cea608Decoder -import androidx.media3.extractor.text.cea.Cea708Decoder -import androidx.media3.extractor.text.dvb.DvbDecoder -import androidx.media3.extractor.text.pgs.PgsDecoder -import androidx.media3.extractor.text.ssa.SsaDecoder -import androidx.media3.extractor.text.subrip.SubripDecoder -import androidx.media3.extractor.text.ttml.TtmlDecoder -import androidx.media3.extractor.text.tx3g.Tx3gDecoder -import androidx.media3.extractor.text.webvtt.Mp4WebvttDecoder -import androidx.media3.extractor.text.webvtt.WebvttDecoder +import androidx.media3.extractor.text.SubtitleParser +import androidx.media3.extractor.text.dvb.DvbParser +import androidx.media3.extractor.text.pgs.PgsParser +import androidx.media3.extractor.text.ssa.SsaParser +import androidx.media3.extractor.text.subrip.SubripParser +import androidx.media3.extractor.text.ttml.TtmlParser +import androidx.media3.extractor.text.tx3g.Tx3gParser +import androidx.media3.extractor.text.webvtt.Mp4WebvttParser +import androidx.media3.extractor.text.webvtt.WebvttParser +import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import org.mozilla.universalchardet.UniversalDetector -import java.nio.ByteBuffer import java.nio.charset.Charset /** * @param fallbackFormat used to create a decoder based on mimetype if the subtitle string is not * enough to identify the subtitle format. **/ -@OptIn(UnstableApi::class) -class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder { +@UnstableApi +class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { companion object { fun updateForcedEncoding(context: Context) { val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) @@ -48,6 +47,8 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder { } } + /** Subtitle offset in milliseconds */ + var subtitleOffset: Long = 0 private const val UTF_8 = "UTF-8" private const val TAG = "CustomDecoder" private var overrideEncoding: String? = null @@ -85,16 +86,7 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder { } } - private var realDecoder: SubtitleDecoder? = null - - override fun getName(): String { - return realDecoder?.name ?: this::javaClass.name - } - - override fun dequeueInputBuffer(): SubtitleInputBuffer { - Log.i(TAG, "dequeueInputBuffer") - return realDecoder?.dequeueInputBuffer() ?: SubtitleInputBuffer() - } + private var realDecoder: SubtitleParser? = null private fun getStr(byteArray: ByteArray): Pair { val encoding = try { @@ -128,101 +120,76 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder { } } - private fun getStr(input: SubtitleInputBuffer): String? { - try { - val data = input.data ?: return null - data.position(0) - val fullDataArr = ByteArray(data.remaining()) - data.get(fullDataArr) - return trimStr(getStr(fullDataArr).first) - } catch (e: Exception) { - Log.e(TAG, "Failed to parse text returning plain data") - logError(e) - return null - } - } + private fun getSubtitleParser(data: String): SubtitleParser? { + // this way we read the subtitle file and decide what decoder to use instead of relying fully on mimetype + //https://github.com/LagradOst/CloudStream-2/blob/ddd774ee66810137ff7bd65dae70bcf3ba2d2489/CloudStreamForms/CloudStreamForms/Script/MainChrome.cs#L388 + val subtitleParser = when { + // "WEBVTT" can be hidden behind invisible characters not filtered by trim + data.substring(0, 10).contains("WEBVTT", ignoreCase = true) -> WebvttParser() + data.startsWith(" TtmlParser() + (data.startsWith( + "[Script Info]", + ignoreCase = true + ) || data.startsWith( + "Title:", + ignoreCase = true + )) -> SsaParser(fallbackFormat?.initializationData) - private fun SubtitleInputBuffer.setSubtitleText(text: String) { -// println("Set subtitle text -----\n$text\n-----") - this.data = ByteBuffer.wrap(text.toByteArray(charset(UTF_8))) - } - - override fun queueInputBuffer(inputBuffer: SubtitleInputBuffer) { - Log.i(TAG, "queueInputBuffer") - try { - val inputString = getStr(inputBuffer) - if (realDecoder == null && !inputString.isNullOrBlank()) { - var str: String = inputString - // this way we read the subtitle file and decide what decoder to use instead of relying fully on mimetype - Log.i(TAG, "Got data from queueInputBuffer") - //https://github.com/LagradOst/CloudStream-2/blob/ddd774ee66810137ff7bd65dae70bcf3ba2d2489/CloudStreamForms/CloudStreamForms/Script/MainChrome.cs#L388 - realDecoder = when { - str.startsWith("WEBVTT", ignoreCase = true) -> WebvttDecoder() - str.startsWith(" TtmlDecoder() - (str.startsWith( - "[Script Info]", - ignoreCase = true - ) || str.startsWith("Title:", ignoreCase = true)) -> SsaDecoder(fallbackFormat?.initializationData) - str.startsWith("1", ignoreCase = true) -> SubripDecoder() - fallbackFormat != null -> { - when (val mimeType = fallbackFormat.sampleMimeType) { - MimeTypes.TEXT_VTT -> WebvttDecoder() - MimeTypes.TEXT_SSA -> SsaDecoder(fallbackFormat.initializationData) - MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttDecoder() - MimeTypes.APPLICATION_TTML -> TtmlDecoder() - MimeTypes.APPLICATION_SUBRIP -> SubripDecoder() - MimeTypes.APPLICATION_TX3G -> Tx3gDecoder(fallbackFormat.initializationData) - MimeTypes.APPLICATION_CEA608, MimeTypes.APPLICATION_MP4CEA608 -> Cea608Decoder( - mimeType, - fallbackFormat.accessibilityChannel, - Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS - ) - MimeTypes.APPLICATION_CEA708 -> Cea708Decoder( - fallbackFormat.accessibilityChannel, - fallbackFormat.initializationData - ) - MimeTypes.APPLICATION_DVBSUBS -> DvbDecoder(fallbackFormat.initializationData) - MimeTypes.APPLICATION_PGS -> PgsDecoder() - MimeTypes.TEXT_EXOPLAYER_CUES -> ExoplayerCuesDecoder() - else -> null - } - } + data.startsWith("1", ignoreCase = true) -> SubripParser() + fallbackFormat != null -> { + when (val mimeType = fallbackFormat.sampleMimeType) { + MimeTypes.TEXT_VTT -> WebvttParser() + MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData) + MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser() + MimeTypes.APPLICATION_TTML -> TtmlParser() + MimeTypes.APPLICATION_SUBRIP -> SubripParser() + MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData) + // These decoders are not converted to parsers yet + // TODO +// MimeTypes.APPLICATION_CEA608, MimeTypes.APPLICATION_MP4CEA608 -> Cea608Decoder( +// mimeType, +// fallbackFormat.accessibilityChannel, +// Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS +// ) +// MimeTypes.APPLICATION_CEA708 -> Cea708Decoder( +// fallbackFormat.accessibilityChannel, +// fallbackFormat.initializationData +// ) + MimeTypes.APPLICATION_DVBSUBS -> DvbParser(fallbackFormat.initializationData) + MimeTypes.APPLICATION_PGS -> PgsParser() else -> null } + } + + else -> null + } + return subtitleParser + } + + override fun parse( + data: ByteArray, + offset: Int, + length: Int, + outputOptions: SubtitleParser.OutputOptions, + output: Consumer + ) { + val customOutput = Consumer { o -> + val updatedCues = CuesWithTiming(o.cues, o.startTimeUs - subtitleOffset.times(1000), o.durationUs) + output.accept(updatedCues) + } + Log.i(TAG, "Parse subtitle, current parser: $realDecoder") + try { + val inputString = getStr(data).first + Log.i(TAG, "Subtitle preview: ${inputString.substring(0, 30)}") + if (inputString.isNotBlank()) { + var str: String = trimStr(inputString) + realDecoder = realDecoder ?: getSubtitleParser(inputString) Log.i( TAG, - "Decoder selected: $realDecoder" + "Parser selected: $realDecoder" ) realDecoder?.let { decoder -> - decoder.dequeueInputBuffer()?.let { buff -> - if (decoder !is SsaDecoder) { - if (regexSubtitlesToRemoveCaptions) - captionRegex.forEach { rgx -> - str = str.replace(rgx, "\n") - } - if (regexSubtitlesToRemoveBloat) - bloatRegex.forEach { rgx -> - str = str.replace(rgx, "\n") - } - } - buff.setSubtitleText(str) - decoder.queueInputBuffer(buff) - Log.i( - TAG, - "Decoder queueInputBuffer successfully" - ) - } - CS3IPlayer.requestSubtitleUpdate?.invoke() - } - } else { - Log.i( - TAG, - "Decoder else queueInputBuffer successfully" - ) - - if (!inputString.isNullOrBlank()) { - var str: String = inputString - if (realDecoder !is SsaDecoder) { + if (decoder !is SsaParser) { if (regexSubtitlesToRemoveCaptions) captionRegex.forEach { rgx -> str = str.replace(rgx, "\n") @@ -235,38 +202,31 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder { str = str.uppercase() } } - inputBuffer.setSubtitleText(str) } - - realDecoder?.queueInputBuffer(inputBuffer) + val array = str.toByteArray() + realDecoder?.parse( + array, + minOf(array.size, offset), + minOf(array.size, length), + outputOptions, + customOutput + ) } } catch (e: Exception) { logError(e) } } - override fun dequeueOutputBuffer(): SubtitleOutputBuffer? { - return realDecoder?.dequeueOutputBuffer() - } - - override fun flush() { - realDecoder?.flush() - } - - override fun release() { - realDecoder?.release() - } - - override fun setPositionUs(positionUs: Long) { - realDecoder?.setPositionUs(positionUs) + override fun getCueReplacementBehavior(): Int { + return realDecoder?.cueReplacementBehavior ?: Format.CUE_REPLACEMENT_BEHAVIOR_REPLACE } } /** See https://github.com/google/ExoPlayer/blob/release-v2/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java */ @OptIn(UnstableApi::class) class CustomSubtitleDecoderFactory : SubtitleDecoderFactory { + override fun supportsFormat(format: Format): Boolean { -// return SubtitleDecoderFactory.DEFAULT.supportsFormat(format) return listOf( MimeTypes.TEXT_VTT, MimeTypes.TEXT_SSA, @@ -283,7 +243,28 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory { ).contains(format.sampleMimeType) } + /** + * Decoders created here persists across reset() + * Do not save state in the decoder which you want to reset (e.g subtitle offset) + **/ override fun createDecoder(format: Format): SubtitleDecoder { - return CustomDecoder(format) + val parser = CustomDecoder(format) + + return DelegatingSubtitleDecoder( + parser::class.simpleName + "Decoder", parser + ) } -} \ No newline at end of file +} + +@OptIn(UnstableApi::class) +/** We need to convert the newer SubtitleParser to an older SubtitleDecoder */ +class DelegatingSubtitleDecoder(name: String, private val parser: SubtitleParser) : + SimpleSubtitleDecoder(name) { + + override fun decode(data: ByteArray, length: Int, reset: Boolean): Subtitle { + if (reset) { + parser.reset() + } + return parser.parseToLegacySubtitle(data, 0, length); + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomTextRenderer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomTextRenderer.kt deleted file mode 100644 index f2b863fb..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomTextRenderer.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -import android.os.Looper -import androidx.annotation.OptIn -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.text.SubtitleDecoderFactory -import androidx.media3.exoplayer.text.TextOutput - -@OptIn(UnstableApi::class) -class CustomTextRenderer( - offset: Long, - output: TextOutput?, - outputLooper: Looper?, - decoderFactory: SubtitleDecoderFactory = SubtitleDecoderFactory.DEFAULT -) : NonFinalTextRenderer(output, outputLooper, decoderFactory) { - private var offsetPositionUs: Long = 0L - - init { - setRenderOffsetMs(offset) - } - - fun setRenderOffsetMs(offset : Long) { - offsetPositionUs = offset * 1000L - } - - fun getRenderOffsetMs() : Long { - return offsetPositionUs / 1000L - } - - override fun render( positionUs: Long, elapsedRealtimeUs: Long) { - super.render(positionUs + offsetPositionUs, elapsedRealtimeUs + offsetPositionUs) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index b2e80749..b51f81ca 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -36,6 +36,7 @@ import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged +import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi import androidx.preference.PreferenceManager import com.google.android.material.button.MaterialButton @@ -296,8 +297,12 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } override fun subtitlesChanged() { - playerBinding?.playerSubtitleOffsetBtt?.isGone = - player.getCurrentPreferredSubtitle() == null + val tracks = player.getVideoTracks() + val isBuiltinSubtitles = tracks.currentTextTracks.all { track -> + track.mimeType == MimeTypes.APPLICATION_MEDIA3_CUES + } + // Subtitle offset is not possible on built-in media3 tracks + playerBinding?.playerSubtitleOffsetBtt?.isGone = isBuiltinSubtitles || tracks.currentTextTracks.isEmpty() } private fun restoreOrientationWithSensor(activity: Activity) { @@ -569,7 +574,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { playerRewHolder.alpha = 1f val rotateLeft = AnimationUtils.loadAnimation(context, R.anim.rotate_left) - exoRew.startAnimation(rotateLeft) + playerRew.startAnimation(rotateLeft) val goLeft = AnimationUtils.loadAnimation(context, R.anim.go_left) goLeft.setAnimationListener(object : Animation.AnimationListener { @@ -602,7 +607,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { playerFfwdHolder.alpha = 1f val rotateRight = AnimationUtils.loadAnimation(context, R.anim.rotate_right) - exoFfwd.startAnimation(rotateRight) + playerFfwd.startAnimation(rotateRight) val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right) goRight.setAnimationListener(object : Animation.AnimationListener { @@ -1535,12 +1540,12 @@ open class FullScreenPlayer : AbstractPlayerFragment() { showSubtitleOffsetDialog() } - exoRew.setOnClickListener { + playerRew.setOnClickListener { autoHide() rewind() } - exoFfwd.setOnClickListener { + playerFfwd.setOnClickListener { autoHide() fastForward() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index 89c6f73b..70e892b5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -168,15 +168,12 @@ interface Track { **/ val id: String? val label: String? - - // val isCurrentlyPlaying: Boolean val language: String? } data class VideoTrack( override val id: String?, override val label: String?, -// override val isCurrentlyPlaying: Boolean, override val language: String?, val width: Int?, val height: Int?, @@ -185,15 +182,24 @@ data class VideoTrack( data class AudioTrack( override val id: String?, override val label: String?, -// override val isCurrentlyPlaying: Boolean, override val language: String?, ) : Track +data class TextTrack( + override val id: String?, + override val label: String?, + override val language: String?, + val mimeType: String?, +) : Track + + data class CurrentTracks( val currentVideoTrack: VideoTrack?, val currentAudioTrack: AudioTrack?, + val currentTextTracks: List, val allVideoTracks: List, val allAudioTracks: List, + val allTextTracks: List, ) class InvalidFileException(msg: String) : Exception(msg) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/NonFinalTextRenderer.java b/app/src/main/java/com/lagradost/cloudstream3/ui/player/NonFinalTextRenderer.java deleted file mode 100644 index 232440cc..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/NonFinalTextRenderer.java +++ /dev/null @@ -1,457 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.lagradost.cloudstream3.ui.player; - -import static androidx.media3.common.text.Cue.DIMEN_UNSET; -import static androidx.media3.common.text.Cue.LINE_TYPE_NUMBER; -import static androidx.media3.common.util.Assertions.checkNotNull; -import static androidx.media3.common.util.Assertions.checkState; -import static java.lang.annotation.ElementType.TYPE_USE; - -import android.os.Handler; -import android.os.Handler.Callback; -import android.os.Looper; -import android.os.Message; - -import androidx.annotation.IntDef; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.OptIn; -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.text.Cue; -import androidx.media3.common.text.CueGroup; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.util.Log; -import androidx.media3.common.util.UnstableApi; -import androidx.media3.common.util.Util; -import androidx.media3.exoplayer.BaseRenderer; -import androidx.media3.exoplayer.FormatHolder; -import androidx.media3.exoplayer.RendererCapabilities; -import androidx.media3.exoplayer.source.SampleStream; -import androidx.media3.exoplayer.text.SubtitleDecoderFactory; -import androidx.media3.exoplayer.text.TextOutput; -import androidx.media3.extractor.text.Subtitle; -import androidx.media3.extractor.text.SubtitleDecoder; -import androidx.media3.extractor.text.SubtitleDecoderException; -import androidx.media3.extractor.text.SubtitleInputBuffer; -import androidx.media3.extractor.text.SubtitleOutputBuffer; - -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; -// DO NOT CONVERT TO KOTLIN AUTOMATICALLY, IT FUCKS UP AND DOES NOT DISPLAY SUBS FOR SOME REASON -// IF YOU CHANGE THE CODE MAKE SURE YOU GET THE CUES CORRECT! - -/** - * A renderer for text. - * - *

{@link Subtitle}s are decoded from sample data using {@link SubtitleDecoder} instances - * obtained from a {@link SubtitleDecoderFactory}. The actual rendering of the subtitle {@link Cue}s - * is delegated to a {@link TextOutput}. - */ -@OptIn(markerClass = UnstableApi.class) -public class NonFinalTextRenderer extends BaseRenderer implements Callback { - - private static final String TAG = "TextRenderer"; - - /** - * @param trackType The track type that the renderer handles. One of the {@link C} {@code - * TRACK_TYPE_*} constants. - * @param outputHandler todo description - */ - public NonFinalTextRenderer(int trackType, @Nullable Handler outputHandler) { - super(trackType); - this.outputHandler = outputHandler; - } - - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - REPLACEMENT_STATE_NONE, - REPLACEMENT_STATE_SIGNAL_END_OF_STREAM, - REPLACEMENT_STATE_WAIT_END_OF_STREAM - }) - private @interface ReplacementState { - } - - /** - * The decoder does not need to be replaced. - */ - private static final int REPLACEMENT_STATE_NONE = 0; - /** - * The decoder needs to be replaced, but we haven't yet signaled an end of stream to the existing - * decoder. We need to do so in order to ensure that it outputs any remaining buffers before we - * release it. - */ - private static final int REPLACEMENT_STATE_SIGNAL_END_OF_STREAM = 1; - /** - * The decoder needs to be replaced, and we've signaled an end of stream to the existing decoder. - * We're waiting for the decoder to output an end of stream signal to indicate that it has output - * any remaining buffers before we release it. - */ - private static final int REPLACEMENT_STATE_WAIT_END_OF_STREAM = 2; - - private static final int MSG_UPDATE_OUTPUT = 0; - - @Nullable - private final Handler outputHandler; - private TextOutput output = null; - private SubtitleDecoderFactory decoderFactory = null; - private FormatHolder formatHolder = null; - - private boolean inputStreamEnded; - private boolean outputStreamEnded; - private boolean waitingForKeyFrame; - private @ReplacementState int decoderReplacementState; - @Nullable - private Format streamFormat; - @Nullable - private SubtitleDecoder decoder; - @Nullable - private SubtitleInputBuffer nextInputBuffer; - @Nullable - private SubtitleOutputBuffer subtitle; - @Nullable - private SubtitleOutputBuffer nextSubtitle; - private int nextSubtitleEventIndex; - private long finalStreamEndPositionUs; - - /** - * @param output The output. - * @param outputLooper The looper associated with the thread on which the output should be called. - * If the output makes use of standard Android UI components, then this should normally be the - * looper associated with the application's main thread, which can be obtained using {@link - * android.app.Activity#getMainLooper()}. Null may be passed if the output should be called - * directly on the player's internal rendering thread. - */ - public NonFinalTextRenderer(TextOutput output, @Nullable Looper outputLooper) { - this(output, outputLooper, SubtitleDecoderFactory.DEFAULT); - } - - /** - * @param output The output. - * @param outputLooper The looper associated with the thread on which the output should be called. - * If the output makes use of standard Android UI components, then this should normally be the - * looper associated with the application's main thread, which can be obtained using {@link - * android.app.Activity#getMainLooper()}. Null may be passed if the output should be called - * directly on the player's internal rendering thread. - * @param decoderFactory A factory from which to obtain {@link SubtitleDecoder} instances. - */ - public NonFinalTextRenderer( - TextOutput output, @Nullable Looper outputLooper, SubtitleDecoderFactory decoderFactory) { - super(C.TRACK_TYPE_TEXT); - this.output = checkNotNull(output); - this.outputHandler = - outputLooper == null ? null : Util.createHandler(outputLooper, /* callback= */ this); - this.decoderFactory = decoderFactory; - formatHolder = new FormatHolder(); - finalStreamEndPositionUs = C.TIME_UNSET; - } - - @NonNull - @Override - public String getName() { - return TAG; - } - - @Override - public @Capabilities int supportsFormat(@NonNull Format format) { - if (decoderFactory.supportsFormat(format)) { - return RendererCapabilities.create( - format.cryptoType == C.CRYPTO_TYPE_NONE ? C.FORMAT_HANDLED : C.FORMAT_UNSUPPORTED_DRM); - } else if (MimeTypes.isText(format.sampleMimeType)) { - return RendererCapabilities.create(C.FORMAT_UNSUPPORTED_SUBTYPE); - } else { - return RendererCapabilities.create(C.FORMAT_UNSUPPORTED_TYPE); - } - } - - /** - * Sets the position at which to stop rendering the current stream. - * - *

Must be called after {@link #setCurrentStreamFinal()}. - * - * @param streamEndPositionUs The position to stop rendering at or {@link C#LENGTH_UNSET} to - * render until the end of the current stream. - */ - // TODO(internal b/181312195): Remove this when it's no longer needed once subtitles are decoded - // on the loading side of SampleQueue. - public void setFinalStreamEndPositionUs(long streamEndPositionUs) { - checkState(isCurrentStreamFinal()); - this.finalStreamEndPositionUs = streamEndPositionUs; - } - - @Override - protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) { - streamFormat = formats[0]; - if (decoder != null) { - decoderReplacementState = REPLACEMENT_STATE_SIGNAL_END_OF_STREAM; - } else { - initDecoder(); - } - } - - @Override - protected void onPositionReset(long positionUs, boolean joining) { - clearOutput(); - inputStreamEnded = false; - outputStreamEnded = false; - finalStreamEndPositionUs = C.TIME_UNSET; - if (decoderReplacementState != REPLACEMENT_STATE_NONE) { - replaceDecoder(); - } else { - releaseBuffers(); - checkNotNull(decoder).flush(); - } - } - - @Override - public void render(long positionUs, long elapsedRealtimeUs) { - if (isCurrentStreamFinal() - && finalStreamEndPositionUs != C.TIME_UNSET - && positionUs >= finalStreamEndPositionUs) { - releaseBuffers(); - outputStreamEnded = true; - } - - if (outputStreamEnded) { - return; - } - - if (nextSubtitle == null) { - checkNotNull(decoder).setPositionUs(positionUs); - try { - nextSubtitle = checkNotNull(decoder).dequeueOutputBuffer(); - } catch (SubtitleDecoderException e) { - handleDecoderError(e); - return; - } - } - - if (getState() != STATE_STARTED) { - return; - } - - boolean textRendererNeedsUpdate = false; - if (subtitle != null) { - // We're iterating through the events in a subtitle. Set textRendererNeedsUpdate if we - // advance to the next event. - long subtitleNextEventTimeUs = getNextEventTime(); - while (subtitleNextEventTimeUs <= positionUs) { - nextSubtitleEventIndex++; - subtitleNextEventTimeUs = getNextEventTime(); - textRendererNeedsUpdate = true; - } - } - if (nextSubtitle != null) { - SubtitleOutputBuffer nextSubtitle = this.nextSubtitle; - if (nextSubtitle.isEndOfStream()) { - if (!textRendererNeedsUpdate && getNextEventTime() == Long.MAX_VALUE) { - if (decoderReplacementState == REPLACEMENT_STATE_WAIT_END_OF_STREAM) { - replaceDecoder(); - } else { - releaseBuffers(); - outputStreamEnded = true; - } - } - } else if (nextSubtitle.timeUs <= positionUs) { - // Advance to the next subtitle. Sync the next event index and trigger an update. - if (subtitle != null) { - subtitle.release(); - } - nextSubtitleEventIndex = nextSubtitle.getNextEventTimeIndex(positionUs); - subtitle = nextSubtitle; - this.nextSubtitle = null; - textRendererNeedsUpdate = true; - } - } - - if (textRendererNeedsUpdate) { - // If textRendererNeedsUpdate then subtitle must be non-null. - checkNotNull(subtitle); - // textRendererNeedsUpdate is set and we're playing. Update the renderer. - updateOutput(subtitle.getCues(positionUs)); - } - - if (decoderReplacementState == REPLACEMENT_STATE_WAIT_END_OF_STREAM) { - return; - } - - try { - while (!inputStreamEnded) { - @Nullable SubtitleInputBuffer nextInputBuffer = this.nextInputBuffer; - if (nextInputBuffer == null) { - nextInputBuffer = checkNotNull(decoder).dequeueInputBuffer(); - if (nextInputBuffer == null) { - return; - } - this.nextInputBuffer = nextInputBuffer; - } - if (decoderReplacementState == REPLACEMENT_STATE_SIGNAL_END_OF_STREAM) { - nextInputBuffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); - checkNotNull(decoder).queueInputBuffer(nextInputBuffer); - this.nextInputBuffer = null; - decoderReplacementState = REPLACEMENT_STATE_WAIT_END_OF_STREAM; - return; - } - // Try and read the next subtitle from the source. - @SampleStream.ReadDataResult int result = readSource(formatHolder, nextInputBuffer, /* readFlags= */ 0); - if (result == C.RESULT_BUFFER_READ) { - if (nextInputBuffer.isEndOfStream()) { - inputStreamEnded = true; - waitingForKeyFrame = false; - } else { - @Nullable Format format = formatHolder.format; - if (format == null) { - // We haven't received a format yet. - return; - } - nextInputBuffer.subsampleOffsetUs = format.subsampleOffsetUs; - nextInputBuffer.flip(); - waitingForKeyFrame &= !nextInputBuffer.isKeyFrame(); - } - if (!waitingForKeyFrame) { - checkNotNull(decoder).queueInputBuffer(nextInputBuffer); - this.nextInputBuffer = null; - } - } else if (result == C.RESULT_NOTHING_READ) { - return; - } - } - } catch (SubtitleDecoderException e) { - handleDecoderError(e); - } - } - - @Override - protected void onDisabled() { - streamFormat = null; - finalStreamEndPositionUs = C.TIME_UNSET; - clearOutput(); - releaseDecoder(); - } - - @Override - public boolean isEnded() { - return outputStreamEnded; - } - - @Override - public boolean isReady() { - // Don't block playback whilst subtitles are loading. - // Note: To change this behavior, it will be necessary to consider [Internal: b/12949941]. - return true; - } - - private void releaseBuffers() { - nextInputBuffer = null; - nextSubtitleEventIndex = C.INDEX_UNSET; - if (subtitle != null) { - subtitle.release(); - subtitle = null; - } - if (nextSubtitle != null) { - nextSubtitle.release(); - nextSubtitle = null; - } - } - - private void releaseDecoder() { - releaseBuffers(); - checkNotNull(decoder).release(); - decoder = null; - decoderReplacementState = REPLACEMENT_STATE_NONE; - } - - private void initDecoder() { - waitingForKeyFrame = true; - decoder = decoderFactory.createDecoder(checkNotNull(streamFormat)); - } - - private void replaceDecoder() { - releaseDecoder(); - initDecoder(); - } - - private long getNextEventTime() { - if (nextSubtitleEventIndex == C.INDEX_UNSET) { - return Long.MAX_VALUE; - } - checkNotNull(subtitle); - return nextSubtitleEventIndex >= subtitle.getEventTimeCount() - ? Long.MAX_VALUE - : subtitle.getEventTime(nextSubtitleEventIndex); - } - - private void updateOutput(List cues) { - if (outputHandler != null) { - outputHandler.obtainMessage(MSG_UPDATE_OUTPUT, cues).sendToTarget(); - } else { - invokeUpdateOutputInternal(cues); - } - } - - private void clearOutput() { - updateOutput(Collections.emptyList()); - } - - @SuppressWarnings("unchecked") - @Override - public boolean handleMessage(Message msg) { - if (msg.what == MSG_UPDATE_OUTPUT) { - invokeUpdateOutputInternal((List) msg.obj); - return true; - } - throw new IllegalStateException(); - } - - private void invokeUpdateOutputInternal(List cues) { - // See https://github.com/google/ExoPlayer/issues/7934 - // SubripDecoder texts tend to be DIMEN_UNSET which pushes up the - // subs unlike WEBVTT which creates an inconsistency - - List fixedCues = cues.stream().map( - cue -> { - Cue.Builder builder = cue.buildUpon(); - - if (cue.line == DIMEN_UNSET) - builder.setLine(-1f, LINE_TYPE_NUMBER); - - return builder.setSize(DIMEN_UNSET).build(); - } - ).collect(Collectors.toList()); - - output.onCues(new CueGroup(fixedCues, 0L)); - } - - /** - * Called when {@link #decoder} throws an exception, so it can be logged and playback can - * continue. - * - *

Logs {@code e} and resets state to allow decoding the next sample. - */ - private void handleDecoderError(SubtitleDecoderException e) { - Log.e(TAG, "Subtitle decoding failed. streamFormat=" + streamFormat, e); - clearOutput(); - replaceDecoder(); - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt index 02a7ee03..82d88b93 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt @@ -47,6 +47,19 @@ data class SubtitleData( return if (origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) url else "$url|$name" } + + /** + * Gets the URL, but tries to fix it if it is malformed. + */ + fun getFixedUrl(): String { + // Some extensions fail to include the protocol, this helps with that. + val fixedSubUrl = if (this.url.startsWith("//")) { + "https:${this.url}" + } else { + this.url + } + return fixedSubUrl + } } @OptIn(UnstableApi::class) diff --git a/app/src/main/res/layout/player_custom_layout.xml b/app/src/main/res/layout/player_custom_layout.xml index be97b978..3d78f0a6 100644 --- a/app/src/main/res/layout/player_custom_layout.xml +++ b/app/src/main/res/layout/player_custom_layout.xml @@ -338,13 +338,13 @@ tools:text="10" />