Skip to content

Commit eedd086

Browse files
committed
Add subtitle offset possibility (by @pulpul-s)
If the synchronization of subtitles is off, there's now no way to make them appear earlier or later. This change introduces such a possibility. I didn't write the code of this commit, the credit goes to @pulpul-s. Signed-off-by: Erik De Boeck <deboeck.erik@gmail.com>
1 parent e2c1b37 commit eedd086

23 files changed

Lines changed: 777 additions & 13 deletions

app/src/main/java/org/jellyfin/androidtv/ui/playback/PlaybackController.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,22 @@ public float getPlaybackSpeed() {
177177
}
178178
}
179179

180+
public long getSubtitleTimingOffsetUs() {
181+
return hasInitializedVideoManager() ? mVideoManager.getSubtitleTimingOffsetUs() : 0L;
182+
}
183+
184+
public void adjustSubtitleTimingOffsetUs(long deltaUs) {
185+
if (hasInitializedVideoManager()) {
186+
mVideoManager.adjustSubtitleTimingOffsetUs(deltaUs);
187+
}
188+
}
189+
190+
public void resetSubtitleTimingOffset() {
191+
if (hasInitializedVideoManager()) {
192+
mVideoManager.resetSubtitleTimingOffset();
193+
}
194+
}
195+
180196
public void setPlaybackSpeed(float speed) {
181197
mRequestedPlaybackSpeed = speed;
182198
if (hasInitializedVideoManager()) {

app/src/main/java/org/jellyfin/androidtv/ui/playback/VideoManager.java

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
import androidx.media3.exoplayer.util.EventLogger;
4242
import androidx.media3.extractor.DefaultExtractorsFactory;
4343
import androidx.media3.extractor.ExtractorsFactory;
44+
import androidx.media3.extractor.text.DefaultSubtitleParserFactory;
45+
import androidx.media3.extractor.text.SubtitleParser;
4446
import androidx.media3.extractor.ts.TsExtractor;
4547
import androidx.media3.ui.AspectRatioFrameLayout;
4648
import androidx.media3.ui.CaptionStyleCompat;
@@ -51,6 +53,8 @@
5153
import org.jellyfin.androidtv.preference.UserPreferences;
5254
import org.jellyfin.androidtv.preference.constant.BufferLength;
5355
import org.jellyfin.androidtv.preference.constant.ZoomMode;
56+
import org.jellyfin.playback.media3.exoplayer.subtitle.SubtitleTimingOffsetRenderersFactory;
57+
import org.jellyfin.playback.media3.exoplayer.subtitle.SubtitleTimingOffsetState;
5458
import org.jellyfin.sdk.api.client.ApiClient;
5559
import org.jellyfin.sdk.model.api.MediaStream;
5660
import org.jellyfin.sdk.model.api.MediaStreamType;
@@ -83,6 +87,7 @@ public class VideoManager {
8387
public ExoPlayer mExoPlayer;
8488
private PlayerView mExoPlayerView;
8589
private Handler mHandler = new Handler();
90+
private final SubtitleTimingOffsetState subtitleTimingOffsetState = new SubtitleTimingOffsetState();
8691

8792
private long mMetaDuration = -1;
8893
private long lastExoPlayerPosition = -1;
@@ -218,7 +223,16 @@ private int determineExoPlayerExtensionRendererMode() {
218223
*/
219224
private ExoPlayer.Builder configureExoplayerBuilder(Context context, AssHandler assHandler) {
220225
ExoPlayer.Builder exoPlayerBuilder = new ExoPlayer.Builder(context);
221-
DefaultRenderersFactory defaultRendererFactory = new DefaultRenderersFactory(context);
226+
227+
SubtitleParser.Factory legacySubtitleParserFactory = assHandler != null
228+
? new AssSubtitleParserFactory(assHandler)
229+
: new DefaultSubtitleParserFactory();
230+
231+
SubtitleTimingOffsetRenderersFactory defaultRendererFactory = new SubtitleTimingOffsetRenderersFactory(
232+
context,
233+
subtitleTimingOffsetState,
234+
legacySubtitleParserFactory
235+
);
222236
defaultRendererFactory.setEnableDecoderFallback(true);
223237
defaultRendererFactory.setExtensionRendererMode(determineExoPlayerExtensionRendererMode());
224238

@@ -238,15 +252,18 @@ private ExoPlayer.Builder configureExoplayerBuilder(Context context, AssHandler
238252
extractorsFactory.setConstantBitrateSeekingAlwaysEnabled(true);
239253
DefaultDataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(context, exoPlayerHttpDataSourceFactory);
240254
if (assHandler != null) {
241-
AssSubtitleParserFactory assSubtitleParserFactory = new AssSubtitleParserFactory(assHandler);
255+
AssSubtitleParserFactory assSubtitleParserFactory = (AssSubtitleParserFactory) legacySubtitleParserFactory;
242256
ExtractorsFactory assExtractorsFactory = AssPlayerKt.withAssMkvSupport(extractorsFactory, assSubtitleParserFactory, assHandler);
243257
DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(dataSourceFactory, assExtractorsFactory);
244-
mediaSourceFactory.setSubtitleParserFactory(assSubtitleParserFactory);
258+
mediaSourceFactory.experimentalParseSubtitlesDuringExtraction(false);
259+
mediaSourceFactory.setSubtitleParserFactory(legacySubtitleParserFactory);
245260
exoPlayerBuilder.setMediaSourceFactory(mediaSourceFactory);
246261
exoPlayerBuilder.setRenderersFactory(new AssRenderersFactory(assHandler, defaultRendererFactory));
247262
} else {
248263
exoPlayerBuilder.setRenderersFactory(defaultRendererFactory);
249-
exoPlayerBuilder.setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory));
264+
exoPlayerBuilder.setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)
265+
.experimentalParseSubtitlesDuringExtraction(false)
266+
.setSubtitleParserFactory(legacySubtitleParserFactory));
250267
}
251268

252269
BufferLength bufferLength = userPreferences.get(UserPreferences.Companion.getBufferLength());
@@ -600,6 +617,19 @@ public void setPlaybackSpeed(float speed) {
600617
mExoPlayer.setPlaybackParameters(new PlaybackParameters(speed));
601618
}
602619

620+
public long getSubtitleTimingOffsetUs() {
621+
return subtitleTimingOffsetState.getOffsetUs();
622+
}
623+
624+
625+
public void adjustSubtitleTimingOffsetUs(long deltaUs) {
626+
subtitleTimingOffsetState.adjustOffsetUs(deltaUs);
627+
}
628+
629+
public void resetSubtitleTimingOffset() {
630+
subtitleTimingOffsetState.setOffsetUs(0L);
631+
}
632+
603633
public void destroy() {
604634
mPlaybackControllerNotifiable = null;
605635
stopPlayback();

app/src/main/java/org/jellyfin/androidtv/ui/playback/overlay/LeanbackOverlayFragment.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment {
2424
private CustomPlaybackTransportControlGlue playerGlue;
2525
private VideoPlayerAdapter playerAdapter;
2626
private boolean shouldShowOverlay = true;
27+
private boolean subtitleOffsetMode = false;
2728
private Lazy<PlaybackControllerContainer> playbackControllerContainer = inject(PlaybackControllerContainer.class);
2829
private final Lazy<UserSettingPreferences> userSettingPreferences = inject(UserSettingPreferences.class);
2930
private Lazy<ImageLoader> imageLoader = inject(ImageLoader.class);
@@ -95,6 +96,27 @@ public void setFading(boolean fadingEnabled) {
9596
playerAdapter.getMasterOverlayFragment().setFadingEnabled(fadingEnabled);
9697
}
9798

99+
public void enterSubtitleOffsetMode() {
100+
subtitleOffsetMode = true;
101+
setShouldShowOverlay(false);
102+
setBackgroundType(BG_NONE);
103+
super.hideControlsOverlay(false);
104+
playerAdapter.getMasterOverlayFragment().hide();
105+
playerAdapter.getMasterOverlayFragment().setFadingEnabled(false);
106+
}
107+
108+
public void exitSubtitleOffsetMode() {
109+
subtitleOffsetMode = false;
110+
setBackgroundType(BG_LIGHT);
111+
setShouldShowOverlay(true);
112+
playerAdapter.getMasterOverlayFragment().setFadingEnabled(true);
113+
showControlsOverlay(false);
114+
}
115+
116+
public boolean isSubtitleOffsetMode() {
117+
return subtitleOffsetMode;
118+
}
119+
98120
public void mediaInfoChanged() {
99121
org.jellyfin.sdk.model.api.BaseItemDto currentlyPlayingItem = playbackControllerContainer.getValue().getPlaybackController().getCurrentlyPlayingItem();
100122
if (currentlyPlayingItem == null) return;

app/src/main/java/org/jellyfin/androidtv/ui/playback/overlay/VideoPlayerAdapter.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
package org.jellyfin.androidtv.ui.playback.overlay;
22

33
import androidx.annotation.NonNull;
4+
import androidx.media3.common.MimeTypes;
45
import androidx.leanback.media.PlayerAdapter;
56

67
import org.jellyfin.androidtv.auth.repository.UserRepository;
78
import org.jellyfin.androidtv.ui.playback.CustomPlaybackOverlayFragment;
89
import org.jellyfin.androidtv.ui.playback.PlaybackController;
10+
import org.jellyfin.androidtv.ui.playback.VideoManagerHelperKt;
911
import org.jellyfin.androidtv.util.Utils;
1012
import org.jellyfin.androidtv.util.apiclient.StreamHelper;
1113
import org.jellyfin.sdk.model.api.ChapterInfo;
14+
import org.jellyfin.sdk.model.api.MediaStream;
15+
import org.jellyfin.sdk.model.api.MediaStreamType;
16+
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod;
1217
import org.jellyfin.sdk.model.api.MediaSourceInfo;
1318
import org.koin.java.KoinJavaComponent;
1419

@@ -104,6 +109,37 @@ public boolean hasSubs() {
104109
return StreamHelper.getSubtitleStreams(playbackController.getCurrentMediaSource()).size() > 0;
105110
}
106111

112+
public boolean hasTimingAdjustableSubtitle() {
113+
MediaSourceInfo mediaSource = playbackController.getCurrentMediaSource();
114+
if (mediaSource == null || mediaSource.getMediaStreams() == null) return false;
115+
116+
int selectedSubtitleStreamIndex = playbackController.getSubtitleStreamIndex();
117+
if (selectedSubtitleStreamIndex < 0) return false;
118+
119+
for (MediaStream stream : mediaSource.getMediaStreams()) {
120+
if (stream.getIndex() != selectedSubtitleStreamIndex) {
121+
continue;
122+
}
123+
124+
if (stream.getType() != MediaStreamType.SUBTITLE) {
125+
return false;
126+
}
127+
128+
SubtitleDeliveryMethod deliveryMethod = stream.getDeliveryMethod();
129+
if (deliveryMethod == SubtitleDeliveryMethod.ENCODE || deliveryMethod == SubtitleDeliveryMethod.DROP) {
130+
return false;
131+
}
132+
133+
String mimeType = VideoManagerHelperKt.getSubtitleMediaStreamCodec(stream);
134+
return MimeTypes.APPLICATION_SUBRIP.equals(mimeType)
135+
|| MimeTypes.TEXT_VTT.equals(mimeType)
136+
|| MimeTypes.TEXT_SSA.equals(mimeType)
137+
|| MimeTypes.APPLICATION_TTML.equals(mimeType);
138+
}
139+
140+
return false;
141+
}
142+
107143
public boolean hasMultiAudio() {
108144
return StreamHelper.getAudioStreams(playbackController.getCurrentMediaSource()).size() > 1;
109145
}

app/src/main/java/org/jellyfin/androidtv/ui/playback/overlay/action/ClosedCaptionsAction.kt

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ class ClosedCaptionsAction(
1717
context: Context,
1818
customPlaybackTransportControlGlue: CustomPlaybackTransportControlGlue,
1919
) : CustomAction(context, customPlaybackTransportControlGlue) {
20+
companion object {
21+
private const val ITEM_SET_OFFSET = Int.MIN_VALUE
22+
}
23+
2024
private var popup: PopupMenu? = null
25+
private val subtitleOffsetPopup = SubtitleOffsetPopup(context)
2126

2227
init {
2328
initializeWithIcon(R.drawable.ic_select_subtitle)
@@ -29,6 +34,8 @@ class ClosedCaptionsAction(
2934
context: Context,
3035
view: View,
3136
) {
37+
subtitleOffsetPopup.dismiss()
38+
3239
if (playbackController.currentStreamInfo == null) {
3340
Timber.w("StreamInfo null trying to obtain subtitles")
3441
Toast.makeText(context, "Unable to obtain subtitle info", Toast.LENGTH_LONG).show()
@@ -37,9 +44,15 @@ class ClosedCaptionsAction(
3744

3845
videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
3946
removePopup()
47+
var openingSubtitleOffsetPopup = false
4048
popup = PopupMenu(context, view, Gravity.END).apply {
4149
with(menu) {
4250
var order = 0
51+
52+
if (videoPlayerAdapter.hasTimingAdjustableSubtitle()) {
53+
add(1, ITEM_SET_OFFSET, order++, context.getString(R.string.lbl_subtitle_offset))
54+
}
55+
4356
add(0, -1, order++, context.getString(R.string.lbl_none)).apply {
4457
isChecked = playbackController.subtitleStreamIndex == -1
4558
}
@@ -55,18 +68,29 @@ class ClosedCaptionsAction(
5568
setGroupCheckable(0, true, false)
5669
}
5770
setOnDismissListener {
58-
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
71+
if (!openingSubtitleOffsetPopup) {
72+
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
73+
}
5974
popup = null
6075
}
6176
setOnMenuItemClickListener { item ->
62-
playbackController.setSubtitleIndex(item.itemId)
63-
true
77+
if (item.itemId == ITEM_SET_OFFSET) {
78+
openingSubtitleOffsetPopup = true
79+
view.post {
80+
subtitleOffsetPopup.show(playbackController, videoPlayerAdapter)
81+
}
82+
false
83+
} else {
84+
playbackController.setSubtitleIndex(item.itemId)
85+
true
86+
}
6487
}
6588
}
6689
popup?.show()
6790
}
6891

6992
fun removePopup() {
7093
popup?.dismiss()
94+
subtitleOffsetPopup.dismiss()
7195
}
7296
}

0 commit comments

Comments
 (0)