diff --git a/app/build.gradle b/app/build.gradle index a766b16a8..e9e1ea124 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -191,7 +191,7 @@ dependencies { // name and the commit hash with the commit hash of the (pushed) commit you want to test // This works thanks to JitPack: https://jitpack.io/ implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' - implementation 'com.github.Theta-Dev:NewPipeExtractor:3fb356a7065c75909ee3856a29be92317c295bb9' + implementation 'com.github.Theta-Dev:NewPipeExtractor:1aa232475e957ce5d2c036406a983db4190ebf2b' implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0' /** Checkstyle **/ 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..5d3679471 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 selectedAudioStreamIndex; + @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.selectedAudioStreamIndex = + 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,38 @@ 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(); + + 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 +306,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 +404,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 +426,29 @@ public class DownloadDialog extends DialogFragment currentInfo.getServiceId())))); } + private void setupAudioTrackSpinner() { + if (getContext() == null) { + return; + } + + dialogBinding.audioTrackSpinner.setAdapter(audioTrackAdapter); + dialogBinding.audioTrackSpinner.setSelection(selectedAudioStreamIndex); + + dialogBinding.audioStreamSpinner.setAdapter(audioStreamsAdapter); + dialogBinding.audioStreamSpinner.setSelection(selectedAudioIndex); + } + private void setupAudioSpinner() { if (getContext() == null) { return; } - dialogBinding.qualitySpinner.setAdapter(audioStreamsAdapter); - dialogBinding.qualitySpinner.setSelection(selectedAudioIndex); + dialogBinding.qualitySpinner.setVisibility(View.GONE); setRadioButtonsState(true); + dialogBinding.audioStreamSpinner.setVisibility(View.VISIBLE); + dialogBinding.audioTrackSpinner.setVisibility( + wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE); + dialogBinding.defaultAudioTrackPresentText.setVisibility(View.GONE); } private void setupVideoSpinner() { @@ -422,7 +458,21 @@ 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.defaultAudioTrackPresentText.setVisibility( + !isVideoOnly && wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE + + ); } private void setupSubtitleSpinner() { @@ -432,7 +482,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.defaultAudioTrackPresentText.setVisibility(View.GONE); } @@ -550,18 +604,27 @@ 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: + selectedAudioStreamIndex = position; + updateSecondaryStreams(); + 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 +670,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 +721,13 @@ public class DownloadDialog extends DialogFragment dialogBinding.subtitleButton.setEnabled(enabled); } + private StreamSizeWrapper getWrappedAudioStreams() { + if (selectedAudioStreamIndex < 0 || selectedAudioStreamIndex > wrappedAudioTracks.size()) { + return StreamSizeWrapper.empty(); + } + return wrappedAudioTracks.getTracksList().get(selectedAudioStreamIndex); + } + private int getSubtitleIndexBy(@NonNull final List streams) { final Localization preferredLocalization = NewPipe.getPreferredLocalization(); @@ -1013,7 +1084,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/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 0164b708f..f8a800b0e 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java @@ -111,6 +111,19 @@ public final class ListHelper { getAudioTrackComparator(context).thenComparing(getAudioFormatComparator(context))); } + 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) { @@ -240,8 +253,50 @@ public final class ListHelper { } // Sort collected streams by name - return collectedStreams.values().stream().sorted(Comparator.comparing(audioStream -> - Localization.audioTrackName(context, audioStream))).collect(Collectors.toList()); + 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()); } /*////////////////////////////////////////////////////////////////////////// @@ -413,8 +468,8 @@ public final class ListHelper { * 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 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 */ static int getAudioIndexByHighestRank(@Nullable final List audioStreams, @@ -615,6 +670,9 @@ public final class ListHelper { /** * 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 */ @@ -628,7 +686,9 @@ public final class ListHelper { /** * Get a {@link Comparator} to compare {@link AudioStream}s by their format and bitrate. * - * @param defaultFormat the default format to look for + *

The prefered stream will be ordered last.

+ * + * @param defaultFormat the default format to look for * @param limitDataUsage choose low bitrate audio stream * @return Comparator */ @@ -655,6 +715,21 @@ public final class ListHelper { /** * Get a {@link Comparator} to compare {@link AudioStream}s by their tracks. * + *

In this order:

+ *
    + *
  1. If {@code preferOriginalAudio}: is 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 */ @@ -677,8 +752,23 @@ public final class ListHelper { /** * Get a {@link Comparator} to compare {@link AudioStream}s by their tracks. * - * @param preferredLanguage Preferred audio stream language - * @param preferOriginalAudio Get the original audio track regardless of its language + *

In this order:

+ *
    + *
  1. If {@code preferOriginalAudio}: is 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 */ @@ -699,10 +789,26 @@ public final class ListHelper { Comparator.nullsFirst(Comparator.comparing( locale -> locale.getISO3Language().equals(langCode)))) .thenComparing(AudioStream::getAudioTrackType, - Comparator.nullsLast(Comparator.comparingInt(trackTypeRanking::indexOf))) + 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/res/layout/download_dialog.xml b/app/src/main/res/layout/download_dialog.xml index 37bbf2b03..6b0a36cc8 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" /> + + + + + + , Toggle all Streams which are not yet supported by the downloader are not shown + The default 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