diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java index d1ee0ee88..88a3484d9 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -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 wrappedAudioStreams; - @State StreamSizeWrapper wrappedVideoStreams; @State StreamSizeWrapper 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 audioStreamsAdapter; private StreamItemAdapter videoStreamsAdapter; private StreamItemAdapter 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 audioStreams = + getStreamsOfSpecifiedDelivery(info.getAudioStreams(), PROGRESSIVE_HTTP); + final List> 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 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>(4); - final List 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 audioStreams = getWrappedAudioStreams(); + final var secondaryStreams = new SparseArrayCompat>(4); + final List 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 getWrappedAudioStreams() { + if (selectedAudioTrackIndex < 0 || selectedAudioTrackIndex > wrappedAudioTracks.size()) { + return StreamSizeWrapper.empty(); + } + return wrappedAudioTracks.getTracksList().get(selectedAudioTrackIndex); + } + private int getSubtitleIndexBy(@NonNull final List 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); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 0950afc3e..8227f1c69 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -162,8 +162,12 @@ public final class VideoDetailFragment private boolean showRelatedItems; private boolean showDescription; private String selectedTabTag; - @AttrRes @NonNull final List tabIcons = new ArrayList<>(); - @StringRes @NonNull final List tabContentDescriptions = new ArrayList<>(); + @AttrRes + @NonNull + final List tabIcons = new ArrayList<>(); + @StringRes + @NonNull + final List 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 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 audioStreams = getUrlAndNonTorrentStreams( + currentInfo.getAudioStreams()); + final List 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 * */ diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java index 9ce99c15b..cd71c64e9 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java @@ -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 availableStreams = + Optional.ofNullable(player.getCurrentMetadata()) + .flatMap(MediaItemTag::getMaybeAudioTrack) + .map(MediaItemTag.AudioTrack::getAudioStreams) + .orElse(null); + final Optional 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 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); + }); + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index b446cbb81..89bdd9d69 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -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}. *

There are multiple types of errors:

@@ -1377,8 +1405,9 @@ public final class Player implements PlaybackListener, Listener { * For any error above that is not explicitly catchable, the player will * create a notification so users are aware. * + * * @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 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. - * + *

* 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. diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java index 84bd9d277..2cca259c2 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java +++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java @@ -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(); } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java index f08086287..1119fb903 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java @@ -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 getMaybeAudioTrack() { + return Optional.empty(); + } + Optional getMaybeExtras(@NonNull Class type); MediaItemTag withExtras(@NonNull T extra); @@ -128,4 +134,37 @@ public interface MediaItemTag { ? null : sortedVideoStreams.get(selectedVideoStreamIndex); } } + + final class AudioTrack { + @NonNull + private final List audioStreams; + private final int selectedAudioStreamIndex; + + private AudioTrack(@NonNull final List audioStreams, + final int selectedAudioStreamIndex) { + this.audioStreams = audioStreams; + this.selectedAudioStreamIndex = selectedAudioStreamIndex; + } + + static AudioTrack of(@NonNull final List audioStreams, + final int selectedAudioStreamIndex) { + return new AudioTrack(audioStreams, selectedAudioStreamIndex); + } + + @NonNull + public List getAudioStreams() { + return audioStreams; + } + + public int getSelectedAudioStreamIndex() { + return selectedAudioStreamIndex; + } + + @Nullable + public AudioStream getSelectedAudioStream() { + return selectedAudioStreamIndex < 0 + || selectedAudioStreamIndex >= audioStreams.size() + ? null : audioStreams.get(selectedAudioStreamIndex); + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java index 4095f2bc8..689f5c72b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java @@ -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 sortedVideoStreams, - final int selectedVideoStreamIndex) { + final int selectedVideoStreamIndex, + @NonNull final List 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 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 getMaybeAudioTrack() { + return Optional.ofNullable(audioTrack); + } + @Override public Optional getMaybeExtras(@NonNull final Class 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); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java index e1d3af335..2d4404b2a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java @@ -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 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 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 audioStreams = getPlayableStreams( - info.getAudioStreams(), info.getServiceId()); - if (!audioStreams.isEmpty()) { - final int index = ListHelper.getDefaultAudioFormat(context, audioStreams); - return getStreamForIndex(index, audioStreams); - } else { - final List 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 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; + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java index 9c8cbb8f6..e204b8372 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java @@ -156,6 +156,16 @@ public interface PlaybackResolver extends Resolver { 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(); } diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java index 0017312cf..670c13934 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java @@ -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 videoStreamsList = ListHelper.getSortedStreamVideosList(context, getPlayableStreams(info.getVideoStreams(), info.getServiceId()), getPlayableStreams(info.getVideoOnlyStreams(), info.getServiceId()), false, true); - final int index; + final List 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 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 sortedVideos); diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java index 9afd1bf24..2638ff041 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java @@ -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 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 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 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 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. */ diff --git a/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java b/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java new file mode 100644 index 000000000..39a05acb3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java @@ -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 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 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> tracksList; + + public AudioTracksWrapper(@NonNull final List> groupedAudioStreams, + @Nullable final Context context) { + this.tracksList = groupedAudioStreams.stream().map(streams -> + new StreamSizeWrapper<>(streams, context)).collect(Collectors.toList()); + } + + public List> getTracksList() { + return tracksList; + } + + public int size() { + return tracksList.size(); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java index 389bcc84f..f45f3786d 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java @@ -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 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 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 HIGH_RESOLUTION_LIST = Set.of("1440p", "2160p"); + // Audio track types in order of priotity. 0=lowest, n=highest + private static final List 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 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 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 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 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 videoStreams, @@ -115,16 +123,36 @@ public final class ListHelper { public static int getDefaultAudioFormat(final Context context, final List 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> groupedAudioStreams) { + if (groupedAudioStreams == null || groupedAudioStreams.isEmpty()) { + return -1; } + + final Comparator cmp = getAudioTrackComparator(context); + final List 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 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 getFilteredAudioStreams( + @NonNull final Context context, + @Nullable final List audioStreams) { + if (audioStreams == null) { + return Collections.emptyList(); + } + + final HashMap collectedStreams = new HashMap<>(); + + final Comparator 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> getGroupedAudioStreams( + @NonNull final Context context, + @Nullable final List audioStreams) { + if (audioStreams == null) { + return Collections.emptyList(); + } + + final HashMap> 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 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 nameCmp = getAudioTrackNameComparator(context); + final Comparator 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 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 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 audioStreams) { - return getAudioIndexByHighestRank(format, audioStreams, - // The "reversed()" is important -> Compares ascending (first = highest rank) - getAudioStreamComparator(AUDIO_FORMAT_EFFICIENCY_RANKING).reversed()); - } - - private static Comparator getAudioStreamComparator( - final List 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 audioStreams, - final Comparator comparator) { + static int getAudioIndexByHighestRank(@Nullable final List audioStreams, + final Comparator 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. + * + *

The prefered stream will be ordered last.

+ * + * @param context app context + * @return Comparator + */ + private static Comparator 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. + * + *

The prefered stream will be ordered last.

+ * + * @param defaultFormat the default format to look for + * @param limitDataUsage choose low bitrate audio stream + * @return Comparator + */ + static Comparator getAudioFormatComparator( + @Nullable final MediaFormat defaultFormat, final boolean limitDataUsage) { + final List formatRanking = limitDataUsage + ? AUDIO_FORMAT_EFFICIENCY_RANKING : AUDIO_FORMAT_QUALITY_RANKING; + + Comparator 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. + * + *

Tracks will be compared this order:

+ *
    + *
  1. If {@code preferOriginalAudio}: use original audio
  2. + *
  3. Language matches {@code preferredLanguage}
  4. + *
  5. + * Track type ranks highest in this order: + * Original > Dubbed > Descriptive + *

    If {@code preferDescriptiveAudio}: + * Descriptive > Dubbed > Original

    + *
  6. + *
  7. Language is English
  8. + *
+ * + *

The prefered track will be ordered last.

+ * + * @param context App context + * @return Comparator + */ + private static Comparator 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. + * + *

Tracks will be compared this order:

+ *
    + *
  1. If {@code preferOriginalAudio}: use original audio
  2. + *
  3. Language matches {@code preferredLanguage}
  4. + *
  5. + * Track type ranks highest in this order: + * Original > Dubbed > Descriptive + *

    If {@code preferDescriptiveAudio}: + * Descriptive > Dubbed > Original

    + *
  6. + *
  7. Language is English
  8. + *
+ * + *

The prefered track will be ordered last.

+ * + * @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 getAudioTrackComparator( + final Locale preferredLanguage, + final boolean preferOriginalAudio, + final boolean preferDescriptiveAudio) { + final String langCode = preferredLanguage.getISO3Language(); + final List 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 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); + } } diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java index 916b902f0..c4034252d 100644 --- a/app/src/main/java/org/schabi/newpipe/util/Localization.java +++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java @@ -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. + * + *

Examples of results returned by this method:

+ *
    + *
  • English (original)
  • + *
  • English (descriptive)
  • + *
  • Spanish (dubbed)
  • + *
+ * + * @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 //////////////////////////////////////////////////////////////////////////*/ diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java index 74de45720..2eb63ff41 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java @@ -224,6 +224,8 @@ public class StreamItemAdapter extends BaseA public static class StreamSizeWrapper implements Serializable { private static final StreamSizeWrapper EMPTY = new StreamSizeWrapper<>(Collections.emptyList(), null); + private static final int SIZE_UNSET = -2; + private final List streamsList; private final long[] streamSizes; private final String unknownSize; @@ -235,7 +237,7 @@ public class StreamItemAdapter extends BaseA this.unknownSize = context == null ? "--.-" : context.getString(R.string.unknown_content); - Arrays.fill(streamSizes, -2); + resetSizes(); } /** @@ -251,7 +253,7 @@ public class StreamItemAdapter extends BaseA final Callable 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 extends BaseA .onErrorReturnItem(true); } + public void resetSizes() { + Arrays.fill(streamSizes, SIZE_UNSET); + } + public static StreamSizeWrapper empty() { //noinspection unchecked return (StreamSizeWrapper) EMPTY; diff --git a/app/src/main/res/layout/download_dialog.xml b/app/src/main/res/layout/download_dialog.xml index 37bbf2b03..67aa1577c 100644 --- a/app/src/main/res/layout/download_dialog.xml +++ b/app/src/main/res/layout/download_dialog.xml @@ -71,11 +71,45 @@ android:minWidth="150dp" tools:listitem="@layout/stream_quality_item" /> + + + + + + + + + + + + @string/none_control_key + prefer_original_audio + prefer_descriptive_audio last_resize_mode diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5730e063e..d2104526a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -94,6 +94,10 @@ Turn off to hide video description and additional information Show meta info Turn off to hide meta info boxes with additional information about the stream creator, stream content or a search request + Prefer original audio + Select the original audio track regardless of the language + Prefer descriptive audio + Select an audio track with descriptions for visually impaired people if available Image cache wiped Wipe cached metadata Remove all cached webpage data @@ -414,6 +418,8 @@ Remove Details Audio Settings + Audio: %s + Audio track Hold to enqueue Show channel details Enqueue @@ -761,12 +767,15 @@ , Toggle all Streams which are not yet supported by the downloader are not shown + An audio track should be already present in this stream The selected stream is not supported by external players No audio streams are available for external players No video streams are available for external players Select quality for external players + Select audio track for external players Unknown format Unknown quality + Unknown Show future items Hide future items Fully watched @@ -779,4 +788,8 @@ 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 Always use ExoPlayer\'s video output surface setting workaround 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 + %s %s + original + dubbed + descriptive \ No newline at end of file diff --git a/app/src/main/res/xml/video_audio_settings.xml b/app/src/main/res/xml/video_audio_settings.xml index 75a925c65..727ce4df4 100644 --- a/app/src/main/res/xml/video_audio_settings.xml +++ b/app/src/main/res/xml/video_audio_settings.xml @@ -61,6 +61,22 @@ app:iconSpaceReserved="false" app:useSimpleSummaryProvider="true" /> + + + + 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 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 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 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 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 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 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 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 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,