Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add ability to use latest audio and subtitles #4514

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
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 @@ -65,6 +65,16 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
*/
var mediaQueuingEnabled = booleanPreference("pref_enable_tv_queuing", true)

/**
* Set audio track to match previous video
*/
var rememberAudio = booleanPreference("pref_remember_audio", true)

/**
* Set subtitle track to match previous video
*/
var rememberSubtitle = booleanPreference("pref_remember_subtitle", true)

/**
* Enable the next up screen or not
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior;
import org.jellyfin.androidtv.preference.constant.ZoomMode;
import org.jellyfin.androidtv.ui.livetv.TvManager;
import org.jellyfin.androidtv.util.PlaybackHelper;
import org.jellyfin.androidtv.util.TimeUtils;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.ReportingHelper;
Expand All @@ -41,16 +42,16 @@
import org.jellyfin.sdk.model.serializer.UUIDSerializerKt;
import org.koin.java.KoinJavaComponent;

import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.stream.Collectors;

import kotlin.Lazy;
import timber.log.Timber;

import java.time.Duration;

public class PlaybackController implements PlaybackControllerNotifiable {
// Frequency to report playback progress
private final static long PROGRESS_REPORTING_INTERVAL = TimeUtils.secondsToMillis(3);
Expand All @@ -63,6 +64,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
private Lazy<org.jellyfin.sdk.api.client.ApiClient> api = inject(org.jellyfin.sdk.api.client.ApiClient.class);
private Lazy<DataRefreshService> dataRefreshService = inject(DataRefreshService.class);
private Lazy<ReportingHelper> reportingHelper = inject(ReportingHelper.class);
final Lazy<PlaybackHelper> playbackHelper = inject(PlaybackHelper.class);

List<BaseItemDto> mItems;
VideoManager mVideoManager;
Expand All @@ -76,7 +78,11 @@ public class PlaybackController implements PlaybackControllerNotifiable {
private CustomPlaybackOverlayFragment mFragment;
private Boolean spinnerOff = false;

protected VideoOptions mCurrentOptions;
protected VideoOptions mCurrentOptions = new VideoOptions();
private MediaStream mPreviousSubtitle;
private MediaStream mPreviousAudio;
private Integer inferredAudioIndex;
private Integer inferredSubtitleIndex;
private int mDefaultAudioIndex = -1;
protected boolean burningSubs = false;
private float mRequestedPlaybackSpeed = -1.0f;
Expand All @@ -102,6 +108,9 @@ public class PlaybackController implements PlaybackControllerNotifiable {
private Display.Mode[] mDisplayModes;
private RefreshRateSwitchingBehavior refreshRateSwitchingBehavior = RefreshRateSwitchingBehavior.DISABLED;

private final Boolean rememberPreviousAudioTrack;
private final Boolean rememberPreviousSubtitleTrack;

public PlaybackController(List<BaseItemDto> items, CustomPlaybackOverlayFragment fragment) {
this(items, fragment, 0);
}
Expand All @@ -115,7 +124,8 @@ public PlaybackController(List<BaseItemDto> items, CustomPlaybackOverlayFragment
mFragment = fragment;
mHandler = new Handler();


rememberPreviousAudioTrack = userPreferences.getValue().get(UserPreferences.Companion.getRememberAudio());
rememberPreviousSubtitleTrack = userPreferences.getValue().get(UserPreferences.Companion.getRememberSubtitle());
refreshRateSwitchingBehavior = userPreferences.getValue().get(UserPreferences.Companion.getRefreshRateSwitchingBehavior());
if (refreshRateSwitchingBehavior != RefreshRateSwitchingBehavior.DISABLED)
getDisplayModes();
Expand Down Expand Up @@ -536,25 +546,83 @@ public void onError(Exception exception) {
}
});
} else {
playbackManager.getValue().getVideoStreamInfo(mFragment, internalOptions, position * 10000, new Response<StreamInfo>() {
@Override
public void onResponse(StreamInfo internalResponse) {
Timber.i("Internal player would %s", internalResponse.getPlayMethod().equals(PlayMethod.TRANSCODE) ? "transcode" : "direct stream");
if (mVideoManager == null)
return;
mCurrentOptions = internalOptions;
if (internalOptions.getSubtitleStreamIndex() == null) burningSubs = internalResponse.getSubtitleDeliveryMethod() == SubtitleDeliveryMethod.ENCODE;
startItem(item, position, internalResponse);
}
if (rememberPreviousAudioTrack || rememberPreviousSubtitleTrack)
buildPreviousOptionsAndStartItem(item, position, internalOptions);
else
getVideoStreamInfoAndStartItem(internalOptions, position, item);
}
}

@Override
public void onError(Exception exception) {
Timber.e(exception, "Unable to get stream info for internal player");
if (mVideoManager == null)
return;
}
});
private void buildPreviousOptionsAndStartItem(BaseItemDto item, Long position, VideoOptions internalOptions) {
playbackHelper.getValue().getLastPlayedItem(mFragment.getContext(), getCurrentlyPlayingItem(), new Response<BaseItemDto>() {
@Override
public void onResponse(BaseItemDto lastPlayedItem) {

final VideoOptions lastPlayedVideoOptions = new VideoOptions();
lastPlayedVideoOptions.setItemId(lastPlayedItem.getId());

playbackManager.getValue().getVideoStreamInfo(mFragment, lastPlayedVideoOptions, 0, new Response<StreamInfo>() {
@Override
public void onResponse(StreamInfo lastPlayedStreamInfo) {
final MediaSourceInfo lastPlayedMediaSource = lastPlayedStreamInfo.getMediaSource();

if (rememberPreviousAudioTrack)
mPreviousAudio = lastPlayedMediaSource.getMediaStreams().get(lastPlayedMediaSource.getDefaultAudioStreamIndex());
if (rememberPreviousSubtitleTrack)
mPreviousSubtitle = lastPlayedMediaSource.getMediaStreams().get(lastPlayedMediaSource.getDefaultSubtitleStreamIndex());

if (item.getMediaStreams() != null && rememberPreviousAudioTrack)
inferredAudioIndex = inferMediaStreamIndex(item, mPreviousAudio);
if (item.getMediaStreams() != null && rememberPreviousSubtitleTrack)
inferredSubtitleIndex = inferMediaStreamIndex(item, mPreviousSubtitle);

getVideoStreamInfoAndStartItem(internalOptions, position, item);
}
});
}
});
}

private Integer inferMediaStreamIndex(final BaseItemDto item, final MediaStream previousMediaStream) {
assert item.getMediaStreams() != null;
Integer newMediaStream;
List<MediaStream> matchingMediaStreamList = item.getMediaStreams()
.stream()
.filter(mediaStream ->
previousMediaStream.getType().equals(mediaStream.getType()) &&
previousMediaStream.getLanguage().equals(mediaStream.getLanguage()))
.collect(Collectors.toList());

if (matchingMediaStreamList.size() == 1) {
newMediaStream = item.getMediaStreams().indexOf(matchingMediaStreamList.get(0));
} else {
newMediaStream = matchingMediaStreamList
.stream()
.filter(mediaStream -> previousMediaStream.getTitle().equals(mediaStream.getTitle()))
.findFirst()
.map(mediaStream -> item.getMediaStreams().indexOf(mediaStream))
.orElseGet(() -> null);
}
return newMediaStream;
}

private void getVideoStreamInfoAndStartItem(VideoOptions internalOptions, Long position, BaseItemDto item) {
playbackManager.getValue().getVideoStreamInfo(mFragment, internalOptions, position * 10000, new Response<StreamInfo>() {
@Override
public void onResponse(StreamInfo internalResponse) {
Timber.i("Internal player would %s", internalResponse.getPlayMethod().equals(PlayMethod.TRANSCODE) ? "transcode" : "direct stream");
if (mVideoManager == null)
return;
if (inferredSubtitleIndex == null)
burningSubs = internalResponse.getSubtitleDeliveryMethod() == SubtitleDeliveryMethod.ENCODE;
startItem(item, position, internalResponse);
}

@Override
public void onError(Exception exception) {
Timber.e(exception, "Unable to get stream info for internal player");
}
});
}

private void handlePlaybackInfoError(Exception exception) {
Expand Down Expand Up @@ -602,7 +670,7 @@ private void startItem(BaseItemDto item, long position, StreamInfo response) {
}

// get subtitle info
mCurrentOptions.setSubtitleStreamIndex(response.getMediaSource().getDefaultSubtitleStreamIndex() != null ? response.getMediaSource().getDefaultSubtitleStreamIndex() : null);
mCurrentOptions.setSubtitleStreamIndex(response.getMediaSource().getDefaultSubtitleStreamIndex());
setDefaultAudioIndex(response);
Timber.d("default audio index set to %s remote default %s", mDefaultAudioIndex, response.getMediaSource().getDefaultAudioStreamIndex());
Timber.d("default sub index set to %s remote default %s", mCurrentOptions.getSubtitleStreamIndex(), response.getMediaSource().getDefaultSubtitleStreamIndex());
Expand Down Expand Up @@ -696,15 +764,17 @@ private void setDefaultAudioIndex(StreamInfo info) {

Integer remoteDefault = info.getMediaSource().getDefaultAudioStreamIndex();
Integer bestGuess = bestGuessAudioTrack(info.getMediaSource());

if (remoteDefault != null)
if (inferredAudioIndex != null)
mDefaultAudioIndex = inferredAudioIndex;
else if (remoteDefault != null)
mDefaultAudioIndex = remoteDefault;
else if (bestGuess != null)
mDefaultAudioIndex = bestGuess;
Timber.d("default audio index set to %s", mDefaultAudioIndex);
}

public void switchAudioStream(int index) {
mVideoManager.setExoPlayerTrack(index, MediaStreamType.AUDIO, getCurrentlyPlayingItem().getMediaStreams());
if (!(isPlaying() || isPaused()) || index < 0)
return;

Expand Down Expand Up @@ -1124,18 +1194,24 @@ public void onPrepared() {
} else {
if (!burningSubs) {
// Make sure the requested subtitles are enabled when external/embedded
Integer currentSubtitleIndex = mCurrentOptions.getSubtitleStreamIndex();
if (currentSubtitleIndex == null) currentSubtitleIndex = -1;
PlaybackControllerHelperKt.setSubtitleIndex(this, currentSubtitleIndex, true);
Integer subtitleIndex;
if (rememberPreviousSubtitleTrack) subtitleIndex = inferredSubtitleIndex;
else subtitleIndex = mCurrentOptions.getSubtitleStreamIndex();

if (subtitleIndex == null) subtitleIndex = -1;
PlaybackControllerHelperKt.setSubtitleIndex(this, subtitleIndex, true);
}

// select an audio track
Integer currentAudioIndex = mCurrentOptions.getAudioStreamIndex();
int eligibleAudioTrack = mDefaultAudioIndex;

// if track switching is done without rebuilding the stream, mCurrentOptions is updated
// otherwise, use the server default
if (mCurrentOptions.getAudioStreamIndex() != null) {
eligibleAudioTrack = mCurrentOptions.getAudioStreamIndex();
if (rememberPreviousAudioTrack && inferredAudioIndex != null)
eligibleAudioTrack = inferredAudioIndex;
else if (currentAudioIndex != null) {
eligibleAudioTrack = currentAudioIndex;
} else if (getCurrentMediaSource().getDefaultAudioStreamIndex() != null) {
eligibleAudioTrack = getCurrentMediaSource().getDefaultAudioStreamIndex();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ class PlaybackAdvancedPreferencesScreen : OptionsFragment() {
setTitle(R.string.lbl_tv_queuing)
bind(userPreferences, UserPreferences.mediaQueuingEnabled)
}

checkbox {
setTitle(R.string.lbl_remember_audio)
setContent(R.string.desc_remember_audio)
bind(userPreferences, UserPreferences.rememberAudio)
}

checkbox {
setTitle(R.string.lbl_remember_subtitle)
setContent(R.string.desc_remember_subtitle)
bind(userPreferences, UserPreferences.rememberSubtitle)
}
}

category {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ interface PlaybackHelper {
retrieveAndPlay(id, shuffle, null, context)

fun playInstantMix(context: Context, item: BaseItemDto)

fun getLastPlayedItem(context: Context, mainItem: BaseItemDto, callback: Response<BaseItemDto?>)
}
Original file line number Diff line number Diff line change
Expand Up @@ -261,4 +261,31 @@ class SdkPlaybackHelper(
is LifecycleOwner -> context.lifecycleScope
else -> ProcessLifecycleOwner.get().lifecycleScope
}

override fun getLastPlayedItem(
context: Context,
mainItem: BaseItemDto,
callback: Response<BaseItemDto?>
) {
getScope(context).launch {
runCatching {
mainItem.seriesId
?.let {
val response by api.tvShowsApi.getEpisodes(
seriesId = it,
isMissing = false,
fields = ItemRepository.itemFields,
enableImages = false,
)
response.items
}
?.filter { it.userData?.played ?: true }
?.sortedByDescending { it.userData?.lastPlayedDate }
?.let { it[0] }
}.fold(
onSuccess = { callback.onResponse(it) },
onFailure = { callback.onError(Exception(it)) }
)
}
}
}
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@
<string name="lbl_no_items">No items</string>
<string name="lbl_empty">Empty</string>
<string name="lbl_tv_queuing">Play next episode automatically</string>
<string name="lbl_remember_audio">Set audio tracked based on previous item</string>
<string name="desc_remember_audio">Try to set the audio track to the closest match to the last video</string>
<string name="lbl_remember_subtitle">Set subtitle tracked based on previous item</string>
<string name="desc_remember_subtitle">Try to set the subtitle track to the closest match to the last video</string>
<string name="lbl_search_hint">Search text (select for keyboard)</string>
<string name="lbl_play_first_unwatched">Play first unwatched</string>
<string name="lbl_mark_unplayed">Mark unplayed</string>
Expand Down