mirror of
https://github.com/recloudstream/cloudstream.git
synced 2024-08-15 01:53:11 +00:00
subtitle offset, fixed #36
This commit is contained in:
parent
cd6c79b961
commit
a1a5af6570
9 changed files with 636 additions and 7 deletions
|
@ -85,6 +85,10 @@ abstract class AbstractPlayerFragment(
|
||||||
throw NotImplementedError()
|
throw NotImplementedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open fun subtitlesChanged() {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
private fun keepScreenOn(on: Boolean) {
|
private fun keepScreenOn(on: Boolean) {
|
||||||
if (on) {
|
if (on) {
|
||||||
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
@ -325,7 +329,7 @@ abstract class AbstractPlayerFragment(
|
||||||
SKIP_OP_VIDEO_PERCENTAGE,
|
SKIP_OP_VIDEO_PERCENTAGE,
|
||||||
PRELOAD_NEXT_EPISODE_PERCENTAGE,
|
PRELOAD_NEXT_EPISODE_PERCENTAGE,
|
||||||
NEXT_WATCH_EPISODE_PERCENTAGE,
|
NEXT_WATCH_EPISODE_PERCENTAGE,
|
||||||
)
|
), subtitlesUpdates = ::subtitlesChanged
|
||||||
)
|
)
|
||||||
|
|
||||||
if (player is CS3IPlayer) {
|
if (player is CS3IPlayer) {
|
||||||
|
|
|
@ -74,6 +74,7 @@ class CS3IPlayer : IPlayer {
|
||||||
private var updateIsPlaying: ((Pair<CSPlayerLoading, CSPlayerLoading>) -> Unit)? = null
|
private var updateIsPlaying: ((Pair<CSPlayerLoading, CSPlayerLoading>) -> Unit)? = null
|
||||||
private var requestAutoFocus: (() -> Unit)? = null
|
private var requestAutoFocus: (() -> Unit)? = null
|
||||||
private var playerError: ((Exception) -> Unit)? = null
|
private var playerError: ((Exception) -> Unit)? = null
|
||||||
|
private var subtitlesUpdates: (() -> Unit)? = null
|
||||||
|
|
||||||
/** width x height */
|
/** width x height */
|
||||||
private var playerDimensionsLoaded: ((Pair<Int, Int>) -> Unit)? = null
|
private var playerDimensionsLoaded: ((Pair<Int, Int>) -> Unit)? = null
|
||||||
|
@ -100,7 +101,8 @@ class CS3IPlayer : IPlayer {
|
||||||
requestedListeningPercentages: List<Int>?,
|
requestedListeningPercentages: List<Int>?,
|
||||||
playerPositionChanged: ((Pair<Long, Long>) -> Unit)?,
|
playerPositionChanged: ((Pair<Long, Long>) -> Unit)?,
|
||||||
nextEpisode: (() -> Unit)?,
|
nextEpisode: (() -> Unit)?,
|
||||||
prevEpisode: (() -> Unit)?
|
prevEpisode: (() -> Unit)?,
|
||||||
|
subtitlesUpdates: (() -> Unit)?
|
||||||
) {
|
) {
|
||||||
this.playerUpdated = playerUpdated
|
this.playerUpdated = playerUpdated
|
||||||
this.updateIsPlaying = updateIsPlaying
|
this.updateIsPlaying = updateIsPlaying
|
||||||
|
@ -111,6 +113,7 @@ class CS3IPlayer : IPlayer {
|
||||||
this.playerPositionChanged = playerPositionChanged
|
this.playerPositionChanged = playerPositionChanged
|
||||||
this.nextEpisode = nextEpisode
|
this.nextEpisode = nextEpisode
|
||||||
this.prevEpisode = prevEpisode
|
this.prevEpisode = prevEpisode
|
||||||
|
this.subtitlesUpdates = subtitlesUpdates
|
||||||
}
|
}
|
||||||
|
|
||||||
// I know, this is not a perfect solution, however it works for fixing subs
|
// I know, this is not a perfect solution, however it works for fixing subs
|
||||||
|
@ -215,6 +218,17 @@ class CS3IPlayer : IPlayer {
|
||||||
} ?: false
|
} ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var currentSubtitleOffset : Long = 0
|
||||||
|
|
||||||
|
override fun setSubtitleOffset(offset: Long) {
|
||||||
|
currentSubtitleOffset = offset
|
||||||
|
currentTextRenderer?.setRenderOffsetMs(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSubtitleOffset(): Long {
|
||||||
|
return currentSubtitleOffset//currentTextRenderer?.getRenderOffsetMs() ?: currentSubtitleOffset
|
||||||
|
}
|
||||||
|
|
||||||
override fun getCurrentPreferredSubtitle(): SubtitleData? {
|
override fun getCurrentPreferredSubtitle(): SubtitleData? {
|
||||||
return subtitleHelper.getAllSubtitles().firstOrNull { sub ->
|
return subtitleHelper.getAllSubtitles().firstOrNull { sub ->
|
||||||
exoPlayerSelectedTracks.any {
|
exoPlayerSelectedTracks.any {
|
||||||
|
@ -251,6 +265,7 @@ class CS3IPlayer : IPlayer {
|
||||||
|
|
||||||
exoPlayer?.release()
|
exoPlayer?.release()
|
||||||
simpleCache?.release()
|
simpleCache?.release()
|
||||||
|
currentTextRenderer = null
|
||||||
|
|
||||||
exoPlayer = null
|
exoPlayer = null
|
||||||
simpleCache = null
|
simpleCache = null
|
||||||
|
@ -395,7 +410,7 @@ class CS3IPlayer : IPlayer {
|
||||||
return trackSelector
|
return trackSelector
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentTextRenderer: TextRenderer? = null
|
var currentTextRenderer: CustomTextRenderer? = null
|
||||||
|
|
||||||
private fun buildExoPlayer(
|
private fun buildExoPlayer(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
@ -404,6 +419,7 @@ class CS3IPlayer : IPlayer {
|
||||||
currentWindow: Int,
|
currentWindow: Int,
|
||||||
playbackPosition: Long,
|
playbackPosition: Long,
|
||||||
playBackSpeed: Float,
|
playBackSpeed: Float,
|
||||||
|
subtitleOffset : Long,
|
||||||
playWhenReady: Boolean = true,
|
playWhenReady: Boolean = true,
|
||||||
cacheFactory: CacheDataSource.Factory? = null,
|
cacheFactory: CacheDataSource.Factory? = null,
|
||||||
trackSelector: TrackSelector? = null,
|
trackSelector: TrackSelector? = null,
|
||||||
|
@ -419,7 +435,8 @@ class CS3IPlayer : IPlayer {
|
||||||
metadataRendererOutput
|
metadataRendererOutput
|
||||||
).map {
|
).map {
|
||||||
if (it is TextRenderer) {
|
if (it is TextRenderer) {
|
||||||
currentTextRenderer = TextRenderer(
|
currentTextRenderer = CustomTextRenderer(
|
||||||
|
subtitleOffset,
|
||||||
textRendererOutput,
|
textRendererOutput,
|
||||||
eventHandler.looper,
|
eventHandler.looper,
|
||||||
CustomSubtitleDecoderFactory()
|
CustomSubtitleDecoderFactory()
|
||||||
|
@ -534,7 +551,8 @@ class CS3IPlayer : IPlayer {
|
||||||
playbackPosition,
|
playbackPosition,
|
||||||
playBackSpeed,
|
playBackSpeed,
|
||||||
playWhenReady = isPlaying, // this keep the current state of the player
|
playWhenReady = isPlaying, // this keep the current state of the player
|
||||||
cacheFactory = cacheFactory
|
cacheFactory = cacheFactory,
|
||||||
|
subtitleOffset = currentSubtitleOffset
|
||||||
)
|
)
|
||||||
|
|
||||||
requestSubtitleUpdate = ::reloadSubs
|
requestSubtitleUpdate = ::reloadSubs
|
||||||
|
@ -558,6 +576,7 @@ class CS3IPlayer : IPlayer {
|
||||||
override fun onTracksInfoChanged(tracksInfo: TracksInfo) {
|
override fun onTracksInfoChanged(tracksInfo: TracksInfo) {
|
||||||
exoPlayerSelectedTracks =
|
exoPlayerSelectedTracks =
|
||||||
tracksInfo.trackGroupInfos.mapNotNull { it.trackGroup.getFormat(0).language?.let { lang -> lang to it.isSelected } }
|
tracksInfo.trackGroupInfos.mapNotNull { it.trackGroup.getFormat(0).language?.let { lang -> lang to it.isSelected } }
|
||||||
|
subtitlesUpdates?.invoke()
|
||||||
super.onTracksInfoChanged(tracksInfo)
|
super.onTracksInfoChanged(tracksInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.player
|
||||||
|
|
||||||
|
import android.os.Looper
|
||||||
|
import com.google.android.exoplayer2.text.SubtitleDecoderFactory
|
||||||
|
import com.google.android.exoplayer2.text.TextOutput
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,6 +11,7 @@ import android.media.AudioManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import android.text.Editable
|
||||||
import android.util.DisplayMetrics
|
import android.util.DisplayMetrics
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
|
@ -20,11 +21,16 @@ import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHO
|
||||||
import android.view.animation.AlphaAnimation
|
import android.view.animation.AlphaAnimation
|
||||||
import android.view.animation.Animation
|
import android.view.animation.Animation
|
||||||
import android.view.animation.AnimationUtils
|
import android.view.animation.AnimationUtils
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.graphics.blue
|
import androidx.core.graphics.blue
|
||||||
import androidx.core.graphics.green
|
import androidx.core.graphics.green
|
||||||
import androidx.core.graphics.red
|
import androidx.core.graphics.red
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.core.widget.doOnTextChanged
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
|
@ -35,6 +41,7 @@ import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.utils.Qualities
|
import com.lagradost.cloudstream3.utils.Qualities
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.getNavigationBarHeight
|
import com.lagradost.cloudstream3.utils.UIHelper.getNavigationBarHeight
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight
|
import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
|
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
|
||||||
|
@ -71,6 +78,19 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
|
||||||
protected var doubleTapEnabled = false
|
protected var doubleTapEnabled = false
|
||||||
protected var doubleTapPauseEnabled = true
|
protected var doubleTapPauseEnabled = true
|
||||||
|
|
||||||
|
protected var subtitleDelay
|
||||||
|
set(value) = try {
|
||||||
|
player.setSubtitleOffset(-value)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e)
|
||||||
|
}
|
||||||
|
get() = try {
|
||||||
|
-player.getSubtitleOffset()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e)
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
|
||||||
//private var useSystemBrightness = false
|
//private var useSystemBrightness = false
|
||||||
protected var useTrueSystemBrightness = true
|
protected var useTrueSystemBrightness = true
|
||||||
private val fullscreenNotch = true //TODO SETTING
|
private val fullscreenNotch = true //TODO SETTING
|
||||||
|
@ -196,6 +216,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
|
||||||
player_top_holder?.startAnimation(fadeAnimation)
|
player_top_holder?.startAnimation(fadeAnimation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun subtitlesChanged() {
|
||||||
|
player_subtitle_offset_btt?.isGone = player.getCurrentPreferredSubtitle() == null
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
activity?.hideSystemUI()
|
activity?.hideSystemUI()
|
||||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||||
|
@ -241,6 +265,77 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
|
||||||
player.seekTime(85000) // skip 85s
|
player.seekTime(85000) // skip 85s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showSubtitleOffsetDialog() {
|
||||||
|
context?.let { ctx ->
|
||||||
|
val builder =
|
||||||
|
AlertDialog.Builder(ctx, R.style.AlertDialogCustom)
|
||||||
|
.setView(R.layout.subtitle_offset)
|
||||||
|
val dialog = builder.create()
|
||||||
|
dialog.show()
|
||||||
|
|
||||||
|
val beforeOffset = subtitleDelay
|
||||||
|
|
||||||
|
val applyButton = dialog.findViewById<TextView>(R.id.apply_btt)!!
|
||||||
|
val cancelButton = dialog.findViewById<TextView>(R.id.cancel_btt)!!
|
||||||
|
val input = dialog.findViewById<EditText>(R.id.subtitle_offset_input)!!
|
||||||
|
val sub = dialog.findViewById<ImageView>(R.id.subtitle_offset_subtract)!!
|
||||||
|
val add = dialog.findViewById<ImageView>(R.id.subtitle_offset_add)!!
|
||||||
|
val subTitle = dialog.findViewById<TextView>(R.id.subtitle_offset_sub_title)!!
|
||||||
|
|
||||||
|
input.doOnTextChanged { text, _, _, _ ->
|
||||||
|
text?.toString()?.toLongOrNull()?.let {
|
||||||
|
subtitleDelay = it
|
||||||
|
when {
|
||||||
|
it > 0L -> {
|
||||||
|
context?.getString(R.string.subtitle_offset_extra_hint_later_format)
|
||||||
|
?.format(it)
|
||||||
|
}
|
||||||
|
it < 0L -> {
|
||||||
|
context?.getString(R.string.subtitle_offset_extra_hint_before_format)
|
||||||
|
?.format(-it)
|
||||||
|
}
|
||||||
|
it == 0L -> {
|
||||||
|
context?.getString(R.string.subtitle_offset_extra_hint_none_format)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}?.let { str ->
|
||||||
|
subTitle.text = str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.text = Editable.Factory.getInstance()?.newEditable(beforeOffset.toString())
|
||||||
|
|
||||||
|
val buttonChange = 100L
|
||||||
|
|
||||||
|
fun changeBy(by: Long) {
|
||||||
|
val current = (input.text?.toString()?.toLongOrNull() ?: 0) + by
|
||||||
|
input.text = Editable.Factory.getInstance()?.newEditable(current.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
add.setOnClickListener {
|
||||||
|
changeBy(buttonChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
sub.setOnClickListener {
|
||||||
|
changeBy(-buttonChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.setOnDismissListener {
|
||||||
|
activity?.hideSystemUI()
|
||||||
|
}
|
||||||
|
applyButton.setOnClickListener {
|
||||||
|
dialog.dismissSafe(activity)
|
||||||
|
player.seekTime(1L)
|
||||||
|
}
|
||||||
|
cancelButton.setOnClickListener {
|
||||||
|
subtitleDelay = beforeOffset
|
||||||
|
dialog.dismissSafe(activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun showSpeedDialog() {
|
private fun showSpeedDialog() {
|
||||||
val speedsText =
|
val speedsText =
|
||||||
listOf(
|
listOf(
|
||||||
|
@ -1055,6 +1150,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
|
||||||
toggleLock()
|
toggleLock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
player_subtitle_offset_btt?.setOnClickListener {
|
||||||
|
showSubtitleOffsetDialog()
|
||||||
|
}
|
||||||
|
|
||||||
exo_rew?.setOnClickListener {
|
exo_rew?.setOnClickListener {
|
||||||
autoHide()
|
autoHide()
|
||||||
rewind()
|
rewind()
|
||||||
|
|
|
@ -71,6 +71,9 @@ interface IPlayer {
|
||||||
fun seekTime(time: Long)
|
fun seekTime(time: Long)
|
||||||
fun seekTo(time: Long)
|
fun seekTo(time: Long)
|
||||||
|
|
||||||
|
fun getSubtitleOffset() : Long // in ms
|
||||||
|
fun setSubtitleOffset(offset : Long) // in ms
|
||||||
|
|
||||||
fun initCallbacks(
|
fun initCallbacks(
|
||||||
playerUpdated: (Any?) -> Unit, // attach player to view
|
playerUpdated: (Any?) -> Unit, // attach player to view
|
||||||
updateIsPlaying: ((Pair<CSPlayerLoading, CSPlayerLoading>) -> Unit)? = null, // (wasPlaying, isPlaying)
|
updateIsPlaying: ((Pair<CSPlayerLoading, CSPlayerLoading>) -> Unit)? = null, // (wasPlaying, isPlaying)
|
||||||
|
@ -81,6 +84,7 @@ interface IPlayer {
|
||||||
playerPositionChanged: ((Pair<Long, Long>) -> Unit)? = null,// (position, duration) this is used to update UI based of the current time
|
playerPositionChanged: ((Pair<Long, Long>) -> Unit)? = null,// (position, duration) this is used to update UI based of the current time
|
||||||
nextEpisode: (() -> Unit)? = null, // this is used by the player to load the next episode
|
nextEpisode: (() -> Unit)? = null, // this is used by the player to load the next episode
|
||||||
prevEpisode: (() -> Unit)? = null, // this is used by the player to load the previous episode
|
prevEpisode: (() -> Unit)? = null, // this is used by the player to load the previous episode
|
||||||
|
subtitlesUpdates: (() -> Unit)? = null, // callback from player to inform that subtitles have updated in some way
|
||||||
)
|
)
|
||||||
|
|
||||||
fun updateSubtitleStyle(style: SaveCaptionStyle)
|
fun updateSubtitleStyle(style: SaveCaptionStyle)
|
||||||
|
|
|
@ -0,0 +1,369 @@
|
||||||
|
/*
|
||||||
|
* 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 android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.Message
|
||||||
|
import androidx.annotation.IntDef
|
||||||
|
import com.google.android.exoplayer2.*
|
||||||
|
import com.google.android.exoplayer2.source.SampleStream.ReadDataResult
|
||||||
|
import com.google.android.exoplayer2.text.*
|
||||||
|
import com.google.android.exoplayer2.util.Assertions
|
||||||
|
import com.google.android.exoplayer2.util.Log
|
||||||
|
import com.google.android.exoplayer2.util.MimeTypes
|
||||||
|
import com.google.android.exoplayer2.util.Util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A renderer for text.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* [Subtitle]s are decoded from sample data using [SubtitleDecoder] instances
|
||||||
|
* obtained from a [SubtitleDecoderFactory]. The actual rendering of the subtitle [Cue]s
|
||||||
|
* is delegated to a [TextOutput].
|
||||||
|
*/
|
||||||
|
open class NonFinalTextRenderer @JvmOverloads constructor(
|
||||||
|
output: TextOutput?,
|
||||||
|
outputLooper: Looper?,
|
||||||
|
private val decoderFactory: SubtitleDecoderFactory = SubtitleDecoderFactory.DEFAULT
|
||||||
|
) :
|
||||||
|
BaseRenderer(C.TRACK_TYPE_TEXT), Handler.Callback {
|
||||||
|
@MustBeDocumented
|
||||||
|
@kotlin.annotation.Retention(AnnotationRetention.SOURCE)
|
||||||
|
@IntDef(
|
||||||
|
REPLACEMENT_STATE_NONE,
|
||||||
|
REPLACEMENT_STATE_SIGNAL_END_OF_STREAM,
|
||||||
|
REPLACEMENT_STATE_WAIT_END_OF_STREAM
|
||||||
|
)
|
||||||
|
private annotation class ReplacementState
|
||||||
|
|
||||||
|
private val outputHandler: Handler? = if (outputLooper == null) null else Util.createHandler(
|
||||||
|
outputLooper, /* callback= */
|
||||||
|
this
|
||||||
|
)
|
||||||
|
private val output: TextOutput = Assertions.checkNotNull(output)
|
||||||
|
private val formatHold: FormatHolder = FormatHolder()
|
||||||
|
private var inputStreamEnded = false
|
||||||
|
private var outputStreamEnded = false
|
||||||
|
private var waitingForKeyFrame = false
|
||||||
|
|
||||||
|
@ReplacementState
|
||||||
|
private var decoderReplacementState = 0
|
||||||
|
private var streamFormat: Format? = null
|
||||||
|
private var decoder: SubtitleDecoder? = null
|
||||||
|
private var nextInputBuffer: SubtitleInputBuffer? = null
|
||||||
|
private var subtitle: SubtitleOutputBuffer? = null
|
||||||
|
private var nextSubtitle: SubtitleOutputBuffer? = null
|
||||||
|
private var nextSubtitleEventIndex = 0
|
||||||
|
private var finalStreamEndPositionUs: Long
|
||||||
|
override fun getName(): String {
|
||||||
|
return TAG
|
||||||
|
}
|
||||||
|
|
||||||
|
@RendererCapabilities.Capabilities
|
||||||
|
override fun supportsFormat(format: Format): Int {
|
||||||
|
return if (decoderFactory.supportsFormat(format)) {
|
||||||
|
RendererCapabilities.create(
|
||||||
|
if (format.cryptoType == C.CRYPTO_TYPE_NONE) C.FORMAT_HANDLED else C.FORMAT_UNSUPPORTED_DRM
|
||||||
|
)
|
||||||
|
} else if (MimeTypes.isText(format.sampleMimeType)) {
|
||||||
|
RendererCapabilities.create(C.FORMAT_UNSUPPORTED_SUBTYPE)
|
||||||
|
} else {
|
||||||
|
RendererCapabilities.create(C.FORMAT_UNSUPPORTED_TYPE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the position at which to stop rendering the current stream.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Must be called after [.setCurrentStreamFinal].
|
||||||
|
*
|
||||||
|
* @param streamEndPositionUs The position to stop rendering at or [C.LENGTH_UNSET] to
|
||||||
|
* render until the end of the current stream.
|
||||||
|
*/
|
||||||
|
|
||||||
|
override fun onStreamChanged(formats: Array<Format>, startPositionUs: Long, offsetUs: Long) {
|
||||||
|
streamFormat = formats[0]
|
||||||
|
if (decoder != null) {
|
||||||
|
decoderReplacementState = REPLACEMENT_STATE_SIGNAL_END_OF_STREAM
|
||||||
|
} else {
|
||||||
|
initDecoder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPositionReset(positionUs: Long, joining: Boolean) {
|
||||||
|
clearOutput()
|
||||||
|
inputStreamEnded = false
|
||||||
|
outputStreamEnded = false
|
||||||
|
finalStreamEndPositionUs = C.TIME_UNSET
|
||||||
|
if (decoderReplacementState != REPLACEMENT_STATE_NONE) {
|
||||||
|
replaceDecoder()
|
||||||
|
} else {
|
||||||
|
releaseBuffers()
|
||||||
|
Assertions.checkNotNull(decoder).flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun render(positionUs: Long, elapsedRealtimeUs: Long) {
|
||||||
|
if (isCurrentStreamFinal
|
||||||
|
&& finalStreamEndPositionUs != C.TIME_UNSET && positionUs >= finalStreamEndPositionUs
|
||||||
|
) {
|
||||||
|
releaseBuffers()
|
||||||
|
outputStreamEnded = true
|
||||||
|
}
|
||||||
|
if (outputStreamEnded) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (nextSubtitle == null) {
|
||||||
|
Assertions.checkNotNull(decoder).setPositionUs(positionUs)
|
||||||
|
nextSubtitle = try {
|
||||||
|
Assertions.checkNotNull(decoder).dequeueOutputBuffer()
|
||||||
|
} catch (e: SubtitleDecoderException) {
|
||||||
|
handleDecoderError(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state != STATE_STARTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var textRendererNeedsUpdate = false
|
||||||
|
if (subtitle != null) {
|
||||||
|
// We're iterating through the events in a subtitle. Set textRendererNeedsUpdate if we
|
||||||
|
// advance to the next event.
|
||||||
|
var subtitleNextEventTimeUs = nextEventTime
|
||||||
|
while (subtitleNextEventTimeUs <= positionUs) {
|
||||||
|
nextSubtitleEventIndex++
|
||||||
|
subtitleNextEventTimeUs = nextEventTime
|
||||||
|
textRendererNeedsUpdate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nextSubtitle != null) {
|
||||||
|
val nextSubtitle = nextSubtitle
|
||||||
|
if (nextSubtitle!!.isEndOfStream) {
|
||||||
|
if (!textRendererNeedsUpdate && nextEventTime == 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.
|
||||||
|
Assertions.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) {
|
||||||
|
var nextInputBuffer = nextInputBuffer
|
||||||
|
if (nextInputBuffer == null) {
|
||||||
|
nextInputBuffer = Assertions.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)
|
||||||
|
Assertions.checkNotNull(decoder).queueInputBuffer(nextInputBuffer)
|
||||||
|
this.nextInputBuffer = null
|
||||||
|
decoderReplacementState = REPLACEMENT_STATE_WAIT_END_OF_STREAM
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Try and read the next subtitle from the source.
|
||||||
|
@ReadDataResult val result =
|
||||||
|
readSource(formatHold, nextInputBuffer, /* readFlags= */0)
|
||||||
|
if (result == C.RESULT_BUFFER_READ) {
|
||||||
|
if (nextInputBuffer.isEndOfStream) {
|
||||||
|
inputStreamEnded = true
|
||||||
|
waitingForKeyFrame = false
|
||||||
|
} else {
|
||||||
|
val format = formatHold.format
|
||||||
|
?: // We haven't received a format yet.
|
||||||
|
return
|
||||||
|
nextInputBuffer.subsampleOffsetUs = format.subsampleOffsetUs
|
||||||
|
nextInputBuffer.flip()
|
||||||
|
waitingForKeyFrame = waitingForKeyFrame and !nextInputBuffer.isKeyFrame
|
||||||
|
}
|
||||||
|
if (!waitingForKeyFrame) {
|
||||||
|
Assertions.checkNotNull(decoder).queueInputBuffer(nextInputBuffer)
|
||||||
|
this.nextInputBuffer = null
|
||||||
|
}
|
||||||
|
} else if (result == C.RESULT_NOTHING_READ) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: SubtitleDecoderException) {
|
||||||
|
handleDecoderError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDisabled() {
|
||||||
|
streamFormat = null
|
||||||
|
finalStreamEndPositionUs = C.TIME_UNSET
|
||||||
|
clearOutput()
|
||||||
|
releaseDecoder()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isEnded(): Boolean {
|
||||||
|
return outputStreamEnded
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isReady(): Boolean {
|
||||||
|
// 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 fun releaseBuffers() {
|
||||||
|
nextInputBuffer = null
|
||||||
|
nextSubtitleEventIndex = C.INDEX_UNSET
|
||||||
|
if (subtitle != null) {
|
||||||
|
subtitle!!.release()
|
||||||
|
subtitle = null
|
||||||
|
}
|
||||||
|
if (nextSubtitle != null) {
|
||||||
|
nextSubtitle!!.release()
|
||||||
|
nextSubtitle = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseDecoder() {
|
||||||
|
releaseBuffers()
|
||||||
|
Assertions.checkNotNull(decoder).release()
|
||||||
|
decoder = null
|
||||||
|
decoderReplacementState = REPLACEMENT_STATE_NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initDecoder() {
|
||||||
|
waitingForKeyFrame = true
|
||||||
|
decoder = decoderFactory.createDecoder(Assertions.checkNotNull(streamFormat))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceDecoder() {
|
||||||
|
releaseDecoder()
|
||||||
|
initDecoder()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val nextEventTime: Long
|
||||||
|
get() {
|
||||||
|
if (nextSubtitleEventIndex == C.INDEX_UNSET) {
|
||||||
|
return Long.MAX_VALUE
|
||||||
|
}
|
||||||
|
Assertions.checkNotNull(subtitle)
|
||||||
|
return if (nextSubtitleEventIndex >= subtitle!!.eventTimeCount) Long.MAX_VALUE else subtitle!!.getEventTime(
|
||||||
|
nextSubtitleEventIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateOutput(cues: List<Cue>) {
|
||||||
|
if (outputHandler != null) {
|
||||||
|
outputHandler.obtainMessage(MSG_UPDATE_OUTPUT, cues).sendToTarget()
|
||||||
|
} else {
|
||||||
|
invokeUpdateOutputInternal(cues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearOutput() {
|
||||||
|
updateOutput(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleMessage(msg: Message): Boolean {
|
||||||
|
return when (msg.what) {
|
||||||
|
MSG_UPDATE_OUTPUT -> {
|
||||||
|
invokeUpdateOutputInternal(msg.obj as List<Cue>)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> throw IllegalStateException()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invokeUpdateOutputInternal(cues: List<Cue>) {
|
||||||
|
output.onCues(cues)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when [.decoder] throws an exception, so it can be logged and playback can
|
||||||
|
* continue.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Logs `e` and resets state to allow decoding the next sample.
|
||||||
|
*/
|
||||||
|
private fun handleDecoderError(e: SubtitleDecoderException) {
|
||||||
|
Log.e(
|
||||||
|
TAG,
|
||||||
|
"Subtitle decoding failed. streamFormat=$streamFormat", e
|
||||||
|
)
|
||||||
|
clearOutput()
|
||||||
|
replaceDecoder()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "TextRenderer"
|
||||||
|
|
||||||
|
/** The decoder does not need to be replaced. */
|
||||||
|
private const val 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 const val 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 const val REPLACEMENT_STATE_WAIT_END_OF_STREAM = 2
|
||||||
|
private const val MSG_UPDATE_OUTPUT = 0
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @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 [ ][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 [SubtitleDecoder] instances.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @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 [ ][android.app.Activity.getMainLooper]. Null may be passed if the output should be called
|
||||||
|
* directly on the player's internal rendering thread.
|
||||||
|
*/
|
||||||
|
init {
|
||||||
|
finalStreamEndPositionUs = C.TIME_UNSET
|
||||||
|
}
|
||||||
|
}
|
|
@ -508,15 +508,26 @@
|
||||||
style="@style/VideoButton"
|
style="@style/VideoButton"
|
||||||
android:nextFocusLeft="@id/player_resize_btt"
|
android:nextFocusLeft="@id/player_resize_btt"
|
||||||
|
|
||||||
android:nextFocusRight="@id/player_sources_btt"
|
android:nextFocusRight="@id/player_subtitle_offset_btt"
|
||||||
app:icon="@drawable/ic_baseline_speed_24"
|
app:icon="@drawable/ic_baseline_speed_24"
|
||||||
tools:text="Speed" />
|
tools:text="Speed" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/player_sources_btt"
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible"
|
||||||
|
android:id="@+id/player_subtitle_offset_btt"
|
||||||
style="@style/VideoButton"
|
style="@style/VideoButton"
|
||||||
android:nextFocusLeft="@id/player_speed_btt"
|
android:nextFocusLeft="@id/player_speed_btt"
|
||||||
|
|
||||||
|
android:nextFocusRight="@id/player_sources_btt"
|
||||||
|
app:icon="@drawable/ic_outline_subtitles_24"
|
||||||
|
android:text="@string/subtitle_offset"/>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/player_sources_btt"
|
||||||
|
style="@style/VideoButton"
|
||||||
|
android:nextFocusLeft="@id/player_subtitle_offset_btt"
|
||||||
|
|
||||||
android:nextFocusRight="@id/player_skip_op"
|
android:nextFocusRight="@id/player_skip_op"
|
||||||
android:text="@string/video_source"
|
android:text="@string/video_source"
|
||||||
app:icon="@drawable/ic_baseline_playlist_play_24" />
|
app:icon="@drawable/ic_baseline_playlist_play_24" />
|
||||||
|
|
86
app/src/main/res/layout/subtitle_offset.xml
Normal file
86
app/src/main/res/layout/subtitle_offset.xml
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:orientation="vertical" xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="300dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
style="@style/WatchHeaderText"
|
||||||
|
android:layout_margin="0dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:text="@string/subtitle_offset_title"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/subtitle_offset_sub_title"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
tools:text="@string/subtitle_offset_extra_hint_none_format"
|
||||||
|
android:textColor="?attr/grayTextColor"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
<LinearLayout
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/subtitle_offset_subtract"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:padding="10dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:src="@drawable/baseline_remove_24"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
app:tint="?attr/white"
|
||||||
|
tools:ignore="ContentDescription" />
|
||||||
|
<EditText
|
||||||
|
android:layout_weight="20"
|
||||||
|
android:id="@+id/subtitle_offset_input"
|
||||||
|
android:inputType="numberSigned"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:autofillHints="@string/subtitle_offset_hint"
|
||||||
|
tools:ignore="LabelFor" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/subtitle_offset_add"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:padding="10dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:src="@drawable/ic_baseline_add_24"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
app:tint="?attr/white"
|
||||||
|
tools:ignore="ContentDescription" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:gravity="bottom|end"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="60dp">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
style="@style/WhiteButton"
|
||||||
|
android:layout_gravity="center_vertical|end"
|
||||||
|
android:visibility="visible"
|
||||||
|
android:text="@string/sort_apply"
|
||||||
|
android:id="@+id/apply_btt"
|
||||||
|
android:layout_width="wrap_content">
|
||||||
|
|
||||||
|
<requestFocus />
|
||||||
|
</com.google.android.material.button.MaterialButton>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
style="@style/BlackButton"
|
||||||
|
android:layout_gravity="center_vertical|end"
|
||||||
|
android:text="@string/sort_cancel"
|
||||||
|
android:id="@+id/cancel_btt"
|
||||||
|
android:layout_width="wrap_content" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
|
@ -377,6 +377,13 @@
|
||||||
<string name="subtitles_depressed">Depressed</string>
|
<string name="subtitles_depressed">Depressed</string>
|
||||||
<string name="subtitles_shadow">Shadow</string>
|
<string name="subtitles_shadow">Shadow</string>
|
||||||
<string name="subtitles_raised">Raised</string>
|
<string name="subtitles_raised">Raised</string>
|
||||||
|
<string name="subtitle_offset">Sync subs</string>
|
||||||
|
<string name="subtitle_offset_hint">1000ms</string>
|
||||||
|
<string name="subtitle_offset_title">Subtitle delay</string>
|
||||||
|
<string name="subtitle_offset_extra_hint_later_format">Use this if the subtitles are shown %dms too early</string>
|
||||||
|
<string name="subtitle_offset_extra_hint_before_format">Use this if subtitles are shown %dms too late</string>
|
||||||
|
<string name="subtitle_offset_extra_hint_none_format">No subtitle delay</string>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Example text (pangram) can optionally be translated; if you do, include all the letters in the alphabet,
|
Example text (pangram) can optionally be translated; if you do, include all the letters in the alphabet,
|
||||||
see:
|
see:
|
||||||
|
|
Loading…
Reference in a new issue