Merge pull request #9937 from Theta-Dev/alang-selector

Add support for multiple audio tracks
This commit is contained in:
Stypox 2023-05-02 10:07:21 +02:00 committed by GitHub
commit 2315b082ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1190 additions and 259 deletions

View File

@ -68,6 +68,8 @@ import org.schabi.newpipe.util.SecondaryStreamHelper;
import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
import org.schabi.newpipe.util.StreamItemAdapter;
import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper;
import org.schabi.newpipe.util.AudioTrackAdapter;
import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper;
import org.schabi.newpipe.util.ThemeHelper;
import java.io.File;
@ -95,12 +97,14 @@ public class DownloadDialog extends DialogFragment
@State
StreamInfo currentInfo;
@State
StreamSizeWrapper<AudioStream> wrappedAudioStreams;
@State
StreamSizeWrapper<VideoStream> wrappedVideoStreams;
@State
StreamSizeWrapper<SubtitlesStream> wrappedSubtitleStreams;
@State
AudioTracksWrapper wrappedAudioTracks;
@State
int selectedAudioTrackIndex;
@State
int selectedVideoIndex; // set in the constructor
@State
int selectedAudioIndex = 0; // default to the first item
@ -117,6 +121,7 @@ public class DownloadDialog extends DialogFragment
private Context context;
private boolean askForSavePath;
private AudioTrackAdapter audioTrackAdapter;
private StreamItemAdapter<AudioStream, Stream> audioStreamsAdapter;
private StreamItemAdapter<VideoStream, AudioStream> videoStreamsAdapter;
private StreamItemAdapter<SubtitlesStream, Stream> subtitleStreamsAdapter;
@ -163,18 +168,26 @@ public class DownloadDialog extends DialogFragment
public DownloadDialog(@NonNull final Context context, @NonNull final StreamInfo info) {
this.currentInfo = info;
final List<AudioStream> audioStreams =
getStreamsOfSpecifiedDelivery(info.getAudioStreams(), PROGRESSIVE_HTTP);
final List<List<AudioStream>> groupedAudioStreams =
ListHelper.getGroupedAudioStreams(context, audioStreams);
this.wrappedAudioTracks = new AudioTracksWrapper(groupedAudioStreams, context);
this.selectedAudioTrackIndex =
ListHelper.getDefaultAudioTrackGroup(context, groupedAudioStreams);
// TODO: Adapt this code when the downloader support other types of stream deliveries
final List<VideoStream> videoStreams = ListHelper.getSortedStreamVideosList(
context,
getStreamsOfSpecifiedDelivery(info.getVideoStreams(), PROGRESSIVE_HTTP),
getStreamsOfSpecifiedDelivery(info.getVideoOnlyStreams(), PROGRESSIVE_HTTP),
false,
false
// If there are multiple languages available, prefer streams without audio
// to allow language selection
wrappedAudioTracks.size() > 1
);
this.wrappedVideoStreams = new StreamSizeWrapper<>(videoStreams, context);
this.wrappedAudioStreams = new StreamSizeWrapper<>(
getStreamsOfSpecifiedDelivery(info.getAudioStreams(), PROGRESSIVE_HTTP), context);
this.wrappedSubtitleStreams = new StreamSizeWrapper<>(
getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context);
@ -212,33 +225,9 @@ public class DownloadDialog extends DialogFragment
setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context));
Icepick.restoreInstanceState(this, savedInstanceState);
final var secondaryStreams = new SparseArrayCompat<SecondaryStreamHelper<AudioStream>>(4);
final List<VideoStream> videoStreams = wrappedVideoStreams.getStreamsList();
for (int i = 0; i < videoStreams.size(); i++) {
if (!videoStreams.get(i).isVideoOnly()) {
continue;
}
final AudioStream audioStream = SecondaryStreamHelper
.getAudioStreamFor(wrappedAudioStreams.getStreamsList(), videoStreams.get(i));
if (audioStream != null) {
secondaryStreams.append(i, new SecondaryStreamHelper<>(wrappedAudioStreams,
audioStream));
} else if (DEBUG) {
final MediaFormat mediaFormat = videoStreams.get(i).getFormat();
if (mediaFormat != null) {
Log.w(TAG, "No audio stream candidates for video format "
+ mediaFormat.name());
} else {
Log.w(TAG, "No audio stream candidates for unknown video format");
}
}
}
this.videoStreamsAdapter = new StreamItemAdapter<>(wrappedVideoStreams, secondaryStreams);
this.audioStreamsAdapter = new StreamItemAdapter<>(wrappedAudioStreams);
this.audioTrackAdapter = new AudioTrackAdapter(wrappedAudioTracks);
this.subtitleStreamsAdapter = new StreamItemAdapter<>(wrappedSubtitleStreams);
updateSecondaryStreams();
final Intent intent = new Intent(context, DownloadManagerService.class);
context.startService(intent);
@ -265,6 +254,39 @@ public class DownloadDialog extends DialogFragment
}, Context.BIND_AUTO_CREATE);
}
/**
* Update the displayed video streams based on the selected audio track.
*/
private void updateSecondaryStreams() {
final StreamSizeWrapper<AudioStream> audioStreams = getWrappedAudioStreams();
final var secondaryStreams = new SparseArrayCompat<SecondaryStreamHelper<AudioStream>>(4);
final List<VideoStream> videoStreams = wrappedVideoStreams.getStreamsList();
wrappedVideoStreams.resetSizes();
for (int i = 0; i < videoStreams.size(); i++) {
if (!videoStreams.get(i).isVideoOnly()) {
continue;
}
final AudioStream audioStream = SecondaryStreamHelper
.getAudioStreamFor(audioStreams.getStreamsList(), videoStreams.get(i));
if (audioStream != null) {
secondaryStreams.append(i, new SecondaryStreamHelper<>(audioStreams, audioStream));
} else if (DEBUG) {
final MediaFormat mediaFormat = videoStreams.get(i).getFormat();
if (mediaFormat != null) {
Log.w(TAG, "No audio stream candidates for video format "
+ mediaFormat.name());
} else {
Log.w(TAG, "No audio stream candidates for unknown video format");
}
}
}
this.videoStreamsAdapter = new StreamItemAdapter<>(wrappedVideoStreams, secondaryStreams);
this.audioStreamsAdapter = new StreamItemAdapter<>(audioStreams);
}
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
final ViewGroup container,
@ -285,13 +307,13 @@ public class DownloadDialog extends DialogFragment
dialogBinding.fileName.setText(FilenameUtils.createFilename(getContext(),
currentInfo.getName()));
selectedAudioIndex = ListHelper
.getDefaultAudioFormat(getContext(), wrappedAudioStreams.getStreamsList());
selectedAudioIndex = ListHelper.getDefaultAudioFormat(getContext(),
getWrappedAudioStreams().getStreamsList());
selectedSubtitleIndex = getSubtitleIndexBy(subtitleStreamsAdapter.getAll());
dialogBinding.qualitySpinner.setOnItemSelectedListener(this);
dialogBinding.audioTrackSpinner.setOnItemSelectedListener(this);
dialogBinding.videoAudioGroup.setOnCheckedChangeListener(this);
initToolbar(dialogBinding.toolbarLayout.toolbar);
@ -383,7 +405,7 @@ public class DownloadDialog extends DialogFragment
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
"Downloading video stream size",
currentInfo.getServiceId()))));
disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedAudioStreams)
disposables.add(StreamSizeWrapper.fetchSizeForWrapper(getWrappedAudioStreams())
.subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
== R.id.audio_button) {
@ -405,14 +427,28 @@ public class DownloadDialog extends DialogFragment
currentInfo.getServiceId()))));
}
private void setupAudioTrackSpinner() {
if (getContext() == null) {
return;
}
dialogBinding.audioTrackSpinner.setAdapter(audioTrackAdapter);
dialogBinding.audioTrackSpinner.setSelection(selectedAudioTrackIndex);
}
private void setupAudioSpinner() {
if (getContext() == null) {
return;
}
dialogBinding.qualitySpinner.setAdapter(audioStreamsAdapter);
dialogBinding.qualitySpinner.setSelection(selectedAudioIndex);
dialogBinding.qualitySpinner.setVisibility(View.GONE);
setRadioButtonsState(true);
dialogBinding.audioStreamSpinner.setAdapter(audioStreamsAdapter);
dialogBinding.audioStreamSpinner.setSelection(selectedAudioIndex);
dialogBinding.audioStreamSpinner.setVisibility(View.VISIBLE);
dialogBinding.audioTrackSpinner.setVisibility(
wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE);
dialogBinding.audioTrackPresentInVideoText.setVisibility(View.GONE);
}
private void setupVideoSpinner() {
@ -422,7 +458,19 @@ public class DownloadDialog extends DialogFragment
dialogBinding.qualitySpinner.setAdapter(videoStreamsAdapter);
dialogBinding.qualitySpinner.setSelection(selectedVideoIndex);
dialogBinding.qualitySpinner.setVisibility(View.VISIBLE);
setRadioButtonsState(true);
dialogBinding.audioStreamSpinner.setVisibility(View.GONE);
onVideoStreamSelected();
}
private void onVideoStreamSelected() {
final boolean isVideoOnly = videoStreamsAdapter.getItem(selectedVideoIndex).isVideoOnly();
dialogBinding.audioTrackSpinner.setVisibility(
isVideoOnly && wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE);
dialogBinding.audioTrackPresentInVideoText.setVisibility(
!isVideoOnly && wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE);
}
private void setupSubtitleSpinner() {
@ -432,7 +480,11 @@ public class DownloadDialog extends DialogFragment
dialogBinding.qualitySpinner.setAdapter(subtitleStreamsAdapter);
dialogBinding.qualitySpinner.setSelection(selectedSubtitleIndex);
dialogBinding.qualitySpinner.setVisibility(View.VISIBLE);
setRadioButtonsState(true);
dialogBinding.audioStreamSpinner.setVisibility(View.GONE);
dialogBinding.audioTrackSpinner.setVisibility(View.GONE);
dialogBinding.audioTrackPresentInVideoText.setVisibility(View.GONE);
}
@ -550,18 +602,31 @@ public class DownloadDialog extends DialogFragment
+ "parent = [" + parent + "], view = [" + view + "], "
+ "position = [" + position + "], id = [" + id + "]");
}
switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) {
case R.id.audio_button:
switch (parent.getId()) {
case R.id.quality_spinner:
switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) {
case R.id.video_button:
selectedVideoIndex = position;
onVideoStreamSelected();
break;
case R.id.subtitle_button:
selectedSubtitleIndex = position;
break;
}
onItemSelectedSetFileName();
break;
case R.id.audio_track_spinner:
final boolean trackChanged = selectedAudioTrackIndex != position;
selectedAudioTrackIndex = position;
if (trackChanged) {
updateSecondaryStreams();
fetchStreamsSize();
}
break;
case R.id.audio_stream_spinner:
selectedAudioIndex = position;
break;
case R.id.video_button:
selectedVideoIndex = position;
break;
case R.id.subtitle_button:
selectedSubtitleIndex = position;
break;
}
onItemSelectedSetFileName();
}
private void onItemSelectedSetFileName() {
@ -607,6 +672,7 @@ public class DownloadDialog extends DialogFragment
protected void setupDownloadOptions() {
setRadioButtonsState(false);
setupAudioTrackSpinner();
final boolean isVideoStreamsAvailable = videoStreamsAdapter.getCount() > 0;
final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0;
@ -657,6 +723,13 @@ public class DownloadDialog extends DialogFragment
dialogBinding.subtitleButton.setEnabled(enabled);
}
private StreamSizeWrapper<AudioStream> getWrappedAudioStreams() {
if (selectedAudioTrackIndex < 0 || selectedAudioTrackIndex > wrappedAudioTracks.size()) {
return StreamSizeWrapper.empty();
}
return wrappedAudioTracks.getTracksList().get(selectedAudioTrackIndex);
}
private int getSubtitleIndexBy(@NonNull final List<SubtitlesStream> streams) {
final Localization preferredLocalization = NewPipe.getPreferredLocalization();
@ -1013,7 +1086,6 @@ public class DownloadDialog extends DialogFragment
psName = Postprocessing.ALGORITHM_WEBM_MUXER;
}
psArgs = null;
final long videoSize = wrappedVideoStreams.getSizeInBytes(
(VideoStream) selectedStream);

View File

@ -162,8 +162,12 @@ public final class VideoDetailFragment
private boolean showRelatedItems;
private boolean showDescription;
private String selectedTabTag;
@AttrRes @NonNull final List<Integer> tabIcons = new ArrayList<>();
@StringRes @NonNull final List<Integer> tabContentDescriptions = new ArrayList<>();
@AttrRes
@NonNull
final List<Integer> tabIcons = new ArrayList<>();
@StringRes
@NonNull
final List<Integer> tabContentDescriptions = new ArrayList<>();
private boolean tabSettingsChanged = false;
private int lastAppBarVerticalOffset = Integer.MAX_VALUE; // prevents useless updates
@ -1040,20 +1044,10 @@ public final class VideoDetailFragment
player.setRecovery();
}
if (!useExternalAudioPlayer) {
openNormalBackgroundPlayer(append);
if (useExternalAudioPlayer) {
showExternalAudioPlaybackDialog();
} else {
final List<AudioStream> audioStreams = getUrlAndNonTorrentStreams(
currentInfo.getAudioStreams());
final int index = ListHelper.getDefaultAudioFormat(activity, audioStreams);
if (index == -1) {
Toast.makeText(activity, R.string.no_audio_streams_available_for_external_players,
Toast.LENGTH_SHORT).show();
return;
}
startOnExternalPlayer(activity, currentInfo, audioStreams.get(index));
openNormalBackgroundPlayer(append);
}
}
@ -1106,7 +1100,7 @@ public final class VideoDetailFragment
if (PreferenceManager.getDefaultSharedPreferences(activity)
.getBoolean(this.getString(R.string.use_external_video_player_key), false)) {
showExternalPlaybackDialog();
showExternalVideoPlaybackDialog();
} else {
replaceQueueIfUserConfirms(this::openMainPlayer);
}
@ -2112,7 +2106,7 @@ public final class VideoDetailFragment
}).show();
}
private void showExternalPlaybackDialog() {
private void showExternalVideoPlaybackDialog() {
if (currentInfo == null) {
return;
}
@ -2159,6 +2153,44 @@ public final class VideoDetailFragment
builder.show();
}
private void showExternalAudioPlaybackDialog() {
if (currentInfo == null) {
return;
}
final List<AudioStream> audioStreams = getUrlAndNonTorrentStreams(
currentInfo.getAudioStreams());
final List<AudioStream> audioTracks =
ListHelper.getFilteredAudioStreams(activity, audioStreams);
if (audioTracks.isEmpty()) {
Toast.makeText(activity, R.string.no_audio_streams_available_for_external_players,
Toast.LENGTH_SHORT).show();
} else if (audioTracks.size() == 1) {
startOnExternalPlayer(activity, currentInfo, audioTracks.get(0));
} else {
final AlertDialog.Builder builder = new AlertDialog.Builder(activity);
builder.setTitle(R.string.select_audio_track_external_players);
builder.setNeutralButton(R.string.open_in_browser, (dialog, i) ->
ShareUtils.openUrlInBrowser(requireActivity(), url));
final int selectedAudioStream =
ListHelper.getDefaultAudioFormat(activity, audioTracks);
final CharSequence[] trackNames = audioTracks.stream()
.map(audioStream -> Localization.audioTrackName(activity, audioStream))
.toArray(CharSequence[]::new);
builder.setSingleChoiceItems(trackNames, selectedAudioStream, null);
builder.setNegativeButton(R.string.cancel, null);
builder.setPositiveButton(R.string.ok, (dialog, i) -> {
final int index = ((AlertDialog) dialog).getListView().getCheckedItemPosition();
startOnExternalPlayer(activity, currentInfo,
audioTracks.get(index));
});
builder.show();
}
}
/*
* Remove unneeded information while waiting for a next task
* */

View File

@ -13,6 +13,7 @@ import android.provider.Settings;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.SubMenu;
import android.view.View;
import android.view.ViewGroup;
import android.widget.SeekBar;
@ -27,11 +28,13 @@ import com.google.android.exoplayer2.PlaybackParameters;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ActivityPlayerQueueControlBinding;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.event.PlayerEventListener;
import org.schabi.newpipe.player.helper.PlaybackParameterDialog;
import org.schabi.newpipe.player.mediaitem.MediaItemTag;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueAdapter;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
@ -44,6 +47,9 @@ import org.schabi.newpipe.util.PermissionHelper;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.ThemeHelper;
import java.util.List;
import java.util.Optional;
public final class PlayQueueActivity extends AppCompatActivity
implements PlayerEventListener, SeekBar.OnSeekBarChangeListener,
View.OnClickListener, PlaybackParameterDialog.Callback {
@ -52,6 +58,8 @@ public final class PlayQueueActivity extends AppCompatActivity
private static final int SMOOTH_SCROLL_MAXIMUM_DISTANCE = 80;
private static final int MENU_ID_AUDIO_TRACK = 71;
private Player player;
private boolean serviceBound;
@ -97,6 +105,7 @@ public final class PlayQueueActivity extends AppCompatActivity
this.menu = m;
getMenuInflater().inflate(R.menu.menu_play_queue, m);
getMenuInflater().inflate(R.menu.menu_play_queue_bg, m);
buildAudioTrackMenu();
onMaybeMuteChanged();
// to avoid null reference
if (player != null) {
@ -153,6 +162,12 @@ public final class PlayQueueActivity extends AppCompatActivity
NavigationHelper.playOnBackgroundPlayer(this, player.getPlayQueue(), true);
return true;
}
if (item.getGroupId() == MENU_ID_AUDIO_TRACK) {
onAudioTrackClick(item.getItemId());
return true;
}
return super.onOptionsItemSelected(item);
}
@ -591,4 +606,69 @@ public final class PlayQueueActivity extends AppCompatActivity
item.setIcon(player.isMuted() ? R.drawable.ic_volume_off : R.drawable.ic_volume_up);
}
}
@Override
public void onAudioTrackUpdate() {
buildAudioTrackMenu();
}
private void buildAudioTrackMenu() {
if (menu == null) {
return;
}
final MenuItem audioTrackSelector = menu.findItem(R.id.action_audio_track);
final List<AudioStream> availableStreams =
Optional.ofNullable(player.getCurrentMetadata())
.flatMap(MediaItemTag::getMaybeAudioTrack)
.map(MediaItemTag.AudioTrack::getAudioStreams)
.orElse(null);
final Optional<AudioStream> selectedAudioStream = player.getSelectedAudioStream();
if (availableStreams == null || availableStreams.size() < 2
|| selectedAudioStream.isEmpty()) {
audioTrackSelector.setVisible(false);
} else {
final SubMenu audioTrackMenu = audioTrackSelector.getSubMenu();
audioTrackMenu.clear();
for (int i = 0; i < availableStreams.size(); i++) {
final AudioStream audioStream = availableStreams.get(i);
audioTrackMenu.add(MENU_ID_AUDIO_TRACK, i, Menu.NONE,
Localization.audioTrackName(this, audioStream));
}
final AudioStream s = selectedAudioStream.get();
final String trackName = Localization.audioTrackName(this, s);
audioTrackSelector.setTitle(
getString(R.string.play_queue_audio_track, trackName));
final String shortName = s.getAudioLocale() != null
? s.getAudioLocale().getLanguage() : trackName;
audioTrackSelector.setTitleCondensed(
shortName.substring(0, Math.min(shortName.length(), 2)));
audioTrackSelector.setVisible(true);
}
}
/**
* Called when an item from the audio track selector is selected.
*
* @param itemId index of the selected item
*/
private void onAudioTrackClick(final int itemId) {
if (player.getCurrentMetadata() == null) {
return;
}
player.getCurrentMetadata().getMaybeAudioTrack().ifPresent(audioTrack -> {
final List<AudioStream> availableStreams = audioTrack.getAudioStreams();
final int selectedStreamIndex = audioTrack.getSelectedAudioStreamIndex();
if (selectedStreamIndex == itemId || availableStreams.size() <= itemId) {
return;
}
final String newAudioTrack = availableStreams.get(itemId).getAudioTrackId();
player.setAudioTrack(newAudioTrack);
});
}
}

View File

@ -86,6 +86,7 @@ import org.schabi.newpipe.databinding.PlayerBinding;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.extractor.stream.VideoStream;
@ -179,13 +180,18 @@ public final class Player implements PlaybackListener, Listener {
//////////////////////////////////////////////////////////////////////////*/
// play queue might be null e.g. while player is starting
@Nullable private PlayQueue playQueue;
@Nullable
private PlayQueue playQueue;
@Nullable private MediaSourceManager playQueueManager;
@Nullable
private MediaSourceManager playQueueManager;
@Nullable private PlayQueueItem currentItem;
@Nullable private MediaItemTag currentMetadata;
@Nullable private Bitmap currentThumbnail;
@Nullable
private PlayQueueItem currentItem;
@Nullable
private MediaItemTag currentMetadata;
@Nullable
private Bitmap currentThumbnail;
/*//////////////////////////////////////////////////////////////////////////
// Player
@ -194,12 +200,17 @@ public final class Player implements PlaybackListener, Listener {
private ExoPlayer simpleExoPlayer;
private AudioReactor audioReactor;
@NonNull private final DefaultTrackSelector trackSelector;
@NonNull private final LoadController loadController;
@NonNull private final DefaultRenderersFactory renderFactory;
@NonNull
private final DefaultTrackSelector trackSelector;
@NonNull
private final LoadController loadController;
@NonNull
private final DefaultRenderersFactory renderFactory;
@NonNull private final VideoPlaybackResolver videoResolver;
@NonNull private final AudioPlaybackResolver audioResolver;
@NonNull
private final VideoPlaybackResolver videoResolver;
@NonNull
private final AudioPlaybackResolver audioResolver;
private final PlayerService service; //TODO try to remove and replace everything with context
@ -224,24 +235,32 @@ public final class Player implements PlaybackListener, Listener {
private BroadcastReceiver broadcastReceiver;
private IntentFilter intentFilter;
@Nullable private PlayerServiceEventListener fragmentListener = null;
@Nullable private PlayerEventListener activityListener = null;
@Nullable
private PlayerServiceEventListener fragmentListener = null;
@Nullable
private PlayerEventListener activityListener = null;
@NonNull private final SerialDisposable progressUpdateDisposable = new SerialDisposable();
@NonNull private final CompositeDisposable databaseUpdateDisposable = new CompositeDisposable();
@NonNull
private final SerialDisposable progressUpdateDisposable = new SerialDisposable();
@NonNull
private final CompositeDisposable databaseUpdateDisposable = new CompositeDisposable();
// This is the only listener we need for thumbnail loading, since there is always at most only
// one thumbnail being loaded at a time. This field is also here to maintain a strong reference,
// which would otherwise be garbage collected since Picasso holds weak references to targets.
@NonNull private final Target currentThumbnailTarget;
@NonNull
private final Target currentThumbnailTarget;
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
@NonNull private final Context context;
@NonNull private final SharedPreferences prefs;
@NonNull private final HistoryRecordManager recordManager;
@NonNull
private final Context context;
@NonNull
private final SharedPreferences prefs;
@NonNull
private final HistoryRecordManager recordManager;
/*//////////////////////////////////////////////////////////////////////////
@ -333,7 +352,7 @@ public final class Player implements PlaybackListener, Listener {
isAudioOnly = audioPlayerSelected();
if (intent.hasExtra(PLAYBACK_QUALITY)) {
setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY));
videoResolver.setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY));
}
// Resolve enqueue intents
@ -341,7 +360,7 @@ public final class Player implements PlaybackListener, Listener {
playQueue.append(newQueue.getStreams());
return;
// Resolve enqueue next intents
// Resolve enqueue next intents
} else if (intent.getBooleanExtra(ENQUEUE_NEXT, false) && playQueue != null) {
final int currentIndex = playQueue.getIndex();
playQueue.append(newQueue.getStreams());
@ -913,7 +932,7 @@ public final class Player implements PlaybackListener, Listener {
private Disposable getProgressUpdateDisposable() {
return Observable.interval(PROGRESS_LOOP_INTERVAL_MILLIS, MILLISECONDS,
AndroidSchedulers.mainThread())
AndroidSchedulers.mainThread())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> triggerProgressUpdate(),
error -> Log.e(TAG, "Progress update failure: ", error));
@ -922,7 +941,6 @@ public final class Player implements PlaybackListener, Listener {
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Playback states
//////////////////////////////////////////////////////////////////////////*/
@ -1244,6 +1262,9 @@ public final class Player implements PlaybackListener, Listener {
}
final StreamInfo previousInfo = Optional.ofNullable(currentMetadata)
.flatMap(MediaItemTag::getMaybeStreamInfo).orElse(null);
final MediaItemTag.AudioTrack previousAudioTrack =
Optional.ofNullable(currentMetadata)
.flatMap(MediaItemTag::getMaybeAudioTrack).orElse(null);
currentMetadata = tag;
if (!currentMetadata.getErrors().isEmpty()) {
@ -1264,6 +1285,12 @@ public final class Player implements PlaybackListener, Listener {
if (previousInfo == null || !previousInfo.getUrl().equals(info.getUrl())) {
// only update with the new stream info if it has actually changed
updateMetadataWith(info);
} else if (previousAudioTrack == null
|| tag.getMaybeAudioTrack()
.map(t -> t.getSelectedAudioStreamIndex()
!= previousAudioTrack.getSelectedAudioStreamIndex())
.orElse(false)) {
notifyAudioTrackUpdateToListeners();
}
});
});
@ -1351,6 +1378,7 @@ public final class Player implements PlaybackListener, Listener {
// Errors
//////////////////////////////////////////////////////////////////////////*/
//region Errors
/**
* Process exceptions produced by {@link com.google.android.exoplayer2.ExoPlayer ExoPlayer}.
* <p>There are multiple types of errors:</p>
@ -1377,8 +1405,9 @@ public final class Player implements PlaybackListener, Listener {
* For any error above that is <b>not</b> explicitly <b>catchable</b>, the player will
* create a notification so users are aware.
* </ul>
*
* @see com.google.android.exoplayer2.Player.Listener#onPlayerError(PlaybackException)
* */
*/
// Any error code not explicitly covered here are either unrelated to NewPipe use case
// (e.g. DRM) or not recoverable (e.g. Decoder error). In both cases, the player should
// shutdown.
@ -1760,6 +1789,7 @@ public final class Player implements PlaybackListener, Listener {
registerStreamViewed();
notifyMetadataUpdateToListeners();
notifyAudioTrackUpdateToListeners();
UIs.call(playerUi -> playerUi.onMetadataChanged(info));
}
@ -1888,6 +1918,12 @@ public final class Player implements PlaybackListener, Listener {
.map(quality -> quality.getSortedVideoStreams()
.get(quality.getSelectedVideoStreamIndex()));
}
public Optional<AudioStream> getSelectedAudioStream() {
return Optional.ofNullable(currentMetadata)
.flatMap(MediaItemTag::getMaybeAudioTrack)
.map(MediaItemTag.AudioTrack::getSelectedAudioStream);
}
//endregion
@ -2019,6 +2055,15 @@ public final class Player implements PlaybackListener, Listener {
}
}
private void notifyAudioTrackUpdateToListeners() {
if (fragmentListener != null) {
fragmentListener.onAudioTrackUpdate();
}
if (activityListener != null) {
activityListener.onAudioTrackUpdate();
}
}
public void useVideoSource(final boolean videoEnabled) {
if (playQueue == null || isAudioOnly == !videoEnabled || audioPlayerSelected()) {
return;
@ -2115,7 +2160,7 @@ public final class Player implements PlaybackListener, Listener {
// because the stream source will be probably the same as the current played
if (sourceType == SourceType.VIDEO_WITH_SEPARATED_AUDIO
|| (sourceType == SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY
&& isNullOrEmpty(streamInfo.getAudioStreams()))) {
&& isNullOrEmpty(streamInfo.getAudioStreams()))) {
// It's not needed to reload the play queue manager only if the content's stream type
// is a video stream, a live stream or an ended live stream
return !StreamTypeUtil.isVideo(streamType);
@ -2177,7 +2222,18 @@ public final class Player implements PlaybackListener, Listener {
}
public void setPlaybackQuality(@Nullable final String quality) {
saveStreamProgressState();
setRecovery();
videoResolver.setPlaybackQuality(quality);
reloadPlayQueueManager();
}
public void setAudioTrack(@Nullable final String audioTrackId) {
saveStreamProgressState();
setRecovery();
videoResolver.setAudioTrack(audioTrackId);
audioResolver.setAudioTrack(audioTrackId);
reloadPlayQueueManager();
}
@ -2255,7 +2311,7 @@ public final class Player implements PlaybackListener, Listener {
/**
* Get the video renderer index of the current playing stream.
*
* <p>
* This method returns the video renderer index of the current
* {@link MappingTrackSelector.MappedTrackInfo} or {@link #RENDERER_UNAVAILABLE} if the current
* {@link MappingTrackSelector.MappedTrackInfo} is null or if there is no video renderer index.

View File

@ -11,5 +11,6 @@ public interface PlayerEventListener {
PlaybackParameters parameters);
void onProgressUpdate(int currentProgress, int duration, int bufferPercent);
void onMetadataUpdate(StreamInfo info, PlayQueue queue);
default void onAudioTrackUpdate() { }
void onServiceStopped();
}

View File

@ -7,6 +7,7 @@ import com.google.android.exoplayer2.MediaItem.RequestMetadata;
import com.google.android.exoplayer2.MediaMetadata;
import com.google.android.exoplayer2.Player;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.extractor.stream.VideoStream;
@ -55,6 +56,11 @@ public interface MediaItemTag {
return Optional.empty();
}
@NonNull
default Optional<AudioTrack> getMaybeAudioTrack() {
return Optional.empty();
}
<T> Optional<T> getMaybeExtras(@NonNull Class<T> type);
<T> MediaItemTag withExtras(@NonNull T extra);
@ -128,4 +134,37 @@ public interface MediaItemTag {
? null : sortedVideoStreams.get(selectedVideoStreamIndex);
}
}
final class AudioTrack {
@NonNull
private final List<AudioStream> audioStreams;
private final int selectedAudioStreamIndex;
private AudioTrack(@NonNull final List<AudioStream> audioStreams,
final int selectedAudioStreamIndex) {
this.audioStreams = audioStreams;
this.selectedAudioStreamIndex = selectedAudioStreamIndex;
}
static AudioTrack of(@NonNull final List<AudioStream> audioStreams,
final int selectedAudioStreamIndex) {
return new AudioTrack(audioStreams, selectedAudioStreamIndex);
}
@NonNull
public List<AudioStream> getAudioStreams() {
return audioStreams;
}
public int getSelectedAudioStreamIndex() {
return selectedAudioStreamIndex;
}
@Nullable
public AudioStream getSelectedAudioStream() {
return selectedAudioStreamIndex < 0
|| selectedAudioStreamIndex >= audioStreams.size()
? null : audioStreams.get(selectedAudioStreamIndex);
}
}
}

View File

@ -2,6 +2,7 @@ package org.schabi.newpipe.player.mediaitem;
import com.google.android.exoplayer2.MediaItem;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.extractor.stream.VideoStream;
@ -25,25 +26,41 @@ public final class StreamInfoTag implements MediaItemTag {
@Nullable
private final MediaItemTag.Quality quality;
@Nullable
private final MediaItemTag.AudioTrack audioTrack;
@Nullable
private final Object extras;
private StreamInfoTag(@NonNull final StreamInfo streamInfo,
@Nullable final MediaItemTag.Quality quality,
@Nullable final MediaItemTag.AudioTrack audioTrack,
@Nullable final Object extras) {
this.streamInfo = streamInfo;
this.quality = quality;
this.audioTrack = audioTrack;
this.extras = extras;
}
public static StreamInfoTag of(@NonNull final StreamInfo streamInfo,
@NonNull final List<VideoStream> sortedVideoStreams,
final int selectedVideoStreamIndex) {
final int selectedVideoStreamIndex,
@NonNull final List<AudioStream> audioStreams,
final int selectedAudioStreamIndex) {
final Quality quality = Quality.of(sortedVideoStreams, selectedVideoStreamIndex);
return new StreamInfoTag(streamInfo, quality, null);
final AudioTrack audioTrack =
AudioTrack.of(audioStreams, selectedAudioStreamIndex);
return new StreamInfoTag(streamInfo, quality, audioTrack, null);
}
public static StreamInfoTag of(@NonNull final StreamInfo streamInfo,
@NonNull final List<AudioStream> audioStreams,
final int selectedAudioStreamIndex) {
final AudioTrack audioTrack =
AudioTrack.of(audioStreams, selectedAudioStreamIndex);
return new StreamInfoTag(streamInfo, null, audioTrack, null);
}
public static StreamInfoTag of(@NonNull final StreamInfo streamInfo) {
return new StreamInfoTag(streamInfo, null, null);
return new StreamInfoTag(streamInfo, null, null, null);
}
@Override
@ -103,6 +120,12 @@ public final class StreamInfoTag implements MediaItemTag {
return Optional.ofNullable(quality);
}
@NonNull
@Override
public Optional<AudioTrack> getMaybeAudioTrack() {
return Optional.ofNullable(audioTrack);
}
@Override
public <T> Optional<T> getMaybeExtras(@NonNull final Class<T> type) {
return Optional.ofNullable(extras).map(type::cast);
@ -110,6 +133,6 @@ public final class StreamInfoTag implements MediaItemTag {
@Override
public StreamInfoTag withExtras(@NonNull final Object extra) {
return new StreamInfoTag(streamInfo, quality, extra);
return new StreamInfoTag(streamInfo, quality, audioTrack, extra);
}
}

View File

@ -1,5 +1,6 @@
package org.schabi.newpipe.player.resolver;
import static org.schabi.newpipe.util.ListHelper.getFilteredAudioStreams;
import static org.schabi.newpipe.util.ListHelper.getPlayableStreams;
import android.content.Context;
@ -28,6 +29,8 @@ public class AudioPlaybackResolver implements PlaybackResolver {
private final Context context;
@NonNull
private final PlayerDataSource dataSource;
@Nullable
private String audioTrack;
public AudioPlaybackResolver(@NonNull final Context context,
@NonNull final PlayerDataSource dataSource) {
@ -35,6 +38,13 @@ public class AudioPlaybackResolver implements PlaybackResolver {
this.dataSource = dataSource;
}
/**
* Get a media source providing audio. If a service has no separate {@link AudioStream}s we
* use a video stream as audio source to support audio background playback.
*
* @param info of the stream
* @return the audio source to use or null if none could be found
*/
@Override
@Nullable
public MediaSource resolve(@NonNull final StreamInfo info) {
@ -43,12 +53,27 @@ public class AudioPlaybackResolver implements PlaybackResolver {
return liveSource;
}
final Stream stream = getAudioSource(info);
if (stream == null) {
return null;
}
final List<AudioStream> audioStreams =
getFilteredAudioStreams(context, info.getAudioStreams());
final Stream stream;
final MediaItemTag tag;
final MediaItemTag tag = StreamInfoTag.of(info);
if (!audioStreams.isEmpty()) {
final int audioIndex =
ListHelper.getAudioFormatIndex(context, audioStreams, audioTrack);
stream = getStreamForIndex(audioIndex, audioStreams);
tag = StreamInfoTag.of(info, audioStreams, audioIndex);
} else {
final List<VideoStream> videoStreams =
getPlayableStreams(info.getVideoStreams(), info.getServiceId());
if (!videoStreams.isEmpty()) {
final int index = ListHelper.getDefaultResolutionIndex(context, videoStreams);
stream = getStreamForIndex(index, videoStreams);
tag = StreamInfoTag.of(info);
} else {
return null;
}
}
try {
return PlaybackResolver.buildMediaSource(
@ -59,31 +84,6 @@ public class AudioPlaybackResolver implements PlaybackResolver {
}
}
/**
* Get a stream to be played as audio. If a service has no separate {@link AudioStream}s we
* use a video stream as audio source to support audio background playback.
*
* @param info of the stream
* @return the audio source to use or null if none could be found
*/
@Nullable
private Stream getAudioSource(@NonNull final StreamInfo info) {
final List<AudioStream> audioStreams = getPlayableStreams(
info.getAudioStreams(), info.getServiceId());
if (!audioStreams.isEmpty()) {
final int index = ListHelper.getDefaultAudioFormat(context, audioStreams);
return getStreamForIndex(index, audioStreams);
} else {
final List<VideoStream> videoStreams = getPlayableStreams(
info.getVideoStreams(), info.getServiceId());
if (!videoStreams.isEmpty()) {
final int index = ListHelper.getDefaultResolutionIndex(context, videoStreams);
return getStreamForIndex(index, videoStreams);
}
}
return null;
}
@Nullable
Stream getStreamForIndex(final int index, @NonNull final List<? extends Stream> streams) {
if (index >= 0 && index < streams.size()) {
@ -91,4 +91,13 @@ public class AudioPlaybackResolver implements PlaybackResolver {
}
return null;
}
@Nullable
public String getAudioTrack() {
return audioTrack;
}
public void setAudioTrack(@Nullable final String audioLanguage) {
this.audioTrack = audioLanguage;
}
}

View File

@ -156,6 +156,16 @@ public interface PlaybackResolver extends Resolver<StreamInfo, MediaSource> {
cacheKey.append(audioStream.getAverageBitrate());
}
if (audioStream.getAudioTrackId() != null) {
cacheKey.append(" ");
cacheKey.append(audioStream.getAudioTrackId());
}
if (audioStream.getAudioLocale() != null) {
cacheKey.append(" ");
cacheKey.append(audioStream.getAudioLocale().getISO3Language());
}
return cacheKey.toString();
}

View File

@ -28,6 +28,7 @@ import java.util.List;
import java.util.Optional;
import static com.google.android.exoplayer2.C.TIME_UNSET;
import static org.schabi.newpipe.util.ListHelper.getFilteredAudioStreams;
import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams;
import static org.schabi.newpipe.util.ListHelper.getPlayableStreams;
@ -44,6 +45,8 @@ public class VideoPlaybackResolver implements PlaybackResolver {
@Nullable
private String playbackQuality;
@Nullable
private String audioTrack;
public enum SourceType {
LIVE_STREAM,
@ -74,19 +77,29 @@ public class VideoPlaybackResolver implements PlaybackResolver {
final List<VideoStream> videoStreamsList = ListHelper.getSortedStreamVideosList(context,
getPlayableStreams(info.getVideoStreams(), info.getServiceId()),
getPlayableStreams(info.getVideoOnlyStreams(), info.getServiceId()), false, true);
final int index;
final List<AudioStream> audioStreamsList =
getFilteredAudioStreams(context, info.getAudioStreams());
final int videoIndex;
if (videoStreamsList.isEmpty()) {
index = -1;
videoIndex = -1;
} else if (playbackQuality == null) {
index = qualityResolver.getDefaultResolutionIndex(videoStreamsList);
videoIndex = qualityResolver.getDefaultResolutionIndex(videoStreamsList);
} else {
index = qualityResolver.getOverrideResolutionIndex(videoStreamsList,
videoIndex = qualityResolver.getOverrideResolutionIndex(videoStreamsList,
getPlaybackQuality());
}
final MediaItemTag tag = StreamInfoTag.of(info, videoStreamsList, index);
final int audioIndex =
ListHelper.getAudioFormatIndex(context, audioStreamsList, audioTrack);
final MediaItemTag tag =
StreamInfoTag.of(info, videoStreamsList, videoIndex, audioStreamsList, audioIndex);
@Nullable final VideoStream video = tag.getMaybeQuality()
.map(MediaItemTag.Quality::getSelectedVideoStream)
.orElse(null);
@Nullable final AudioStream audio = tag.getMaybeAudioTrack()
.map(MediaItemTag.AudioTrack::getSelectedAudioStream)
.orElse(null);
if (video != null) {
try {
@ -99,15 +112,9 @@ public class VideoPlaybackResolver implements PlaybackResolver {
}
}
// Create optional audio stream source
final List<AudioStream> audioStreams = getPlayableStreams(
info.getAudioStreams(), info.getServiceId());
final AudioStream audio = audioStreams.isEmpty() ? null : audioStreams.get(
ListHelper.getDefaultAudioFormat(context, audioStreams));
// Use the audio stream if there is no video stream, or
// merge with audio stream in case if video does not contain audio
if (audio != null && (video == null || video.isVideoOnly())) {
if (audio != null && (video == null || video.isVideoOnly() || audioTrack != null)) {
try {
final MediaSource audioSource = PlaybackResolver.buildMediaSource(
dataSource, audio, info, PlaybackResolver.cacheKeyOf(info, audio), tag);
@ -180,6 +187,15 @@ public class VideoPlaybackResolver implements PlaybackResolver {
this.playbackQuality = playbackQuality;
}
@Nullable
public String getAudioTrack() {
return audioTrack;
}
public void setAudioTrack(@Nullable final String audioLanguage) {
this.audioTrack = audioLanguage;
}
public interface QualityResolver {
int getDefaultResolutionIndex(List<VideoStream> sortedVideos);

View File

@ -63,6 +63,7 @@ import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.PlayerBinding;
import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
@ -78,6 +79,7 @@ import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper;
import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHolder;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.KoreUtils;
import org.schabi.newpipe.util.external_communication.ShareUtils;
@ -108,7 +110,8 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
protected PlayerBinding binding;
private final Handler controlsVisibilityHandler = new Handler(Looper.getMainLooper());
@Nullable private SurfaceHolderCallback surfaceHolderCallback;
@Nullable
private SurfaceHolderCallback surfaceHolderCallback;
boolean surfaceIsSetup = false;
@ -117,11 +120,13 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
//////////////////////////////////////////////////////////////////////////*/
private static final int POPUP_MENU_ID_QUALITY = 69;
private static final int POPUP_MENU_ID_AUDIO_TRACK = 70;
private static final int POPUP_MENU_ID_PLAYBACK_SPEED = 79;
private static final int POPUP_MENU_ID_CAPTION = 89;
protected boolean isSomePopupMenuVisible = false;
private PopupMenu qualityPopupMenu;
private PopupMenu audioTrackPopupMenu;
protected PopupMenu playbackSpeedPopupMenu;
private PopupMenu captionPopupMenu;
@ -146,7 +151,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
//region Constructor, setup, destroy
protected VideoPlayerUi(@NonNull final Player player,
@NonNull final PlayerBinding playerBinding) {
@NonNull final PlayerBinding playerBinding) {
super(player);
binding = playerBinding;
setupFromView();
@ -173,6 +178,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
R.style.DarkPopupMenu);
qualityPopupMenu = new PopupMenu(themeWrapper, binding.qualityTextView);
audioTrackPopupMenu = new PopupMenu(themeWrapper, binding.audioTrackTextView);
playbackSpeedPopupMenu = new PopupMenu(context, binding.playbackSpeed);
captionPopupMenu = new PopupMenu(themeWrapper, binding.captionTextView);
@ -190,6 +196,8 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
protected void initListeners() {
binding.qualityTextView.setOnClickListener(makeOnClickListener(this::onQualityClicked));
binding.audioTrackTextView.setOnClickListener(
makeOnClickListener(this::onAudioTracksClicked));
binding.playbackSpeed.setOnClickListener(makeOnClickListener(this::onPlaybackSpeedClicked));
binding.playbackSeekBar.setOnSeekBarChangeListener(this);
@ -266,6 +274,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
protected void deinitListeners() {
binding.qualityTextView.setOnClickListener(null);
binding.audioTrackTextView.setOnClickListener(null);
binding.playbackSpeed.setOnClickListener(null);
binding.playbackSeekBar.setOnSeekBarChangeListener(null);
binding.captionTextView.setOnClickListener(null);
@ -419,6 +428,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
binding.topControls.setPaddingRelative(controlsPad, playerTopPad, controlsPad, 0);
binding.bottomControls.setPaddingRelative(controlsPad, 0, controlsPad, 0);
binding.qualityTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad);
binding.audioTrackTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad);
binding.playbackSpeed.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad);
binding.playbackSpeed.setMinimumWidth(buttonsMinWidth);
binding.captionTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad);
@ -524,6 +534,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
/**
* Sets the current duration into the corresponding elements.
*
* @param currentProgress the current progress, in milliseconds
*/
private void updatePlayBackElementsCurrentDuration(final int currentProgress) {
@ -536,6 +547,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
/**
* Sets the video duration time into all control components (e.g. seekbar).
*
* @param duration the video duration, in milliseconds
*/
private void setVideoDurationToControls(final int duration) {
@ -984,6 +996,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
private void updateStreamRelatedViews() {
player.getCurrentStreamInfo().ifPresent(info -> {
binding.qualityTextView.setVisibility(View.GONE);
binding.audioTrackTextView.setVisibility(View.GONE);
binding.playbackSpeed.setVisibility(View.GONE);
binding.playbackEndTime.setVisibility(View.GONE);
@ -1019,6 +1032,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
}
buildQualityMenu();
buildAudioTrackMenu();
binding.qualityTextView.setVisibility(View.VISIBLE);
binding.surfaceView.setVisibility(View.VISIBLE);
@ -1067,6 +1081,34 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
.ifPresent(s -> binding.qualityTextView.setText(s.getResolution()));
}
private void buildAudioTrackMenu() {
if (audioTrackPopupMenu == null) {
return;
}
audioTrackPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_AUDIO_TRACK);
final List<AudioStream> availableStreams = Optional.ofNullable(player.getCurrentMetadata())
.flatMap(MediaItemTag::getMaybeAudioTrack)
.map(MediaItemTag.AudioTrack::getAudioStreams)
.orElse(null);
if (availableStreams == null || availableStreams.size() < 2) {
return;
}
for (int i = 0; i < availableStreams.size(); i++) {
final AudioStream audioStream = availableStreams.get(i);
audioTrackPopupMenu.getMenu().add(POPUP_MENU_ID_AUDIO_TRACK, i, Menu.NONE,
Localization.audioTrackName(context, audioStream));
}
player.getSelectedAudioStream()
.ifPresent(s -> binding.audioTrackTextView.setText(
Localization.audioTrackName(context, s)));
binding.audioTrackTextView.setVisibility(View.VISIBLE);
audioTrackPopupMenu.setOnMenuItemClickListener(this);
audioTrackPopupMenu.setOnDismissListener(this);
}
private void buildPlaybackSpeedMenu() {
if (playbackSpeedPopupMenu == null) {
return;
@ -1175,6 +1217,11 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
.ifPresent(binding.qualityTextView::setText);
}
private void onAudioTracksClicked() {
audioTrackPopupMenu.show();
isSomePopupMenuVisible = true;
}
/**
* Called when an item of the quality selector or the playback speed selector is selected.
*/
@ -1187,26 +1234,10 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
}
if (menuItem.getGroupId() == POPUP_MENU_ID_QUALITY) {
final int menuItemIndex = menuItem.getItemId();
@Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata();
if (currentMetadata == null || currentMetadata.getMaybeQuality().isEmpty()) {
return true;
}
final MediaItemTag.Quality quality = currentMetadata.getMaybeQuality().get();
final List<VideoStream> availableStreams = quality.getSortedVideoStreams();
final int selectedStreamIndex = quality.getSelectedVideoStreamIndex();
if (selectedStreamIndex == menuItemIndex || availableStreams.size() <= menuItemIndex) {
return true;
}
player.saveStreamProgressState(); //TODO added, check if good
final String newResolution = availableStreams.get(menuItemIndex).getResolution();
player.setRecovery();
player.setPlaybackQuality(newResolution);
player.reloadPlayQueueManager();
binding.qualityTextView.setText(menuItem.getTitle());
onQualityItemClick(menuItem);
return true;
} else if (menuItem.getGroupId() == POPUP_MENU_ID_AUDIO_TRACK) {
onAudioTrackItemClick(menuItem);
return true;
} else if (menuItem.getGroupId() == POPUP_MENU_ID_PLAYBACK_SPEED) {
final int speedIndex = menuItem.getItemId();
@ -1219,6 +1250,47 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
return false;
}
private void onQualityItemClick(@NonNull final MenuItem menuItem) {
final int menuItemIndex = menuItem.getItemId();
@Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata();
if (currentMetadata == null || currentMetadata.getMaybeQuality().isEmpty()) {
return;
}
final MediaItemTag.Quality quality = currentMetadata.getMaybeQuality().get();
final List<VideoStream> availableStreams = quality.getSortedVideoStreams();
final int selectedStreamIndex = quality.getSelectedVideoStreamIndex();
if (selectedStreamIndex == menuItemIndex || availableStreams.size() <= menuItemIndex) {
return;
}
final String newResolution = availableStreams.get(menuItemIndex).getResolution();
player.setPlaybackQuality(newResolution);
binding.qualityTextView.setText(menuItem.getTitle());
}
private void onAudioTrackItemClick(@NonNull final MenuItem menuItem) {
final int menuItemIndex = menuItem.getItemId();
@Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata();
if (currentMetadata == null || currentMetadata.getMaybeAudioTrack().isEmpty()) {
return;
}
final MediaItemTag.AudioTrack audioTrack =
currentMetadata.getMaybeAudioTrack().get();
final List<AudioStream> availableStreams = audioTrack.getAudioStreams();
final int selectedStreamIndex = audioTrack.getSelectedAudioStreamIndex();
if (selectedStreamIndex == menuItemIndex || availableStreams.size() <= menuItemIndex) {
return;
}
final String newAudioTrack = availableStreams.get(menuItemIndex).getAudioTrackId();
player.setAudioTrack(newAudioTrack);
binding.audioTrackTextView.setText(menuItem.getTitle());
}
/**
* Called when some popup menu is dismissed.
*/

View File

@ -0,0 +1,94 @@
package org.schabi.newpipe.util;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper;
import java.io.Serializable;
import java.util.List;
import java.util.stream.Collectors;
/**
* A list adapter for groups of {@link AudioStream}s (audio tracks).
*/
public class AudioTrackAdapter extends BaseAdapter {
private final AudioTracksWrapper tracksWrapper;
public AudioTrackAdapter(final AudioTracksWrapper tracksWrapper) {
this.tracksWrapper = tracksWrapper;
}
@Override
public int getCount() {
return tracksWrapper.size();
}
@Override
public List<AudioStream> getItem(final int position) {
return tracksWrapper.getTracksList().get(position).getStreamsList();
}
@Override
public long getItemId(final int position) {
return position;
}
@Override
public View getView(final int position, final View convertView, final ViewGroup parent) {
final var context = parent.getContext();
final View view;
if (convertView == null) {
view = LayoutInflater.from(context).inflate(
R.layout.stream_quality_item, parent, false);
} else {
view = convertView;
}
final ImageView woSoundIconView = view.findViewById(R.id.wo_sound_icon);
final TextView formatNameView = view.findViewById(R.id.stream_format_name);
final TextView qualityView = view.findViewById(R.id.stream_quality);
final TextView sizeView = view.findViewById(R.id.stream_size);
final List<AudioStream> streams = getItem(position);
final AudioStream stream = streams.get(0);
woSoundIconView.setVisibility(View.GONE);
sizeView.setVisibility(View.VISIBLE);
if (stream.getAudioTrackId() != null) {
formatNameView.setText(stream.getAudioTrackId());
}
qualityView.setText(Localization.audioTrackName(context, stream));
return view;
}
public static class AudioTracksWrapper implements Serializable {
private final List<StreamSizeWrapper<AudioStream>> tracksList;
public AudioTracksWrapper(@NonNull final List<List<AudioStream>> groupedAudioStreams,
@Nullable final Context context) {
this.tracksList = groupedAudioStreams.stream().map(streams ->
new StreamSizeWrapper<>(streams, context)).collect(Collectors.toList());
}
public List<StreamSizeWrapper<AudioStream>> getTracksList() {
return tracksList;
}
public int size() {
return tracksList.size();
}
}
}

View File

@ -15,6 +15,7 @@ import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.AudioTrackType;
import org.schabi.newpipe.extractor.stream.DeliveryMethod;
import org.schabi.newpipe.extractor.stream.Stream;
import org.schabi.newpipe.extractor.stream.VideoStream;
@ -25,6 +26,7 @@ import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
@ -38,11 +40,17 @@ public final class ListHelper {
// Audio format in order of quality. 0=lowest quality, n=highest quality
private static final List<MediaFormat> AUDIO_FORMAT_QUALITY_RANKING =
List.of(MediaFormat.MP3, MediaFormat.WEBMA, MediaFormat.M4A);
// Audio format in order of efficiency. 0=most efficient, n=least efficient
// Audio format in order of efficiency. 0=least efficient, n=most efficient
private static final List<MediaFormat> AUDIO_FORMAT_EFFICIENCY_RANKING =
List.of(MediaFormat.WEBMA, MediaFormat.M4A, MediaFormat.MP3);
List.of(MediaFormat.MP3, MediaFormat.M4A, MediaFormat.WEBMA);
// Use a Set for better performance
private static final Set<String> HIGH_RESOLUTION_LIST = Set.of("1440p", "2160p");
// Audio track types in order of priotity. 0=lowest, n=highest
private static final List<AudioTrackType> AUDIO_TRACK_TYPE_RANKING =
List.of(AudioTrackType.DESCRIPTIVE, AudioTrackType.DUBBED, AudioTrackType.ORIGINAL);
// Audio track types in order of priotity when descriptive audio is preferred.
private static final List<AudioTrackType> AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE =
List.of(AudioTrackType.ORIGINAL, AudioTrackType.DUBBED, AudioTrackType.DESCRIPTIVE);
/**
* List of supported YouTube Itag ids.
@ -62,10 +70,10 @@ public final class ListHelper {
private ListHelper() { }
/**
* @see #getDefaultResolutionIndex(String, String, MediaFormat, List)
* @param context Android app context
* @param videoStreams list of the video streams to check
* @return index of the video stream with the default index
* @see #getDefaultResolutionIndex(String, String, MediaFormat, List)
*/
public static int getDefaultResolutionIndex(final Context context,
final List<VideoStream> videoStreams) {
@ -75,11 +83,11 @@ public final class ListHelper {
}
/**
* @see #getDefaultResolutionIndex(String, String, MediaFormat, List)
* @param context Android app context
* @param videoStreams list of the video streams to check
* @param defaultResolution the default resolution to look for
* @return index of the video stream with the default index
* @see #getDefaultResolutionIndex(String, String, MediaFormat, List)
*/
public static int getResolutionIndex(final Context context,
final List<VideoStream> videoStreams,
@ -88,10 +96,10 @@ public final class ListHelper {
}
/**
* @see #getDefaultResolutionIndex(String, String, MediaFormat, List)
* @param context Android app context
* @param videoStreams list of the video streams to check
* @param context Android app context
* @param videoStreams list of the video streams to check
* @return index of the video stream with the default index
* @see #getDefaultResolutionIndex(String, String, MediaFormat, List)
*/
public static int getPopupDefaultResolutionIndex(final Context context,
final List<VideoStream> videoStreams) {
@ -101,11 +109,11 @@ public final class ListHelper {
}
/**
* @see #getDefaultResolutionIndex(String, String, MediaFormat, List)
* @param context Android app context
* @param videoStreams list of the video streams to check
* @param defaultResolution the default resolution to look for
* @return index of the video stream with the default index
* @see #getDefaultResolutionIndex(String, String, MediaFormat, List)
*/
public static int getPopupResolutionIndex(final Context context,
final List<VideoStream> videoStreams,
@ -115,16 +123,36 @@ public final class ListHelper {
public static int getDefaultAudioFormat(final Context context,
final List<AudioStream> audioStreams) {
final MediaFormat defaultFormat = getDefaultFormat(context,
R.string.default_audio_format_key, R.string.default_audio_format_value);
return getAudioIndexByHighestRank(audioStreams,
getAudioTrackComparator(context).thenComparing(getAudioFormatComparator(context)));
}
// If the user has chosen to limit resolution to conserve mobile data
// usage then we should also limit our audio usage.
if (isLimitingDataUsage(context)) {
return getMostCompactAudioIndex(defaultFormat, audioStreams);
} else {
return getHighestQualityAudioIndex(defaultFormat, audioStreams);
public static int getDefaultAudioTrackGroup(final Context context,
final List<List<AudioStream>> groupedAudioStreams) {
if (groupedAudioStreams == null || groupedAudioStreams.isEmpty()) {
return -1;
}
final Comparator<AudioStream> cmp = getAudioTrackComparator(context);
final List<AudioStream> highestRanked = groupedAudioStreams.stream()
.max((o1, o2) -> cmp.compare(o1.get(0), o2.get(0)))
.orElse(null);
return groupedAudioStreams.indexOf(highestRanked);
}
public static int getAudioFormatIndex(final Context context,
final List<AudioStream> audioStreams,
@Nullable final String trackId) {
if (trackId != null) {
for (int i = 0; i < audioStreams.size(); i++) {
final AudioStream s = audioStreams.get(i);
if (s.getAudioTrackId() != null
&& s.getAudioTrackId().equals(trackId)) {
return i;
}
}
}
return getDefaultAudioFormat(context, audioStreams);
}
/**
@ -211,6 +239,90 @@ public final class ListHelper {
videoOnlyStreams, ascendingOrder, preferVideoOnlyStreams);
}
/**
* Filter the list of audio streams and return a list with the preferred stream for
* each audio track. Streams are sorted with the preferred language in the first position.
*
* @param context the context to search for the track to give preference
* @param audioStreams the list of audio streams
* @return the sorted, filtered list
*/
public static List<AudioStream> getFilteredAudioStreams(
@NonNull final Context context,
@Nullable final List<AudioStream> audioStreams) {
if (audioStreams == null) {
return Collections.emptyList();
}
final HashMap<String, AudioStream> collectedStreams = new HashMap<>();
final Comparator<AudioStream> cmp = getAudioFormatComparator(context);
for (final AudioStream stream : audioStreams) {
if (stream.getDeliveryMethod() == DeliveryMethod.TORRENT) {
continue;
}
final String trackId = Objects.toString(stream.getAudioTrackId(), "");
final AudioStream presentStream = collectedStreams.get(trackId);
if (presentStream == null || cmp.compare(stream, presentStream) > 0) {
collectedStreams.put(trackId, stream);
}
}
// Filter unknown audio tracks if there are multiple tracks
if (collectedStreams.size() > 1) {
collectedStreams.remove("");
}
// Sort collected streams by name
return collectedStreams.values().stream().sorted(getAudioTrackNameComparator(context))
.collect(Collectors.toList());
}
/**
* Group the list of audioStreams by their track ID and sort the resulting list by track name.
*
* @param context app context to get track names for sorting
* @param audioStreams list of audio streams
* @return list of audio streams lists representing individual tracks
*/
public static List<List<AudioStream>> getGroupedAudioStreams(
@NonNull final Context context,
@Nullable final List<AudioStream> audioStreams) {
if (audioStreams == null) {
return Collections.emptyList();
}
final HashMap<String, List<AudioStream>> collectedStreams = new HashMap<>();
for (final AudioStream stream : audioStreams) {
final String trackId = Objects.toString(stream.getAudioTrackId(), "");
if (collectedStreams.containsKey(trackId)) {
collectedStreams.get(trackId).add(stream);
} else {
final List<AudioStream> list = new ArrayList<>();
list.add(stream);
collectedStreams.put(trackId, list);
}
}
// Filter unknown audio tracks if there are multiple tracks
if (collectedStreams.size() > 1) {
collectedStreams.remove("");
}
// Sort tracks alphabetically, sort track streams by quality
final Comparator<AudioStream> nameCmp = getAudioTrackNameComparator(context);
final Comparator<AudioStream> formatCmp = getAudioFormatComparator(context);
return collectedStreams.values().stream()
.sorted((o1, o2) -> nameCmp.compare(o1.get(0), o2.get(0)))
.map(streams -> streams.stream().sorted(formatCmp).collect(Collectors.toList()))
.collect(Collectors.toList());
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
@ -325,8 +437,8 @@ public final class ListHelper {
// Filter out higher resolutions (or not if high resolutions should always be shown)
.filter(stream -> showHigherResolutions
|| !HIGH_RESOLUTION_LIST.contains(stream.getResolution()
// Replace any frame rate with nothing
.replaceAll("p\\d+$", "p")))
// Replace any frame rate with nothing
.replaceAll("p\\d+$", "p")))
.collect(Collectors.toList());
final HashMap<String, VideoStream> hashMap = new HashMap<>();
@ -376,72 +488,22 @@ public final class ListHelper {
return videoStreams;
}
/**
* Get the audio from the list with the highest quality.
* Format will be ignored if it yields no results.
*
* @param format The target format type or null if it doesn't matter
* @param audioStreams List of audio streams
* @return Index of audio stream that produces the most compact results or -1 if not found
*/
static int getHighestQualityAudioIndex(@Nullable final MediaFormat format,
@Nullable final List<AudioStream> audioStreams) {
return getAudioIndexByHighestRank(format, audioStreams,
// Compares descending (last = highest rank)
getAudioStreamComparator(AUDIO_FORMAT_QUALITY_RANKING));
}
/**
* Get the audio from the list with the lowest bitrate and most efficient format.
* Format will be ignored if it yields no results.
*
* @param format The target format type or null if it doesn't matter
* @param audioStreams List of audio streams
* @return Index of audio stream that produces the most compact results or -1 if not found
*/
static int getMostCompactAudioIndex(@Nullable final MediaFormat format,
@Nullable final List<AudioStream> audioStreams) {
return getAudioIndexByHighestRank(format, audioStreams,
// The "reversed()" is important -> Compares ascending (first = highest rank)
getAudioStreamComparator(AUDIO_FORMAT_EFFICIENCY_RANKING).reversed());
}
private static Comparator<AudioStream> getAudioStreamComparator(
final List<MediaFormat> formatRanking) {
return Comparator.nullsLast(Comparator.comparingInt(AudioStream::getAverageBitrate))
.thenComparingInt(stream -> formatRanking.indexOf(stream.getFormat()));
}
/**
* Get the audio-stream from the list with the highest rank, depending on the comparator.
* Format will be ignored if it yields no results.
*
* @param targetedFormat The target format type or null if it doesn't matter
* @param audioStreams List of audio streams
* @param comparator The comparator used for determining the max/best/highest ranked value
* @param audioStreams List of audio streams
* @param comparator The comparator used for determining the max/best/highest ranked value
* @return Index of audio stream that produces the highest ranked result or -1 if not found
*/
private static int getAudioIndexByHighestRank(@Nullable final MediaFormat targetedFormat,
@Nullable final List<AudioStream> audioStreams,
final Comparator<AudioStream> comparator) {
static int getAudioIndexByHighestRank(@Nullable final List<AudioStream> audioStreams,
final Comparator<AudioStream> comparator) {
if (audioStreams == null || audioStreams.isEmpty()) {
return -1;
}
final AudioStream highestRankedAudioStream = audioStreams.stream()
.filter(audioStream -> targetedFormat == null
|| audioStream.getFormat() == targetedFormat)
.max(comparator)
.orElse(null);
if (highestRankedAudioStream == null) {
// Fallback: Ignore targetedFormat if not null
if (targetedFormat != null) {
return getAudioIndexByHighestRank(null, audioStreams, comparator);
}
// targetedFormat is already null -> return -1
return -1;
}
.max(comparator).orElse(null);
return audioStreams.indexOf(highestRankedAudioStream);
}
@ -629,4 +691,149 @@ public final class ListHelper {
return manager.isActiveNetworkMetered();
}
/**
* Get a {@link Comparator} to compare {@link AudioStream}s by their format and bitrate.
*
* <p>The prefered stream will be ordered last.</p>
*
* @param context app context
* @return Comparator
*/
private static Comparator<AudioStream> getAudioFormatComparator(
final @NonNull Context context) {
final MediaFormat defaultFormat = getDefaultFormat(context,
R.string.default_audio_format_key, R.string.default_audio_format_value);
return getAudioFormatComparator(defaultFormat, isLimitingDataUsage(context));
}
/**
* Get a {@link Comparator} to compare {@link AudioStream}s by their format and bitrate.
*
* <p>The prefered stream will be ordered last.</p>
*
* @param defaultFormat the default format to look for
* @param limitDataUsage choose low bitrate audio stream
* @return Comparator
*/
static Comparator<AudioStream> getAudioFormatComparator(
@Nullable final MediaFormat defaultFormat, final boolean limitDataUsage) {
final List<MediaFormat> formatRanking = limitDataUsage
? AUDIO_FORMAT_EFFICIENCY_RANKING : AUDIO_FORMAT_QUALITY_RANKING;
Comparator<AudioStream> bitrateComparator =
Comparator.comparingInt(AudioStream::getAverageBitrate);
if (limitDataUsage) {
bitrateComparator = bitrateComparator.reversed();
}
return Comparator.comparing(AudioStream::getFormat, (o1, o2) -> {
if (defaultFormat != null) {
return Boolean.compare(o1 == defaultFormat, o2 == defaultFormat);
}
return 0;
}).thenComparing(bitrateComparator).thenComparingInt(
stream -> formatRanking.indexOf(stream.getFormat()));
}
/**
* Get a {@link Comparator} to compare {@link AudioStream}s by their tracks.
*
* <p>Tracks will be compared this order:</p>
* <ol>
* <li>If {@code preferOriginalAudio}: use original audio</li>
* <li>Language matches {@code preferredLanguage}</li>
* <li>
* Track type ranks highest in this order:
* <i>Original</i> > <i>Dubbed</i> > <i>Descriptive</i>
* <p>If {@code preferDescriptiveAudio}:
* <i>Descriptive</i> > <i>Dubbed</i> > <i>Original</i></p>
* </li>
* <li>Language is English</li>
* </ol>
*
* <p>The prefered track will be ordered last.</p>
*
* @param context App context
* @return Comparator
*/
private static Comparator<AudioStream> getAudioTrackComparator(
@NonNull final Context context) {
final SharedPreferences preferences =
PreferenceManager.getDefaultSharedPreferences(context);
final Locale preferredLanguage = Localization.getPreferredLocale(context);
final boolean preferOriginalAudio =
preferences.getBoolean(context.getString(R.string.prefer_original_audio_key),
false);
final boolean preferDescriptiveAudio =
preferences.getBoolean(context.getString(R.string.prefer_descriptive_audio_key),
false);
return getAudioTrackComparator(preferredLanguage, preferOriginalAudio,
preferDescriptiveAudio);
}
/**
* Get a {@link Comparator} to compare {@link AudioStream}s by their tracks.
*
* <p>Tracks will be compared this order:</p>
* <ol>
* <li>If {@code preferOriginalAudio}: use original audio</li>
* <li>Language matches {@code preferredLanguage}</li>
* <li>
* Track type ranks highest in this order:
* <i>Original</i> > <i>Dubbed</i> > <i>Descriptive</i>
* <p>If {@code preferDescriptiveAudio}:
* <i>Descriptive</i> > <i>Dubbed</i> > <i>Original</i></p>
* </li>
* <li>Language is English</li>
* </ol>
*
* <p>The prefered track will be ordered last.</p>
*
* @param preferredLanguage Preferred audio stream language
* @param preferOriginalAudio Get the original audio track regardless of its language
* @param preferDescriptiveAudio Prefer the descriptive audio track if available
* @return Comparator
*/
static Comparator<AudioStream> getAudioTrackComparator(
final Locale preferredLanguage,
final boolean preferOriginalAudio,
final boolean preferDescriptiveAudio) {
final String langCode = preferredLanguage.getISO3Language();
final List<AudioTrackType> trackTypeRanking = preferDescriptiveAudio
? AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE : AUDIO_TRACK_TYPE_RANKING;
return Comparator.comparing(AudioStream::getAudioTrackType, (o1, o2) -> {
if (preferOriginalAudio) {
return Boolean.compare(
o1 == AudioTrackType.ORIGINAL, o2 == AudioTrackType.ORIGINAL);
}
return 0;
}).thenComparing(AudioStream::getAudioLocale,
Comparator.nullsFirst(Comparator.comparing(
locale -> locale.getISO3Language().equals(langCode))))
.thenComparing(AudioStream::getAudioTrackType,
Comparator.nullsFirst(Comparator.comparingInt(trackTypeRanking::indexOf)))
.thenComparing(AudioStream::getAudioLocale,
Comparator.nullsFirst(Comparator.comparing(
locale -> locale.getISO3Language().equals(
Locale.ENGLISH.getISO3Language()))));
}
/**
* Get a {@link Comparator} to compare {@link AudioStream}s by their languages and track types
* for alphabetical sorting.
*
* @param context app context for localization
* @return Comparator
*/
private static Comparator<AudioStream> getAudioTrackNameComparator(
@NonNull final Context context) {
final Locale appLoc = Localization.getAppLocale(context);
return Comparator.comparing(AudioStream::getAudioLocale, Comparator.nullsLast(
Comparator.comparing(locale -> locale.getDisplayName(appLoc))))
.thenComparing(AudioStream::getAudioTrackType);
}
}

View File

@ -11,6 +11,7 @@ import android.text.TextUtils;
import android.util.DisplayMetrics;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.PluralsRes;
import androidx.annotation.StringRes;
import androidx.core.math.MathUtils;
@ -21,6 +22,8 @@ import org.ocpsoft.prettytime.units.Decade;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.localization.ContentCountry;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.AudioTrackType;
import java.math.BigDecimal;
import java.math.RoundingMode;
@ -261,6 +264,52 @@ public final class Localization {
}
}
/**
* Get the localized name of an audio track.
*
* <p>Examples of results returned by this method:</p>
* <ul>
* <li>English (original)</li>
* <li>English (descriptive)</li>
* <li>Spanish (dubbed)</li>
* </ul>
*
* @param context the context used to get the app language
* @param track an {@link AudioStream} of the track
* @return the localized name of the audio track
*/
public static String audioTrackName(final Context context, final AudioStream track) {
final String name;
if (track.getAudioLocale() != null) {
name = track.getAudioLocale().getDisplayLanguage(getAppLocale(context));
} else if (track.getAudioTrackName() != null) {
name = track.getAudioTrackName();
} else {
name = context.getString(R.string.unknown_audio_track);
}
if (track.getAudioTrackType() != null) {
final String trackType = audioTrackType(context, track.getAudioTrackType());
if (trackType != null) {
return context.getString(R.string.audio_track_name, name, trackType);
}
}
return name;
}
@Nullable
private static String audioTrackType(final Context context, final AudioTrackType trackType) {
switch (trackType) {
case ORIGINAL:
return context.getString(R.string.audio_track_type_original);
case DUBBED:
return context.getString(R.string.audio_track_type_dubbed);
case DESCRIPTIVE:
return context.getString(R.string.audio_track_type_descriptive);
}
return null;
}
/*//////////////////////////////////////////////////////////////////////////
// Pretty Time
//////////////////////////////////////////////////////////////////////////*/

View File

@ -224,6 +224,8 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
public static class StreamSizeWrapper<T extends Stream> implements Serializable {
private static final StreamSizeWrapper<Stream> EMPTY =
new StreamSizeWrapper<>(Collections.emptyList(), null);
private static final int SIZE_UNSET = -2;
private final List<T> streamsList;
private final long[] streamSizes;
private final String unknownSize;
@ -235,7 +237,7 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
this.unknownSize = context == null
? "--.-" : context.getString(R.string.unknown_content);
Arrays.fill(streamSizes, -2);
resetSizes();
}
/**
@ -251,7 +253,7 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
final Callable<Boolean> fetchAndSet = () -> {
boolean hasChanged = false;
for (final X stream : streamsWrapper.getStreamsList()) {
if (streamsWrapper.getSizeInBytes(stream) > -2) {
if (streamsWrapper.getSizeInBytes(stream) > SIZE_UNSET) {
continue;
}
@ -269,6 +271,10 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
.onErrorReturnItem(true);
}
public void resetSizes() {
Arrays.fill(streamSizes, SIZE_UNSET);
}
public static <X extends Stream> StreamSizeWrapper<X> empty() {
//noinspection unchecked
return (StreamSizeWrapper<X>) EMPTY;

View File

@ -71,11 +71,45 @@
android:minWidth="150dp"
tools:listitem="@layout/stream_quality_item" />
<Spinner
android:id="@+id/audio_track_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/quality_spinner"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:layout_marginBottom="12dp"
android:minWidth="150dp"
tools:visibility="gone" />
<Spinner
android:id="@+id/audio_stream_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/audio_track_spinner"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:layout_marginBottom="12dp"
android:minWidth="150dp"
tools:visibility="gone" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/audio_track_present_in_video_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/audio_stream_spinner"
android:layout_marginLeft="24dp"
android:layout_marginRight="24dp"
android:layout_marginBottom="12dp"
android:gravity="center"
android:text="@string/audio_track_present_in_video"
android:textSize="12sp" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/threads_text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/quality_spinner"
android:layout_below="@+id/audio_track_present_in_video_text"
android:layout_marginLeft="24dp"
android:layout_marginRight="24dp"
android:layout_marginBottom="6dp"

View File

@ -157,6 +157,22 @@
tools:text="The Video Artist LONG very LONG very Long" />
</LinearLayout>
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/audioTrackTextView"
android:layout_width="wrap_content"
android:layout_height="35dp"
android:layout_marginEnd="8dp"
android:background="?attr/selectableItemBackground"
android:gravity="center"
android:minWidth="0dp"
android:padding="@dimen/player_main_buttons_padding"
android:textColor="@android:color/white"
android:textStyle="bold"
android:visibility="gone"
tools:ignore="HardcodedText,RtlHardcoded"
tools:visibility="visible"
tools:text="English (Original)" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/qualityTextView"
android:layout_width="wrap_content"

View File

@ -18,6 +18,14 @@
android:visible="true"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_audio_track"
android:tooltipText="@string/audio_track"
android:visible="false"
app:showAsAction="ifRoom">
<menu />
</item>
<item
android:id="@+id/action_mute"
android:icon="@drawable/ic_volume_off"

View File

@ -219,6 +219,8 @@
<item>@string/none_control_key</item>
</string-array>
<string name="prefer_original_audio_key">prefer_original_audio</string>
<string name="prefer_descriptive_audio_key">prefer_descriptive_audio</string>
<string name="last_resize_mode">last_resize_mode</string>
<!-- DEBUG ONLY -->

View File

@ -94,6 +94,10 @@
<string name="show_description_summary">Turn off to hide video description and additional information</string>
<string name="show_meta_info_title">Show meta info</string>
<string name="show_meta_info_summary">Turn off to hide meta info boxes with additional information about the stream creator, stream content or a search request</string>
<string name="prefer_original_audio_title">Prefer original audio</string>
<string name="prefer_original_audio_summary">Select the original audio track regardless of the language</string>
<string name="prefer_descriptive_audio_title">Prefer descriptive audio</string>
<string name="prefer_descriptive_audio_summary">Select an audio track with descriptions for visually impaired people if available</string>
<string name="thumbnail_cache_wipe_complete_notice">Image cache wiped</string>
<string name="metadata_cache_wipe_title">Wipe cached metadata</string>
<string name="metadata_cache_wipe_summary">Remove all cached webpage data</string>
@ -414,6 +418,8 @@
<string name="play_queue_remove">Remove</string>
<string name="play_queue_stream_detail">Details</string>
<string name="play_queue_audio_settings">Audio Settings</string>
<string name="play_queue_audio_track">Audio: %s</string>
<string name="audio_track">Audio track</string>
<string name="hold_to_append">Hold to enqueue</string>
<string name="show_channel_details">Show channel details</string>
<string name="enqueue_stream">Enqueue</string>
@ -761,12 +767,15 @@
<string name="enumeration_comma">,</string>
<string name="toggle_all">Toggle all</string>
<string name="streams_not_yet_supported_removed">Streams which are not yet supported by the downloader are not shown</string>
<string name="audio_track_present_in_video">An audio track should be already present in this stream</string>
<string name="selected_stream_external_player_not_supported">The selected stream is not supported by external players</string>
<string name="no_audio_streams_available_for_external_players">No audio streams are available for external players</string>
<string name="no_video_streams_available_for_external_players">No video streams are available for external players</string>
<string name="select_quality_external_players">Select quality for external players</string>
<string name="select_audio_track_external_players">Select audio track for external players</string>
<string name="unknown_format">Unknown format</string>
<string name="unknown_quality">Unknown quality</string>
<string name="unknown_audio_track">Unknown</string>
<string name="feed_toggle_show_future_items">Show future items</string>
<string name="feed_toggle_hide_future_items">Hide future items</string>
<string name="feed_show_watched">Fully watched</string>
@ -779,4 +788,8 @@
<string name="use_exoplayer_decoder_fallback_summary">Enable this option if you have decoder initialization issues, which falls back to lower-priority decoders if primary decoders initialization fail. This may result in poor playback performance than when using primary decoders</string>
<string name="always_use_exoplayer_set_output_surface_workaround_title">Always use ExoPlayer\'s video output surface setting workaround</string>
<string name="always_use_exoplayer_set_output_surface_workaround_summary">This workaround releases and re-instantiates video codecs when a surface change occurs, instead of setting the surface to the codec directly. Already used by ExoPlayer on some devices with this issue, this setting has only an effect on Android 6 and higher\n\nEnabling this option may prevent playback errors when switching the current video player or switching to fullscreen</string>
<string name="audio_track_name">%s %s</string>
<string name="audio_track_type_original">original</string>
<string name="audio_track_type_dubbed">dubbed</string>
<string name="audio_track_type_descriptive">descriptive</string>
</resources>

View File

@ -61,6 +61,22 @@
app:iconSpaceReserved="false"
app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="@string/prefer_original_audio_key"
android:summary="@string/prefer_original_audio_summary"
android:title="@string/prefer_original_audio_title"
app:singleLineTitle="false"
app:iconSpaceReserved="false"/>
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="@string/prefer_descriptive_audio_key"
android:summary="@string/prefer_descriptive_audio_summary"
android:title="@string/prefer_descriptive_audio_title"
app:singleLineTitle="false"
app:iconSpaceReserved="false"/>
<PreferenceScreen
android:fragment="org.schabi.newpipe.settings.ExoPlayerSettingsFragment"
android:key="@string/exoplayer_settings_key"

View File

@ -3,10 +3,13 @@ package org.schabi.newpipe.util;
import org.junit.Test;
import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.AudioTrackType;
import org.schabi.newpipe.extractor.stream.VideoStream;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@ -29,6 +32,15 @@ public class ListHelperTest {
generateAudioStream("mp3-192", MediaFormat.MP3, 192),
generateAudioStream("webma-320", MediaFormat.WEBMA, 320));
private static final List<AudioStream> AUDIO_TRACKS_TEST_LIST = List.of(
generateAudioTrack("en.or", "en.or", Locale.ENGLISH, AudioTrackType.ORIGINAL),
generateAudioTrack("en.du", "en.du", Locale.ENGLISH, AudioTrackType.DUBBED),
generateAudioTrack("en.ds", "en.ds", Locale.ENGLISH, AudioTrackType.DESCRIPTIVE),
generateAudioTrack("unknown", null, null, null),
generateAudioTrack("de.du", "de.du", Locale.GERMAN, AudioTrackType.DUBBED),
generateAudioTrack("de.ds", "de.ds", Locale.GERMAN, AudioTrackType.DESCRIPTIVE)
);
private static final List<VideoStream> VIDEO_STREAMS_TEST_LIST = List.of(
generateVideoStream("mpeg_4-720", MediaFormat.MPEG_4, "720p", false),
generateVideoStream("v3gpp-240", MediaFormat.v3GPP, "240p", false),
@ -199,24 +211,29 @@ public class ListHelperTest {
@Test
public void getHighestQualityAudioFormatTest() {
AudioStream stream = AUDIO_STREAMS_TEST_LIST.get(ListHelper.getHighestQualityAudioIndex(
MediaFormat.M4A, AUDIO_STREAMS_TEST_LIST));
Comparator<AudioStream> cmp = ListHelper.getAudioFormatComparator(MediaFormat.M4A, false);
AudioStream stream = AUDIO_STREAMS_TEST_LIST.get(ListHelper.getAudioIndexByHighestRank(
AUDIO_STREAMS_TEST_LIST, cmp));
assertEquals(320, stream.getAverageBitrate());
assertEquals(MediaFormat.M4A, stream.getFormat());
stream = AUDIO_STREAMS_TEST_LIST.get(ListHelper.getHighestQualityAudioIndex(
MediaFormat.WEBMA, AUDIO_STREAMS_TEST_LIST));
cmp = ListHelper.getAudioFormatComparator(MediaFormat.WEBMA, false);
stream = AUDIO_STREAMS_TEST_LIST.get(ListHelper.getAudioIndexByHighestRank(
AUDIO_STREAMS_TEST_LIST, cmp));
assertEquals(320, stream.getAverageBitrate());
assertEquals(MediaFormat.WEBMA, stream.getFormat());
stream = AUDIO_STREAMS_TEST_LIST.get(ListHelper.getHighestQualityAudioIndex(
MediaFormat.MP3, AUDIO_STREAMS_TEST_LIST));
cmp = ListHelper.getAudioFormatComparator(MediaFormat.MP3, false);
stream = AUDIO_STREAMS_TEST_LIST.get(ListHelper.getAudioIndexByHighestRank(
AUDIO_STREAMS_TEST_LIST, cmp));
assertEquals(192, stream.getAverageBitrate());
assertEquals(MediaFormat.MP3, stream.getFormat());
}
@Test
public void getHighestQualityAudioFormatPreferredAbsent() {
final Comparator<AudioStream> cmp =
ListHelper.getAudioFormatComparator(MediaFormat.MP3, false);
//////////////////////////////////////////
// Doesn't contain the preferred format //
@ -227,8 +244,7 @@ public class ListHelperTest {
generateAudioStream("webma-192", MediaFormat.WEBMA, 192));
// List doesn't contains this format
// It should fallback to the highest bitrate audio no matter what format it is
AudioStream stream = testList.get(ListHelper.getHighestQualityAudioIndex(
MediaFormat.MP3, testList));
AudioStream stream = testList.get(ListHelper.getAudioIndexByHighestRank(testList, cmp));
assertEquals(192, stream.getAverageBitrate());
assertEquals(MediaFormat.WEBMA, stream.getFormat());
@ -246,44 +262,51 @@ public class ListHelperTest {
generateAudioStream("webma-192-4", MediaFormat.WEBMA, 192)));
// List doesn't contains this format, it should fallback to the highest bitrate audio and
// the highest quality format.
stream = testList.get(ListHelper.getHighestQualityAudioIndex(MediaFormat.MP3, testList));
stream =
testList.get(ListHelper.getAudioIndexByHighestRank(testList, cmp));
assertEquals(192, stream.getAverageBitrate());
assertEquals(MediaFormat.M4A, stream.getFormat());
// Adding a new format and bitrate. Adding another stream will have no impact since
// it's not a preferred format.
testList.add(generateAudioStream("webma-192-5", MediaFormat.WEBMA, 192));
stream = testList.get(ListHelper.getHighestQualityAudioIndex(MediaFormat.MP3, testList));
stream =
testList.get(ListHelper.getAudioIndexByHighestRank(testList, cmp));
assertEquals(192, stream.getAverageBitrate());
assertEquals(MediaFormat.M4A, stream.getFormat());
}
@Test
public void getHighestQualityAudioNull() {
assertEquals(-1, ListHelper.getHighestQualityAudioIndex(null, null));
assertEquals(-1, ListHelper.getHighestQualityAudioIndex(null, new ArrayList<>()));
final Comparator<AudioStream> cmp = ListHelper.getAudioFormatComparator(null, false);
assertEquals(-1, ListHelper.getAudioIndexByHighestRank(null, cmp));
assertEquals(-1, ListHelper.getAudioIndexByHighestRank(new ArrayList<>(), cmp));
}
@Test
public void getLowestQualityAudioFormatTest() {
AudioStream stream = AUDIO_STREAMS_TEST_LIST.get(ListHelper.getMostCompactAudioIndex(
MediaFormat.M4A, AUDIO_STREAMS_TEST_LIST));
Comparator<AudioStream> cmp = ListHelper.getAudioFormatComparator(MediaFormat.M4A, true);
AudioStream stream = AUDIO_STREAMS_TEST_LIST.get(ListHelper.getAudioIndexByHighestRank(
AUDIO_STREAMS_TEST_LIST, cmp));
assertEquals(128, stream.getAverageBitrate());
assertEquals(MediaFormat.M4A, stream.getFormat());
stream = AUDIO_STREAMS_TEST_LIST.get(ListHelper.getMostCompactAudioIndex(
MediaFormat.WEBMA, AUDIO_STREAMS_TEST_LIST));
cmp = ListHelper.getAudioFormatComparator(MediaFormat.WEBMA, true);
stream = AUDIO_STREAMS_TEST_LIST.get(ListHelper.getAudioIndexByHighestRank(
AUDIO_STREAMS_TEST_LIST, cmp));
assertEquals(64, stream.getAverageBitrate());
assertEquals(MediaFormat.WEBMA, stream.getFormat());
stream = AUDIO_STREAMS_TEST_LIST.get(ListHelper.getMostCompactAudioIndex(
MediaFormat.MP3, AUDIO_STREAMS_TEST_LIST));
cmp = ListHelper.getAudioFormatComparator(MediaFormat.MP3, true);
stream = AUDIO_STREAMS_TEST_LIST.get(ListHelper.getAudioIndexByHighestRank(
AUDIO_STREAMS_TEST_LIST, cmp));
assertEquals(64, stream.getAverageBitrate());
assertEquals(MediaFormat.MP3, stream.getFormat());
}
@Test
public void getLowestQualityAudioFormatPreferredAbsent() {
Comparator<AudioStream> cmp = ListHelper.getAudioFormatComparator(MediaFormat.MP3, true);
//////////////////////////////////////////
// Doesn't contain the preferred format //
@ -294,14 +317,13 @@ public class ListHelperTest {
generateAudioStream("webma-192-1", MediaFormat.WEBMA, 192)));
// List doesn't contains this format
// It should fallback to the most compact audio no matter what format it is.
AudioStream stream = testList.get(ListHelper.getMostCompactAudioIndex(
MediaFormat.MP3, testList));
AudioStream stream = testList.get(ListHelper.getAudioIndexByHighestRank(testList, cmp));
assertEquals(128, stream.getAverageBitrate());
assertEquals(MediaFormat.M4A, stream.getFormat());
// WEBMA is more compact than M4A
testList.add(generateAudioStream("webma-192-2", MediaFormat.WEBMA, 128));
stream = testList.get(ListHelper.getMostCompactAudioIndex(MediaFormat.MP3, testList));
stream = testList.get(ListHelper.getAudioIndexByHighestRank(testList, cmp));
assertEquals(128, stream.getAverageBitrate());
assertEquals(MediaFormat.WEBMA, stream.getFormat());
@ -318,20 +340,58 @@ public class ListHelperTest {
generateAudioStream("m4a-192-3", MediaFormat.M4A, 192)));
// List doesn't contain this format
// It should fallback to the most compact audio no matter what format it is.
stream = testList.get(ListHelper.getMostCompactAudioIndex(MediaFormat.MP3, testList));
stream = testList.get(
ListHelper.getAudioIndexByHighestRank(testList, cmp));
assertEquals(192, stream.getAverageBitrate());
assertEquals(MediaFormat.WEBMA, stream.getFormat());
// Should be same as above
stream = testList.get(ListHelper.getMostCompactAudioIndex(null, testList));
cmp = ListHelper.getAudioFormatComparator(null, true);
stream = testList.get(
ListHelper.getAudioIndexByHighestRank(testList, cmp));
assertEquals(192, stream.getAverageBitrate());
assertEquals(MediaFormat.WEBMA, stream.getFormat());
}
@Test
public void getLowestQualityAudioNull() {
assertEquals(-1, ListHelper.getMostCompactAudioIndex(null, null));
assertEquals(-1, ListHelper.getMostCompactAudioIndex(null, new ArrayList<>()));
final Comparator<AudioStream> cmp = ListHelper.getAudioFormatComparator(null, false);
assertEquals(-1, ListHelper.getAudioIndexByHighestRank(null, cmp));
assertEquals(-1, ListHelper.getAudioIndexByHighestRank(new ArrayList<>(), cmp));
}
@Test
public void getAudioTrack() {
// English language
Comparator<AudioStream> cmp =
ListHelper.getAudioTrackComparator(Locale.ENGLISH, false, false);
AudioStream stream = AUDIO_TRACKS_TEST_LIST.get(ListHelper.getAudioIndexByHighestRank(
AUDIO_TRACKS_TEST_LIST, cmp));
assertEquals("en.or", stream.getId());
// German language
cmp = ListHelper.getAudioTrackComparator(Locale.GERMAN, false, false);
stream = AUDIO_TRACKS_TEST_LIST.get(ListHelper.getAudioIndexByHighestRank(
AUDIO_TRACKS_TEST_LIST, cmp));
assertEquals("de.du", stream.getId());
// German language, but prefer original
cmp = ListHelper.getAudioTrackComparator(Locale.GERMAN, true, false);
stream = AUDIO_TRACKS_TEST_LIST.get(ListHelper.getAudioIndexByHighestRank(
AUDIO_TRACKS_TEST_LIST, cmp));
assertEquals("en.or", stream.getId());
// Prefer descriptive audio
cmp = ListHelper.getAudioTrackComparator(Locale.ENGLISH, false, true);
stream = AUDIO_TRACKS_TEST_LIST.get(ListHelper.getAudioIndexByHighestRank(
AUDIO_TRACKS_TEST_LIST, cmp));
assertEquals("en.ds", stream.getId());
// Japanese language, fall back to original
cmp = ListHelper.getAudioTrackComparator(Locale.JAPANESE, true, false);
stream = AUDIO_TRACKS_TEST_LIST.get(ListHelper.getAudioIndexByHighestRank(
AUDIO_TRACKS_TEST_LIST, cmp));
assertEquals("en.or", stream.getId());
}
@Test
@ -390,6 +450,22 @@ public class ListHelperTest {
.build();
}
private static AudioStream generateAudioTrack(
@NonNull final String id,
@Nullable final String trackId,
@Nullable final Locale locale,
@Nullable final AudioTrackType trackType) {
return new AudioStream.Builder()
.setId(id)
.setContent("", true)
.setMediaFormat(MediaFormat.M4A)
.setAverageBitrate(128)
.setAudioTrackId(trackId)
.setAudioLocale(locale)
.setAudioTrackType(trackType)
.build();
}
@NonNull
private static VideoStream generateVideoStream(@NonNull final String id,
@Nullable final MediaFormat mediaFormat,