Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,22 @@ public float getPlaybackSpeed() {
}
}

public long getSubtitleTimingOffsetUs() {
return hasInitializedVideoManager() ? mVideoManager.getSubtitleTimingOffsetUs() : 0L;
}

public void adjustSubtitleTimingOffsetUs(long deltaUs) {
if (hasInitializedVideoManager()) {
mVideoManager.adjustSubtitleTimingOffsetUs(deltaUs);
}
}

public void resetSubtitleTimingOffset() {
if (hasInitializedVideoManager()) {
mVideoManager.resetSubtitleTimingOffset();
}
}

public void setPlaybackSpeed(float speed) {
mRequestedPlaybackSpeed = speed;
if (hasInitializedVideoManager()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
import androidx.media3.exoplayer.util.EventLogger;
import androidx.media3.extractor.DefaultExtractorsFactory;
import androidx.media3.extractor.ExtractorsFactory;
import androidx.media3.extractor.text.DefaultSubtitleParserFactory;
import androidx.media3.extractor.text.SubtitleParser;
import androidx.media3.extractor.ts.TsExtractor;
import androidx.media3.ui.AspectRatioFrameLayout;
import androidx.media3.ui.CaptionStyleCompat;
Expand All @@ -51,6 +53,8 @@
import org.jellyfin.androidtv.preference.UserPreferences;
import org.jellyfin.androidtv.preference.constant.BufferLength;
import org.jellyfin.androidtv.preference.constant.ZoomMode;
import org.jellyfin.playback.media3.exoplayer.subtitle.SubtitleTimingOffsetRenderersFactory;
import org.jellyfin.playback.media3.exoplayer.subtitle.SubtitleTimingOffsetState;
import org.jellyfin.sdk.api.client.ApiClient;
import org.jellyfin.sdk.model.api.MediaStream;
import org.jellyfin.sdk.model.api.MediaStreamType;
Expand Down Expand Up @@ -83,6 +87,7 @@ public class VideoManager {
public ExoPlayer mExoPlayer;
private PlayerView mExoPlayerView;
private Handler mHandler = new Handler();
private final SubtitleTimingOffsetState subtitleTimingOffsetState = new SubtitleTimingOffsetState();

private long mMetaDuration = -1;
private long lastExoPlayerPosition = -1;
Expand Down Expand Up @@ -218,35 +223,46 @@ private int determineExoPlayerExtensionRendererMode() {
*/
private ExoPlayer.Builder configureExoplayerBuilder(Context context, AssHandler assHandler) {
ExoPlayer.Builder exoPlayerBuilder = new ExoPlayer.Builder(context);
DefaultRenderersFactory defaultRendererFactory = new DefaultRenderersFactory(context);
defaultRendererFactory.setEnableDecoderFallback(true);
defaultRendererFactory.setExtensionRendererMode(determineExoPlayerExtensionRendererMode());

DefaultTrackSelector trackSelector = new DefaultTrackSelector(context);
trackSelector.setParameters(trackSelector.buildUponParameters()
.setAudioOffloadPreferences(new TrackSelectionParameters.AudioOffloadPreferences.Builder()
.setAudioOffloadMode(TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
.build()
)
.setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
.build()

DefaultDataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(
context,
exoPlayerHttpDataSourceFactory
);
DefaultExtractorsFactory extractorsFactory = new DefaultExtractorsFactory();
extractorsFactory.setTsExtractorTimestampSearchBytes(
3 * TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES
);
exoPlayerBuilder.setTrackSelector(trackSelector);

DefaultExtractorsFactory extractorsFactory = new DefaultExtractorsFactory().setTsExtractorTimestampSearchBytes(TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES * 3);
extractorsFactory.setConstantBitrateSeekingEnabled(true);
extractorsFactory.setConstantBitrateSeekingAlwaysEnabled(true);
DefaultDataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(context, exoPlayerHttpDataSourceFactory);
if (assHandler != null) {
AssSubtitleParserFactory assSubtitleParserFactory = new AssSubtitleParserFactory(assHandler);
SubtitleTimingOffsetRenderersFactory rendererFactory = new SubtitleTimingOffsetRenderersFactory(
context,
subtitleTimingOffsetState,
assSubtitleParserFactory
);
rendererFactory.setEnableDecoderFallback(true);
rendererFactory.setExtensionRendererMode(determineExoPlayerExtensionRendererMode());

ExtractorsFactory assExtractorsFactory = AssPlayerKt.withAssMkvSupport(extractorsFactory, assSubtitleParserFactory, assHandler);
DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(dataSourceFactory, assExtractorsFactory);
mediaSourceFactory.experimentalParseSubtitlesDuringExtraction(false);
mediaSourceFactory.setSubtitleParserFactory(assSubtitleParserFactory);
exoPlayerBuilder.setMediaSourceFactory(mediaSourceFactory);
exoPlayerBuilder.setRenderersFactory(new AssRenderersFactory(assHandler, defaultRendererFactory));
exoPlayerBuilder.setRenderersFactory(new AssRenderersFactory(assHandler, rendererFactory));
} else {
exoPlayerBuilder.setRenderersFactory(defaultRendererFactory);
exoPlayerBuilder.setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory));
DefaultSubtitleParserFactory defaultSubtitleParserFactory = new DefaultSubtitleParserFactory();
SubtitleTimingOffsetRenderersFactory rendererFactory = new SubtitleTimingOffsetRenderersFactory(
context,
subtitleTimingOffsetState,
defaultSubtitleParserFactory
);
rendererFactory.setEnableDecoderFallback(true);
rendererFactory.setExtensionRendererMode(determineExoPlayerExtensionRendererMode());

exoPlayerBuilder.setRenderersFactory(rendererFactory);
exoPlayerBuilder.setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)
.experimentalParseSubtitlesDuringExtraction(false)
.setSubtitleParserFactory(defaultSubtitleParserFactory));
}

BufferLength bufferLength = userPreferences.get(UserPreferences.Companion.getBufferLength());
Expand Down Expand Up @@ -600,6 +616,19 @@ public void setPlaybackSpeed(float speed) {
mExoPlayer.setPlaybackParameters(new PlaybackParameters(speed));
}

public long getSubtitleTimingOffsetUs() {
return subtitleTimingOffsetState.getOffsetUs();
}


public void adjustSubtitleTimingOffsetUs(long deltaUs) {
subtitleTimingOffsetState.adjustOffsetUs(deltaUs);
}

public void resetSubtitleTimingOffset() {
subtitleTimingOffsetState.setOffsetUs(0L);
}

public void destroy() {
mPlaybackControllerNotifiable = null;
stopPlayback();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment {
private CustomPlaybackTransportControlGlue playerGlue;
private VideoPlayerAdapter playerAdapter;
private boolean shouldShowOverlay = true;
private boolean subtitleOffsetMode = false;
private Lazy<PlaybackControllerContainer> playbackControllerContainer = inject(PlaybackControllerContainer.class);
private final Lazy<UserSettingPreferences> userSettingPreferences = inject(UserSettingPreferences.class);
private Lazy<ImageLoader> imageLoader = inject(ImageLoader.class);
Expand Down Expand Up @@ -95,6 +96,27 @@ public void setFading(boolean fadingEnabled) {
playerAdapter.getMasterOverlayFragment().setFadingEnabled(fadingEnabled);
}

public void enterSubtitleOffsetMode() {
subtitleOffsetMode = true;
setShouldShowOverlay(false);
setBackgroundType(BG_NONE);
super.hideControlsOverlay(false);
playerAdapter.getMasterOverlayFragment().hide();
playerAdapter.getMasterOverlayFragment().setFadingEnabled(false);
}

public void exitSubtitleOffsetMode() {
subtitleOffsetMode = false;
setBackgroundType(BG_LIGHT);
setShouldShowOverlay(true);
playerAdapter.getMasterOverlayFragment().setFadingEnabled(true);
showControlsOverlay(false);
}

public boolean isSubtitleOffsetMode() {
return subtitleOffsetMode;
}

public void mediaInfoChanged() {
org.jellyfin.sdk.model.api.BaseItemDto currentlyPlayingItem = playbackControllerContainer.getValue().getPlaybackController().getCurrentlyPlayingItem();
if (currentlyPlayingItem == null) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@
import org.jellyfin.androidtv.auth.repository.UserRepository;
import org.jellyfin.androidtv.ui.playback.CustomPlaybackOverlayFragment;
import org.jellyfin.androidtv.ui.playback.PlaybackController;
import org.jellyfin.playback.media3.exoplayer.subtitle.SubtitleTimingOffsetFormatsKt;
import org.jellyfin.androidtv.ui.playback.VideoManagerHelperKt;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.StreamHelper;
import org.jellyfin.sdk.model.api.ChapterInfo;
import org.jellyfin.sdk.model.api.MediaStream;
import org.jellyfin.sdk.model.api.MediaStreamType;
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod;
import org.jellyfin.sdk.model.api.MediaSourceInfo;
import org.koin.java.KoinJavaComponent;

Expand Down Expand Up @@ -104,6 +109,34 @@ public boolean hasSubs() {
return StreamHelper.getSubtitleStreams(playbackController.getCurrentMediaSource()).size() > 0;
}

public boolean hasTimingAdjustableSubtitle() {
MediaSourceInfo mediaSource = playbackController.getCurrentMediaSource();
if (mediaSource == null || mediaSource.getMediaStreams() == null) return false;

int selectedSubtitleStreamIndex = playbackController.getSubtitleStreamIndex();
if (selectedSubtitleStreamIndex < 0) return false;

for (MediaStream stream : mediaSource.getMediaStreams()) {
if (stream.getIndex() != selectedSubtitleStreamIndex) {
continue;
}

if (stream.getType() != MediaStreamType.SUBTITLE) {
return false;
}

SubtitleDeliveryMethod deliveryMethod = stream.getDeliveryMethod();
if (deliveryMethod == SubtitleDeliveryMethod.ENCODE || deliveryMethod == SubtitleDeliveryMethod.DROP) {
return false;
}

String mimeType = VideoManagerHelperKt.getSubtitleMediaStreamCodec(stream);
return SubtitleTimingOffsetFormatsKt.isSubtitleTimingOffsetSupported(mimeType);
}

return false;
}

public boolean hasMultiAudio() {
return StreamHelper.getAudioStreams(playbackController.getCurrentMediaSource()).size() > 1;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ class ClosedCaptionsAction(
context: Context,
customPlaybackTransportControlGlue: CustomPlaybackTransportControlGlue,
) : CustomAction(context, customPlaybackTransportControlGlue) {
companion object {
private const val ITEM_SET_OFFSET = Int.MIN_VALUE
}

private var popup: PopupMenu? = null
private val subtitleOffsetPopup = SubtitleOffsetPopup(context)

init {
initializeWithIcon(R.drawable.ic_select_subtitle)
Expand All @@ -29,6 +34,8 @@ class ClosedCaptionsAction(
context: Context,
view: View,
) {
subtitleOffsetPopup.dismiss()

if (playbackController.currentStreamInfo == null) {
Timber.w("StreamInfo null trying to obtain subtitles")
Toast.makeText(context, "Unable to obtain subtitle info", Toast.LENGTH_LONG).show()
Expand All @@ -37,9 +44,15 @@ class ClosedCaptionsAction(

videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
removePopup()
var openingSubtitleOffsetPopup = false
popup = PopupMenu(context, view, Gravity.END).apply {
with(menu) {
var order = 0

if (videoPlayerAdapter.hasTimingAdjustableSubtitle()) {
add(1, ITEM_SET_OFFSET, order++, context.getString(R.string.lbl_subtitle_offset))
}

add(0, -1, order++, context.getString(R.string.lbl_none)).apply {
isChecked = playbackController.subtitleStreamIndex == -1
}
Expand All @@ -55,18 +68,29 @@ class ClosedCaptionsAction(
setGroupCheckable(0, true, false)
}
setOnDismissListener {
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
if (!openingSubtitleOffsetPopup) {
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
}
popup = null
}
setOnMenuItemClickListener { item ->
playbackController.setSubtitleIndex(item.itemId)
true
if (item.itemId == ITEM_SET_OFFSET) {
openingSubtitleOffsetPopup = true
view.post {
subtitleOffsetPopup.show(playbackController, videoPlayerAdapter)
}
false
} else {
playbackController.setSubtitleIndex(item.itemId)
true
}
}
}
popup?.show()
}

fun removePopup() {
popup?.dismiss()
subtitleOffsetPopup.dismiss()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.jellyfin.androidtv.ui.playback.overlay.action

import java.util.Locale
import kotlin.math.abs
import kotlin.time.Duration

fun formatSubtitleOffsetSeconds(offsetUs: Long): String {
val seconds = offsetUs / 1_000_000.0
val safeSeconds = if (abs(seconds) < 0.05) 0.0 else seconds
return String.format(Locale.getDefault(), "%+.1f", safeSeconds)
}

fun formatSubtitleOffsetSeconds(offset: Duration): String =
formatSubtitleOffsetSeconds(offset.inWholeMicroseconds)
Loading