subtitle offset, fixed #36

This commit is contained in:
LagradOst 2022-02-13 19:06:36 +01:00
parent cd6c79b961
commit a1a5af6570
9 changed files with 636 additions and 7 deletions

View File

@ -85,6 +85,10 @@ abstract class AbstractPlayerFragment(
throw NotImplementedError()
}
open fun subtitlesChanged() {
throw NotImplementedError()
}
private fun keepScreenOn(on: Boolean) {
if (on) {
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@ -325,7 +329,7 @@ abstract class AbstractPlayerFragment(
SKIP_OP_VIDEO_PERCENTAGE,
PRELOAD_NEXT_EPISODE_PERCENTAGE,
NEXT_WATCH_EPISODE_PERCENTAGE,
)
), subtitlesUpdates = ::subtitlesChanged
)
if (player is CS3IPlayer) {

View File

@ -74,6 +74,7 @@ class CS3IPlayer : IPlayer {
private var updateIsPlaying: ((Pair<CSPlayerLoading, CSPlayerLoading>) -> Unit)? = null
private var requestAutoFocus: (() -> Unit)? = null
private var playerError: ((Exception) -> Unit)? = null
private var subtitlesUpdates: (() -> Unit)? = null
/** width x height */
private var playerDimensionsLoaded: ((Pair<Int, Int>) -> Unit)? = null
@ -100,7 +101,8 @@ class CS3IPlayer : IPlayer {
requestedListeningPercentages: List<Int>?,
playerPositionChanged: ((Pair<Long, Long>) -> Unit)?,
nextEpisode: (() -> Unit)?,
prevEpisode: (() -> Unit)?
prevEpisode: (() -> Unit)?,
subtitlesUpdates: (() -> Unit)?
) {
this.playerUpdated = playerUpdated
this.updateIsPlaying = updateIsPlaying
@ -111,6 +113,7 @@ class CS3IPlayer : IPlayer {
this.playerPositionChanged = playerPositionChanged
this.nextEpisode = nextEpisode
this.prevEpisode = prevEpisode
this.subtitlesUpdates = subtitlesUpdates
}
// I know, this is not a perfect solution, however it works for fixing subs
@ -215,6 +218,17 @@ class CS3IPlayer : IPlayer {
} ?: 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? {
return subtitleHelper.getAllSubtitles().firstOrNull { sub ->
exoPlayerSelectedTracks.any {
@ -251,6 +265,7 @@ class CS3IPlayer : IPlayer {
exoPlayer?.release()
simpleCache?.release()
currentTextRenderer = null
exoPlayer = null
simpleCache = null
@ -395,7 +410,7 @@ class CS3IPlayer : IPlayer {
return trackSelector
}
var currentTextRenderer: TextRenderer? = null
var currentTextRenderer: CustomTextRenderer? = null
private fun buildExoPlayer(
context: Context,
@ -404,6 +419,7 @@ class CS3IPlayer : IPlayer {
currentWindow: Int,
playbackPosition: Long,
playBackSpeed: Float,
subtitleOffset : Long,
playWhenReady: Boolean = true,
cacheFactory: CacheDataSource.Factory? = null,
trackSelector: TrackSelector? = null,
@ -419,7 +435,8 @@ class CS3IPlayer : IPlayer {
metadataRendererOutput
).map {
if (it is TextRenderer) {
currentTextRenderer = TextRenderer(
currentTextRenderer = CustomTextRenderer(
subtitleOffset,
textRendererOutput,
eventHandler.looper,
CustomSubtitleDecoderFactory()
@ -534,7 +551,8 @@ class CS3IPlayer : IPlayer {
playbackPosition,
playBackSpeed,
playWhenReady = isPlaying, // this keep the current state of the player
cacheFactory = cacheFactory
cacheFactory = cacheFactory,
subtitleOffset = currentSubtitleOffset
)
requestSubtitleUpdate = ::reloadSubs
@ -558,6 +576,7 @@ class CS3IPlayer : IPlayer {
override fun onTracksInfoChanged(tracksInfo: TracksInfo) {
exoPlayerSelectedTracks =
tracksInfo.trackGroupInfos.mapNotNull { it.trackGroup.getFormat(0).language?.let { lang -> lang to it.isSelected } }
subtitlesUpdates?.invoke()
super.onTracksInfoChanged(tracksInfo)
}

View File

@ -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)
}
}

View File

@ -11,6 +11,7 @@ import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.text.Editable
import android.util.DisplayMetrics
import android.view.KeyEvent
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.Animation
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.green
import androidx.core.graphics.red
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged
import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
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.SingleSelectionHelper.showDialog
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.getStatusBarHeight
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
@ -71,6 +78,19 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
protected var doubleTapEnabled = false
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
protected var useTrueSystemBrightness = true
private val fullscreenNotch = true //TODO SETTING
@ -196,6 +216,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
player_top_holder?.startAnimation(fadeAnimation)
}
override fun subtitlesChanged() {
player_subtitle_offset_btt?.isGone = player.getCurrentPreferredSubtitle() == null
}
override fun onResume() {
activity?.hideSystemUI()
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
@ -241,6 +265,77 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
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() {
val speedsText =
listOf(
@ -1055,6 +1150,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
toggleLock()
}
player_subtitle_offset_btt?.setOnClickListener {
showSubtitleOffsetDialog()
}
exo_rew?.setOnClickListener {
autoHide()
rewind()

View File

@ -71,6 +71,9 @@ interface IPlayer {
fun seekTime(time: Long)
fun seekTo(time: Long)
fun getSubtitleOffset() : Long // in ms
fun setSubtitleOffset(offset : Long) // in ms
fun initCallbacks(
playerUpdated: (Any?) -> Unit, // attach player to view
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
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
subtitlesUpdates: (() -> Unit)? = null, // callback from player to inform that subtitles have updated in some way
)
fun updateSubtitleStyle(style: SaveCaptionStyle)

View File

@ -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
}
}

View File

@ -508,15 +508,26 @@
style="@style/VideoButton"
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"
tools:text="Speed" />
<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"
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:text="@string/video_source"
app:icon="@drawable/ic_baseline_playlist_play_24" />

View 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>

View File

@ -377,6 +377,13 @@
<string name="subtitles_depressed">Depressed</string>
<string name="subtitles_shadow">Shadow</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,
see: