forked from recloudstream/cloudstream
		
	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…
	
	Add table
		Add a link
		
	
		Reference in a new issue