mirror of
https://github.com/recloudstream/cloudstream.git
synced 2024-08-15 01:53:11 +00:00
Update media dependency
This commit is contained in:
parent
7936ccf5d3
commit
458ea29ec9
12 changed files with 235 additions and 683 deletions
|
@ -191,15 +191,15 @@ dependencies {
|
||||||
implementation("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
|
implementation("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
|
||||||
|
|
||||||
// Media 3 (ExoPlayer)
|
// Media 3 (ExoPlayer)
|
||||||
implementation("androidx.media3:media3-ui:1.1.1")
|
implementation("androidx.media3:media3-ui:1.4.0")
|
||||||
implementation("androidx.media3:media3-cast:1.1.1")
|
implementation("androidx.media3:media3-cast:1.4.0")
|
||||||
implementation("androidx.media3:media3-common:1.1.1")
|
implementation("androidx.media3:media3-common:1.4.0")
|
||||||
implementation("androidx.media3:media3-session:1.1.1")
|
implementation("androidx.media3:media3-session:1.4.0")
|
||||||
implementation("androidx.media3:media3-exoplayer:1.1.1")
|
implementation("androidx.media3:media3-exoplayer:1.4.0")
|
||||||
implementation("com.google.android.mediahome:video:1.0.0")
|
implementation("com.google.android.mediahome:video:1.0.0")
|
||||||
implementation("androidx.media3:media3-exoplayer-hls:1.1.1")
|
implementation("androidx.media3:media3-exoplayer-hls:1.4.0")
|
||||||
implementation("androidx.media3:media3-exoplayer-dash:1.1.1")
|
implementation("androidx.media3:media3-exoplayer-dash:1.4.0")
|
||||||
implementation("androidx.media3:media3-datasource-okhttp:1.1.1")
|
implementation("androidx.media3:media3-datasource-okhttp:1.4.0")
|
||||||
|
|
||||||
// PlayBack
|
// PlayBack
|
||||||
implementation("com.jaredrummler:colorpicker:1.1.0") // Subtitle Color Picker
|
implementation("com.jaredrummler:colorpicker:1.1.0") // Subtitle Color Picker
|
||||||
|
|
|
@ -25,7 +25,6 @@ import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.session.MediaSession
|
import androidx.media3.session.MediaSession
|
||||||
import androidx.media3.ui.*
|
import androidx.media3.ui.*
|
||||||
|
|
|
@ -35,7 +35,6 @@ import androidx.media3.datasource.cache.SimpleCache
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import androidx.media3.exoplayer.DefaultLoadControl
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.SeekParameters
|
import androidx.media3.exoplayer.SeekParameters
|
||||||
import androidx.media3.exoplayer.dash.DashMediaSource
|
import androidx.media3.exoplayer.dash.DashMediaSource
|
||||||
|
@ -88,6 +87,7 @@ const val toleranceBeforeUs = 300_000L
|
||||||
* seek position, in microseconds. Must be non-negative.
|
* seek position, in microseconds. Must be non-negative.
|
||||||
*/
|
*/
|
||||||
const val toleranceAfterUs = 300_000L
|
const val toleranceAfterUs = 300_000L
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
class CS3IPlayer : IPlayer {
|
class CS3IPlayer : IPlayer {
|
||||||
private var isPlaying = false
|
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.
|
* 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
|
* Boolean = if it's active
|
||||||
* */
|
* */
|
||||||
private var playerSelectedSubtitleTracks = listOf<Pair<String, Boolean>>()
|
private var playerSelectedSubtitleTracks = listOf<Pair<String, Boolean>>()
|
||||||
|
@ -188,6 +188,10 @@ class CS3IPlayer : IPlayer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun String.stripTrackId(): String {
|
||||||
|
return this.replace(Regex("""^\d+:"""), "")
|
||||||
|
}
|
||||||
|
|
||||||
fun initSubtitles(subView: SubtitleView?, subHolder: FrameLayout?, style: SaveCaptionStyle?) {
|
fun initSubtitles(subView: SubtitleView?, subHolder: FrameLayout?, style: SaveCaptionStyle?) {
|
||||||
subtitleHelper.initSubtitles(subView, subHolder, style)
|
subtitleHelper.initSubtitles(subView, subHolder, style)
|
||||||
}
|
}
|
||||||
|
@ -272,7 +276,11 @@ class CS3IPlayer : IPlayer {
|
||||||
return this.firstNotNullOfOrNull { group ->
|
return this.firstNotNullOfOrNull { group ->
|
||||||
(0 until group.mediaTrackGroup.length).map {
|
(0 until group.mediaTrackGroup.length).map {
|
||||||
group.getTrackFormat(it) to it
|
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 }
|
?.let { group.mediaTrackGroup to it.second }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -355,21 +363,28 @@ class CS3IPlayer : IPlayer {
|
||||||
|
|
||||||
private fun Format.toAudioTrack(): AudioTrack {
|
private fun Format.toAudioTrack(): AudioTrack {
|
||||||
return AudioTrack(
|
return AudioTrack(
|
||||||
this.id,
|
this.id?.stripTrackId(),
|
||||||
this.label,
|
this.label,
|
||||||
// isPlaying,
|
|
||||||
this.language
|
this.language
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Format.toSubtitleTrack(): TextTrack {
|
||||||
|
return TextTrack(
|
||||||
|
this.id?.stripTrackId(),
|
||||||
|
this.label,
|
||||||
|
this.language,
|
||||||
|
this.sampleMimeType
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun Format.toVideoTrack(): VideoTrack {
|
private fun Format.toVideoTrack(): VideoTrack {
|
||||||
return VideoTrack(
|
return VideoTrack(
|
||||||
this.id,
|
this.id?.stripTrackId(),
|
||||||
this.label,
|
this.label,
|
||||||
// isPlaying,
|
|
||||||
this.language,
|
this.language,
|
||||||
this.width,
|
this.width,
|
||||||
this.height
|
this.height,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -381,11 +396,20 @@ class CS3IPlayer : IPlayer {
|
||||||
val audioTracks = allTracks.filter { it.type == TRACK_TYPE_AUDIO }.getFormats()
|
val audioTracks = allTracks.filter { it.type == TRACK_TYPE_AUDIO }.getFormats()
|
||||||
.map { it.first.toAudioTrack() }
|
.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(
|
return CurrentTracks(
|
||||||
exoPlayer?.videoFormat?.toVideoTrack(),
|
exoPlayer?.videoFormat?.toVideoTrack(),
|
||||||
exoPlayer?.audioFormat?.toAudioTrack(),
|
exoPlayer?.audioFormat?.toAudioTrack(),
|
||||||
|
currentTextTracks,
|
||||||
videoTracks,
|
videoTracks,
|
||||||
audioTracks
|
audioTracks,
|
||||||
|
textTracks
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -455,7 +479,8 @@ class CS3IPlayer : IPlayer {
|
||||||
|
|
||||||
override fun setSubtitleOffset(offset: Long) {
|
override fun setSubtitleOffset(offset: Long) {
|
||||||
currentSubtitleOffset = offset
|
currentSubtitleOffset = offset
|
||||||
currentTextRenderer?.setRenderOffsetMs(offset)
|
CustomDecoder.subtitleOffset = offset
|
||||||
|
currentTextRenderer?.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getSubtitleOffset(): Long {
|
override fun getSubtitleOffset(): Long {
|
||||||
|
@ -503,7 +528,6 @@ class CS3IPlayer : IPlayer {
|
||||||
release()
|
release()
|
||||||
}
|
}
|
||||||
//simpleCache?.release()
|
//simpleCache?.release()
|
||||||
currentTextRenderer = null
|
|
||||||
|
|
||||||
exoPlayer = null
|
exoPlayer = null
|
||||||
//simpleCache = null
|
//simpleCache = null
|
||||||
|
@ -601,7 +625,10 @@ class CS3IPlayer : IPlayer {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Context.createOfflineSource(): DataSource.Factory {
|
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? {
|
private fun getCache(context: Context, cacheSize: Long): SimpleCache? {
|
||||||
|
@ -646,7 +673,8 @@ class CS3IPlayer : IPlayer {
|
||||||
return trackSelector
|
return trackSelector
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentTextRenderer: CustomTextRenderer? = null
|
private var currentSubtitleDecoder: CustomSubtitleDecoderFactory? = null
|
||||||
|
private var currentTextRenderer: TextRenderer? = null
|
||||||
|
|
||||||
private fun buildExoPlayer(
|
private fun buildExoPlayer(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
@ -672,8 +700,8 @@ class CS3IPlayer : IPlayer {
|
||||||
.setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput ->
|
.setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput ->
|
||||||
DefaultRenderersFactory(context).apply {
|
DefaultRenderersFactory(context).apply {
|
||||||
setEnableDecoderFallback(true)
|
setEnableDecoderFallback(true)
|
||||||
// Enable Ffmpeg extension
|
// Enable Ffmpeg extension.
|
||||||
setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON)
|
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER)
|
||||||
}.createRenderers(
|
}.createRenderers(
|
||||||
eventHandler,
|
eventHandler,
|
||||||
videoRendererEventListener,
|
videoRendererEventListener,
|
||||||
|
@ -682,14 +710,23 @@ class CS3IPlayer : IPlayer {
|
||||||
metadataRendererOutput
|
metadataRendererOutput
|
||||||
).map {
|
).map {
|
||||||
if (it is TextRenderer) {
|
if (it is TextRenderer) {
|
||||||
val currentTextRenderer = CustomTextRenderer(
|
CustomDecoder.subtitleOffset = subtitleOffset
|
||||||
subtitleOffset,
|
val decoder = CustomSubtitleDecoderFactory()
|
||||||
|
val currentTextRenderer = TextRenderer(
|
||||||
textRendererOutput,
|
textRendererOutput,
|
||||||
eventHandler.looper,
|
eventHandler.looper,
|
||||||
CustomSubtitleDecoderFactory()
|
decoder
|
||||||
).also { renderer -> this.currentTextRenderer = renderer }
|
).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
|
currentTextRenderer
|
||||||
} else it
|
} else
|
||||||
|
it
|
||||||
}.toTypedArray()
|
}.toTypedArray()
|
||||||
}
|
}
|
||||||
.setTrackSelector(
|
.setTrackSelector(
|
||||||
|
@ -952,7 +989,8 @@ class CS3IPlayer : IPlayer {
|
||||||
playerSelectedSubtitleTracks =
|
playerSelectedSubtitleTracks =
|
||||||
textTracks.map { group ->
|
textTracks.map { group ->
|
||||||
group.getFormats().mapNotNull { (format, _) ->
|
group.getFormats().mapNotNull { (format, _) ->
|
||||||
(format.id ?: return@mapNotNull null) to group.isSelected
|
(format.id?.stripTrackId()
|
||||||
|
?: return@mapNotNull null) to group.isSelected
|
||||||
}
|
}
|
||||||
}.flatten()
|
}.flatten()
|
||||||
|
|
||||||
|
@ -970,7 +1008,7 @@ class CS3IPlayer : IPlayer {
|
||||||
fromTwoLettersToLanguage(format.language!!)
|
fromTwoLettersToLanguage(format.language!!)
|
||||||
?: format.language!!,
|
?: format.language!!,
|
||||||
// See setPreferredTextLanguage
|
// See setPreferredTextLanguage
|
||||||
format.id!!,
|
format.id!!.stripTrackId(),
|
||||||
SubtitleOrigin.EMBEDDED_IN_VIDEO,
|
SubtitleOrigin.EMBEDDED_IN_VIDEO,
|
||||||
format.sampleMimeType ?: MimeTypes.APPLICATION_SUBRIP,
|
format.sampleMimeType ?: MimeTypes.APPLICATION_SUBRIP,
|
||||||
emptyMap(),
|
emptyMap(),
|
||||||
|
@ -1210,7 +1248,7 @@ class CS3IPlayer : IPlayer {
|
||||||
): Pair<List<SingleSampleMediaSource>, List<SubtitleData>> {
|
): Pair<List<SingleSampleMediaSource>, List<SubtitleData>> {
|
||||||
val activeSubtitles = ArrayList<SubtitleData>()
|
val activeSubtitles = ArrayList<SubtitleData>()
|
||||||
val subSources = subHelper.getAllSubtitles().mapNotNull { sub ->
|
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)
|
.setMimeType(sub.mimeType)
|
||||||
.setLanguage("_${sub.name}")
|
.setLanguage("_${sub.name}")
|
||||||
.setId(sub.getId())
|
.setId(sub.getId())
|
||||||
|
|
|
@ -3,37 +3,36 @@ package com.lagradost.cloudstream3.ui.player
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import androidx.media3.common.Format
|
import androidx.media3.common.Format
|
||||||
import androidx.media3.common.MimeTypes
|
import androidx.media3.common.MimeTypes
|
||||||
|
import androidx.media3.common.util.Consumer
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.text.ExoplayerCuesDecoder
|
|
||||||
import androidx.media3.exoplayer.text.SubtitleDecoderFactory
|
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.SubtitleDecoder
|
||||||
import androidx.media3.extractor.text.SubtitleInputBuffer
|
import androidx.media3.extractor.text.SubtitleParser
|
||||||
import androidx.media3.extractor.text.SubtitleOutputBuffer
|
import androidx.media3.extractor.text.dvb.DvbParser
|
||||||
import androidx.media3.extractor.text.cea.Cea608Decoder
|
import androidx.media3.extractor.text.pgs.PgsParser
|
||||||
import androidx.media3.extractor.text.cea.Cea708Decoder
|
import androidx.media3.extractor.text.ssa.SsaParser
|
||||||
import androidx.media3.extractor.text.dvb.DvbDecoder
|
import androidx.media3.extractor.text.subrip.SubripParser
|
||||||
import androidx.media3.extractor.text.pgs.PgsDecoder
|
import androidx.media3.extractor.text.ttml.TtmlParser
|
||||||
import androidx.media3.extractor.text.ssa.SsaDecoder
|
import androidx.media3.extractor.text.tx3g.Tx3gParser
|
||||||
import androidx.media3.extractor.text.subrip.SubripDecoder
|
import androidx.media3.extractor.text.webvtt.Mp4WebvttParser
|
||||||
import androidx.media3.extractor.text.ttml.TtmlDecoder
|
import androidx.media3.extractor.text.webvtt.WebvttParser
|
||||||
import androidx.media3.extractor.text.tx3g.Tx3gDecoder
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.media3.extractor.text.webvtt.Mp4WebvttDecoder
|
|
||||||
import androidx.media3.extractor.text.webvtt.WebvttDecoder
|
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import org.mozilla.universalchardet.UniversalDetector
|
import org.mozilla.universalchardet.UniversalDetector
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param fallbackFormat used to create a decoder based on mimetype if the subtitle string is not
|
* @param fallbackFormat used to create a decoder based on mimetype if the subtitle string is not
|
||||||
* enough to identify the subtitle format.
|
* enough to identify the subtitle format.
|
||||||
**/
|
**/
|
||||||
@OptIn(UnstableApi::class)
|
@UnstableApi
|
||||||
class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder {
|
class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
|
||||||
companion object {
|
companion object {
|
||||||
fun updateForcedEncoding(context: Context) {
|
fun updateForcedEncoding(context: Context) {
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(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 UTF_8 = "UTF-8"
|
||||||
private const val TAG = "CustomDecoder"
|
private const val TAG = "CustomDecoder"
|
||||||
private var overrideEncoding: String? = null
|
private var overrideEncoding: String? = null
|
||||||
|
@ -85,16 +86,7 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var realDecoder: SubtitleDecoder? = null
|
private var realDecoder: SubtitleParser? = null
|
||||||
|
|
||||||
override fun getName(): String {
|
|
||||||
return realDecoder?.name ?: this::javaClass.name
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun dequeueInputBuffer(): SubtitleInputBuffer {
|
|
||||||
Log.i(TAG, "dequeueInputBuffer")
|
|
||||||
return realDecoder?.dequeueInputBuffer() ?: SubtitleInputBuffer()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getStr(byteArray: ByteArray): Pair<String, Charset> {
|
private fun getStr(byteArray: ByteArray): Pair<String, Charset> {
|
||||||
val encoding = try {
|
val encoding = try {
|
||||||
|
@ -128,101 +120,76 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getStr(input: SubtitleInputBuffer): String? {
|
private fun getSubtitleParser(data: String): SubtitleParser? {
|
||||||
try {
|
// this way we read the subtitle file and decide what decoder to use instead of relying fully on mimetype
|
||||||
val data = input.data ?: return null
|
//https://github.com/LagradOst/CloudStream-2/blob/ddd774ee66810137ff7bd65dae70bcf3ba2d2489/CloudStreamForms/CloudStreamForms/Script/MainChrome.cs#L388
|
||||||
data.position(0)
|
val subtitleParser = when {
|
||||||
val fullDataArr = ByteArray(data.remaining())
|
// "WEBVTT" can be hidden behind invisible characters not filtered by trim
|
||||||
data.get(fullDataArr)
|
data.substring(0, 10).contains("WEBVTT", ignoreCase = true) -> WebvttParser()
|
||||||
return trimStr(getStr(fullDataArr).first)
|
data.startsWith("<?xml version=\"", ignoreCase = true) -> TtmlParser()
|
||||||
} catch (e: Exception) {
|
(data.startsWith(
|
||||||
Log.e(TAG, "Failed to parse text returning plain data")
|
"[Script Info]",
|
||||||
logError(e)
|
ignoreCase = true
|
||||||
return null
|
) || data.startsWith(
|
||||||
}
|
"Title:",
|
||||||
}
|
ignoreCase = true
|
||||||
|
)) -> SsaParser(fallbackFormat?.initializationData)
|
||||||
|
|
||||||
private fun SubtitleInputBuffer.setSubtitleText(text: String) {
|
data.startsWith("1", ignoreCase = true) -> SubripParser()
|
||||||
// println("Set subtitle text -----\n$text\n-----")
|
fallbackFormat != null -> {
|
||||||
this.data = ByteBuffer.wrap(text.toByteArray(charset(UTF_8)))
|
when (val mimeType = fallbackFormat.sampleMimeType) {
|
||||||
}
|
MimeTypes.TEXT_VTT -> WebvttParser()
|
||||||
|
MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData)
|
||||||
override fun queueInputBuffer(inputBuffer: SubtitleInputBuffer) {
|
MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser()
|
||||||
Log.i(TAG, "queueInputBuffer")
|
MimeTypes.APPLICATION_TTML -> TtmlParser()
|
||||||
try {
|
MimeTypes.APPLICATION_SUBRIP -> SubripParser()
|
||||||
val inputString = getStr(inputBuffer)
|
MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData)
|
||||||
if (realDecoder == null && !inputString.isNullOrBlank()) {
|
// These decoders are not converted to parsers yet
|
||||||
var str: String = inputString
|
// TODO
|
||||||
// this way we read the subtitle file and decide what decoder to use instead of relying fully on mimetype
|
// MimeTypes.APPLICATION_CEA608, MimeTypes.APPLICATION_MP4CEA608 -> Cea608Decoder(
|
||||||
Log.i(TAG, "Got data from queueInputBuffer")
|
// mimeType,
|
||||||
//https://github.com/LagradOst/CloudStream-2/blob/ddd774ee66810137ff7bd65dae70bcf3ba2d2489/CloudStreamForms/CloudStreamForms/Script/MainChrome.cs#L388
|
// fallbackFormat.accessibilityChannel,
|
||||||
realDecoder = when {
|
// Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS
|
||||||
str.startsWith("WEBVTT", ignoreCase = true) -> WebvttDecoder()
|
// )
|
||||||
str.startsWith("<?xml version=\"", ignoreCase = true) -> TtmlDecoder()
|
// MimeTypes.APPLICATION_CEA708 -> Cea708Decoder(
|
||||||
(str.startsWith(
|
// fallbackFormat.accessibilityChannel,
|
||||||
"[Script Info]",
|
// fallbackFormat.initializationData
|
||||||
ignoreCase = true
|
// )
|
||||||
) || str.startsWith("Title:", ignoreCase = true)) -> SsaDecoder(fallbackFormat?.initializationData)
|
MimeTypes.APPLICATION_DVBSUBS -> DvbParser(fallbackFormat.initializationData)
|
||||||
str.startsWith("1", ignoreCase = true) -> SubripDecoder()
|
MimeTypes.APPLICATION_PGS -> PgsParser()
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
return subtitleParser
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parse(
|
||||||
|
data: ByteArray,
|
||||||
|
offset: Int,
|
||||||
|
length: Int,
|
||||||
|
outputOptions: SubtitleParser.OutputOptions,
|
||||||
|
output: Consumer<CuesWithTiming>
|
||||||
|
) {
|
||||||
|
val customOutput = Consumer<CuesWithTiming> { 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(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"Decoder selected: $realDecoder"
|
"Parser selected: $realDecoder"
|
||||||
)
|
)
|
||||||
realDecoder?.let { decoder ->
|
realDecoder?.let { decoder ->
|
||||||
decoder.dequeueInputBuffer()?.let { buff ->
|
if (decoder !is SsaParser) {
|
||||||
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 (regexSubtitlesToRemoveCaptions)
|
if (regexSubtitlesToRemoveCaptions)
|
||||||
captionRegex.forEach { rgx ->
|
captionRegex.forEach { rgx ->
|
||||||
str = str.replace(rgx, "\n")
|
str = str.replace(rgx, "\n")
|
||||||
|
@ -235,38 +202,31 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder {
|
||||||
str = str.uppercase()
|
str = str.uppercase()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inputBuffer.setSubtitleText(str)
|
|
||||||
}
|
}
|
||||||
|
val array = str.toByteArray()
|
||||||
realDecoder?.queueInputBuffer(inputBuffer)
|
realDecoder?.parse(
|
||||||
|
array,
|
||||||
|
minOf(array.size, offset),
|
||||||
|
minOf(array.size, length),
|
||||||
|
outputOptions,
|
||||||
|
customOutput
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError(e)
|
logError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun dequeueOutputBuffer(): SubtitleOutputBuffer? {
|
override fun getCueReplacementBehavior(): Int {
|
||||||
return realDecoder?.dequeueOutputBuffer()
|
return realDecoder?.cueReplacementBehavior ?: Format.CUE_REPLACEMENT_BEHAVIOR_REPLACE
|
||||||
}
|
|
||||||
|
|
||||||
override fun flush() {
|
|
||||||
realDecoder?.flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun release() {
|
|
||||||
realDecoder?.release()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setPositionUs(positionUs: Long) {
|
|
||||||
realDecoder?.setPositionUs(positionUs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** See https://github.com/google/ExoPlayer/blob/release-v2/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java */
|
/** See https://github.com/google/ExoPlayer/blob/release-v2/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java */
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
class CustomSubtitleDecoderFactory : SubtitleDecoderFactory {
|
class CustomSubtitleDecoderFactory : SubtitleDecoderFactory {
|
||||||
|
|
||||||
override fun supportsFormat(format: Format): Boolean {
|
override fun supportsFormat(format: Format): Boolean {
|
||||||
// return SubtitleDecoderFactory.DEFAULT.supportsFormat(format)
|
|
||||||
return listOf(
|
return listOf(
|
||||||
MimeTypes.TEXT_VTT,
|
MimeTypes.TEXT_VTT,
|
||||||
MimeTypes.TEXT_SSA,
|
MimeTypes.TEXT_SSA,
|
||||||
|
@ -283,7 +243,28 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory {
|
||||||
).contains(format.sampleMimeType)
|
).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 {
|
override fun createDecoder(format: Format): SubtitleDecoder {
|
||||||
return CustomDecoder(format)
|
val parser = CustomDecoder(format)
|
||||||
|
|
||||||
|
return DelegatingSubtitleDecoder(
|
||||||
|
parser::class.simpleName + "Decoder", parser
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -36,6 +36,7 @@ import androidx.core.view.isGone
|
||||||
import androidx.core.view.isInvisible
|
import androidx.core.view.isInvisible
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.widget.doOnTextChanged
|
import androidx.core.widget.doOnTextChanged
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
|
@ -296,8 +297,12 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun subtitlesChanged() {
|
override fun subtitlesChanged() {
|
||||||
playerBinding?.playerSubtitleOffsetBtt?.isGone =
|
val tracks = player.getVideoTracks()
|
||||||
player.getCurrentPreferredSubtitle() == null
|
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) {
|
private fun restoreOrientationWithSensor(activity: Activity) {
|
||||||
|
@ -569,7 +574,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
|
||||||
playerRewHolder.alpha = 1f
|
playerRewHolder.alpha = 1f
|
||||||
|
|
||||||
val rotateLeft = AnimationUtils.loadAnimation(context, R.anim.rotate_left)
|
val rotateLeft = AnimationUtils.loadAnimation(context, R.anim.rotate_left)
|
||||||
exoRew.startAnimation(rotateLeft)
|
playerRew.startAnimation(rotateLeft)
|
||||||
|
|
||||||
val goLeft = AnimationUtils.loadAnimation(context, R.anim.go_left)
|
val goLeft = AnimationUtils.loadAnimation(context, R.anim.go_left)
|
||||||
goLeft.setAnimationListener(object : Animation.AnimationListener {
|
goLeft.setAnimationListener(object : Animation.AnimationListener {
|
||||||
|
@ -602,7 +607,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
|
||||||
playerFfwdHolder.alpha = 1f
|
playerFfwdHolder.alpha = 1f
|
||||||
|
|
||||||
val rotateRight = AnimationUtils.loadAnimation(context, R.anim.rotate_right)
|
val rotateRight = AnimationUtils.loadAnimation(context, R.anim.rotate_right)
|
||||||
exoFfwd.startAnimation(rotateRight)
|
playerFfwd.startAnimation(rotateRight)
|
||||||
|
|
||||||
val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right)
|
val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right)
|
||||||
goRight.setAnimationListener(object : Animation.AnimationListener {
|
goRight.setAnimationListener(object : Animation.AnimationListener {
|
||||||
|
@ -1535,12 +1540,12 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
|
||||||
showSubtitleOffsetDialog()
|
showSubtitleOffsetDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
exoRew.setOnClickListener {
|
playerRew.setOnClickListener {
|
||||||
autoHide()
|
autoHide()
|
||||||
rewind()
|
rewind()
|
||||||
}
|
}
|
||||||
|
|
||||||
exoFfwd.setOnClickListener {
|
playerFfwd.setOnClickListener {
|
||||||
autoHide()
|
autoHide()
|
||||||
fastForward()
|
fastForward()
|
||||||
}
|
}
|
||||||
|
|
|
@ -168,15 +168,12 @@ interface Track {
|
||||||
**/
|
**/
|
||||||
val id: String?
|
val id: String?
|
||||||
val label: String?
|
val label: String?
|
||||||
|
|
||||||
// val isCurrentlyPlaying: Boolean
|
|
||||||
val language: String?
|
val language: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
data class VideoTrack(
|
data class VideoTrack(
|
||||||
override val id: String?,
|
override val id: String?,
|
||||||
override val label: String?,
|
override val label: String?,
|
||||||
// override val isCurrentlyPlaying: Boolean,
|
|
||||||
override val language: String?,
|
override val language: String?,
|
||||||
val width: Int?,
|
val width: Int?,
|
||||||
val height: Int?,
|
val height: Int?,
|
||||||
|
@ -185,15 +182,24 @@ data class VideoTrack(
|
||||||
data class AudioTrack(
|
data class AudioTrack(
|
||||||
override val id: String?,
|
override val id: String?,
|
||||||
override val label: String?,
|
override val label: String?,
|
||||||
// override val isCurrentlyPlaying: Boolean,
|
|
||||||
override val language: String?,
|
override val language: String?,
|
||||||
) : Track
|
) : Track
|
||||||
|
|
||||||
|
data class TextTrack(
|
||||||
|
override val id: String?,
|
||||||
|
override val label: String?,
|
||||||
|
override val language: String?,
|
||||||
|
val mimeType: String?,
|
||||||
|
) : Track
|
||||||
|
|
||||||
|
|
||||||
data class CurrentTracks(
|
data class CurrentTracks(
|
||||||
val currentVideoTrack: VideoTrack?,
|
val currentVideoTrack: VideoTrack?,
|
||||||
val currentAudioTrack: AudioTrack?,
|
val currentAudioTrack: AudioTrack?,
|
||||||
|
val currentTextTracks: List<TextTrack>,
|
||||||
val allVideoTracks: List<VideoTrack>,
|
val allVideoTracks: List<VideoTrack>,
|
||||||
val allAudioTracks: List<AudioTrack>,
|
val allAudioTracks: List<AudioTrack>,
|
||||||
|
val allTextTracks: List<TextTrack>,
|
||||||
)
|
)
|
||||||
|
|
||||||
class InvalidFileException(msg: String) : Exception(msg)
|
class InvalidFileException(msg: String) : Exception(msg)
|
||||||
|
|
|
@ -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.
|
|
||||||
*
|
|
||||||
* <p>{@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.
|
|
||||||
*
|
|
||||||
* <p>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<Cue> 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<Cue>) msg.obj);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
throw new IllegalStateException();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void invokeUpdateOutputInternal(List<Cue> 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<Cue> 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.
|
|
||||||
*
|
|
||||||
* <p>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();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -47,6 +47,19 @@ data class SubtitleData(
|
||||||
return if (origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) url
|
return if (origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) url
|
||||||
else "$url|$name"
|
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)
|
@OptIn(UnstableApi::class)
|
||||||
|
|
|
@ -338,13 +338,13 @@
|
||||||
tools:text="10" />
|
tools:text="10" />
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@id/exo_rew"
|
android:id="@+id/player_rew"
|
||||||
android:layout_width="70dp"
|
android:layout_width="70dp"
|
||||||
android:layout_height="70dp"
|
android:layout_height="70dp"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
|
|
||||||
android:background="@drawable/video_tap_button_skip"
|
android:background="@drawable/video_tap_button_skip"
|
||||||
android:nextFocusLeft="@id/exo_rew"
|
android:nextFocusLeft="@id/player_rew"
|
||||||
android:nextFocusUp="@id/player_go_back"
|
android:nextFocusUp="@id/player_go_back"
|
||||||
android:nextFocusDown="@id/player_lock"
|
android:nextFocusDown="@id/player_lock"
|
||||||
android:padding="10dp"
|
android:padding="10dp"
|
||||||
|
@ -371,9 +371,9 @@
|
||||||
android:layout_height="70dp"
|
android:layout_height="70dp"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:background="@drawable/video_tap_button"
|
android:background="@drawable/video_tap_button"
|
||||||
android:nextFocusLeft="@id/exo_rew"
|
android:nextFocusLeft="@id/player_rew"
|
||||||
|
|
||||||
android:nextFocusRight="@id/exo_ffwd"
|
android:nextFocusRight="@id/player_ffwd"
|
||||||
|
|
||||||
android:nextFocusUp="@id/player_go_back"
|
android:nextFocusUp="@id/player_go_back"
|
||||||
android:nextFocusDown="@id/player_lock"
|
android:nextFocusDown="@id/player_lock"
|
||||||
|
@ -406,13 +406,13 @@
|
||||||
tools:text="10" />
|
tools:text="10" />
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@id/exo_ffwd"
|
android:id="@+id/player_ffwd"
|
||||||
android:layout_width="70dp"
|
android:layout_width="70dp"
|
||||||
android:layout_height="70dp"
|
android:layout_height="70dp"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
|
|
||||||
android:background="@drawable/video_tap_button_skip"
|
android:background="@drawable/video_tap_button_skip"
|
||||||
android:nextFocusRight="@id/exo_rew"
|
android:nextFocusRight="@id/player_rew"
|
||||||
android:nextFocusUp="@id/player_go_back"
|
android:nextFocusUp="@id/player_go_back"
|
||||||
android:nextFocusDown="@id/player_lock"
|
android:nextFocusDown="@id/player_lock"
|
||||||
android:padding="10dp"
|
android:padding="10dp"
|
||||||
|
|
|
@ -442,13 +442,13 @@
|
||||||
tools:text="10" />
|
tools:text="10" />
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@id/exo_rew"
|
android:id="@id/player_rew"
|
||||||
android:layout_width="70dp"
|
android:layout_width="70dp"
|
||||||
android:layout_height="70dp"
|
android:layout_height="70dp"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
|
|
||||||
android:background="@drawable/video_tap_button_skip"
|
android:background="@drawable/video_tap_button_skip"
|
||||||
android:nextFocusLeft="@id/exo_rew"
|
android:nextFocusLeft="@id/player_rew"
|
||||||
android:nextFocusUp="@id/player_go_back"
|
android:nextFocusUp="@id/player_go_back"
|
||||||
android:nextFocusDown="@id/player_pause_play"
|
android:nextFocusDown="@id/player_pause_play"
|
||||||
android:padding="10dp"
|
android:padding="10dp"
|
||||||
|
@ -484,13 +484,13 @@
|
||||||
tools:text="10" />
|
tools:text="10" />
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@id/exo_ffwd"
|
android:id="@id/player_ffwd"
|
||||||
android:layout_width="70dp"
|
android:layout_width="70dp"
|
||||||
android:layout_height="70dp"
|
android:layout_height="70dp"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
|
|
||||||
android:background="@drawable/video_tap_button_skip"
|
android:background="@drawable/video_tap_button_skip"
|
||||||
android:nextFocusRight="@id/exo_rew"
|
android:nextFocusRight="@id/player_rew"
|
||||||
android:nextFocusUp="@id/player_go_back"
|
android:nextFocusUp="@id/player_go_back"
|
||||||
android:nextFocusDown="@id/player_pause_play"
|
android:nextFocusDown="@id/player_pause_play"
|
||||||
android:padding="10dp"
|
android:padding="10dp"
|
||||||
|
|
|
@ -326,13 +326,13 @@
|
||||||
tools:text="10" />
|
tools:text="10" />
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@id/exo_rew"
|
android:id="@id/player_rew"
|
||||||
android:layout_width="70dp"
|
android:layout_width="70dp"
|
||||||
android:layout_height="70dp"
|
android:layout_height="70dp"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
|
|
||||||
android:background="@drawable/video_tap_button_skip"
|
android:background="@drawable/video_tap_button_skip"
|
||||||
android:nextFocusLeft="@id/exo_rew"
|
android:nextFocusLeft="@id/player_rew"
|
||||||
android:nextFocusUp="@id/player_go_back"
|
android:nextFocusUp="@id/player_go_back"
|
||||||
android:nextFocusDown="@id/player_lock"
|
android:nextFocusDown="@id/player_lock"
|
||||||
android:padding="10dp"
|
android:padding="10dp"
|
||||||
|
@ -359,9 +359,9 @@
|
||||||
android:layout_height="70dp"
|
android:layout_height="70dp"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:background="@drawable/video_tap_button"
|
android:background="@drawable/video_tap_button"
|
||||||
android:nextFocusLeft="@id/exo_rew"
|
android:nextFocusLeft="@id/player_rew"
|
||||||
|
|
||||||
android:nextFocusRight="@id/exo_ffwd"
|
android:nextFocusRight="@id/player_ffwd"
|
||||||
|
|
||||||
android:nextFocusUp="@id/player_go_back"
|
android:nextFocusUp="@id/player_go_back"
|
||||||
android:nextFocusDown="@id/player_lock"
|
android:nextFocusDown="@id/player_lock"
|
||||||
|
@ -394,13 +394,13 @@
|
||||||
tools:text="10" />
|
tools:text="10" />
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@id/exo_ffwd"
|
android:id="@id/player_ffwd"
|
||||||
android:layout_width="70dp"
|
android:layout_width="70dp"
|
||||||
android:layout_height="70dp"
|
android:layout_height="70dp"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
|
|
||||||
android:background="@drawable/video_tap_button_skip"
|
android:background="@drawable/video_tap_button_skip"
|
||||||
android:nextFocusRight="@id/exo_rew"
|
android:nextFocusRight="@id/player_rew"
|
||||||
android:nextFocusUp="@id/player_go_back"
|
android:nextFocusUp="@id/player_go_back"
|
||||||
android:nextFocusDown="@id/player_lock"
|
android:nextFocusDown="@id/player_lock"
|
||||||
android:padding="10dp"
|
android:padding="10dp"
|
||||||
|
|
Loading…
Reference in a new issue