From 210834fbe933654804016e547833288f90e2135e Mon Sep 17 00:00:00 2001 From: AudricV <74829229+AudricV@users.noreply.github.com> Date: Thu, 16 Jun 2022 11:13:19 +0200 Subject: [PATCH 01/20] Add support of other delivery methods than progressive HTTP (in the player only) Detailed changes: - External players: - Add a message instruction about stream selection; - Add a message when there is no stream available for external players; - Return now HLS, DASH and SmoothStreaming URL contents, in addition to progressive HTTP ones. - Player: - Support DASH, HLS and SmoothStreaming streams for videos, whether they are content URLs or the manifests themselves, in addition to progressive HTTP ones; - Use a custom HttpDataSource to play YouTube contents, based of ExoPlayer's default one, which allows better spoofing of official clients (custom user-agent and headers (depending of the client used), use of range and rn (set dynamically by the DataSource) parameters); - Fetch YouTube progressive contents as DASH streams, like official clients, support fully playback of livestreams which have ended recently and OTF streams; - Use ExoPlayer's default retries count for contents on non-fatal errors (instead of Integer.MAX_VALUE for non-live contents and 5 for live contents). - Download dialog: - Add message about support of progressive HTTP streams only for downloading; - Remove several duplicated code and update relevant usages; - Support downloading of contents with an unknown media format. - ListHelper: - Catch NumberFormatException when trying to compare two video streams between them. - Tests: - Update ListHelperTest and StreamItemAdapterTest to fix breaking changes in the extractor. - Other places: - Fixes deprecation of changes made in the extractor; - Improve some code related to the files changed. - Issues fixed and/or improved with the changes: - Seeking of PeerTube HLS streams (the duration shown was the one from the stream duration and not the one parsed, incomplete because HLS streams are fragmented MP4s with multiple sidx boxes, for which seeking is not supported by ExoPlayer) (the app now uses the HLS manifest returned for each quality, in the master playlist (not fetched and computed by the extractor)); - Crash when loading PeerTube streams with a separated audio; - Lack of some streams on some YouTube videos (OTF streams); - Loading times of YouTube streams, after a quality change or a playback start; - View count of YouTube ended livestreams interpreted as watching count (this type of streams is not interpreted anymore as livestreams); - Watchable time of YouTube ended livestreams; - Playback of SoundCloud HLS-only tracks (which cannot be downloaded anymore because the workaround which was used is being removed by SoundCloud, so it has been removed from the extractor). --- app/build.gradle | 2 +- .../newpipe/util/StreamItemAdapterTest.kt | 36 +- .../org/schabi/newpipe/RouterActivity.java | 18 +- .../newpipe/download/DownloadDialog.java | 261 +++-- .../fragments/detail/VideoDetailFragment.java | 82 +- .../holder/StreamMiniInfoItemHolder.java | 6 +- .../newpipe/local/feed/item/StreamItem.kt | 4 +- .../org/schabi/newpipe/player/Player.java | 55 +- .../datasource/YoutubeHttpDataSource.java | 1031 +++++++++++++++++ .../newpipe/player/helper/CacheFactory.java | 118 +- .../NonUriHlsPlaylistParserFactory.java | 50 + .../player/helper/PlayerDataSource.java | 129 ++- .../newpipe/player/helper/PlayerHelper.java | 67 +- .../listeners/view/QualityClickListener.kt | 2 +- .../resolver/AudioPlaybackResolver.java | 27 +- .../player/resolver/PlaybackResolver.java | 411 ++++++- .../resolver/VideoPlaybackResolver.java | 89 +- .../org/schabi/newpipe/util/ListHelper.java | 130 ++- .../schabi/newpipe/util/NavigationHelper.java | 85 +- .../newpipe/util/SecondaryStreamHelper.java | 52 +- .../newpipe/util/StreamItemAdapter.java | 46 +- .../schabi/newpipe/util/StreamTypeUtil.java | 8 +- .../giga/get/DownloadMissionRecover.java | 28 +- .../shandian/giga/get/MissionRecoveryInfo.kt | 12 +- app/src/main/res/layout/download_dialog.xml | 13 + app/src/main/res/values/strings.xml | 7 + .../schabi/newpipe/util/ListHelperTest.java | 187 +-- 27 files changed, 2417 insertions(+), 539 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/NonUriHlsPlaylistParserFactory.java diff --git a/app/build.gradle b/app/build.gradle index 44fd7512b..995dae6ed 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -190,7 +190,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.TeamNewPipe:NewPipeExtractor:ac1c22d81c65b7b0c5427f4e1989f5256d617f32' + implementation 'com.github.TeamNewPipe:NewPipeExtractor:1b51eab664ec7cbd2295c96d8b43000379cd1b7b' /** Checkstyle **/ checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}" diff --git a/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt b/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt index a9aa40d82..016feb576 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt @@ -91,7 +91,12 @@ class StreamItemAdapterTest { context, StreamItemAdapter.StreamSizeWrapper( (0 until 5).map { - SubtitlesStream(MediaFormat.SRT, "pt-BR", "https://example.com", false) + SubtitlesStream.Builder() + .setContent("https://example.com", true) + .setMediaFormat(MediaFormat.SRT) + .setLanguageCode("pt-BR") + .setAutoGenerated(false) + .build() }, context ), @@ -108,7 +113,14 @@ class StreamItemAdapterTest { val adapter = StreamItemAdapter( context, StreamItemAdapter.StreamSizeWrapper( - (0 until 5).map { AudioStream("https://example.com/$it", MediaFormat.OPUS, 192) }, + (0 until 5).map { + AudioStream.Builder() + .setId(Stream.ID_UNKNOWN) + .setContent("https://example.com/$it", true) + .setMediaFormat(MediaFormat.OPUS) + .setAverageBitrate(192) + .build() + }, context ), null @@ -126,7 +138,13 @@ class StreamItemAdapterTest { private fun getVideoStreams(vararg videoOnly: Boolean) = StreamItemAdapter.StreamSizeWrapper( videoOnly.map { - VideoStream("https://example.com", MediaFormat.MPEG_4, "720p", it) + VideoStream.Builder() + .setId(Stream.ID_UNKNOWN) + .setContent("https://example.com", true) + .setMediaFormat(MediaFormat.MPEG_4) + .setResolution("720p") + .setIsVideoOnly(it) + .build() }, context ) @@ -138,8 +156,16 @@ class StreamItemAdapterTest { private fun getAudioStreams(vararg shouldBeValid: Boolean) = getSecondaryStreamsFromList( shouldBeValid.map { - if (it) AudioStream("https://example.com", MediaFormat.OPUS, 192) - else null + if (it) { + AudioStream.Builder() + .setId(Stream.ID_UNKNOWN) + .setContent("https://example.com", true) + .setMediaFormat(MediaFormat.OPUS) + .setAverageBitrate(192) + .build() + } else { + null + } } ) diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index cc89c0fed..96f8ff1bc 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -58,7 +58,6 @@ import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException; import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.player.MainPlayer; @@ -677,22 +676,15 @@ public class RouterActivity extends AppCompatActivity { .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(result -> { - final List sortedVideoStreams = ListHelper - .getSortedStreamVideosList(this, result.getVideoStreams(), - result.getVideoOnlyStreams(), false, false); - final int selectedVideoStreamIndex = ListHelper - .getDefaultResolutionIndex(this, sortedVideoStreams); + final DownloadDialog downloadDialog = DownloadDialog.newInstance(this, result); + downloadDialog.setSelectedVideoStream(ListHelper.getDefaultResolutionIndex( + this, downloadDialog.wrappedVideoStreams.getStreamsList())); + downloadDialog.setOnDismissListener(dialog -> finish()); final FragmentManager fm = getSupportFragmentManager(); - final DownloadDialog downloadDialog = DownloadDialog.newInstance(result); - downloadDialog.setVideoStreams(sortedVideoStreams); - downloadDialog.setAudioStreams(result.getAudioStreams()); - downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex); - downloadDialog.setOnDismissListener(dialog -> finish()); downloadDialog.show(fm, "downloadDialog"); fm.executePendingTransactions(); - }, throwable -> - showUnsupportedUrlDialog(currentUrl))); + }, throwable -> showUnsupportedUrlDialog(currentUrl))); } @Override 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 f5c226908..73ba8c74a 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -48,6 +48,7 @@ import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.DeliveryMethod; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.SubtitlesStream; @@ -71,6 +72,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Objects; import icepick.Icepick; import icepick.State; @@ -82,6 +84,7 @@ import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; import us.shandian.giga.service.MissionState; +import static org.schabi.newpipe.util.ListHelper.keepStreamsWithDelivery; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; public class DownloadDialog extends DialogFragment @@ -92,11 +95,11 @@ public class DownloadDialog extends DialogFragment @State StreamInfo currentInfo; @State - StreamSizeWrapper wrappedAudioStreams = StreamSizeWrapper.empty(); + public StreamSizeWrapper wrappedAudioStreams = StreamSizeWrapper.empty(); @State - StreamSizeWrapper wrappedVideoStreams = StreamSizeWrapper.empty(); + public StreamSizeWrapper wrappedVideoStreams = StreamSizeWrapper.empty(); @State - StreamSizeWrapper wrappedSubtitleStreams = StreamSizeWrapper.empty(); + public StreamSizeWrapper wrappedSubtitleStreams = StreamSizeWrapper.empty(); @State int selectedVideoIndex = 0; @State @@ -138,28 +141,39 @@ public class DownloadDialog extends DialogFragment registerForActivityResult( new StartActivityForResult(), this::requestDownloadPickVideoFolderResult); - /*////////////////////////////////////////////////////////////////////////// // Instance creation //////////////////////////////////////////////////////////////////////////*/ - public static DownloadDialog newInstance(final StreamInfo info) { - final DownloadDialog dialog = new DownloadDialog(); - dialog.setInfo(info); - return dialog; - } + @NonNull + public static DownloadDialog newInstance(final Context context, + @NonNull final StreamInfo info) { + // TODO: Adapt this code when the downloader support other types of stream deliveries + final List videoStreams = new ArrayList<>(info.getVideoStreams()); + final List progressiveHttpVideoStreams = + keepStreamsWithDelivery(videoStreams, DeliveryMethod.PROGRESSIVE_HTTP); - public static DownloadDialog newInstance(final Context context, final StreamInfo info) { - final ArrayList streamsList = new ArrayList<>(ListHelper - .getSortedStreamVideosList(context, info.getVideoStreams(), - info.getVideoOnlyStreams(), false, false)); - final int selectedStreamIndex = ListHelper.getDefaultResolutionIndex(context, streamsList); + final List videoOnlyStreams = new ArrayList<>(info.getVideoOnlyStreams()); + final List progressiveHttpVideoOnlyStreams = + keepStreamsWithDelivery(videoOnlyStreams, DeliveryMethod.PROGRESSIVE_HTTP); - final DownloadDialog instance = newInstance(info); - instance.setVideoStreams(streamsList); - instance.setSelectedVideoStream(selectedStreamIndex); - instance.setAudioStreams(info.getAudioStreams()); - instance.setSubtitleStreams(info.getSubtitles()); + final List audioStreams = new ArrayList<>(info.getAudioStreams()); + final List progressiveHttpAudioStreams = + keepStreamsWithDelivery(audioStreams, DeliveryMethod.PROGRESSIVE_HTTP); + + final List subtitlesStreams = new ArrayList<>(info.getSubtitles()); + final List progressiveHttpSubtitlesStreams = + keepStreamsWithDelivery(subtitlesStreams, DeliveryMethod.PROGRESSIVE_HTTP); + + final List videoStreamsList = new ArrayList<>( + ListHelper.getSortedStreamVideosList(context, progressiveHttpVideoStreams, + progressiveHttpVideoOnlyStreams, false, false)); + + final DownloadDialog instance = new DownloadDialog(); + instance.setInfo(info); + instance.setVideoStreams(videoStreamsList); + instance.setAudioStreams(progressiveHttpAudioStreams); + instance.setSubtitleStreams(progressiveHttpSubtitlesStreams); return instance; } @@ -169,45 +183,69 @@ public class DownloadDialog extends DialogFragment // Setters //////////////////////////////////////////////////////////////////////////*/ - private void setInfo(final StreamInfo info) { + private void setInfo(@NonNull final StreamInfo info) { this.currentInfo = info; } - public void setAudioStreams(final List audioStreams) { - setAudioStreams(new StreamSizeWrapper<>(audioStreams, getContext())); + public void setAudioStreams(@NonNull final List audioStreams) { + this.wrappedAudioStreams = new StreamSizeWrapper<>(audioStreams, getContext()); } - public void setAudioStreams(final StreamSizeWrapper was) { - this.wrappedAudioStreams = was; + public void setVideoStreams(@NonNull final List videoStreams) { + this.wrappedVideoStreams = new StreamSizeWrapper<>(videoStreams, getContext()); } - public void setVideoStreams(final List videoStreams) { - setVideoStreams(new StreamSizeWrapper<>(videoStreams, getContext())); - } - - public void setVideoStreams(final StreamSizeWrapper wvs) { - this.wrappedVideoStreams = wvs; - } - - public void setSubtitleStreams(final List subtitleStreams) { - setSubtitleStreams(new StreamSizeWrapper<>(subtitleStreams, getContext())); - } - - public void setSubtitleStreams( - final StreamSizeWrapper wss) { - this.wrappedSubtitleStreams = wss; + public void setSubtitleStreams(@NonNull final List subtitleStreams) { + this.wrappedSubtitleStreams = new StreamSizeWrapper<>(subtitleStreams, getContext()); } + /** + * Set the selected video stream, by using its index in the stream list. + * + * The index of the select video stream will be not set if this index is not in the bounds + * of the stream list. + * + * @param svi the index of the selected {@link VideoStream} + */ public void setSelectedVideoStream(final int svi) { - this.selectedVideoIndex = svi; + if (selectedStreamIsInBoundsOfWrappedStreams(svi, this.wrappedVideoStreams)) { + this.selectedVideoIndex = svi; + } } + /** + * Set the selected audio stream, by using its index in the stream list. + * + * The index of the select audio stream will be not set if this index is not in the bounds + * of the stream list. + * + * @param sai the index of the selected {@link AudioStream} + */ public void setSelectedAudioStream(final int sai) { - this.selectedAudioIndex = sai; + if (selectedStreamIsInBoundsOfWrappedStreams(sai, this.wrappedAudioStreams)) { + this.selectedAudioIndex = sai; + } } + /** + * Set the selected subtitles stream, by using its index in the stream list. + * + * The index of the select subtitles stream will be not set if this index is not in the bounds + * of the stream list. + * + * @param ssi the index of the selected {@link SubtitlesStream} + */ public void setSelectedSubtitleStream(final int ssi) { - this.selectedSubtitleIndex = ssi; + if (selectedStreamIsInBoundsOfWrappedStreams(ssi, this.wrappedSubtitleStreams)) { + this.selectedSubtitleIndex = ssi; + } + } + + private boolean selectedStreamIsInBoundsOfWrappedStreams( + final int selectedIndexStream, + final StreamSizeWrapper wrappedStreams) { + return selectedIndexStream > 0 + && selectedIndexStream < wrappedStreams.getStreamsList().size(); } public void setOnDismissListener(@Nullable final OnDismissListener onDismissListener) { @@ -249,11 +287,16 @@ public class DownloadDialog extends DialogFragment .getAudioStreamFor(wrappedAudioStreams.getStreamsList(), videoStreams.get(i)); if (audioStream != null) { - secondaryStreams - .append(i, new SecondaryStreamHelper<>(wrappedAudioStreams, audioStream)); + secondaryStreams.append(i, new SecondaryStreamHelper<>(wrappedAudioStreams, + audioStream)); } else if (DEBUG) { - Log.w(TAG, "No audio stream candidates for video format " - + videoStreams.get(i).getFormat().name()); + 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"); + } } } @@ -288,7 +331,8 @@ public class DownloadDialog extends DialogFragment } @Override - public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, + public View onCreateView(@NonNull final LayoutInflater inflater, + final ViewGroup container, final Bundle savedInstanceState) { if (DEBUG) { Log.d(TAG, "onCreateView() called with: " @@ -299,14 +343,15 @@ public class DownloadDialog extends DialogFragment } @Override - public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + public void onViewCreated(@NonNull final View view, + @Nullable final Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); dialogBinding = DownloadDialogBinding.bind(view); dialogBinding.fileName.setText(FilenameUtils.createFilename(getContext(), currentInfo.getName())); selectedAudioIndex = ListHelper - .getDefaultAudioFormat(getContext(), currentInfo.getAudioStreams()); + .getDefaultAudioFormat(getContext(), wrappedAudioStreams.getStreamsList()); selectedSubtitleIndex = getSubtitleIndexBy(subtitleStreamsAdapter.getAll()); @@ -324,7 +369,8 @@ public class DownloadDialog extends DialogFragment dialogBinding.threads.setProgress(threads - 1); dialogBinding.threads.setOnSeekBarChangeListener(new SimpleOnSeekBarChangeListener() { @Override - public void onProgressChanged(@NonNull final SeekBar seekbar, final int progress, + public void onProgressChanged(@NonNull final SeekBar seekbar, + final int progress, final boolean fromUser) { final int newProgress = progress + 1; prefs.edit().putInt(getString(R.string.default_download_threads), newProgress) @@ -469,7 +515,7 @@ public class DownloadDialog extends DialogFragment result, getString(R.string.download_path_video_key), DownloadManager.TAG_VIDEO); } - private void requestDownloadSaveAsResult(final ActivityResult result) { + private void requestDownloadSaveAsResult(@NonNull final ActivityResult result) { if (result.getResultCode() != Activity.RESULT_OK) { return; } @@ -486,8 +532,8 @@ public class DownloadDialog extends DialogFragment return; } - final DocumentFile docFile - = DocumentFile.fromSingleUri(context, result.getData().getData()); + final DocumentFile docFile = DocumentFile.fromSingleUri(context, + result.getData().getData()); if (docFile == null) { showFailedDialog(R.string.general_error); return; @@ -498,7 +544,7 @@ public class DownloadDialog extends DialogFragment docFile.getType()); } - private void requestDownloadPickFolderResult(final ActivityResult result, + private void requestDownloadPickFolderResult(@NonNull final ActivityResult result, final String key, final String tag) { if (result.getResultCode() != Activity.RESULT_OK) { @@ -518,12 +564,11 @@ public class DownloadDialog extends DialogFragment StoredDirectoryHelper.PERMISSION_FLAGS); } - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putString(key, uri.toString()).apply(); + PreferenceManager.getDefaultSharedPreferences(context).edit().putString(key, + uri.toString()).apply(); try { - final StoredDirectoryHelper mainStorage - = new StoredDirectoryHelper(context, uri, tag); + final StoredDirectoryHelper mainStorage = new StoredDirectoryHelper(context, uri, tag); checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, mimeTmp); } catch (final IOException e) { @@ -561,8 +606,10 @@ public class DownloadDialog extends DialogFragment } @Override - public void onItemSelected(final AdapterView parent, final View view, - final int position, final long id) { + public void onItemSelected(final AdapterView parent, + final View view, + final int position, + final long id) { if (DEBUG) { Log.d(TAG, "onItemSelected() called with: " + "parent = [" + parent + "], view = [" + view + "], " @@ -597,14 +644,16 @@ public class DownloadDialog extends DialogFragment final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0; final boolean isSubtitleStreamsAvailable = subtitleStreamsAdapter.getCount() > 0; - dialogBinding.audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE : View.GONE); - dialogBinding.videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE : View.GONE); + dialogBinding.audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE + : View.GONE); + dialogBinding.videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE + : View.GONE); dialogBinding.subtitleButton.setVisibility(isSubtitleStreamsAvailable ? View.VISIBLE : View.GONE); prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); final String defaultMedia = prefs.getString(getString(R.string.last_used_download_type), - getString(R.string.last_download_type_video_key)); + getString(R.string.last_download_type_video_key)); if (isVideoStreamsAvailable && (defaultMedia.equals(getString(R.string.last_download_type_video_key)))) { @@ -640,7 +689,7 @@ public class DownloadDialog extends DialogFragment dialogBinding.subtitleButton.setEnabled(enabled); } - private int getSubtitleIndexBy(final List streams) { + private int getSubtitleIndexBy(@NonNull final List streams) { final Localization preferredLocalization = NewPipe.getPreferredLocalization(); int candidate = 0; @@ -666,8 +715,10 @@ public class DownloadDialog extends DialogFragment return candidate; } + @NonNull private String getNameEditText() { - final String str = dialogBinding.fileName.getText().toString().trim(); + final String str = Objects.requireNonNull(dialogBinding.fileName.getText()).toString() + .trim(); return FilenameUtils.createFilename(context, str.isEmpty() ? currentInfo.getName() : str); } @@ -683,12 +734,8 @@ public class DownloadDialog extends DialogFragment } private void launchDirectoryPicker(final ActivityResultLauncher launcher) { - NoFileManagerSafeGuard.launchSafe( - launcher, - StoredDirectoryHelper.getPicker(context), - TAG, - context - ); + NoFileManagerSafeGuard.launchSafe(launcher, StoredDirectoryHelper.getPicker(context), TAG, + context); } private void prepareSelectedDownload() { @@ -710,30 +757,46 @@ public class DownloadDialog extends DialogFragment mimeTmp = "audio/ogg"; filenameTmp += "opus"; } else { - mimeTmp = format.mimeType; - filenameTmp += format.suffix; + if (format != null) { + mimeTmp = format.mimeType; + } + if (format != null) { + filenameTmp += format.suffix; + } } break; case R.id.video_button: selectedMediaType = getString(R.string.last_download_type_video_key); mainStorage = mainStorageVideo; format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); - mimeTmp = format.mimeType; - filenameTmp += format.suffix; + if (format != null) { + mimeTmp = format.mimeType; + } + if (format != null) { + filenameTmp += format.suffix; + } break; case R.id.subtitle_button: selectedMediaType = getString(R.string.last_download_type_subtitle_key); mainStorage = mainStorageVideo; // subtitle & video files go together format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat(); - mimeTmp = format.mimeType; - filenameTmp += (format == MediaFormat.TTML ? MediaFormat.SRT : format).suffix; + if (format != null) { + mimeTmp = format.mimeType; + } + + if (format == MediaFormat.TTML) { + filenameTmp += MediaFormat.SRT.suffix; + } else { + if (format != null) { + filenameTmp += format.suffix; + } + } break; default: throw new RuntimeException("No stream selected"); } - if (!askForSavePath - && (mainStorage == null + if (!askForSavePath && (mainStorage == null || mainStorage.isDirect() == NewPipeSettings.useStorageAccessFramework(context) || mainStorage.isInvalidSafStorage())) { // Pick new download folder if one of: @@ -767,18 +830,16 @@ public class DownloadDialog extends DialogFragment initialPath = Uri.parse(initialSavePath.getAbsolutePath()); } - NoFileManagerSafeGuard.launchSafe( - requestDownloadSaveAsLauncher, - StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath), - TAG, - context - ); + NoFileManagerSafeGuard.launchSafe(requestDownloadSaveAsLauncher, + StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath), TAG, + context); return; } // check for existing file with the same name - checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, mimeTmp); + checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, + mimeTmp); // remember the last media type downloaded by the user prefs.edit().putString(getString(R.string.last_used_download_type), selectedMediaType) @@ -786,7 +847,8 @@ public class DownloadDialog extends DialogFragment } private void checkSelectedDownload(final StoredDirectoryHelper mainStorage, - final Uri targetFile, final String filename, + final Uri targetFile, + final String filename, final String mime) { StoredFileHelper storage; @@ -947,7 +1009,7 @@ public class DownloadDialog extends DialogFragment storage.truncate(); } } catch (final IOException e) { - Log.e(TAG, "failed to truncate the file: " + storage.getUri().toString(), e); + Log.e(TAG, "Failed to truncate the file: " + storage.getUri().toString(), e); showFailedDialog(R.string.overwrite_failed); return; } @@ -992,8 +1054,8 @@ public class DownloadDialog extends DialogFragment } psArgs = null; - final long videoSize = wrappedVideoStreams - .getSizeInBytes((VideoStream) selectedStream); + final long videoSize = wrappedVideoStreams.getSizeInBytes( + (VideoStream) selectedStream); // set nearLength, only, if both sizes are fetched or known. This probably // does not work on slow networks but is later updated in the downloader @@ -1009,7 +1071,7 @@ public class DownloadDialog extends DialogFragment if (selectedStream.getFormat() == MediaFormat.TTML) { psName = Postprocessing.ALGORITHM_TTML_CONVERTER; - psArgs = new String[]{ + psArgs = new String[] { selectedStream.getFormat().getSuffix(), "false" // ignore empty frames }; @@ -1020,17 +1082,22 @@ public class DownloadDialog extends DialogFragment } if (secondaryStream == null) { - urls = new String[]{ - selectedStream.getUrl() + urls = new String[] { + selectedStream.getContent() }; - recoveryInfo = new MissionRecoveryInfo[]{ + recoveryInfo = new MissionRecoveryInfo[] { new MissionRecoveryInfo(selectedStream) }; } else { - urls = new String[]{ - selectedStream.getUrl(), secondaryStream.getUrl() + if (secondaryStream.getDeliveryMethod() != DeliveryMethod.PROGRESSIVE_HTTP) { + throw new IllegalArgumentException("Unsupported stream delivery format" + + secondaryStream.getDeliveryMethod()); + } + + urls = new String[] { + selectedStream.getContent(), secondaryStream.getContent() }; - recoveryInfo = new MissionRecoveryInfo[]{new MissionRecoveryInfo(selectedStream), + recoveryInfo = new MissionRecoveryInfo[] {new MissionRecoveryInfo(selectedStream), new MissionRecoveryInfo(secondaryStream)}; } 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 8c260461c..f5bd1f363 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 @@ -94,6 +94,7 @@ import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.external_communication.ShareUtils; @@ -121,6 +122,7 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientat import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired; import static org.schabi.newpipe.player.playqueue.PlayQueueItem.RECOVERY_UNSET; import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; +import static org.schabi.newpipe.util.ListHelper.removeNonUrlAndTorrentStreams; public final class VideoDetailFragment extends BaseStateFragment @@ -186,8 +188,7 @@ public final class VideoDetailFragment @Nullable private Disposable positionSubscriber = null; - private List sortedVideoStreams; - private int selectedVideoStreamIndex = -1; + private List videoStreamsForExternalPlayers; private BottomSheetBehavior bottomSheetBehavior; private BroadcastReceiver broadcastReceiver; @@ -1547,11 +1548,13 @@ public final class VideoDetailFragment binding.detailSubChannelThumbnailView.setImageDrawable(buddyDrawable); binding.detailUploaderThumbnailView.setImageDrawable(buddyDrawable); + final StreamType streamType = info.getStreamType(); + if (info.getViewCount() >= 0) { - if (info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { + if (streamType.equals(StreamType.AUDIO_LIVE_STREAM)) { binding.detailViewCountView.setText(Localization.listeningCount(activity, info.getViewCount())); - } else if (info.getStreamType().equals(StreamType.LIVE_STREAM)) { + } else if (streamType.equals(StreamType.LIVE_STREAM)) { binding.detailViewCountView.setText(Localization .localizeWatchingCount(activity, info.getViewCount())); } else { @@ -1612,14 +1615,13 @@ public final class VideoDetailFragment binding.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE); binding.detailSecondaryControlPanel.setVisibility(View.GONE); - sortedVideoStreams = ListHelper.getSortedStreamVideosList( - activity, - info.getVideoStreams(), - info.getVideoOnlyStreams(), - false, - false); - selectedVideoStreamIndex = ListHelper - .getDefaultResolutionIndex(activity, sortedVideoStreams); + final List videoStreams = removeNonUrlAndTorrentStreams( + new ArrayList<>(currentInfo.getVideoStreams())); + final List videoOnlyStreams = removeNonUrlAndTorrentStreams( + new ArrayList<>(currentInfo.getVideoOnlyStreams())); + videoStreamsForExternalPlayers = ListHelper.getSortedStreamVideosList(activity, + videoStreams, videoOnlyStreams, false, false); + updateProgressInfo(info); initThumbnailViews(info); showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, @@ -1645,8 +1647,8 @@ public final class VideoDetailFragment } } - binding.detailControlsDownload.setVisibility(info.getStreamType() == StreamType.LIVE_STREAM - || info.getStreamType() == StreamType.AUDIO_LIVE_STREAM ? View.GONE : View.VISIBLE); + binding.detailControlsDownload.setVisibility( + StreamTypeUtil.isLiveStream(streamType) ? View.GONE : View.VISIBLE); binding.detailControlsBackground.setVisibility(info.getAudioStreams().isEmpty() ? View.GONE : View.VISIBLE); @@ -1687,11 +1689,10 @@ public final class VideoDetailFragment } try { - final DownloadDialog downloadDialog = DownloadDialog.newInstance(currentInfo); - downloadDialog.setVideoStreams(sortedVideoStreams); - downloadDialog.setAudioStreams(currentInfo.getAudioStreams()); - downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex); - downloadDialog.setSubtitleStreams(currentInfo.getSubtitles()); + final DownloadDialog downloadDialog = DownloadDialog.newInstance(activity, + currentInfo); + downloadDialog.setSelectedVideoStream(ListHelper.getDefaultResolutionIndex(activity, + downloadDialog.wrappedVideoStreams.getStreamsList())); downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog"); } catch (final Exception e) { @@ -1722,8 +1723,7 @@ public final class VideoDetailFragment binding.detailPositionView.setVisibility(View.GONE); // TODO: Remove this check when separation of concerns is done. // (live streams weren't getting updated because they are mixed) - if (!info.getStreamType().equals(StreamType.LIVE_STREAM) - && !info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { + if (!StreamTypeUtil.isLiveStream(info.getStreamType())) { return; } } else { @@ -2151,25 +2151,33 @@ public final class VideoDetailFragment } private void showExternalPlaybackDialog() { - if (sortedVideoStreams == null) { + if (currentInfo == null) { return; } - final CharSequence[] resolutions = new CharSequence[sortedVideoStreams.size()]; - for (int i = 0; i < sortedVideoStreams.size(); i++) { - resolutions[i] = sortedVideoStreams.get(i).getResolution(); - } - final AlertDialog.Builder builder = new AlertDialog.Builder(activity) - .setNegativeButton(R.string.cancel, null) - .setNeutralButton(R.string.open_in_browser, (dialog, i) -> - ShareUtils.openUrlInBrowser(requireActivity(), url) - ); - // Maybe there are no video streams available, show just `open in browser` button - if (resolutions.length > 0) { - builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndex, (dialog, i) -> { + + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle(R.string.select_quality_external_players); + builder.setNegativeButton(android.R.string.cancel, null); + builder.setNeutralButton(R.string.open_in_browser, (dialog, i) -> + ShareUtils.openUrlInBrowser(requireActivity(), url)); + if (videoStreamsForExternalPlayers.isEmpty()) { + builder.setMessage(R.string.no_video_streams_available_for_external_players); + } else { + final int selectedVideoStreamIndexForExternalPlayers = + ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers); + final CharSequence[] resolutions = + new CharSequence[videoStreamsForExternalPlayers.size()]; + + for (int i = 0; i < videoStreamsForExternalPlayers.size(); i++) { + resolutions[i] = videoStreamsForExternalPlayers.get(i).getResolution(); + } + + builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndexForExternalPlayers, + (dialog, i) -> { dialog.dismiss(); - startOnExternalPlayer(activity, currentInfo, sortedVideoStreams.get(i)); - } - ); + startOnExternalPlayer(activity, currentInfo, + videoStreamsForExternalPlayers.get(i)); + }); } builder.show(); } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java index 79772a6a3..83211d4dd 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java @@ -96,9 +96,10 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { case VIDEO_STREAM: case LIVE_STREAM: case AUDIO_LIVE_STREAM: + case POST_LIVE_STREAM: + case POST_LIVE_AUDIO_STREAM: enableLongClick(item); break; - case FILE: case NONE: default: disableLongClick(); @@ -114,7 +115,8 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { final StreamStateEntity state = historyRecordManager.loadStreamState(infoItem).blockingGet()[0]; if (state != null && item.getDuration() > 0 - && item.getStreamType() != StreamType.LIVE_STREAM) { + && item.getStreamType() != StreamType.LIVE_STREAM + && item.getStreamType() != StreamType.AUDIO_LIVE_STREAM) { itemProgressView.setMax((int) item.getDuration()); if (itemProgressView.getVisibility() == View.VISIBLE) { itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt index 217e3f3e3..96d395aa5 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt @@ -14,6 +14,8 @@ import org.schabi.newpipe.databinding.ListStreamItemBinding import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_STREAM import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM +import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_AUDIO_STREAM +import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_STREAM import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.PicassoHelper @@ -109,7 +111,7 @@ data class StreamItem( } override fun isLongClickable() = when (stream.streamType) { - AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM -> true + AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM, POST_LIVE_STREAM, POST_LIVE_AUDIO_STREAM -> true else -> false } 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 583da4764..d2aed7623 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -1744,24 +1744,9 @@ public final class Player implements if (exoPlayerIsNull()) { return; } - // Use duration of currentItem for non-live streams, - // because HLS streams are fragmented - // and thus the whole duration is not available to the player - // TODO: revert #6307 when introducing proper HLS support - final int duration; - if (currentItem != null - && !StreamTypeUtil.isLiveStream(currentItem.getStreamType()) - ) { - // convert seconds to milliseconds - duration = (int) (currentItem.getDuration() * 1000); - } else { - duration = (int) simpleExoPlayer.getDuration(); - } - onUpdateProgress( - Math.max((int) simpleExoPlayer.getCurrentPosition(), 0), - duration, - simpleExoPlayer.getBufferedPercentage() - ); + + onUpdateProgress(Math.max((int) simpleExoPlayer.getCurrentPosition(), 0), + (int) simpleExoPlayer.getDuration(), simpleExoPlayer.getBufferedPercentage()); } private Disposable getProgressUpdateDisposable() { @@ -3399,6 +3384,7 @@ public final class Player implements switch (info.getStreamType()) { case AUDIO_STREAM: + case POST_LIVE_AUDIO_STREAM: binding.surfaceView.setVisibility(View.GONE); binding.endScreen.setVisibility(View.VISIBLE); binding.playbackEndTime.setVisibility(View.VISIBLE); @@ -3417,6 +3403,7 @@ public final class Player implements break; case VIDEO_STREAM: + case POST_LIVE_STREAM: if (currentMetadata == null || !currentMetadata.getMaybeQuality().isPresent() || (info.getVideoStreams().isEmpty() @@ -3484,10 +3471,10 @@ public final class Player implements for (int i = 0; i < availableStreams.size(); i++) { final VideoStream videoStream = availableStreams.get(i); qualityPopupMenu.getMenu().add(POPUP_MENU_ID_QUALITY, i, Menu.NONE, MediaFormat - .getNameById(videoStream.getFormatId()) + " " + videoStream.resolution); + .getNameById(videoStream.getFormatId()) + " " + videoStream.getResolution()); } if (getSelectedVideoStream() != null) { - binding.qualityTextView.setText(getSelectedVideoStream().resolution); + binding.qualityTextView.setText(getSelectedVideoStream().getResolution()); } qualityPopupMenu.setOnMenuItemClickListener(this); qualityPopupMenu.setOnDismissListener(this); @@ -3605,7 +3592,7 @@ public final class Player implements } saveStreamProgressState(); //TODO added, check if good - final String newResolution = availableStreams.get(menuItemIndex).resolution; + final String newResolution = availableStreams.get(menuItemIndex).getResolution(); setRecovery(); setPlaybackQuality(newResolution); reloadPlayQueueManager(); @@ -3633,7 +3620,7 @@ public final class Player implements } isSomePopupMenuVisible = false; //TODO check if this works if (getSelectedVideoStream() != null) { - binding.qualityTextView.setText(getSelectedVideoStream().resolution); + binding.qualityTextView.setText(getSelectedVideoStream().getResolution()); } if (isPlaying()) { hideControls(DEFAULT_CONTROLS_DURATION, 0); @@ -4250,7 +4237,8 @@ public final class Player implements } else { final StreamType streamType = info.getStreamType(); if (streamType == StreamType.AUDIO_STREAM - || streamType == StreamType.AUDIO_LIVE_STREAM) { + || streamType == StreamType.AUDIO_LIVE_STREAM + || streamType == StreamType.POST_LIVE_AUDIO_STREAM) { // Nothing to do more than setting the recovery position setRecovery(); return; @@ -4285,13 +4273,15 @@ public final class Player implements * the content is not an audio content, but also if none of the following cases is met: * *
    - *
  • the content is an {@link StreamType#AUDIO_STREAM audio stream} or an - * {@link StreamType#AUDIO_LIVE_STREAM audio live stream};
  • + *
  • the content is an {@link StreamType#AUDIO_STREAM audio stream}, an + * {@link StreamType#AUDIO_LIVE_STREAM audio live stream}, or a + * {@link StreamType#POST_LIVE_AUDIO_STREAM ended audio live stream};
  • *
  • the content is a {@link StreamType#LIVE_STREAM live stream} and the source type is a * {@link SourceType#LIVE_STREAM live source};
  • *
  • the content's source is {@link SourceType#VIDEO_WITH_SEPARATED_AUDIO a video stream * with a separated audio source} or has no audio-only streams available and is a - * {@link StreamType#LIVE_STREAM live stream} or a + * {@link StreamType#VIDEO_STREAM video stream}, an + * {@link StreamType#POST_LIVE_STREAM ended live stream}, or a * {@link StreamType#LIVE_STREAM live stream}. *
  • *
@@ -4309,14 +4299,17 @@ public final class Player implements final StreamType streamType = streamInfo.getStreamType(); if (videoRendererIndex == RENDERER_UNAVAILABLE && streamType != StreamType.AUDIO_STREAM - && streamType != StreamType.AUDIO_LIVE_STREAM) { + && streamType != StreamType.AUDIO_LIVE_STREAM + && streamType != StreamType.POST_LIVE_AUDIO_STREAM) { return true; } // The content is an audio stream, an audio live stream, or a live stream with a live // source: it's not needed to reload the play queue manager because the stream source will // be the same - if ((streamType == StreamType.AUDIO_STREAM || streamType == StreamType.AUDIO_LIVE_STREAM) + if ((streamType == StreamType.AUDIO_STREAM + || streamType == StreamType.POST_LIVE_AUDIO_STREAM + || streamType == StreamType.AUDIO_LIVE_STREAM) || (streamType == StreamType.LIVE_STREAM && sourceType == SourceType.LIVE_STREAM)) { return false; @@ -4331,8 +4324,10 @@ public final class Player implements || (sourceType == SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY && isNullOrEmpty(streamInfo.getAudioStreams()))) { // It's not needed to reload the play queue manager only if the content's stream type - // is a video stream or a live stream - return streamType != StreamType.VIDEO_STREAM && streamType != StreamType.LIVE_STREAM; + // is a video stream, a live stream or an ended live stream + return streamType != StreamType.VIDEO_STREAM + && streamType != StreamType.LIVE_STREAM + && streamType != StreamType.POST_LIVE_STREAM; } // Other cases: the play queue manager reload is needed diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java new file mode 100644 index 000000000..acf9c6a47 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java @@ -0,0 +1,1031 @@ +/* + * Based on ExoPlayer's DefaultHttpDataSource, version 2.17.1. + * + * Original source code copyright (C) 2016 The Android Open Source Project, licensed under the + * Apache License, Version 2.0. + */ + +package org.schabi.newpipe.player.datasource; + +import static com.google.android.exoplayer2.upstream.DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS; +import static com.google.android.exoplayer2.upstream.DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS; +import static com.google.android.exoplayer2.upstream.HttpUtil.buildRangeRequestHeader; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getAndroidUserAgent; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getIosUserAgent; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isAndroidStreamingUrl; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isIosStreamingUrl; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebStreamingUrl; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5SimplyEmbeddedPlayerStreamingUrl; +import static java.lang.Math.min; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.PlaybackException; +import com.google.android.exoplayer2.upstream.BaseDataSource; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSourceException; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DataSpec.HttpMethod; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.upstream.HttpUtil; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Predicate; +import com.google.common.collect.ForwardingMap; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Sets; +import com.google.common.net.HttpHeaders; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.lang.reflect.Method; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.NoRouteToHostException; +import java.net.URL; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.zip.GZIPInputStream; + +/** + * An {@link HttpDataSource} that uses Android's {@link HttpURLConnection}, based on + * {@link com.google.android.exoplayer2.upstream.DefaultHttpDataSource}, for YouTube streams. + * + *

+ * It adds more headers to {@code videoplayback} URLs, such as {@code Origin}, {@code Referer} + * (only where it's relevant) and also more parameters, such as {@code rn} and replaces the use of + * the {@code Range} header by the corresponding parameter ({@code range}), if enabled. + *

+ */ +@SuppressWarnings({"squid:S3011", "squid:S4738"}) +public final class YoutubeHttpDataSource extends BaseDataSource implements HttpDataSource { + + /** + * {@link DataSource.Factory} for {@link YoutubeHttpDataSource} instances. + */ + public static final class Factory implements HttpDataSource.Factory { + + private final RequestProperties defaultRequestProperties; + + @Nullable + private TransferListener transferListener; + @Nullable + private Predicate contentTypePredicate; + private int connectTimeoutMs; + private int readTimeoutMs; + private boolean allowCrossProtocolRedirects; + private boolean keepPostFor302Redirects; + + @Nullable + private String userAgentForNonMobileStreams; + private boolean rangeParameterEnabled; + private boolean rnParameterEnabled; + + /** + * Creates an instance. + */ + public Factory() { + defaultRequestProperties = new RequestProperties(); + connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MILLIS; + readTimeoutMs = DEFAULT_READ_TIMEOUT_MILLIS; + } + + @NonNull + @Override + public Factory setDefaultRequestProperties( + @NonNull final Map defaultRequestPropertiesMap) { + defaultRequestProperties.clearAndSet(defaultRequestPropertiesMap); + return this; + } + + /** + * Sets the user agent that will be used, only for non-mobile streams. + * + *

+ * The default is {@code null}, which causes the default user agent of the underlying + * platform to be used. + *

+ * + * @param userAgentForNonMobileStreamsValue The user agent that will be used for non-mobile + * streams, or {@code null} to use the default + * user agent of the underlying platform. + * @return This factory. + */ + public Factory setUserAgentForNonMobileStreams( + @Nullable final String userAgentForNonMobileStreamsValue) { + userAgentForNonMobileStreams = userAgentForNonMobileStreamsValue; + return this; + } + + /** + * Sets the connect timeout, in milliseconds. + * + *

+ * The default is {@link DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS}. + *

+ * + * @param connectTimeoutMsValue The connect timeout, in milliseconds, that will be used. + * @return This factory. + */ + public Factory setConnectTimeoutMs(final int connectTimeoutMsValue) { + connectTimeoutMs = connectTimeoutMsValue; + return this; + } + + /** + * Sets the read timeout, in milliseconds. + * + *

The default is {@link DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS}. + * + * @param readTimeoutMsValue The connect timeout, in milliseconds, that will be used. + * @return This factory. + */ + public Factory setReadTimeoutMs(final int readTimeoutMsValue) { + readTimeoutMs = readTimeoutMsValue; + return this; + } + + /** + * Sets whether to allow cross protocol redirects. + * + *

The default is {@code false}. + * + * @param allowCrossProtocolRedirectsValue Whether to allow cross protocol redirects. + * @return This factory. + */ + public Factory setAllowCrossProtocolRedirects( + final boolean allowCrossProtocolRedirectsValue) { + allowCrossProtocolRedirects = allowCrossProtocolRedirectsValue; + return this; + } + + /** + * Sets whether the use of the {@code range} parameter instead of the {@code Range} header + * to request ranges of streams is enabled. + * + *

+ * Note that it must be not enabled on streams which are using a {@link + * com.google.android.exoplayer2.source.ProgressiveMediaSource}, as it will break playback + * for them (some exceptions may be thrown). + *

+ * + * @param rangeParameterEnabledValue whether the use of the {@code range} parameter instead + * of the {@code Range} header (must be only enabled when + * non-{@code ProgressiveMediaSource}s) + * @return This factory. + */ + public Factory setRangeParameterEnabled(final boolean rangeParameterEnabledValue) { + rangeParameterEnabled = rangeParameterEnabledValue; + return this; + } + + /** + * Sets whether the use of the {@code rn}, which stands for request number, parameter is + * enabled. + * + *

+ * Note that it should be not enabled on streams which are using {@code /} to delimit URLs + * parameters, such as the streams of HLS manifests. + *

+ * + * @param rnParameterEnabledValue whether the appending the {@code rn} parameter to + * {@code videoplayback} URLs + * @return This factory. + */ + public Factory setRnParameterEnabled(final boolean rnParameterEnabledValue) { + rnParameterEnabled = rnParameterEnabledValue; + return this; + } + + /** + * Sets a content type {@link Predicate}. If a content type is rejected by the predicate + * then a {@link HttpDataSource.InvalidContentTypeException} is thrown from + * {@link YoutubeHttpDataSource#open(DataSpec)}. + * + *

+ * The default is {@code null}. + *

+ * + * @param contentTypePredicateToSet The content type {@link Predicate}, or {@code null} to + * clear a predicate that was previously set. + * @return This factory. + */ + public Factory setContentTypePredicate( + @Nullable final Predicate contentTypePredicateToSet) { + this.contentTypePredicate = contentTypePredicateToSet; + return this; + } + + /** + * Sets the {@link TransferListener} that will be used. + * + *

The default is {@code null}. + * + *

See {@link DataSource#addTransferListener(TransferListener)}. + * + * @param transferListenerToUse The listener that will be used. + * @return This factory. + */ + public Factory setTransferListener( + @Nullable final TransferListener transferListenerToUse) { + this.transferListener = transferListenerToUse; + return this; + } + + /** + * Sets whether we should keep the POST method and body when we have HTTP 302 redirects for + * a POST request. + * + * @param keepPostFor302RedirectsValue Whether we should keep the POST method and body when + * we have HTTP 302 redirects for a POST request. + * @return This factory. + */ + public Factory setKeepPostFor302Redirects(final boolean keepPostFor302RedirectsValue) { + this.keepPostFor302Redirects = keepPostFor302RedirectsValue; + return this; + } + + @NonNull + @Override + public YoutubeHttpDataSource createDataSource() { + final YoutubeHttpDataSource dataSource = new YoutubeHttpDataSource( + userAgentForNonMobileStreams, + connectTimeoutMs, + readTimeoutMs, + allowCrossProtocolRedirects, + rangeParameterEnabled, + rnParameterEnabled, + defaultRequestProperties, + contentTypePredicate, + keepPostFor302Redirects); + if (transferListener != null) { + dataSource.addTransferListener(transferListener); + } + return dataSource; + } + } + + private static final String TAG = YoutubeHttpDataSource.class.getSimpleName(); + private static final int MAX_REDIRECTS = 20; // Same limit as okhttp. + private static final int HTTP_STATUS_TEMPORARY_REDIRECT = 307; + private static final int HTTP_STATUS_PERMANENT_REDIRECT = 308; + private static final long MAX_BYTES_TO_DRAIN = 2048; + + private static final String RN_PARAMETER = "&rn="; + private static final String YOUTUBE_BASE_URL = "https://www.youtube.com"; + + private final boolean allowCrossProtocolRedirects; + private final boolean rangeParameterEnabled; + private final boolean rnParameterEnabled; + + private final int connectTimeoutMillis; + private final int readTimeoutMillis; + @Nullable + private final String userAgent; + @Nullable + private final RequestProperties defaultRequestProperties; + private final RequestProperties requestProperties; + private final boolean keepPostFor302Redirects; + + @Nullable + private final Predicate contentTypePredicate; + @Nullable + private DataSpec dataSpec; + @Nullable + private HttpURLConnection connection; + @Nullable + private InputStream inputStream; + private boolean opened; + private int responseCode; + private long bytesToRead; + private long bytesRead; + + private long requestNumber; + + @SuppressWarnings("checkstyle:ParameterNumber") + private YoutubeHttpDataSource(@Nullable final String userAgent, + final int connectTimeoutMillis, + final int readTimeoutMillis, + final boolean allowCrossProtocolRedirects, + final boolean rangeParameterEnabled, + final boolean rnParameterEnabled, + @Nullable final RequestProperties defaultRequestProperties, + @Nullable final Predicate contentTypePredicate, + final boolean keepPostFor302Redirects) { + super(true); + this.userAgent = userAgent; + this.connectTimeoutMillis = connectTimeoutMillis; + this.readTimeoutMillis = readTimeoutMillis; + this.allowCrossProtocolRedirects = allowCrossProtocolRedirects; + this.rangeParameterEnabled = rangeParameterEnabled; + this.rnParameterEnabled = rnParameterEnabled; + this.defaultRequestProperties = defaultRequestProperties; + this.contentTypePredicate = contentTypePredicate; + this.requestProperties = new RequestProperties(); + this.keepPostFor302Redirects = keepPostFor302Redirects; + this.requestNumber = 0; + } + + @Override + @Nullable + public Uri getUri() { + return connection == null ? null : Uri.parse(connection.getURL().toString()); + } + + @Override + public int getResponseCode() { + return connection == null || responseCode <= 0 ? -1 : responseCode; + } + + @NonNull + @Override + public Map> getResponseHeaders() { + if (connection == null) { + return ImmutableMap.of(); + } + // connection.getHeaderFields() always contains a null key with a value like + // ["HTTP/1.1 200 OK"]. The response code is available from + // HttpURLConnection#getResponseCode() and the HTTP version is fixed when establishing the + // connection. + // DataSource#getResponseHeaders() doesn't allow null keys in the returned map, so we need + // to remove it. + // connection.getHeaderFields() returns a special unmodifiable case-insensitive Map + // so we can't just remove the null key or make a copy without the null key. Instead we + // wrap it in a ForwardingMap subclass that ignores and filters out null keys in the read + // methods. + return new NullFilteringHeadersMap(connection.getHeaderFields()); + } + + @Override + public void setRequestProperty(@NonNull final String name, @NonNull final String value) { + checkNotNull(name); + checkNotNull(value); + requestProperties.set(name, value); + } + + @Override + public void clearRequestProperty(@NonNull final String name) { + checkNotNull(name); + requestProperties.remove(name); + } + + @Override + public void clearAllRequestProperties() { + requestProperties.clear(); + } + + /** + * Opens the source to read the specified data. + */ + @Override + public long open(@NonNull final DataSpec dataSpecParameter) throws HttpDataSourceException { + this.dataSpec = dataSpecParameter; + bytesRead = 0; + bytesToRead = 0; + transferInitializing(dataSpecParameter); + + final HttpURLConnection httpURLConnection; + final String responseMessage; + try { + this.connection = makeConnection(dataSpec); + httpURLConnection = this.connection; + responseCode = httpURLConnection.getResponseCode(); + responseMessage = httpURLConnection.getResponseMessage(); + } catch (final IOException e) { + closeConnectionQuietly(); + throw HttpDataSourceException.createForIOException(e, dataSpec, + HttpDataSourceException.TYPE_OPEN); + } + + // Check for a valid response code. + if (responseCode < 200 || responseCode > 299) { + final Map> headers = httpURLConnection.getHeaderFields(); + if (responseCode == 416) { + final long documentSize = HttpUtil.getDocumentSize( + httpURLConnection.getHeaderField(HttpHeaders.CONTENT_RANGE)); + if (dataSpecParameter.position == documentSize) { + opened = true; + transferStarted(dataSpecParameter); + return dataSpecParameter.length != C.LENGTH_UNSET + ? dataSpecParameter.length + : 0; + } + } + + final InputStream errorStream = httpURLConnection.getErrorStream(); + byte[] errorResponseBody; + try { + errorResponseBody = errorStream != null + ? Util.toByteArray(errorStream) + : Util.EMPTY_BYTE_ARRAY; + } catch (final IOException e) { + errorResponseBody = Util.EMPTY_BYTE_ARRAY; + } + + closeConnectionQuietly(); + final IOException cause = responseCode == 416 ? new DataSourceException( + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE) + : null; + throw new InvalidResponseCodeException(responseCode, responseMessage, cause, headers, + dataSpec, errorResponseBody); + } + + // Check for a valid content type. + final String contentType = httpURLConnection.getContentType(); + if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) { + closeConnectionQuietly(); + throw new InvalidContentTypeException(contentType, dataSpecParameter); + } + + final long bytesToSkip; + if (!rangeParameterEnabled) { + // If we requested a range starting from a non-zero position and received a 200 rather + // than a 206, then the server does not support partial requests. We'll need to + // manually skip to the requested position. + bytesToSkip = responseCode == 200 && dataSpecParameter.position != 0 + ? dataSpecParameter.position + : 0; + } else { + bytesToSkip = 0; + } + + + // Determine the length of the data to be read, after skipping. + final boolean isCompressed = isCompressed(httpURLConnection); + if (!isCompressed) { + if (dataSpecParameter.length != C.LENGTH_UNSET) { + bytesToRead = dataSpecParameter.length; + } else { + final long contentLength = HttpUtil.getContentLength( + httpURLConnection.getHeaderField(HttpHeaders.CONTENT_LENGTH), + httpURLConnection.getHeaderField(HttpHeaders.CONTENT_RANGE)); + bytesToRead = contentLength != C.LENGTH_UNSET + ? (contentLength - bytesToSkip) + : C.LENGTH_UNSET; + } + } else { + // Gzip is enabled. If the server opts to use gzip then the content length in the + // response will be that of the compressed data, which isn't what we want. Always use + // the dataSpec length in this case. + bytesToRead = dataSpecParameter.length; + } + + try { + inputStream = httpURLConnection.getInputStream(); + if (isCompressed) { + inputStream = new GZIPInputStream(inputStream); + } + } catch (final IOException e) { + closeConnectionQuietly(); + throw new HttpDataSourceException(e, dataSpec, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN); + } + + opened = true; + transferStarted(dataSpecParameter); + + try { + skipFully(bytesToSkip, dataSpec); + } catch (final IOException e) { + closeConnectionQuietly(); + if (e instanceof HttpDataSourceException) { + throw (HttpDataSourceException) e; + } + throw new HttpDataSourceException(e, dataSpec, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN); + } + + return bytesToRead; + } + + @Override + public int read(@NonNull final byte[] buffer, final int offset, final int length) + throws HttpDataSourceException { + try { + return readInternal(buffer, offset, length); + } catch (final IOException e) { + throw HttpDataSourceException.createForIOException(e, castNonNull(dataSpec), + HttpDataSourceException.TYPE_READ); + } + } + + @Override + public void close() throws HttpDataSourceException { + try { + final InputStream connectionInputStream = this.inputStream; + if (connectionInputStream != null) { + final long bytesRemaining = bytesToRead == C.LENGTH_UNSET + ? C.LENGTH_UNSET + : bytesToRead - bytesRead; + maybeTerminateInputStream(connection, bytesRemaining); + + try { + connectionInputStream.close(); + } catch (final IOException e) { + throw new HttpDataSourceException(e, castNonNull(dataSpec), + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_CLOSE); + } + } + } finally { + inputStream = null; + closeConnectionQuietly(); + if (opened) { + opened = false; + transferEnded(); + } + } + } + + @NonNull + private HttpURLConnection makeConnection(@NonNull final DataSpec dataSpecToUse) + throws IOException { + URL url = new URL(dataSpecToUse.uri.toString()); + @HttpMethod int httpMethod = dataSpecToUse.httpMethod; + @Nullable byte[] httpBody = dataSpecToUse.httpBody; + final long position = dataSpecToUse.position; + final long length = dataSpecToUse.length; + final boolean allowGzip = dataSpecToUse.isFlagSet(DataSpec.FLAG_ALLOW_GZIP); + + if (!allowCrossProtocolRedirects && !keepPostFor302Redirects) { + // HttpURLConnection disallows cross-protocol redirects, but otherwise performs + // redirection automatically. This is the behavior we want, so use it. + return makeConnection(url, httpMethod, httpBody, position, length, allowGzip, true, + dataSpecToUse.httpRequestHeaders); + } + + // We need to handle redirects ourselves to allow cross-protocol redirects or to keep the + // POST request method for 302. + int redirectCount = 0; + while (redirectCount++ <= MAX_REDIRECTS) { + final HttpURLConnection httpURLConnection = makeConnection(url, httpMethod, httpBody, + position, length, allowGzip, false, dataSpecToUse.httpRequestHeaders); + final int httpURLConnectionResponseCode = httpURLConnection.getResponseCode(); + final String location = httpURLConnection.getHeaderField("Location"); + if ((httpMethod == DataSpec.HTTP_METHOD_GET || httpMethod == DataSpec.HTTP_METHOD_HEAD) + && (httpURLConnectionResponseCode == HttpURLConnection.HTTP_MULT_CHOICE + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_PERM + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_TEMP + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_SEE_OTHER + || httpURLConnectionResponseCode == HTTP_STATUS_TEMPORARY_REDIRECT + || httpURLConnectionResponseCode == HTTP_STATUS_PERMANENT_REDIRECT)) { + httpURLConnection.disconnect(); + url = handleRedirect(url, location, dataSpecToUse); + } else if (httpMethod == DataSpec.HTTP_METHOD_POST + && (httpURLConnectionResponseCode == HttpURLConnection.HTTP_MULT_CHOICE + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_PERM + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_TEMP + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_SEE_OTHER)) { + httpURLConnection.disconnect(); + final boolean shouldKeepPost = keepPostFor302Redirects + && responseCode == HttpURLConnection.HTTP_MOVED_TEMP; + if (!shouldKeepPost) { + // POST request follows the redirect and is transformed into a GET request. + httpMethod = DataSpec.HTTP_METHOD_GET; + httpBody = null; + } + url = handleRedirect(url, location, dataSpecToUse); + } else { + return httpURLConnection; + } + } + + // If we get here we've been redirected more times than are permitted. + throw new HttpDataSourceException( + new NoRouteToHostException("Too many redirects: " + redirectCount), + dataSpecToUse, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + + /** + * Configures a connection and opens it. + * + * @param url The url to connect to. + * @param httpMethod The http method. + * @param httpBody The body data, or {@code null} if not required. + * @param position The byte offset of the requested data. + * @param length The length of the requested data, or {@link C#LENGTH_UNSET}. + * @param allowGzip Whether to allow the use of gzip. + * @param followRedirects Whether to follow redirects. + * @param requestParameters parameters (HTTP headers) to include in request. + * @return the connection opened + */ + @SuppressWarnings("checkstyle:ParameterNumber") + @NonNull + private HttpURLConnection makeConnection( + @NonNull final URL url, + @HttpMethod final int httpMethod, + @Nullable final byte[] httpBody, + final long position, + final long length, + final boolean allowGzip, + final boolean followRedirects, + final Map requestParameters) throws IOException { + String requestUrl = url.toString(); + + // Don't add the request number parameter if it has been already added (for instance in + // DASH manifests) or if that's not a videoplayback URL + final boolean isVideoPlaybackUrl = url.getPath().startsWith("/videoplayback"); + if (isVideoPlaybackUrl && rnParameterEnabled && !requestUrl.contains(RN_PARAMETER)) { + requestUrl += RN_PARAMETER + requestNumber; + ++requestNumber; + } + + if (rangeParameterEnabled && isVideoPlaybackUrl) { + final String rangeParameterBuilt = buildRangeParameter(position, length); + if (rangeParameterBuilt != null) { + requestUrl += rangeParameterBuilt; + } + } + + final HttpURLConnection httpURLConnection = openConnection(new URL(requestUrl)); + httpURLConnection.setConnectTimeout(connectTimeoutMillis); + httpURLConnection.setReadTimeout(readTimeoutMillis); + + final Map requestHeaders = new HashMap<>(); + if (defaultRequestProperties != null) { + requestHeaders.putAll(defaultRequestProperties.getSnapshot()); + } + requestHeaders.putAll(requestProperties.getSnapshot()); + requestHeaders.putAll(requestParameters); + + for (final Map.Entry property : requestHeaders.entrySet()) { + httpURLConnection.setRequestProperty(property.getKey(), property.getValue()); + } + + if (!rangeParameterEnabled) { + final String rangeHeader = buildRangeRequestHeader(position, length); + if (rangeHeader != null) { + httpURLConnection.setRequestProperty(HttpHeaders.RANGE, rangeHeader); + } + } + + if (isWebStreamingUrl(requestUrl) + || isTvHtml5SimplyEmbeddedPlayerStreamingUrl(requestUrl)) { + httpURLConnection.setRequestProperty(HttpHeaders.ORIGIN, YOUTUBE_BASE_URL); + httpURLConnection.setRequestProperty(HttpHeaders.REFERER, YOUTUBE_BASE_URL); + httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_DEST, "empty"); + httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_MODE, "cors"); + httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_SITE, "cross-site"); + } + + httpURLConnection.setRequestProperty(HttpHeaders.TE, "trailers"); + + final boolean isAnAndroidStreamingUrl = isAndroidStreamingUrl(requestUrl); + final boolean isAnIosStreamingUrl = isIosStreamingUrl(requestUrl); + if (isAnAndroidStreamingUrl) { + // Improvement which may be done: find the content country used to request YouTube + // contents to add it in the user agent instead of using the default + httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, + getAndroidUserAgent(null)); + } else if (isAnIosStreamingUrl) { + httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, + getIosUserAgent(null)); + } else if (userAgent != null) { + httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, userAgent); + } + + httpURLConnection.setRequestProperty(HttpHeaders.ACCEPT_ENCODING, + allowGzip ? "gzip" : "identity"); + httpURLConnection.setInstanceFollowRedirects(followRedirects); + httpURLConnection.setDoOutput(httpBody != null); + + // Mobile clients uses POST requests to fetch contents + httpURLConnection.setRequestMethod(isAnAndroidStreamingUrl || isAnIosStreamingUrl + ? "POST" + : DataSpec.getStringForHttpMethod(httpMethod)); + + if (httpBody != null) { + httpURLConnection.setFixedLengthStreamingMode(httpBody.length); + httpURLConnection.connect(); + final OutputStream os = httpURLConnection.getOutputStream(); + os.write(httpBody); + os.close(); + } else { + httpURLConnection.connect(); + } + return httpURLConnection; + } + + /** + * Creates an {@link HttpURLConnection} that is connected with the {@code url}. + * + * @param url the {@link URL} to create an {@link HttpURLConnection} + * @return an {@link HttpURLConnection} created with the {@code url} + */ + private HttpURLConnection openConnection(@NonNull final URL url) throws IOException { + return (HttpURLConnection) url.openConnection(); + } + + /** + * Handles a redirect. + * + * @param originalUrl The original URL. + * @param location The Location header in the response. May be {@code null}. + * @param dataSpecToHandleRedirect The {@link DataSpec}. + * @return The next URL. + * @throws HttpDataSourceException If redirection isn't possible. + */ + @NonNull + private URL handleRedirect(final URL originalUrl, + @Nullable final String location, + final DataSpec dataSpecToHandleRedirect) + throws HttpDataSourceException { + if (location == null) { + throw new HttpDataSourceException("Null location redirect", dataSpecToHandleRedirect, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + + // Form the new url. + final URL url; + try { + url = new URL(originalUrl, location); + } catch (final MalformedURLException e) { + throw new HttpDataSourceException(e, dataSpecToHandleRedirect, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + + // Check that the protocol of the new url is supported. + final String protocol = url.getProtocol(); + if (!"https".equals(protocol) && !"http".equals(protocol)) { + throw new HttpDataSourceException("Unsupported protocol redirect: " + protocol, + dataSpecToHandleRedirect, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + + if (!allowCrossProtocolRedirects && !protocol.equals(originalUrl.getProtocol())) { + throw new HttpDataSourceException( + "Disallowed cross-protocol redirect (" + + originalUrl.getProtocol() + + " to " + + protocol + + ")", + dataSpecToHandleRedirect, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + + return url; + } + + /** + * Attempts to skip the specified number of bytes in full. + * + * @param bytesToSkip The number of bytes to skip. + * @param dataSpecToUse The {@link DataSpec}. + * @throws IOException If the thread is interrupted during the operation, or if the data ended + * before skipping the specified number of bytes. + */ + @SuppressWarnings("checkstyle:FinalParameters") + private void skipFully(long bytesToSkip, final DataSpec dataSpecToUse) throws IOException { + if (bytesToSkip == 0) { + return; + } + + final byte[] skipBuffer = new byte[4096]; + while (bytesToSkip > 0) { + final int readLength = (int) min(bytesToSkip, skipBuffer.length); + final int read = castNonNull(inputStream).read(skipBuffer, 0, readLength); + if (Thread.currentThread().isInterrupted()) { + throw new HttpDataSourceException( + new InterruptedIOException(), + dataSpecToUse, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN); + } + + if (read == -1) { + throw new HttpDataSourceException( + dataSpecToUse, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + HttpDataSourceException.TYPE_OPEN); + } + + bytesToSkip -= read; + bytesTransferred(read); + } + } + + /** + * Reads up to {@code length} bytes of data and stores them into {@code buffer}, starting at + * index {@code offset}. + * + *

+ * This method blocks until at least one byte of data can be read, the end of the opened range + * is detected, or an exception is thrown. + *

+ * + * @param buffer The buffer into which the read data should be stored. + * @param offset The start offset into {@code buffer} at which data should be written. + * @param readLength The maximum number of bytes to read. + * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if the end of the opened + * range is reached. + * @throws IOException If an error occurs reading from the source. + */ + @SuppressWarnings("checkstyle:FinalParameters") + private int readInternal(final byte[] buffer, final int offset, int readLength) + throws IOException { + if (readLength == 0) { + return 0; + } + if (bytesToRead != C.LENGTH_UNSET) { + final long bytesRemaining = bytesToRead - bytesRead; + if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + readLength = (int) min(readLength, bytesRemaining); + } + + final int read = castNonNull(inputStream).read(buffer, offset, readLength); + if (read == -1) { + return C.RESULT_END_OF_INPUT; + } + + bytesRead += read; + bytesTransferred(read); + return read; + } + + /** + * On platform API levels 19 and 20, okhttp's implementation of {@link InputStream#close} can + * block for a long time if the stream has a lot of data remaining. Call this method before + * closing the input stream to make a best effort to cause the input stream to encounter an + * unexpected end of input, working around this issue. On other platform API levels, the method + * does nothing. + * + * @param connection The connection whose {@link InputStream} should be terminated. + * @param bytesRemaining The number of bytes remaining to be read from the input stream if its + * length is known. {@link C#LENGTH_UNSET} otherwise. + */ + private static void maybeTerminateInputStream(@Nullable final HttpURLConnection connection, + final long bytesRemaining) { + if (connection == null || Util.SDK_INT < 19 || Util.SDK_INT > 20) { + return; + } + + try { + final InputStream inputStream = connection.getInputStream(); + if (bytesRemaining == C.LENGTH_UNSET) { + // If the input stream has already ended, do nothing. The socket may be re-used. + if (inputStream.read() == -1) { + return; + } + } else if (bytesRemaining <= MAX_BYTES_TO_DRAIN) { + // There isn't much data left. Prefer to allow it to drain, which may allow the + // socket to be re-used. + return; + } + final String className = inputStream.getClass().getName(); + if ("com.android.okhttp.internal.http.HttpTransport$ChunkedInputStream" + .equals(className) + || "com.android.okhttp.internal.http.HttpTransport$FixedLengthInputStream" + .equals(className)) { + final Class superclass = inputStream.getClass().getSuperclass(); + final Method unexpectedEndOfInput = checkNotNull(superclass).getDeclaredMethod( + "unexpectedEndOfInput"); + unexpectedEndOfInput.setAccessible(true); + unexpectedEndOfInput.invoke(inputStream); + } + } catch (final Exception e) { + // If an IOException then the connection didn't ever have an input stream, or it was + // closed already. If another type of exception then something went wrong, most likely + // the device isn't using okhttp. + } + } + + /** + * Closes the current connection quietly, if there is one. + */ + private void closeConnectionQuietly() { + if (connection != null) { + try { + connection.disconnect(); + } catch (final Exception e) { + Log.e(TAG, "Unexpected error while disconnecting", e); + } + connection = null; + } + } + + private static boolean isCompressed(@NonNull final HttpURLConnection connection) { + final String contentEncoding = connection.getHeaderField("Content-Encoding"); + return "gzip".equalsIgnoreCase(contentEncoding); + } + + /** + * Builds a {@code range} parameter for the given position and length. + * + *

+ * To fetch its contents, YouTube use range requests which append a {@code range} parameter + * to videoplayback URLs instead of the {@code Range} header (even if the server respond + * correctly when requesting a range of a ressouce with it). + *

+ * + *

+ * The parameter works in the same way as the header. + *

+ * + * @param position The request position. + * @param length The request length, or {@link C#LENGTH_UNSET} if the request is unbounded. + * @return The corresponding {@code range} parameter, or {@code null} if this parameter is + * unnecessary because the whole resource is being requested. + */ + @Nullable + private static String buildRangeParameter(final long position, final long length) { + if (position == 0 && length == C.LENGTH_UNSET) { + return null; + } + + final StringBuilder rangeParameter = new StringBuilder(); + rangeParameter.append("&range="); + rangeParameter.append(position); + rangeParameter.append("-"); + if (length != C.LENGTH_UNSET) { + rangeParameter.append(position + length - 1); + } + return rangeParameter.toString(); + } + + private static final class NullFilteringHeadersMap + extends ForwardingMap> { + private final Map> headers; + + NullFilteringHeadersMap(final Map> headers) { + this.headers = headers; + } + + @NonNull + @Override + protected Map> delegate() { + return headers; + } + + @Override + public boolean containsKey(@Nullable final Object key) { + return key != null && super.containsKey(key); + } + + @Nullable + @Override + public List get(@Nullable final Object key) { + return key == null ? null : super.get(key); + } + + @NonNull + @Override + public Set keySet() { + return Sets.filter(super.keySet(), Objects::nonNull); + } + + @NonNull + @Override + public Set>> entrySet() { + return Sets.filter(super.entrySet(), entry -> entry.getKey() != null); + } + + @Override + public int size() { + return super.size() - (super.containsKey(null) ? 1 : 0); + } + + @Override + public boolean isEmpty() { + return super.isEmpty() || (super.size() == 1 && super.containsKey(null)); + } + + @Override + public boolean containsValue(@Nullable final Object value) { + return super.standardContainsValue(value); + } + + @Override + public boolean equals(@Nullable final Object object) { + return object != null && super.standardEquals(object); + } + + @Override + public int hashCode() { + return super.standardHashCode(); + } + } +} + diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java index 98e04d466..47371533a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java @@ -3,6 +3,9 @@ package org.schabi.newpipe.player.helper; import android.content.Context; import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.google.android.exoplayer2.database.StandaloneDatabaseProvider; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSource; @@ -14,45 +17,58 @@ import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; +import org.schabi.newpipe.player.datasource.YoutubeHttpDataSource; + import java.io.File; -import androidx.annotation.NonNull; - -/* package-private */ class CacheFactory implements DataSource.Factory { - private static final String TAG = "CacheFactory"; +/* package-private */ final class CacheFactory implements DataSource.Factory { + private static final String TAG = CacheFactory.class.getSimpleName(); private static final String CACHE_FOLDER_NAME = "exoplayer"; - private static final int CACHE_FLAGS = CacheDataSource.FLAG_BLOCK_ON_CACHE - | CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; - - private final DataSource.Factory dataSourceFactory; - private final File cacheDir; - private final long maxFileSize; - - // Creating cache on every instance may cause problems with multiple players when - // sources are not ExtractorMediaSource - // see: https://stackoverflow.com/questions/28700391/using-cache-in-exoplayer - // todo: make this a singleton? + private static final int CACHE_FLAGS = CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; private static SimpleCache cache; - CacheFactory(@NonNull final Context context, - @NonNull final String userAgent, - @NonNull final TransferListener transferListener) { - this(context, userAgent, transferListener, PlayerHelper.getPreferredCacheSize(), - PlayerHelper.getPreferredFileSize()); + private final long maxFileSize; + private final Context context; + private final String userAgent; + private final TransferListener transferListener; + private final DataSource.Factory upstreamDataSourceFactory; + + public static class Builder { + private final Context context; + private final String userAgent; + private final TransferListener transferListener; + private DataSource.Factory upstreamDataSourceFactory; + + Builder(@NonNull final Context context, + @NonNull final String userAgent, + @NonNull final TransferListener transferListener) { + this.context = context; + this.userAgent = userAgent; + this.transferListener = transferListener; + } + + public void setUpstreamDataSourceFactory( + @Nullable final DataSource.Factory upstreamDataSourceFactory) { + this.upstreamDataSourceFactory = upstreamDataSourceFactory; + } + + public CacheFactory build() { + return new CacheFactory(context, userAgent, transferListener, + upstreamDataSourceFactory); + } } private CacheFactory(@NonNull final Context context, @NonNull final String userAgent, @NonNull final TransferListener transferListener, - final long maxCacheSize, - final long maxFileSize) { - this.maxFileSize = maxFileSize; + @Nullable final DataSource.Factory upstreamDataSourceFactory) { + this.context = context; + this.userAgent = userAgent; + this.transferListener = transferListener; + this.upstreamDataSourceFactory = upstreamDataSourceFactory; - dataSourceFactory = new DefaultDataSource - .Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(userAgent)) - .setTransferListener(transferListener); - cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); + final File cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); if (!cacheDir.exists()) { //noinspection ResultOfMethodCallIgnored cacheDir.mkdir(); @@ -60,37 +76,43 @@ import androidx.annotation.NonNull; if (cache == null) { final LeastRecentlyUsedCacheEvictor evictor - = new LeastRecentlyUsedCacheEvictor(maxCacheSize); + = new LeastRecentlyUsedCacheEvictor(PlayerHelper.getPreferredCacheSize()); cache = new SimpleCache(cacheDir, evictor, new StandaloneDatabaseProvider(context)); + Log.d(TAG, "initExoPlayerCache: cacheDir = " + cacheDir.getAbsolutePath()); } + + maxFileSize = PlayerHelper.getPreferredFileSize(); } @NonNull @Override public DataSource createDataSource() { - Log.d(TAG, "initExoPlayerCache: cacheDir = " + cacheDir.getAbsolutePath()); - final DataSource dataSource = dataSourceFactory.createDataSource(); + final DataSource.Factory upstreamDataSourceFactoryToUse; + if (upstreamDataSourceFactory == null) { + upstreamDataSourceFactoryToUse = new DefaultHttpDataSource.Factory() + .setUserAgent(userAgent); + } else { + if (upstreamDataSourceFactory instanceof DefaultHttpDataSource.Factory) { + upstreamDataSourceFactoryToUse = + ((DefaultHttpDataSource.Factory) upstreamDataSourceFactory) + .setUserAgent(userAgent); + } else if (upstreamDataSourceFactory instanceof YoutubeHttpDataSource.Factory) { + upstreamDataSourceFactoryToUse = + ((YoutubeHttpDataSource.Factory) upstreamDataSourceFactory) + .setUserAgentForNonMobileStreams(userAgent); + } else { + upstreamDataSourceFactoryToUse = upstreamDataSourceFactory; + } + } + + final DefaultDataSource dataSource = new DefaultDataSource.Factory(context, + upstreamDataSourceFactoryToUse) + .setTransferListener(transferListener) + .createDataSource(); + final FileDataSource fileSource = new FileDataSource(); final CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize); - return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null); } - - public void tryDeleteCacheFiles() { - if (!cacheDir.exists() || !cacheDir.isDirectory()) { - return; - } - - try { - for (final File file : cacheDir.listFiles()) { - final String filePath = file.getAbsolutePath(); - final boolean deleteSuccessful = file.delete(); - - Log.d(TAG, "tryDeleteCacheFiles: " + filePath + " deleted = " + deleteSuccessful); - } - } catch (final Exception e) { - Log.e(TAG, "Failed to delete file.", e); - } - } } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/NonUriHlsPlaylistParserFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/NonUriHlsPlaylistParserFactory.java new file mode 100644 index 000000000..a3a25fd1d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/NonUriHlsPlaylistParserFactory.java @@ -0,0 +1,50 @@ +package org.schabi.newpipe.player.helper; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsMultivariantPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParserFactory; +import com.google.android.exoplayer2.upstream.ParsingLoadable; + +import java.io.IOException; +import java.io.InputStream; + +/** + * A {@link HlsPlaylistParserFactory} for non-URI HLS sources. + */ +public final class NonUriHlsPlaylistParserFactory implements HlsPlaylistParserFactory { + + private final HlsPlaylist hlsPlaylist; + + public NonUriHlsPlaylistParserFactory(final HlsPlaylist hlsPlaylist) { + this.hlsPlaylist = hlsPlaylist; + } + + private final class NonUriHlsPlayListParser implements ParsingLoadable.Parser { + + @Override + public HlsPlaylist parse(final Uri uri, + final InputStream inputStream) throws IOException { + return hlsPlaylist; + } + } + + @NonNull + @Override + public ParsingLoadable.Parser createPlaylistParser() { + return new NonUriHlsPlayListParser(); + } + + @NonNull + @Override + public ParsingLoadable.Parser createPlaylistParser( + @NonNull final HlsMultivariantPlaylist multivariantPlaylist, + @Nullable final HlsMediaPlaylist previousMediaPlaylist) { + return new NonUriHlsPlayListParser(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java index 405f6fd37..61d8baffc 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -2,21 +2,27 @@ package org.schabi.newpipe.player.helper; import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.source.SingleSampleMediaSource; import com.google.android.exoplayer2.source.dash.DashMediaSource; import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource; import com.google.android.exoplayer2.source.hls.HlsMediaSource; import com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTracker; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParserFactory; import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSource; import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; -import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.TransferListener; -import androidx.annotation.NonNull; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator; +import org.schabi.newpipe.player.datasource.YoutubeHttpDataSource; public class PlayerDataSource { @@ -29,79 +35,120 @@ public class PlayerDataSource { * early. */ private static final double PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT = 15; - private static final int MANIFEST_MINIMUM_RETRY = 5; - private static final int EXTRACTOR_MINIMUM_RETRY = Integer.MAX_VALUE; + + /** + * The maximum number of generated manifests per cache, in + * {@link YoutubeProgressiveDashManifestCreator}, {@link YoutubeOtfDashManifestCreator} and + * {@link YoutubePostLiveStreamDvrDashManifestCreator}. + */ + private static final int MAXIMUM_SIZE_CACHED_GENERATED_MANIFESTS_PER_CACHE = 500; private final int continueLoadingCheckIntervalBytes; - private final DataSource.Factory cacheDataSourceFactory; + private final CacheFactory.Builder cacheDataSourceFactoryBuilder; private final DataSource.Factory cachelessDataSourceFactory; public PlayerDataSource(@NonNull final Context context, @NonNull final String userAgent, @NonNull final TransferListener transferListener) { continueLoadingCheckIntervalBytes = PlayerHelper.getProgressiveLoadIntervalBytes(context); - cacheDataSourceFactory = new CacheFactory(context, userAgent, transferListener); - cachelessDataSourceFactory = new DefaultDataSource - .Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(userAgent)) + cacheDataSourceFactoryBuilder = new CacheFactory.Builder(context, userAgent, + transferListener); + cachelessDataSourceFactory = new DefaultDataSource.Factory(context, + new DefaultHttpDataSource.Factory().setUserAgent(userAgent)) .setTransferListener(transferListener); + + YoutubeProgressiveDashManifestCreator.getCache().setMaximumSize( + MAXIMUM_SIZE_CACHED_GENERATED_MANIFESTS_PER_CACHE); + YoutubeOtfDashManifestCreator.getCache().setMaximumSize( + MAXIMUM_SIZE_CACHED_GENERATED_MANIFESTS_PER_CACHE); + YoutubePostLiveStreamDvrDashManifestCreator.getCache().setMaximumSize( + MAXIMUM_SIZE_CACHED_GENERATED_MANIFESTS_PER_CACHE); } public SsMediaSource.Factory getLiveSsMediaSourceFactory() { - return new SsMediaSource.Factory( - new DefaultSsChunkSource.Factory(cachelessDataSourceFactory), - cachelessDataSourceFactory - ) - .setLoadErrorHandlingPolicy( - new DefaultLoadErrorHandlingPolicy(MANIFEST_MINIMUM_RETRY)) - .setLivePresentationDelayMs(LIVE_STREAM_EDGE_GAP_MILLIS); + return getSSMediaSourceFactory().setLivePresentationDelayMs(LIVE_STREAM_EDGE_GAP_MILLIS); } public HlsMediaSource.Factory getLiveHlsMediaSourceFactory() { return new HlsMediaSource.Factory(cachelessDataSourceFactory) .setAllowChunklessPreparation(true) - .setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy( - MANIFEST_MINIMUM_RETRY)) .setPlaylistTrackerFactory((dataSourceFactory, loadErrorHandlingPolicy, playlistParserFactory) -> new DefaultHlsPlaylistTracker(dataSourceFactory, loadErrorHandlingPolicy, - playlistParserFactory, PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT) - ); + playlistParserFactory, + PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT)); } public DashMediaSource.Factory getLiveDashMediaSourceFactory() { return new DashMediaSource.Factory( getDefaultDashChunkSourceFactory(cachelessDataSourceFactory), - cachelessDataSourceFactory - ) - .setLoadErrorHandlingPolicy( - new DefaultLoadErrorHandlingPolicy(MANIFEST_MINIMUM_RETRY)); + cachelessDataSourceFactory); } - private DefaultDashChunkSource.Factory getDefaultDashChunkSourceFactory( - final DataSource.Factory dataSourceFactory - ) { - return new DefaultDashChunkSource.Factory(dataSourceFactory); - } - - public HlsMediaSource.Factory getHlsMediaSourceFactory() { - return new HlsMediaSource.Factory(cacheDataSourceFactory); + public HlsMediaSource.Factory getHlsMediaSourceFactory( + @Nullable final HlsPlaylistParserFactory hlsPlaylistParserFactory) { + final HlsMediaSource.Factory factory = new HlsMediaSource.Factory( + cacheDataSourceFactoryBuilder.build()); + if (hlsPlaylistParserFactory != null) { + factory.setPlaylistParserFactory(hlsPlaylistParserFactory); + } + return factory; } public DashMediaSource.Factory getDashMediaSourceFactory() { return new DashMediaSource.Factory( - getDefaultDashChunkSourceFactory(cacheDataSourceFactory), - cacheDataSourceFactory - ); + getDefaultDashChunkSourceFactory(cacheDataSourceFactoryBuilder.build()), + cacheDataSourceFactoryBuilder.build()); } - public ProgressiveMediaSource.Factory getExtractorMediaSourceFactory() { - return new ProgressiveMediaSource.Factory(cacheDataSourceFactory) - .setContinueLoadingCheckIntervalBytes(continueLoadingCheckIntervalBytes) - .setLoadErrorHandlingPolicy( - new DefaultLoadErrorHandlingPolicy(EXTRACTOR_MINIMUM_RETRY)); + public ProgressiveMediaSource.Factory getProgressiveMediaSourceFactory() { + return new ProgressiveMediaSource.Factory(cacheDataSourceFactoryBuilder.build()) + .setContinueLoadingCheckIntervalBytes(continueLoadingCheckIntervalBytes); } - public SingleSampleMediaSource.Factory getSampleMediaSourceFactory() { - return new SingleSampleMediaSource.Factory(cacheDataSourceFactory); + public SsMediaSource.Factory getSSMediaSourceFactory() { + return new SsMediaSource.Factory( + new DefaultSsChunkSource.Factory(cachelessDataSourceFactory), + cachelessDataSourceFactory); + } + + public SingleSampleMediaSource.Factory getSingleSampleMediaSourceFactory() { + return new SingleSampleMediaSource.Factory(cacheDataSourceFactoryBuilder.build()); + } + + public DashMediaSource.Factory getYoutubeDashMediaSourceFactory() { + cacheDataSourceFactoryBuilder.setUpstreamDataSourceFactory( + getYoutubeHttpDataSourceFactory(true, true)); + return new DashMediaSource.Factory( + getDefaultDashChunkSourceFactory(cacheDataSourceFactoryBuilder.build()), + cacheDataSourceFactoryBuilder.build()); + } + + public HlsMediaSource.Factory getYoutubeHlsMediaSourceFactory() { + cacheDataSourceFactoryBuilder.setUpstreamDataSourceFactory( + getYoutubeHttpDataSourceFactory(false, false)); + return new HlsMediaSource.Factory(cacheDataSourceFactoryBuilder.build()); + } + + public ProgressiveMediaSource.Factory getYoutubeProgressiveMediaSourceFactory() { + cacheDataSourceFactoryBuilder.setUpstreamDataSourceFactory( + getYoutubeHttpDataSourceFactory(false, true)); + return new ProgressiveMediaSource.Factory(cacheDataSourceFactoryBuilder.build()) + .setContinueLoadingCheckIntervalBytes(continueLoadingCheckIntervalBytes); + } + + @NonNull + private DefaultDashChunkSource.Factory getDefaultDashChunkSourceFactory( + final DataSource.Factory dataSourceFactory) { + return new DefaultDashChunkSource.Factory(dataSourceFactory); + } + + @NonNull + private YoutubeHttpDataSource.Factory getYoutubeHttpDataSourceFactory( + final boolean rangeParameterEnabled, + final boolean rnParameterEnabled) { + return new YoutubeHttpDataSource.Factory() + .setRangeParameterEnabled(rangeParameterEnabled) + .setRnParameterEnabled(rnParameterEnabled); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java index b73c6cf7f..d924f9314 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java @@ -3,6 +3,8 @@ package org.schabi.newpipe.player.helper; import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF; import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; +import static org.schabi.newpipe.extractor.stream.AudioStream.UNKNOWN_BITRATE; +import static org.schabi.newpipe.extractor.stream.VideoStream.RESOLUTION_UNKNOWN; import static org.schabi.newpipe.player.Player.IDLE_WINDOW_FLAGS; import static org.schabi.newpipe.player.Player.PLAYER_TYPE; import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_ALWAYS; @@ -110,12 +112,14 @@ public final class PlayerHelper { int MINIMIZE_ON_EXIT_MODE_POPUP = 2; } - private PlayerHelper() { } + private PlayerHelper() { + } //////////////////////////////////////////////////////////////////////////// // Exposed helpers //////////////////////////////////////////////////////////////////////////// + @NonNull public static String getTimeString(final int milliSeconds) { final int seconds = (milliSeconds % 60000) / 1000; final int minutes = (milliSeconds % 3600000) / 60000; @@ -131,15 +135,18 @@ public final class PlayerHelper { ).toString(); } + @NonNull public static String formatSpeed(final double speed) { return SPEED_FORMATTER.format(speed); } + @NonNull public static String formatPitch(final double pitch) { return PITCH_FORMATTER.format(pitch); } - public static String subtitleMimeTypesOf(final MediaFormat format) { + @NonNull + public static String subtitleMimeTypesOf(@NonNull final MediaFormat format) { switch (format) { case VTT: return MimeTypes.TEXT_VTT; @@ -192,14 +199,48 @@ public final class PlayerHelper { @NonNull public static String cacheKeyOf(@NonNull final StreamInfo info, - @NonNull final VideoStream video) { - return info.getUrl() + video.getResolution() + video.getFormat().getName(); + @NonNull final VideoStream videoStream) { + String cacheKey = info.getUrl() + " " + videoStream.getId(); + + final String resolution = videoStream.getResolution(); + final MediaFormat mediaFormat = videoStream.getFormat(); + if (resolution.equals(RESOLUTION_UNKNOWN) && mediaFormat == null) { + // The hash code is only used in the cache key in the case when the resolution and the + // media format are unknown + cacheKey += " " + videoStream.hashCode(); + } else { + if (mediaFormat != null) { + cacheKey += " " + videoStream.getFormat().getName(); + } + if (!resolution.equals(RESOLUTION_UNKNOWN)) { + cacheKey += " " + resolution; + } + } + + return cacheKey; } @NonNull public static String cacheKeyOf(@NonNull final StreamInfo info, - @NonNull final AudioStream audio) { - return info.getUrl() + audio.getAverageBitrate() + audio.getFormat().getName(); + @NonNull final AudioStream audioStream) { + String cacheKey = info.getUrl() + " " + audioStream.getId(); + + final int averageBitrate = audioStream.getAverageBitrate(); + final MediaFormat mediaFormat = audioStream.getFormat(); + if (averageBitrate == UNKNOWN_BITRATE && mediaFormat == null) { + // The hash code is only used in the cache key in the case when the resolution and the + // media format are unknown + cacheKey += " " + audioStream.hashCode(); + } else { + if (mediaFormat != null) { + cacheKey += " " + audioStream.getFormat().getName(); + } + if (averageBitrate != UNKNOWN_BITRATE) { + cacheKey += " " + averageBitrate; + } + } + + return cacheKey; } /** @@ -233,7 +274,7 @@ public final class PlayerHelper { return null; } - if (relatedItems.get(0) != null && relatedItems.get(0) instanceof StreamInfoItem + if (relatedItems.get(0) instanceof StreamInfoItem && !urls.contains(relatedItems.get(0).getUrl())) { return getAutoQueuedSinglePlayQueue((StreamInfoItem) relatedItems.get(0)); } @@ -335,6 +376,7 @@ public final class PlayerHelper { return 2 * 1024 * 1024L; // ExoPlayer CacheDataSink.MIN_RECOMMENDED_FRAGMENT_SIZE } + @NonNull public static ExoTrackSelection.Factory getQualitySelector() { return new AdaptiveTrackSelection.Factory( 1000, @@ -389,7 +431,7 @@ public final class PlayerHelper { /** * @param context the Android context * @return the screen brightness to use. A value less than 0 (the default) means to use the - * preferred screen brightness + * preferred screen brightness */ public static float getScreenBrightness(@NonNull final Context context) { final SharedPreferences sp = getPreferences(context); @@ -480,7 +522,8 @@ public final class PlayerHelper { return REPEAT_MODE_ONE; case REPEAT_MODE_ONE: return REPEAT_MODE_ALL; - case REPEAT_MODE_ALL: default: + case REPEAT_MODE_ALL: + default: return REPEAT_MODE_OFF; } } @@ -548,7 +591,7 @@ public final class PlayerHelper { player.getContext().getResources().getDimension(R.dimen.popup_default_width); final float popupWidth = popupRememberSizeAndPos ? player.getPrefs().getFloat(player.getContext().getString( - R.string.popup_saved_width_key), defaultSize) + R.string.popup_saved_width_key), defaultSize) : defaultSize; final float popupHeight = getMinimumVideoHeight(popupWidth); @@ -564,10 +607,10 @@ public final class PlayerHelper { final int centerY = (int) (player.getScreenHeight() / 2f - popupHeight / 2f); popupLayoutParams.x = popupRememberSizeAndPos ? player.getPrefs().getInt(player.getContext().getString( - R.string.popup_saved_x_key), centerX) : centerX; + R.string.popup_saved_x_key), centerX) : centerX; popupLayoutParams.y = popupRememberSizeAndPos ? player.getPrefs().getInt(player.getContext().getString( - R.string.popup_saved_y_key), centerY) : centerY; + R.string.popup_saved_y_key), centerY) : centerY; return popupLayoutParams; } diff --git a/app/src/main/java/org/schabi/newpipe/player/listeners/view/QualityClickListener.kt b/app/src/main/java/org/schabi/newpipe/player/listeners/view/QualityClickListener.kt index b103ac0e6..43e8288e6 100644 --- a/app/src/main/java/org/schabi/newpipe/player/listeners/view/QualityClickListener.kt +++ b/app/src/main/java/org/schabi/newpipe/player/listeners/view/QualityClickListener.kt @@ -32,7 +32,7 @@ class QualityClickListener( val videoStream = player.selectedVideoStream if (videoStream != null) { player.binding.qualityTextView.text = - MediaFormat.getNameById(videoStream.formatId) + " " + videoStream.resolution + MediaFormat.getNameById(videoStream.formatId) + " " + videoStream.getResolution() } player.saveWasPlaying() 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 9bded9331..765475b2f 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,13 +1,15 @@ package org.schabi.newpipe.player.resolver; +import static org.schabi.newpipe.util.ListHelper.removeTorrentStreams; + import android.content.Context; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.android.exoplayer2.source.MediaSource; -import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.player.helper.PlayerDataSource; @@ -16,7 +18,13 @@ import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.ListHelper; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + public class AudioPlaybackResolver implements PlaybackResolver { + private static final String TAG = AudioPlaybackResolver.class.getSimpleName(); + @NonNull private final Context context; @NonNull @@ -31,19 +39,28 @@ public class AudioPlaybackResolver implements PlaybackResolver { @Override @Nullable public MediaSource resolve(@NonNull final StreamInfo info) { - final MediaSource liveSource = maybeBuildLiveMediaSource(dataSource, info); + final MediaSource liveSource = PlaybackResolver.maybeBuildLiveMediaSource(dataSource, info); if (liveSource != null) { return liveSource; } - final int index = ListHelper.getDefaultAudioFormat(context, info.getAudioStreams()); + final List audioStreams = new ArrayList<>(info.getAudioStreams()); + removeTorrentStreams(audioStreams); + + final int index = ListHelper.getDefaultAudioFormat(context, audioStreams); if (index < 0 || index >= info.getAudioStreams().size()) { return null; } final AudioStream audio = info.getAudioStreams().get(index); final MediaItemTag tag = StreamInfoTag.of(info); - return buildMediaSource(dataSource, audio.getUrl(), PlayerHelper.cacheKeyOf(info, audio), - MediaFormat.getSuffixById(audio.getFormatId()), tag); + + try { + return PlaybackResolver.buildMediaSource( + dataSource, audio, info, PlayerHelper.cacheKeyOf(info, audio), tag); + } catch (final IOException e) { + Log.e(TAG, "Unable to create audio source:", e); + return null; + } } } 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 90b38ed51..4c1b67dfc 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 @@ -1,15 +1,38 @@ package org.schabi.newpipe.player.resolver; +import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; +import static org.schabi.newpipe.player.helper.PlayerDataSource.LIVE_STREAM_EDGE_GAP_MILLIS; + import android.net.Uri; -import android.text.TextUtils; +import android.util.Log; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.util.Util; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.source.dash.DashMediaSource; +import com.google.android.exoplayer2.source.dash.manifest.DashManifest; +import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; +import com.google.android.exoplayer2.source.hls.HlsMediaSource; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser; +import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; +import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest; +import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifestParser; +import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.extractor.services.youtube.ItagItem; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.CreationException; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.DeliveryMethod; +import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.player.helper.NonUriHlsPlaylistParserFactory; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediaitem.StreamInfoTag; @@ -18,13 +41,17 @@ import org.schabi.newpipe.util.StreamTypeUtil; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import static org.schabi.newpipe.player.helper.PlayerDataSource.LIVE_STREAM_EDGE_GAP_MILLIS; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Objects; public interface PlaybackResolver extends Resolver { + String TAG = PlaybackResolver.class.getSimpleName(); @Nullable - default MediaSource maybeBuildLiveMediaSource(@NonNull final PlayerDataSource dataSource, - @NonNull final StreamInfo info) { + static MediaSource maybeBuildLiveMediaSource(@NonNull final PlayerDataSource dataSource, + @NonNull final StreamInfo info) { final StreamType streamType = info.getStreamType(); if (!StreamTypeUtil.isLiveStream(streamType)) { return null; @@ -41,10 +68,10 @@ public interface PlaybackResolver extends Resolver { } @NonNull - default MediaSource buildLiveMediaSource(@NonNull final PlayerDataSource dataSource, - @NonNull final String sourceUrl, - @C.ContentType final int type, - @NonNull final MediaItemTag metadata) { + static MediaSource buildLiveMediaSource(@NonNull final PlayerDataSource dataSource, + @NonNull final String sourceUrl, + @C.ContentType final int type, + @NonNull final MediaItemTag metadata) { final MediaSource.Factory factory; switch (type) { case C.TYPE_SS: @@ -67,46 +94,342 @@ public interface PlaybackResolver extends Resolver { .setLiveConfiguration( new MediaItem.LiveConfiguration.Builder() .setTargetOffsetMs(LIVE_STREAM_EDGE_GAP_MILLIS) - .build() - ) - .build() - ); + .build()) + .build()); } @NonNull - default MediaSource buildMediaSource(@NonNull final PlayerDataSource dataSource, - @NonNull final String sourceUrl, - @NonNull final String cacheKey, - @NonNull final String overrideExtension, - @NonNull final MediaItemTag metadata) { - final Uri uri = Uri.parse(sourceUrl); - @C.ContentType final int type = TextUtils.isEmpty(overrideExtension) - ? Util.inferContentType(uri) : Util.inferContentType("." + overrideExtension); - - final MediaSource.Factory factory; - switch (type) { - case C.TYPE_SS: - factory = dataSource.getLiveSsMediaSourceFactory(); - break; - case C.TYPE_DASH: - factory = dataSource.getDashMediaSourceFactory(); - break; - case C.TYPE_HLS: - factory = dataSource.getHlsMediaSourceFactory(); - break; - case C.TYPE_OTHER: - factory = dataSource.getExtractorMediaSourceFactory(); - break; - default: - throw new IllegalStateException("Unsupported type: " + type); + static MediaSource buildMediaSource(@NonNull final PlayerDataSource dataSource, + @NonNull final Stream stream, + @NonNull final StreamInfo streamInfo, + @NonNull final String cacheKey, + @NonNull final MediaItemTag metadata) + throws IOException { + if (streamInfo.getService() == ServiceList.YouTube) { + return createYoutubeMediaSource(stream, streamInfo, dataSource, cacheKey, metadata); } - return factory.createMediaSource( + final DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); + switch (deliveryMethod) { + case PROGRESSIVE_HTTP: + return buildProgressiveMediaSource(dataSource, stream, cacheKey, metadata); + case DASH: + return buildDashMediaSource(dataSource, stream, cacheKey, metadata); + case HLS: + return buildHlsMediaSource(dataSource, stream, cacheKey, metadata); + case SS: + return buildSSMediaSource(dataSource, stream, cacheKey, metadata); + // Torrent streams are not supported by ExoPlayer + default: + throw new IllegalArgumentException("Unsupported delivery type: " + deliveryMethod); + } + } + + @NonNull + private static ProgressiveMediaSource buildProgressiveMediaSource( + @NonNull final PlayerDataSource dataSource, + @NonNull final T stream, + @NonNull final String cacheKey, + @NonNull final MediaItemTag metadata) throws IOException { + final String url = stream.getContent(); + + if (isNullOrEmpty(url)) { + throw new IOException( + "Try to generate a progressive media source from an empty string or from a " + + "null object"); + } else { + return dataSource.getProgressiveMediaSourceFactory().createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(url)) + .setCustomCacheKey(cacheKey) + .build()); + } + } + + @NonNull + private static DashMediaSource buildDashMediaSource( + @NonNull final PlayerDataSource dataSource, + @NonNull final T stream, + @NonNull final String cacheKey, + @NonNull final MediaItemTag metadata) throws IOException { + final boolean isUrlStream = stream.isUrl(); + if (isUrlStream && isNullOrEmpty(stream.getContent())) { + throw new IOException("Try to generate a DASH media source from an empty string or " + + "from a null object"); + } + + if (isUrlStream) { + return dataSource.getDashMediaSourceFactory().createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } else { + String baseUrl = stream.getManifestUrl(); + if (baseUrl == null) { + baseUrl = ""; + } + + final Uri uri = Uri.parse(baseUrl); + + return dataSource.getDashMediaSourceFactory().createMediaSource( + createDashManifest(stream.getContent(), stream), + new MediaItem.Builder() + .setTag(metadata) + .setUri(uri) + .setCustomCacheKey(cacheKey) + .build()); + } + } + + @NonNull + private static DashManifest createDashManifest( + @NonNull final String manifestContent, + @NonNull final T stream) throws IOException { + try { + final ByteArrayInputStream dashManifestInput = new ByteArrayInputStream( + manifestContent.getBytes(StandardCharsets.UTF_8)); + String baseUrl = stream.getManifestUrl(); + if (baseUrl == null) { + baseUrl = ""; + } + + return new DashManifestParser().parse(Uri.parse(baseUrl), dashManifestInput); + } catch (final IOException e) { + throw new IOException("Error when parsing manual DASH manifest", e); + } + } + + @NonNull + private static HlsMediaSource buildHlsMediaSource( + @NonNull final PlayerDataSource dataSource, + @NonNull final T stream, + @NonNull final String cacheKey, + @NonNull final MediaItemTag metadata) throws IOException { + final boolean isUrlStream = stream.isUrl(); + if (isUrlStream && isNullOrEmpty(stream.getContent())) { + throw new IOException("Try to generate an HLS media source from an empty string or " + + "from a null object"); + } + + if (isUrlStream) { + return dataSource.getHlsMediaSourceFactory(null).createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } else { + String baseUrl = stream.getManifestUrl(); + if (baseUrl == null) { + baseUrl = ""; + } + + final Uri uri = Uri.parse(baseUrl); + + final HlsPlaylist hlsPlaylist; + try { + final ByteArrayInputStream hlsManifestInput = new ByteArrayInputStream( + stream.getContent().getBytes(StandardCharsets.UTF_8)); + hlsPlaylist = new HlsPlaylistParser().parse(uri, hlsManifestInput); + } catch (final IOException e) { + throw new IOException("Error when parsing manual HLS manifest", e); + } + + return dataSource.getHlsMediaSourceFactory( + new NonUriHlsPlaylistParserFactory(hlsPlaylist)) + .createMediaSource(new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + } + + @NonNull + private static SsMediaSource buildSSMediaSource( + @NonNull final PlayerDataSource dataSource, + @NonNull final T stream, + @NonNull final String cacheKey, + @NonNull final MediaItemTag metadata) throws IOException { + final boolean isUrlStream = stream.isUrl(); + if (isUrlStream && isNullOrEmpty(stream.getContent())) { + throw new IOException("Try to generate an SmoothStreaming media source from an empty " + + "string or from a null object"); + } + + if (isUrlStream) { + return dataSource.getSSMediaSourceFactory().createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } else { + String baseUrl = stream.getManifestUrl(); + if (baseUrl == null) { + baseUrl = ""; + } + + final Uri uri = Uri.parse(baseUrl); + + final SsManifest smoothStreamingManifest; + try { + final ByteArrayInputStream smoothStreamingManifestInput = new ByteArrayInputStream( + stream.getContent().getBytes(StandardCharsets.UTF_8)); + smoothStreamingManifest = new SsManifestParser().parse(uri, + smoothStreamingManifestInput); + } catch (final IOException e) { + throw new IOException("Error when parsing manual SmoothStreaming manifest", e); + } + + return dataSource.getSSMediaSourceFactory().createMediaSource( + smoothStreamingManifest, + new MediaItem.Builder() + .setTag(metadata) + .setUri(uri) + .setCustomCacheKey(cacheKey) + .build()); + } + } + + private static MediaSource createYoutubeMediaSource( + final T stream, + final StreamInfo streamInfo, + final PlayerDataSource dataSource, + final String cacheKey, + final MediaItemTag metadata) throws IOException { + if (!(stream instanceof AudioStream || stream instanceof VideoStream)) { + throw new IOException("Try to generate a DASH manifest of a YouTube " + + stream.getClass() + " " + stream.getContent()); + } + + final StreamType streamType = streamInfo.getStreamType(); + if (streamType == StreamType.VIDEO_STREAM) { + return createYoutubeMediaSourceOfVideoStreamType(dataSource, stream, streamInfo, + cacheKey, metadata); + } else if (streamType == StreamType.POST_LIVE_STREAM) { + // If the content is not an URL, uses the DASH delivery method and if the stream type + // of the stream is a post live stream, it means that the content is an ended + // livestream so we need to generate the manifest corresponding to the content + // (which is the last segment of the stream) + + try { + final ItagItem itagItem = Objects.requireNonNull(stream.getItagItem()); + final String manifestString = YoutubePostLiveStreamDvrDashManifestCreator + .fromPostLiveStreamDvrStreamingUrl(stream.getContent(), + itagItem, + itagItem.getTargetDurationSec(), + streamInfo.getDuration()); + return buildYoutubeManualDashMediaSource(dataSource, + createDashManifest(manifestString, stream), stream, cacheKey, + metadata); + } catch (final CreationException | NullPointerException e) { + Log.e(TAG, "Error when generating the DASH manifest of YouTube ended live stream", + e); + throw new IOException("Error when generating the DASH manifest of YouTube ended " + + "live stream " + stream.getContent(), e); + } + } else { + throw new IllegalArgumentException("DASH manifest generation of YouTube livestreams is " + + "not supported"); + } + } + + private static MediaSource createYoutubeMediaSourceOfVideoStreamType( + @NonNull final PlayerDataSource dataSource, + @NonNull final T stream, + @NonNull final StreamInfo streamInfo, + @NonNull final String cacheKey, + @NonNull final MediaItemTag metadata) throws IOException { + final DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); + switch (deliveryMethod) { + case PROGRESSIVE_HTTP: + if ((stream instanceof VideoStream && ((VideoStream) stream).isVideoOnly()) + || stream instanceof AudioStream) { + try { + final String manifestString = YoutubeProgressiveDashManifestCreator + .fromProgressiveStreamingUrl(stream.getContent(), + Objects.requireNonNull(stream.getItagItem()), + streamInfo.getDuration()); + return buildYoutubeManualDashMediaSource(dataSource, + createDashManifest(manifestString, stream), stream, cacheKey, + metadata); + } catch (final CreationException | IOException | NullPointerException e) { + Log.w(TAG, "Error when generating or parsing DASH manifest of " + + "YouTube progressive stream, falling back to a " + + "ProgressiveMediaSource.", e); + return buildYoutubeProgressiveMediaSource(dataSource, stream, cacheKey, + metadata); + } + } else { + // Legacy progressive streams, subtitles are handled by + // VideoPlaybackResolver + return buildYoutubeProgressiveMediaSource(dataSource, stream, cacheKey, + metadata); + } + case DASH: + // If the content is not a URL, uses the DASH delivery method and if the stream + // type of the stream is a video stream, it means the content is an OTF stream + // so we need to generate the manifest corresponding to the content (which is + // the base URL of the OTF stream). + + try { + final String manifestString = YoutubeOtfDashManifestCreator + .fromOtfStreamingUrl(stream.getContent(), + Objects.requireNonNull(stream.getItagItem()), + streamInfo.getDuration()); + return buildYoutubeManualDashMediaSource(dataSource, + createDashManifest(manifestString, stream), stream, cacheKey, + metadata); + } catch (final CreationException | NullPointerException e) { + Log.e(TAG, + "Error when generating the DASH manifest of YouTube OTF stream", e); + throw new IOException( + "Error when generating the DASH manifest of YouTube OTF stream " + + stream.getContent(), e); + } + case HLS: + return dataSource.getYoutubeHlsMediaSourceFactory().createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + default: + throw new IOException("Unsupported delivery method for YouTube contents: " + + deliveryMethod); + } + } + + @NonNull + private static DashMediaSource buildYoutubeManualDashMediaSource( + @NonNull final PlayerDataSource dataSource, + @NonNull final DashManifest dashManifest, + @NonNull final T stream, + @NonNull final String cacheKey, + @NonNull final MediaItemTag metadata) { + return dataSource.getYoutubeDashMediaSourceFactory().createMediaSource(dashManifest, new MediaItem.Builder() - .setTag(metadata) - .setUri(uri) - .setCustomCacheKey(cacheKey) - .build() - ); + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + + @NonNull + private static ProgressiveMediaSource buildYoutubeProgressiveMediaSource( + @NonNull final PlayerDataSource dataSource, + @NonNull final T stream, + @NonNull final String cacheKey, + @NonNull final MediaItemTag metadata) { + return dataSource.getYoutubeProgressiveMediaSourceFactory() + .createMediaSource(new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); } } 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 1aa7a5a18..24ca2e63a 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 @@ -2,6 +2,7 @@ package org.schabi.newpipe.player.resolver; import android.content.Context; import android.net.Uri; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -22,13 +23,18 @@ import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.ListHelper; +import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Optional; import static com.google.android.exoplayer2.C.TIME_UNSET; +import static org.schabi.newpipe.util.ListHelper.removeNonUrlAndTorrentStreams; +import static org.schabi.newpipe.util.ListHelper.removeTorrentStreams; public class VideoPlaybackResolver implements PlaybackResolver { + private static final String TAG = VideoPlaybackResolver.class.getSimpleName(); + @NonNull private final Context context; @NonNull @@ -57,17 +63,22 @@ public class VideoPlaybackResolver implements PlaybackResolver { @Override @Nullable public MediaSource resolve(@NonNull final StreamInfo info) { - final MediaSource liveSource = maybeBuildLiveMediaSource(dataSource, info); + final MediaSource liveSource = PlaybackResolver.maybeBuildLiveMediaSource(dataSource, info); if (liveSource != null) { streamSourceType = SourceType.LIVE_STREAM; return liveSource; } final List mediaSources = new ArrayList<>(); + final List videoStreams = new ArrayList<>(info.getVideoStreams()); + final List videoOnlyStreams = new ArrayList<>(info.getVideoOnlyStreams()); + + removeTorrentStreams(videoStreams); + removeTorrentStreams(videoOnlyStreams); // Create video stream source final List videos = ListHelper.getSortedStreamVideosList(context, - info.getVideoStreams(), info.getVideoOnlyStreams(), false, true); + videoStreams, videoOnlyStreams, false, true); final int index; if (videos.isEmpty()) { index = -1; @@ -82,24 +93,34 @@ public class VideoPlaybackResolver implements PlaybackResolver { .orElse(null); if (video != null) { - final MediaSource streamSource = buildMediaSource(dataSource, video.getUrl(), - PlayerHelper.cacheKeyOf(info, video), - MediaFormat.getSuffixById(video.getFormatId()), tag); - mediaSources.add(streamSource); + try { + final MediaSource streamSource = PlaybackResolver.buildMediaSource( + dataSource, video, info, PlayerHelper.cacheKeyOf(info, video), tag); + mediaSources.add(streamSource); + } catch (final IOException e) { + Log.e(TAG, "Unable to create video source:", e); + return null; + } } // Create optional audio stream source final List audioStreams = info.getAudioStreams(); + removeTorrentStreams(audioStreams); 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)) { - final MediaSource audioSource = buildMediaSource(dataSource, audio.getUrl(), - PlayerHelper.cacheKeyOf(info, audio), - MediaFormat.getSuffixById(audio.getFormatId()), tag); - mediaSources.add(audioSource); - streamSourceType = SourceType.VIDEO_WITH_SEPARATED_AUDIO; + // merge with audio stream in case if video does not contain audio + if (audio != null && (video == null || video.isVideoOnly())) { + try { + final MediaSource audioSource = PlaybackResolver.buildMediaSource( + dataSource, audio, info, PlayerHelper.cacheKeyOf(info, audio), tag); + mediaSources.add(audioSource); + streamSourceType = SourceType.VIDEO_WITH_SEPARATED_AUDIO; + } catch (final IOException e) { + Log.e(TAG, "Unable to create audio source:", e); + return null; + } } else { streamSourceType = SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY; } @@ -111,33 +132,35 @@ public class VideoPlaybackResolver implements PlaybackResolver { // Below are auxiliary media sources // Create subtitle sources - if (info.getSubtitles() != null) { - for (final SubtitlesStream subtitle : info.getSubtitles()) { - final String mimeType = PlayerHelper.subtitleMimeTypesOf(subtitle.getFormat()); - if (mimeType == null) { - continue; + final List subtitlesStreams = info.getSubtitles(); + if (subtitlesStreams != null) { + // Torrent and non URL subtitles are not supported by ExoPlayer + final List nonTorrentAndUrlStreams = removeNonUrlAndTorrentStreams( + subtitlesStreams); + for (final SubtitlesStream subtitle : nonTorrentAndUrlStreams) { + final MediaFormat mediaFormat = subtitle.getFormat(); + if (mediaFormat != null) { + @C.RoleFlags final int textRoleFlag = subtitle.isAutoGenerated() + ? C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND + : C.ROLE_FLAG_CAPTION; + final MediaItem.SubtitleConfiguration textMediaItem = + new MediaItem.SubtitleConfiguration.Builder( + Uri.parse(subtitle.getContent())) + .setMimeType(mediaFormat.getMimeType()) + .setRoleFlags(textRoleFlag) + .setLanguage(PlayerHelper.captionLanguageOf(context, subtitle)) + .build(); + final MediaSource textSource = dataSource.getSingleSampleMediaSourceFactory() + .createMediaSource(textMediaItem, TIME_UNSET); + mediaSources.add(textSource); } - final @C.RoleFlags int textRoleFlag = subtitle.isAutoGenerated() - ? C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND - : C.ROLE_FLAG_CAPTION; - final MediaItem.SubtitleConfiguration textMediaItem = - new MediaItem.SubtitleConfiguration.Builder(Uri.parse(subtitle.getUrl())) - .setMimeType(mimeType) - .setRoleFlags(textRoleFlag) - .setLanguage(PlayerHelper.captionLanguageOf(context, subtitle)) - .build(); - final MediaSource textSource = dataSource - .getSampleMediaSourceFactory() - .createMediaSource(textMediaItem, TIME_UNSET); - mediaSources.add(textSource); } } if (mediaSources.size() == 1) { return mediaSources.get(0); } else { - return new MergingMediaSource(mediaSources.toArray( - new MediaSource[0])); + return new MergingMediaSource(true, mediaSources.toArray(new MediaSource[0])); } } 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 c3ccef87c..3a03e0b30 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java @@ -13,6 +13,8 @@ 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.DeliveryMethod; +import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.VideoStream; import java.util.ArrayList; @@ -21,6 +23,7 @@ import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; +import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.Set; @@ -37,10 +40,9 @@ public final class ListHelper { // Audio format in order of efficiency. 0=most efficient, n=least efficient private static final List AUDIO_FORMAT_EFFICIENCY_RANKING = Arrays.asList(MediaFormat.WEBMA, MediaFormat.M4A, MediaFormat.MP3); - - private static final Set HIGH_RESOLUTION_LIST - // Uses a HashSet for better performance - = new HashSet<>(Arrays.asList("1440p", "2160p", "1440p60", "2160p60")); + // Use a HashSet for better performance + private static final Set HIGH_RESOLUTION_LIST = new HashSet<>( + Arrays.asList("1440p", "2160p")); private ListHelper() { } @@ -110,6 +112,83 @@ public final class ListHelper { } } + /** + * Return a {@link Stream} list which uses the given delivery method from a {@link Stream} + * list. + * + * @param streamList the original stream list + * @param deliveryMethod the delivery method + * @param the item type's class that extends {@link Stream} + * @return a stream list which uses the given delivery method + */ + @NonNull + public static List keepStreamsWithDelivery( + @NonNull final List streamList, + final DeliveryMethod deliveryMethod) { + if (streamList.isEmpty()) { + return Collections.emptyList(); + } + + final Iterator streamListIterator = streamList.iterator(); + while (streamListIterator.hasNext()) { + if (streamListIterator.next().getDeliveryMethod() != deliveryMethod) { + streamListIterator.remove(); + } + } + + return streamList; + } + + /** + * Return a {@link Stream} list which only contains URL streams and non-torrent streams. + * + * @param streamList the original stream list + * @param the item type's class that extends {@link Stream} + * @return a stream list which only contains URL streams and non-torrent streams + */ + @NonNull + public static List removeNonUrlAndTorrentStreams( + @NonNull final List streamList) { + if (streamList.isEmpty()) { + return Collections.emptyList(); + } + + final Iterator streamListIterator = streamList.iterator(); + while (streamListIterator.hasNext()) { + final S stream = streamListIterator.next(); + if (!stream.isUrl() || stream.getDeliveryMethod() == DeliveryMethod.TORRENT) { + streamListIterator.remove(); + } + } + + return streamList; + } + + /** + * Return a {@link Stream} list which only contains non-torrent streams. + * + * @param streamList the original stream list + * @param the item type's class that extends {@link Stream} + * @return a stream list which only contains non-torrent streams + */ + @NonNull + public static List removeTorrentStreams( + @NonNull final List streamList) { + if (streamList.isEmpty()) { + return Collections.emptyList(); + } + + final Iterator streamListIterator = streamList.iterator(); + while (streamListIterator.hasNext()) { + final S stream = streamListIterator.next(); + if (stream.getDeliveryMethod() == DeliveryMethod.TORRENT) { + streamListIterator.remove(); + } + } + + return streamList; + } + /** * Join the two lists of video streams (video_only and normal videos), * and sort them according with default format chosen by the user. @@ -177,7 +256,7 @@ public final class ListHelper { static int getDefaultResolutionIndex(final String defaultResolution, final String bestResolutionKey, final MediaFormat defaultFormat, - final List videoStreams) { + @Nullable final List videoStreams) { if (videoStreams == null || videoStreams.isEmpty()) { return -1; } @@ -233,7 +312,9 @@ public final class ListHelper { .flatMap(List::stream) // Filter out higher resolutions (or not if high resolutions should always be shown) .filter(stream -> showHigherResolutions - || !HIGH_RESOLUTION_LIST.contains(stream.getResolution())) + || !HIGH_RESOLUTION_LIST.contains(stream.getResolution() + // Replace any frame rate with nothing + .replaceAll("p\\d+$", "p"))) .collect(Collectors.toList()); final HashMap hashMap = new HashMap<>(); @@ -366,8 +447,9 @@ public final class ListHelper { * @param videoStreams the available video streams * @return the index of the preferred video stream */ - static int getVideoStreamIndex(final String targetResolution, final MediaFormat targetFormat, - final List videoStreams) { + static int getVideoStreamIndex(@NonNull final String targetResolution, + final MediaFormat targetFormat, + @NonNull final List videoStreams) { int fullMatchIndex = -1; int fullMatchNoRefreshIndex = -1; int resMatchOnlyIndex = -1; @@ -428,7 +510,7 @@ public final class ListHelper { * @param videoStreams the list of video streams to check * @return the index of the preferred video stream */ - private static int getDefaultResolutionWithDefaultFormat(final Context context, + private static int getDefaultResolutionWithDefaultFormat(@NonNull final Context context, final String defaultResolution, final List videoStreams) { final MediaFormat defaultFormat = getDefaultFormat(context, @@ -437,7 +519,7 @@ public final class ListHelper { context.getString(R.string.best_resolution_key), defaultFormat, videoStreams); } - private static MediaFormat getDefaultFormat(final Context context, + private static MediaFormat getDefaultFormat(@NonNull final Context context, @StringRes final int defaultFormatKey, @StringRes final int defaultFormatValueKey) { final SharedPreferences preferences @@ -457,8 +539,8 @@ public final class ListHelper { return defaultMediaFormat; } - private static MediaFormat getMediaFormatFromKey(final Context context, - final String formatKey) { + private static MediaFormat getMediaFormatFromKey(@NonNull final Context context, + @NonNull final String formatKey) { MediaFormat format = null; if (formatKey.equals(context.getString(R.string.video_webm_key))) { format = MediaFormat.WEBM; @@ -496,12 +578,20 @@ public final class ListHelper { - formatRanking.indexOf(streamB.getFormat()); } - private static int compareVideoStreamResolution(final String r1, final String r2) { - final int res1 = Integer.parseInt(r1.replaceAll("0p\\d+$", "1") - .replaceAll("[^\\d.]", "")); - final int res2 = Integer.parseInt(r2.replaceAll("0p\\d+$", "1") - .replaceAll("[^\\d.]", "")); - return res1 - res2; + private static int compareVideoStreamResolution(@NonNull final String r1, + @NonNull final String r2) { + try { + final int res1 = Integer.parseInt(r1.replaceAll("0p\\d+$", "1") + .replaceAll("[^\\d.]", "")); + final int res2 = Integer.parseInt(r2.replaceAll("0p\\d+$", "1") + .replaceAll("[^\\d.]", "")); + return res1 - res2; + } catch (final NumberFormatException e) { + // Consider the first one greater because we don't know if the two streams are + // different or not (a NumberFormatException was thrown so we don't know the resolution + // of one stream or of all streams) + return 1; + } } // Compares the quality of two video streams. @@ -536,7 +626,7 @@ public final class ListHelper { * @param context App context * @return maximum resolution allowed or null if there is no maximum */ - private static String getResolutionLimit(final Context context) { + private static String getResolutionLimit(@NonNull final Context context) { String resolutionLimit = null; if (isMeteredNetwork(context)) { final SharedPreferences preferences @@ -555,7 +645,7 @@ public final class ListHelper { * @param context App context * @return {@code true} if connected to a metered network */ - public static boolean isMeteredNetwork(final Context context) { + public static boolean isMeteredNetwork(@NonNull final Context context) { final ConnectivityManager manager = ContextCompat.getSystemService(context, ConnectivityManager.class); if (manager == null || manager.getActiveNetworkInfo() == null) { diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index e55114a2d..c3246857e 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -33,6 +33,7 @@ import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.DeliveryMethod; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; @@ -60,7 +61,9 @@ import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.settings.SettingsActivity; import org.schabi.newpipe.util.external_communication.ShareUtils; -import java.util.ArrayList; +import java.util.List; + +import static org.schabi.newpipe.util.ListHelper.removeNonUrlAndTorrentStreams; public final class NavigationHelper { public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; @@ -217,30 +220,44 @@ public final class NavigationHelper { public static void playOnExternalAudioPlayer(@NonNull final Context context, @NonNull final StreamInfo info) { - final int index = ListHelper.getDefaultAudioFormat(context, info.getAudioStreams()); - - if (index == -1) { + final List audioStreams = info.getAudioStreams(); + if (audioStreams.isEmpty()) { Toast.makeText(context, R.string.audio_streams_empty, Toast.LENGTH_SHORT).show(); return; } + final List audioStreamsForExternalPlayers = removeNonUrlAndTorrentStreams( + audioStreams); + if (audioStreamsForExternalPlayers.isEmpty()) { + Toast.makeText(context, R.string.no_audio_streams_available_for_external_players, + Toast.LENGTH_SHORT).show(); + return; + } + final int index = ListHelper.getDefaultAudioFormat(context, + audioStreamsForExternalPlayers); - final AudioStream audioStream = info.getAudioStreams().get(index); + final AudioStream audioStream = audioStreamsForExternalPlayers.get(index); playOnExternalPlayer(context, info.getName(), info.getUploaderName(), audioStream); } - public static void playOnExternalVideoPlayer(@NonNull final Context context, + public static void playOnExternalVideoPlayer(final Context context, @NonNull final StreamInfo info) { - final ArrayList videoStreamsList = new ArrayList<>( - ListHelper.getSortedStreamVideosList(context, info.getVideoStreams(), null, false, - false)); - final int index = ListHelper.getDefaultResolutionIndex(context, videoStreamsList); - - if (index == -1) { + final List videoStreams = info.getVideoStreams(); + if (videoStreams.isEmpty()) { Toast.makeText(context, R.string.video_streams_empty, Toast.LENGTH_SHORT).show(); return; } + final List videoStreamsForExternalPlayers = + ListHelper.getSortedStreamVideosList(context, + removeNonUrlAndTorrentStreams(videoStreams), null, false, false); + if (videoStreamsForExternalPlayers.isEmpty()) { + Toast.makeText(context, R.string.no_video_streams_available_for_external_players, + Toast.LENGTH_SHORT).show(); + return; + } + final int index = ListHelper.getDefaultResolutionIndex(context, + videoStreamsForExternalPlayers); - final VideoStream videoStream = videoStreamsList.get(index); + final VideoStream videoStream = videoStreamsForExternalPlayers.get(index); playOnExternalPlayer(context, info.getName(), info.getUploaderName(), videoStream); } @@ -248,9 +265,49 @@ public final class NavigationHelper { @Nullable final String name, @Nullable final String artist, @NonNull final Stream stream) { + final DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); + final String mimeType; + if (deliveryMethod == DeliveryMethod.PROGRESSIVE_HTTP) { + if (stream.getFormat() != null) { + mimeType = stream.getFormat().getMimeType(); + } else { + if (stream instanceof AudioStream) { + mimeType = "audio/*"; + } else if (stream instanceof VideoStream) { + mimeType = "video/*"; + } else { + // This should never be reached, because subtitles are not opened in external + // players + return; + } + } + } else { + if (!stream.isUrl() || deliveryMethod == DeliveryMethod.TORRENT) { + Toast.makeText(context, R.string.selected_stream_external_player_not_supported, + Toast.LENGTH_SHORT).show(); + return; + } else { + switch (deliveryMethod) { + case HLS: + mimeType = "application/x-mpegURL"; + break; + case DASH: + mimeType = "application/dash+xml"; + break; + case SS: + mimeType = "application/vnd.ms-sstr+xml"; + break; + default: + // Progressive HTTP streams are handled above and torrents streams are not + // exposed to external players + mimeType = ""; + } + } + } + final Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); - intent.setDataAndType(Uri.parse(stream.getUrl()), stream.getFormat().getMimeType()); + intent.setDataAndType(Uri.parse(stream.getContent()), mimeType); intent.putExtra(Intent.EXTRA_TITLE, name); intent.putExtra("title", name); intent.putExtra("artist", artist); diff --git a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java index 8c697d327..96124da87 100644 --- a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.util; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.AudioStream; @@ -14,7 +15,8 @@ public class SecondaryStreamHelper { private final int position; private final StreamSizeWrapper streams; - public SecondaryStreamHelper(final StreamSizeWrapper streams, final T selectedStream) { + public SecondaryStreamHelper(@NonNull final StreamSizeWrapper streams, + final T selectedStream) { this.streams = streams; this.position = streams.getStreamsList().indexOf(selectedStream); if (this.position < 0) { @@ -29,33 +31,37 @@ public class SecondaryStreamHelper { * @param videoStream desired video ONLY stream * @return selected audio stream or null if a candidate was not found */ + @Nullable public static AudioStream getAudioStreamFor(@NonNull final List audioStreams, @NonNull final VideoStream videoStream) { - switch (videoStream.getFormat()) { - case WEBM: - case MPEG_4:// ¿is mpeg-4 DASH? - break; - default: - return null; - } - - final boolean m4v = videoStream.getFormat() == MediaFormat.MPEG_4; - - for (final AudioStream audio : audioStreams) { - if (audio.getFormat() == (m4v ? MediaFormat.M4A : MediaFormat.WEBMA)) { - return audio; + final MediaFormat mediaFormat = videoStream.getFormat(); + if (mediaFormat != null) { + switch (mediaFormat) { + case WEBM: + case MPEG_4:// ¿is mpeg-4 DASH? + break; + default: + return null; } - } - if (m4v) { - return null; - } + final boolean m4v = (mediaFormat == MediaFormat.MPEG_4); - // retry, but this time in reverse order - for (int i = audioStreams.size() - 1; i >= 0; i--) { - final AudioStream audio = audioStreams.get(i); - if (audio.getFormat() == MediaFormat.WEBMA_OPUS) { - return audio; + for (final AudioStream audio : audioStreams) { + if (audio.getFormat() == (m4v ? MediaFormat.M4A : MediaFormat.WEBMA)) { + return audio; + } + } + + if (m4v) { + return null; + } + + // retry, but this time in reverse order + for (int i = audioStreams.size() - 1; i >= 0; i--) { + final AudioStream audio = audioStreams.get(i); + if (audio.getFormat() == MediaFormat.WEBMA_OPUS) { + return audio; + } } } 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 03342a497..11f982921 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java @@ -10,6 +10,8 @@ import android.widget.ImageView; import android.widget.Spinner; import android.widget.TextView; +import androidx.annotation.NonNull; + import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.MediaFormat; @@ -87,7 +89,8 @@ public class StreamItemAdapter extends BaseA } @Override - public View getDropDownView(final int position, final View convertView, + public View getDropDownView(final int position, + final View convertView, final ViewGroup parent) { return getCustomView(position, convertView, parent, true); } @@ -98,7 +101,10 @@ public class StreamItemAdapter extends BaseA convertView, parent, false); } - private View getCustomView(final int position, final View view, final ViewGroup parent, + @NonNull + private View getCustomView(final int position, + final View view, + final ViewGroup parent, final boolean isDropdownItem) { View convertView = view; if (convertView == null) { @@ -112,6 +118,7 @@ public class StreamItemAdapter extends BaseA final TextView sizeView = convertView.findViewById(R.id.stream_size); final T stream = getItem(position); + final MediaFormat mediaFormat = stream.getFormat(); int woSoundIconVisibility = View.GONE; String qualityString; @@ -135,24 +142,32 @@ public class StreamItemAdapter extends BaseA } } else if (stream instanceof AudioStream) { final AudioStream audioStream = ((AudioStream) stream); - qualityString = audioStream.getAverageBitrate() > 0 - ? audioStream.getAverageBitrate() + "kbps" - : audioStream.getFormat().getName(); + if (audioStream.getAverageBitrate() > 0) { + qualityString = audioStream.getAverageBitrate() + "kbps"; + } else if (mediaFormat != null) { + qualityString = mediaFormat.getName(); + } else { + qualityString = context.getString(R.string.unknown_quality); + } } else if (stream instanceof SubtitlesStream) { qualityString = ((SubtitlesStream) stream).getDisplayLanguageName(); if (((SubtitlesStream) stream).isAutoGenerated()) { qualityString += " (" + context.getString(R.string.caption_auto_generated) + ")"; } } else { - qualityString = stream.getFormat().getSuffix(); + if (mediaFormat != null) { + qualityString = mediaFormat.getSuffix(); + } else { + qualityString = context.getString(R.string.unknown_quality); + } } if (streamsWrapper.getSizeInBytes(position) > 0) { final SecondaryStreamHelper secondary = secondaryStreams == null ? null : secondaryStreams.get(position); if (secondary != null) { - final long size - = secondary.getSizeInBytes() + streamsWrapper.getSizeInBytes(position); + final long size = secondary.getSizeInBytes() + + streamsWrapper.getSizeInBytes(position); sizeView.setText(Utility.formatBytes(size)); } else { sizeView.setText(streamsWrapper.getFormattedSize(position)); @@ -164,11 +179,15 @@ public class StreamItemAdapter extends BaseA if (stream instanceof SubtitlesStream) { formatNameView.setText(((SubtitlesStream) stream).getLanguageTag()); - } else if (stream.getFormat() == MediaFormat.WEBMA_OPUS) { - // noinspection AndroidLintSetTextI18n - formatNameView.setText("opus"); } else { - formatNameView.setText(stream.getFormat().getName()); + if (mediaFormat == null) { + formatNameView.setText(context.getString(R.string.unknown_format)); + } else if (mediaFormat == MediaFormat.WEBMA_OPUS) { + // noinspection AndroidLintSetTextI18n + formatNameView.setText("opus"); + } else { + formatNameView.setText(mediaFormat.getName()); + } } qualityView.setText(qualityString); @@ -233,6 +252,7 @@ public class StreamItemAdapter extends BaseA * @param streamsWrapper the wrapper * @return a {@link Single} that returns a boolean indicating if any elements were changed */ + @NonNull public static Single fetchSizeForWrapper( final StreamSizeWrapper streamsWrapper) { final Callable fetchAndSet = () -> { @@ -243,7 +263,7 @@ public class StreamItemAdapter extends BaseA } final long contentLength = DownloaderImpl.getInstance().getContentLength( - stream.getUrl()); + stream.getContent()); streamsWrapper.setSize(stream, contentLength); hasChanged = true; } diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java b/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java index 87b3eed4f..b0b6f4507 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java @@ -3,7 +3,7 @@ package org.schabi.newpipe.util; import org.schabi.newpipe.extractor.stream.StreamType; /** - * Utility class for {@link org.schabi.newpipe.extractor.stream.StreamType}. + * Utility class for {@link StreamType}. */ public final class StreamTypeUtil { private StreamTypeUtil() { @@ -11,10 +11,10 @@ public final class StreamTypeUtil { } /** - * Checks if the streamType is a livestream. + * Check if the {@link StreamType} of a stream is a livestream. * - * @param streamType - * @return true when the streamType is a + * @param streamType the stream type of the stream + * @return true if the streamType is a * {@link StreamType#LIVE_STREAM} or {@link StreamType#AUDIO_LIVE_STREAM} */ public static boolean isLiveStream(final StreamType streamType) { diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java b/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java index 90886b63c..e001c6f3f 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java @@ -6,6 +6,7 @@ import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.DeliveryMethod; import org.schabi.newpipe.extractor.stream.StreamExtractor; import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; @@ -131,31 +132,38 @@ public class DownloadMissionRecover extends Thread { switch (mRecovery.getKind()) { case 'a': - for (AudioStream audio : mExtractor.getAudioStreams()) { - if (audio.getAverageBitrate() == mRecovery.getDesiredBitrate() && audio.getFormat() == mRecovery.getFormat()) { - url = audio.getUrl(); + for (final AudioStream audio : mExtractor.getAudioStreams()) { + if (audio.getAverageBitrate() == mRecovery.getDesiredBitrate() + && audio.getFormat() == mRecovery.getFormat() + && audio.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP) { + url = audio.getContent(); break; } } break; case 'v': - List videoStreams; + final List videoStreams; if (mRecovery.isDesired2()) videoStreams = mExtractor.getVideoOnlyStreams(); else videoStreams = mExtractor.getVideoStreams(); - for (VideoStream video : videoStreams) { - if (video.resolution.equals(mRecovery.getDesired()) && video.getFormat() == mRecovery.getFormat()) { - url = video.getUrl(); + for (final VideoStream video : videoStreams) { + if (video.getResolution().equals(mRecovery.getDesired()) + && video.getFormat() == mRecovery.getFormat() + && video.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP) { + url = video.getContent(); break; } } break; case 's': - for (SubtitlesStream subtitles : mExtractor.getSubtitles(mRecovery.getFormat())) { + for (final SubtitlesStream subtitles : mExtractor.getSubtitles(mRecovery + .getFormat())) { String tag = subtitles.getLanguageTag(); - if (tag.equals(mRecovery.getDesired()) && subtitles.isAutoGenerated() == mRecovery.isDesired2()) { - url = subtitles.getUrl(); + if (tag.equals(mRecovery.getDesired()) + && subtitles.isAutoGenerated() == mRecovery.isDesired2() + && subtitles.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP) { + url = subtitles.getContent(); break; } } diff --git a/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt b/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt index 11293a610..c2f9dc9b2 100644 --- a/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt +++ b/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt @@ -11,23 +11,23 @@ import java.io.Serializable @Parcelize class MissionRecoveryInfo( - var format: MediaFormat, + var format: MediaFormat?, var desired: String? = null, var isDesired2: Boolean = false, var desiredBitrate: Int = 0, var kind: Char = Char.MIN_VALUE, var validateCondition: String? = null ) : Serializable, Parcelable { - constructor(stream: Stream) : this(format = stream.getFormat()!!) { + constructor(stream: Stream) : this(format = stream.format) { when (stream) { is AudioStream -> { - desiredBitrate = stream.averageBitrate + desiredBitrate = stream.getAverageBitrate() isDesired2 = false kind = 'a' } is VideoStream -> { - desired = stream.resolution - isDesired2 = stream.isVideoOnly + desired = stream.getResolution() + isDesired2 = stream.isVideoOnly() kind = 'v' } is SubtitlesStream -> { @@ -62,7 +62,7 @@ class MissionRecoveryInfo( } } str.append(" format=") - .append(format.getName()) + .append(format?.getName()) .append(' ') .append(info) .append('}') diff --git a/app/src/main/res/layout/download_dialog.xml b/app/src/main/res/layout/download_dialog.xml index 33e18c64a..4a9c0711f 100644 --- a/app/src/main/res/layout/download_dialog.xml +++ b/app/src/main/res/layout/download_dialog.xml @@ -82,6 +82,7 @@ android:text="@string/msg_threads" /> + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 80f79cfdd..1ab39d302 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -740,4 +740,11 @@ You now subscribed to this channel , Toggle all + Note that streams which are not supported by the downloader yet have been removed + 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 + Unknown format + Unknown quality \ No newline at end of file diff --git a/app/src/test/java/org/schabi/newpipe/util/ListHelperTest.java b/app/src/test/java/org/schabi/newpipe/util/ListHelperTest.java index 531837ea2..c9d570c7d 100644 --- a/app/src/test/java/org/schabi/newpipe/util/ListHelperTest.java +++ b/app/src/test/java/org/schabi/newpipe/util/ListHelperTest.java @@ -13,38 +13,41 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + public class ListHelperTest { private static final String BEST_RESOLUTION_KEY = "best_resolution"; private static final List AUDIO_STREAMS_TEST_LIST = Arrays.asList( - new AudioStream("", MediaFormat.M4A, /**/ 128), - new AudioStream("", MediaFormat.WEBMA, /**/ 192), - new AudioStream("", MediaFormat.MP3, /**/ 64), - new AudioStream("", MediaFormat.WEBMA, /**/ 192), - new AudioStream("", MediaFormat.M4A, /**/ 128), - new AudioStream("", MediaFormat.MP3, /**/ 128), - new AudioStream("", MediaFormat.WEBMA, /**/ 64), - new AudioStream("", MediaFormat.M4A, /**/ 320), - new AudioStream("", MediaFormat.MP3, /**/ 192), - new AudioStream("", MediaFormat.WEBMA, /**/ 320)); + generateAudioStream("m4a-128-1", MediaFormat.M4A, 128), + generateAudioStream("webma-192", MediaFormat.WEBMA, 192), + generateAudioStream("mp3-64", MediaFormat.MP3, 64), + generateAudioStream("webma-192", MediaFormat.WEBMA, 192), + generateAudioStream("m4a-128-2", MediaFormat.M4A, 128), + generateAudioStream("mp3-128", MediaFormat.MP3, 128), + generateAudioStream("webma-64", MediaFormat.WEBMA, 64), + generateAudioStream("m4a-320", MediaFormat.M4A, 320), + generateAudioStream("mp3-192", MediaFormat.MP3, 192), + generateAudioStream("webma-320", MediaFormat.WEBMA, 320)); private static final List VIDEO_STREAMS_TEST_LIST = Arrays.asList( - new VideoStream("", MediaFormat.MPEG_4, /**/ "720p"), - new VideoStream("", MediaFormat.v3GPP, /**/ "240p"), - new VideoStream("", MediaFormat.WEBM, /**/ "480p"), - new VideoStream("", MediaFormat.v3GPP, /**/ "144p"), - new VideoStream("", MediaFormat.MPEG_4, /**/ "360p"), - new VideoStream("", MediaFormat.WEBM, /**/ "360p")); + generateVideoStream("mpeg_4-720", MediaFormat.MPEG_4, "720p", false), + generateVideoStream("v3gpp-240", MediaFormat.v3GPP, "240p", false), + generateVideoStream("webm-480", MediaFormat.WEBM, "480p", false), + generateVideoStream("v3gpp-144", MediaFormat.v3GPP, "144p", false), + generateVideoStream("mpeg_4-360", MediaFormat.MPEG_4, "360p", false), + generateVideoStream("webm-360", MediaFormat.WEBM, "360p", false)); private static final List VIDEO_ONLY_STREAMS_TEST_LIST = Arrays.asList( - new VideoStream("", MediaFormat.MPEG_4, /**/ "720p", true), - new VideoStream("", MediaFormat.MPEG_4, /**/ "720p", true), - new VideoStream("", MediaFormat.MPEG_4, /**/ "2160p", true), - new VideoStream("", MediaFormat.MPEG_4, /**/ "1440p60", true), - new VideoStream("", MediaFormat.WEBM, /**/ "720p60", true), - new VideoStream("", MediaFormat.MPEG_4, /**/ "2160p60", true), - new VideoStream("", MediaFormat.MPEG_4, /**/ "720p60", true), - new VideoStream("", MediaFormat.MPEG_4, /**/ "1080p", true), - new VideoStream("", MediaFormat.MPEG_4, /**/ "1080p60", true)); + generateVideoStream("mpeg_4-720-1", MediaFormat.MPEG_4, "720p", true), + generateVideoStream("mpeg_4-720-2", MediaFormat.MPEG_4, "720p", true), + generateVideoStream("mpeg_4-2160", MediaFormat.MPEG_4, "2160p", true), + generateVideoStream("mpeg_4-1440_60", MediaFormat.MPEG_4, "1440p60", true), + generateVideoStream("webm-720_60", MediaFormat.WEBM, "720p60", true), + generateVideoStream("mpeg_4-2160_60", MediaFormat.MPEG_4, "2160p60", true), + generateVideoStream("mpeg_4-720_60", MediaFormat.MPEG_4, "720p60", true), + generateVideoStream("mpeg_4-1080", MediaFormat.MPEG_4, "1080p", true), + generateVideoStream("mpeg_4-1080_60", MediaFormat.MPEG_4, "1080p60", true)); @Test public void getSortedStreamVideosListTest() { @@ -56,7 +59,8 @@ public class ListHelperTest { assertEquals(expected.size(), result.size()); for (int i = 0; i < result.size(); i++) { - assertEquals(expected.get(i), result.get(i).resolution); + assertEquals(result.get(i).getResolution(), expected.get(i)); + assertEquals(expected.get(i), result.get(i).getResolution()); } //////////////////// @@ -69,7 +73,7 @@ public class ListHelperTest { "720p", "480p", "360p", "240p", "144p"); assertEquals(expected.size(), result.size()); for (int i = 0; i < result.size(); i++) { - assertEquals(expected.get(i), result.get(i).resolution); + assertEquals(expected.get(i), result.get(i).getResolution()); } } @@ -83,8 +87,8 @@ public class ListHelperTest { assertEquals(expected.size(), result.size()); for (int i = 0; i < result.size(); i++) { - assertEquals(expected.get(i), result.get(i).resolution); - assertTrue(result.get(i).isVideoOnly); + assertEquals(expected.get(i), result.get(i).getResolution()); + assertTrue(result.get(i).isVideoOnly()); } ////////////////////////////////////////////////////////// @@ -96,8 +100,8 @@ public class ListHelperTest { expected = Arrays.asList("720p", "480p", "360p", "240p", "144p"); assertEquals(expected.size(), result.size()); for (int i = 0; i < result.size(); i++) { - assertEquals(expected.get(i), result.get(i).resolution); - assertFalse(result.get(i).isVideoOnly); + assertEquals(expected.get(i), result.get(i).getResolution()); + assertFalse(result.get(i).isVideoOnly()); } ///////////////////////////////////////////////////////////////// @@ -113,10 +117,9 @@ public class ListHelperTest { assertEquals(expected.size(), result.size()); for (int i = 0; i < result.size(); i++) { - assertEquals(expected.get(i), result.get(i).resolution); - assertEquals( - expectedVideoOnly.contains(result.get(i).resolution), - result.get(i).isVideoOnly); + assertEquals(expected.get(i), result.get(i).getResolution()); + assertEquals(expectedVideoOnly.contains(result.get(i).getResolution()), + result.get(i).isVideoOnly()); } } @@ -132,66 +135,66 @@ public class ListHelperTest { "1080p60", "1080p", "720p60", "720p", "480p", "360p", "240p", "144p"); assertEquals(expected.size(), result.size()); for (int i = 0; i < result.size(); i++) { - assertEquals(expected.get(i), result.get(i).resolution); + assertEquals(expected.get(i), result.get(i).getResolution()); } } @Test public void getDefaultResolutionTest() { final List testList = Arrays.asList( - new VideoStream("", MediaFormat.MPEG_4, /**/ "720p"), - new VideoStream("", MediaFormat.v3GPP, /**/ "240p"), - new VideoStream("", MediaFormat.WEBM, /**/ "480p"), - new VideoStream("", MediaFormat.WEBM, /**/ "240p"), - new VideoStream("", MediaFormat.MPEG_4, /**/ "240p"), - new VideoStream("", MediaFormat.WEBM, /**/ "144p"), - new VideoStream("", MediaFormat.MPEG_4, /**/ "360p"), - new VideoStream("", MediaFormat.WEBM, /**/ "360p")); + generateVideoStream("mpeg_4-720", MediaFormat.MPEG_4, "720p", false), + generateVideoStream("v3gpp-240", MediaFormat.v3GPP, "240p", false), + generateVideoStream("webm-480", MediaFormat.WEBM, "480p", false), + generateVideoStream("webm-240", MediaFormat.WEBM, "240p", false), + generateVideoStream("mpeg_4-240", MediaFormat.MPEG_4, "240p", false), + generateVideoStream("webm-144", MediaFormat.WEBM, "144p", false), + generateVideoStream("mpeg_4-360", MediaFormat.MPEG_4, "360p", false), + generateVideoStream("webm-360", MediaFormat.WEBM, "360p", false)); VideoStream result = testList.get(ListHelper.getDefaultResolutionIndex( "720p", BEST_RESOLUTION_KEY, MediaFormat.MPEG_4, testList)); - assertEquals("720p", result.resolution); + assertEquals("720p", result.getResolution()); assertEquals(MediaFormat.MPEG_4, result.getFormat()); // Have resolution and the format result = testList.get(ListHelper.getDefaultResolutionIndex( "480p", BEST_RESOLUTION_KEY, MediaFormat.WEBM, testList)); - assertEquals("480p", result.resolution); + assertEquals("480p", result.getResolution()); assertEquals(MediaFormat.WEBM, result.getFormat()); // Have resolution but not the format result = testList.get(ListHelper.getDefaultResolutionIndex( "480p", BEST_RESOLUTION_KEY, MediaFormat.MPEG_4, testList)); - assertEquals("480p", result.resolution); + assertEquals("480p", result.getResolution()); assertEquals(MediaFormat.WEBM, result.getFormat()); // Have resolution and the format result = testList.get(ListHelper.getDefaultResolutionIndex( "240p", BEST_RESOLUTION_KEY, MediaFormat.WEBM, testList)); - assertEquals("240p", result.resolution); + assertEquals("240p", result.getResolution()); assertEquals(MediaFormat.WEBM, result.getFormat()); // The best resolution result = testList.get(ListHelper.getDefaultResolutionIndex( BEST_RESOLUTION_KEY, BEST_RESOLUTION_KEY, MediaFormat.WEBM, testList)); - assertEquals("720p", result.resolution); + assertEquals("720p", result.getResolution()); assertEquals(MediaFormat.MPEG_4, result.getFormat()); // Doesn't have the 60fps variant and format result = testList.get(ListHelper.getDefaultResolutionIndex( "720p60", BEST_RESOLUTION_KEY, MediaFormat.WEBM, testList)); - assertEquals("720p", result.resolution); + assertEquals("720p", result.getResolution()); assertEquals(MediaFormat.MPEG_4, result.getFormat()); // Doesn't have the 60fps variant result = testList.get(ListHelper.getDefaultResolutionIndex( "480p60", BEST_RESOLUTION_KEY, MediaFormat.WEBM, testList)); - assertEquals("480p", result.resolution); + assertEquals("480p", result.getResolution()); assertEquals(MediaFormat.WEBM, result.getFormat()); // Doesn't have the resolution, will return the best one result = testList.get(ListHelper.getDefaultResolutionIndex( "2160p60", BEST_RESOLUTION_KEY, MediaFormat.WEBM, testList)); - assertEquals("720p", result.resolution); + assertEquals("720p", result.getResolution()); assertEquals(MediaFormat.MPEG_4, result.getFormat()); } @@ -221,8 +224,8 @@ public class ListHelperTest { //////////////////////////////////////// List testList = Arrays.asList( - new AudioStream("", MediaFormat.M4A, /**/ 128), - new AudioStream("", MediaFormat.WEBMA, /**/ 192)); + generateAudioStream("m4a-128", MediaFormat.M4A, 128), + 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( @@ -235,13 +238,13 @@ public class ListHelperTest { ////////////////////////////////////////////////////// testList = new ArrayList<>(Arrays.asList( - new AudioStream("", MediaFormat.WEBMA, /**/ 192), - new AudioStream("", MediaFormat.M4A, /**/ 192), - new AudioStream("", MediaFormat.WEBMA, /**/ 192), - new AudioStream("", MediaFormat.M4A, /**/ 192), - new AudioStream("", MediaFormat.WEBMA, /**/ 192), - new AudioStream("", MediaFormat.M4A, /**/ 192), - new AudioStream("", MediaFormat.WEBMA, /**/ 192))); + generateAudioStream("webma-192-1", MediaFormat.WEBMA, 192), + generateAudioStream("m4a-192-1", MediaFormat.M4A, 192), + generateAudioStream("webma-192-2", MediaFormat.WEBMA, 192), + generateAudioStream("m4a-192-2", MediaFormat.M4A, 192), + generateAudioStream("webma-192-3", MediaFormat.WEBMA, 192), + generateAudioStream("m4a-192-3", MediaFormat.M4A, 192), + 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)); @@ -250,7 +253,7 @@ public class ListHelperTest { // Adding a new format and bitrate. Adding another stream will have no impact since // it's not a preferred format. - testList.add(new AudioStream("", MediaFormat.WEBMA, /**/ 192)); + testList.add(generateAudioStream("webma-192-5", MediaFormat.WEBMA, 192)); stream = testList.get(ListHelper.getHighestQualityAudioIndex(MediaFormat.MP3, testList)); assertEquals(192, stream.getAverageBitrate()); assertEquals(MediaFormat.M4A, stream.getFormat()); @@ -288,8 +291,8 @@ public class ListHelperTest { //////////////////////////////////////// List testList = new ArrayList<>(Arrays.asList( - new AudioStream("", MediaFormat.M4A, /**/ 128), - new AudioStream("", MediaFormat.WEBMA, /**/ 192))); + generateAudioStream("m4a-128", MediaFormat.M4A, 128), + 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( @@ -298,7 +301,7 @@ public class ListHelperTest { assertEquals(MediaFormat.M4A, stream.getFormat()); // WEBMA is more compact than M4A - testList.add(new AudioStream("", MediaFormat.WEBMA, /**/ 128)); + testList.add(generateAudioStream("webma-192-2", MediaFormat.WEBMA, 128)); stream = testList.get(ListHelper.getMostCompactAudioIndex(MediaFormat.MP3, testList)); assertEquals(128, stream.getAverageBitrate()); assertEquals(MediaFormat.WEBMA, stream.getFormat()); @@ -308,12 +311,12 @@ public class ListHelperTest { ////////////////////////////////////////////////////// testList = new ArrayList<>(Arrays.asList( - new AudioStream("", MediaFormat.WEBMA, /**/ 192), - new AudioStream("", MediaFormat.M4A, /**/ 192), - new AudioStream("", MediaFormat.WEBMA, /**/ 256), - new AudioStream("", MediaFormat.M4A, /**/ 192), - new AudioStream("", MediaFormat.WEBMA, /**/ 192), - new AudioStream("", MediaFormat.M4A, /**/ 192))); + generateAudioStream("webma-192-1", MediaFormat.WEBMA, 192), + generateAudioStream("m4a-192-1", MediaFormat.M4A, 192), + generateAudioStream("webma-256", MediaFormat.WEBMA, 256), + generateAudioStream("m4a-192-2", MediaFormat.M4A, 192), + generateAudioStream("webma-192-2", MediaFormat.WEBMA, 192), + 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)); @@ -335,14 +338,14 @@ public class ListHelperTest { @Test public void getVideoDefaultStreamIndexCombinations() { final List testList = Arrays.asList( - new VideoStream("", MediaFormat.MPEG_4, /**/ "1080p"), - new VideoStream("", MediaFormat.MPEG_4, /**/ "720p60"), - new VideoStream("", MediaFormat.MPEG_4, /**/ "720p"), - new VideoStream("", MediaFormat.WEBM, /**/ "480p"), - new VideoStream("", MediaFormat.MPEG_4, /**/ "360p"), - new VideoStream("", MediaFormat.WEBM, /**/ "360p"), - new VideoStream("", MediaFormat.v3GPP, /**/ "240p60"), - new VideoStream("", MediaFormat.WEBM, /**/ "144p")); + generateVideoStream("mpeg_4-1080", MediaFormat.MPEG_4, "1080p", false), + generateVideoStream("mpeg_4-720_60", MediaFormat.MPEG_4, "720p60", false), + generateVideoStream("mpeg_4-720", MediaFormat.MPEG_4, "720p", false), + generateVideoStream("webm-480", MediaFormat.WEBM, "480p", false), + generateVideoStream("mpeg_4-360", MediaFormat.MPEG_4, "360p", false), + generateVideoStream("webm-360", MediaFormat.WEBM, "360p", false), + generateVideoStream("v3gpp-240_60", MediaFormat.v3GPP, "240p60", false), + generateVideoStream("webm-144", MediaFormat.WEBM, "144p", false)); // exact matches assertEquals(1, ListHelper.getVideoStreamIndex("720p60", MediaFormat.MPEG_4, testList)); @@ -375,4 +378,30 @@ public class ListHelperTest { // Can't find a match assertEquals(-1, ListHelper.getVideoStreamIndex("100p", null, testList)); } + + @NonNull + private static AudioStream generateAudioStream(@NonNull final String id, + @Nullable final MediaFormat mediaFormat, + final int averageBitrate) { + return new AudioStream.Builder() + .setId(id) + .setContent("", true) + .setMediaFormat(mediaFormat) + .setAverageBitrate(averageBitrate) + .build(); + } + + @NonNull + private static VideoStream generateVideoStream(@NonNull final String id, + @Nullable final MediaFormat mediaFormat, + @NonNull final String resolution, + final boolean isVideoOnly) { + return new VideoStream.Builder() + .setId(id) + .setContent("", true) + .setIsVideoOnly(isVideoOnly) + .setResolution(resolution) + .setMediaFormat(mediaFormat) + .build(); + } } From 7d6bf4b0cac4fced6463bfc84c40f48ef57cb00d Mon Sep 17 00:00:00 2001 From: AudricV <74829229+AudricV@users.noreply.github.com> Date: Thu, 16 Jun 2022 11:13:31 +0200 Subject: [PATCH 02/20] Improve dialog of streams for external players and fix use of the wrong codec in the list of available streams in it after a codec change in Video and Audio settings The VideoDetailFragment will now get video streams dynamically instead of storing them as a field, so the good codec can be chosen by ListHelper. To select a stream to play, user has now to select the quality in the list of available qualities and then press the new OK button in the alert dialog. --- .../fragments/detail/VideoDetailFragment.java | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) 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 f5bd1f363..bb09681f5 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 @@ -188,7 +188,6 @@ public final class VideoDetailFragment @Nullable private Disposable positionSubscriber = null; - private List videoStreamsForExternalPlayers; private BottomSheetBehavior bottomSheetBehavior; private BroadcastReceiver broadcastReceiver; @@ -1615,13 +1614,6 @@ public final class VideoDetailFragment binding.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE); binding.detailSecondaryControlPanel.setVisibility(View.GONE); - final List videoStreams = removeNonUrlAndTorrentStreams( - new ArrayList<>(currentInfo.getVideoStreams())); - final List videoOnlyStreams = removeNonUrlAndTorrentStreams( - new ArrayList<>(currentInfo.getVideoOnlyStreams())); - videoStreamsForExternalPlayers = ListHelper.getSortedStreamVideosList(activity, - videoStreams, videoOnlyStreams, false, false); - updateProgressInfo(info); initThumbnailViews(info); showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, @@ -2155,13 +2147,21 @@ public final class VideoDetailFragment return; } + final List videoStreams = removeNonUrlAndTorrentStreams( + new ArrayList<>(currentInfo.getVideoStreams())); + final List videoOnlyStreams = removeNonUrlAndTorrentStreams( + new ArrayList<>(currentInfo.getVideoOnlyStreams())); + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); builder.setTitle(R.string.select_quality_external_players); - builder.setNegativeButton(android.R.string.cancel, null); builder.setNeutralButton(R.string.open_in_browser, (dialog, i) -> ShareUtils.openUrlInBrowser(requireActivity(), url)); + final List videoStreamsForExternalPlayers = + ListHelper.getSortedStreamVideosList(activity, videoStreams, videoOnlyStreams, + false, false); if (videoStreamsForExternalPlayers.isEmpty()) { builder.setMessage(R.string.no_video_streams_available_for_external_players); + builder.setPositiveButton(R.string.ok, null); } else { final int selectedVideoStreamIndexForExternalPlayers = ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers); @@ -2173,11 +2173,19 @@ public final class VideoDetailFragment } builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndexForExternalPlayers, - (dialog, i) -> { - dialog.dismiss(); - startOnExternalPlayer(activity, currentInfo, - videoStreamsForExternalPlayers.get(i)); - }); + null); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton(R.string.ok, (dialog, i) -> { + final int index = ((AlertDialog) dialog).getListView().getCheckedItemPosition(); + // We don't have to manage the index validity because if there is no stream + // available for external players, this code will be not executed and if there is + // no stream which matches the default resolution, 0 is returned by + // ListHelper.getDefaultResolutionIndex. + // The index cannot be outside the bounds of the list as its always between 0 and + // the list size - 1, . + startOnExternalPlayer(activity, currentInfo, + videoStreamsForExternalPlayers.get(index)); + }); } builder.show(); } From fbee310261b5ea42127febda0e64ba48df51e26a Mon Sep 17 00:00:00 2001 From: AudricV <74829229+AudricV@users.noreply.github.com> Date: Thu, 16 Jun 2022 11:13:39 +0200 Subject: [PATCH 03/20] Move SimpleCache creation in PlayerDataSource to avoid an IllegalStateException This IllegalStateException, almost not reproducible, indicates that another SimpleCache instance uses the cache folder, which was so trying to be created at least twice. Moving the SimpleCache creation in PlayerDataSource should avoid this exception. --- .../newpipe/player/helper/CacheFactory.java | 41 ++++++++----------- .../player/helper/PlayerDataSource.java | 34 +++++++++++++++ 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java index 47371533a..b09d8d643 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java @@ -1,12 +1,10 @@ package org.schabi.newpipe.player.helper; import android.content.Context; -import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.database.StandaloneDatabaseProvider; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSource; import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; @@ -14,31 +12,26 @@ import com.google.android.exoplayer2.upstream.FileDataSource; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.upstream.cache.CacheDataSink; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; -import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; import org.schabi.newpipe.player.datasource.YoutubeHttpDataSource; -import java.io.File; - /* package-private */ final class CacheFactory implements DataSource.Factory { - private static final String TAG = CacheFactory.class.getSimpleName(); - - private static final String CACHE_FOLDER_NAME = "exoplayer"; private static final int CACHE_FLAGS = CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; - private static SimpleCache cache; private final long maxFileSize; private final Context context; private final String userAgent; private final TransferListener transferListener; private final DataSource.Factory upstreamDataSourceFactory; + private final SimpleCache simpleCache; public static class Builder { private final Context context; private final String userAgent; private final TransferListener transferListener; private DataSource.Factory upstreamDataSourceFactory; + private SimpleCache simpleCache; Builder(@NonNull final Context context, @NonNull final String userAgent, @@ -53,8 +46,16 @@ import java.io.File; this.upstreamDataSourceFactory = upstreamDataSourceFactory; } + public void setSimpleCache(@NonNull final SimpleCache simpleCache) { + this.simpleCache = simpleCache; + } + public CacheFactory build() { - return new CacheFactory(context, userAgent, transferListener, + if (simpleCache == null) { + throw new IllegalStateException("No SimpleCache instance has been specified. " + + "Please specify one with setSimpleCache"); + } + return new CacheFactory(context, userAgent, transferListener, simpleCache, upstreamDataSourceFactory); } } @@ -62,25 +63,14 @@ import java.io.File; private CacheFactory(@NonNull final Context context, @NonNull final String userAgent, @NonNull final TransferListener transferListener, + @NonNull final SimpleCache simpleCache, @Nullable final DataSource.Factory upstreamDataSourceFactory) { this.context = context; this.userAgent = userAgent; this.transferListener = transferListener; + this.simpleCache = simpleCache; this.upstreamDataSourceFactory = upstreamDataSourceFactory; - final File cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); - if (!cacheDir.exists()) { - //noinspection ResultOfMethodCallIgnored - cacheDir.mkdir(); - } - - if (cache == null) { - final LeastRecentlyUsedCacheEvictor evictor - = new LeastRecentlyUsedCacheEvictor(PlayerHelper.getPreferredCacheSize()); - cache = new SimpleCache(cacheDir, evictor, new StandaloneDatabaseProvider(context)); - Log.d(TAG, "initExoPlayerCache: cacheDir = " + cacheDir.getAbsolutePath()); - } - maxFileSize = PlayerHelper.getPreferredFileSize(); } @@ -112,7 +102,8 @@ import java.io.File; .createDataSource(); final FileDataSource fileSource = new FileDataSource(); - final CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize); - return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null); + final CacheDataSink dataSink = new CacheDataSink(simpleCache, maxFileSize); + return new CacheDataSource(simpleCache, dataSource, fileSource, dataSink, CACHE_FLAGS, + null); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java index 61d8baffc..68c9223c9 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -1,10 +1,12 @@ package org.schabi.newpipe.player.helper; import android.content.Context; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.database.StandaloneDatabaseProvider; import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.source.SingleSampleMediaSource; import com.google.android.exoplayer2.source.dash.DashMediaSource; @@ -18,12 +20,16 @@ import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSource; import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; +import com.google.android.exoplayer2.upstream.cache.SimpleCache; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator; import org.schabi.newpipe.player.datasource.YoutubeHttpDataSource; +import java.io.File; + public class PlayerDataSource { public static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000; @@ -43,6 +49,18 @@ public class PlayerDataSource { */ private static final int MAXIMUM_SIZE_CACHED_GENERATED_MANIFESTS_PER_CACHE = 500; + /** + * The folder name in which the ExoPlayer cache will be written. + */ + private static final String CACHE_FOLDER_NAME = "exoplayer"; + + /** + * The {@link SimpleCache} instance which will be used to build + * {@link com.google.android.exoplayer2.upstream.cache.CacheDataSource}s instances (with + * {@link CacheFactory}). + */ + private static SimpleCache cache; + private final int continueLoadingCheckIntervalBytes; private final CacheFactory.Builder cacheDataSourceFactoryBuilder; private final DataSource.Factory cachelessDataSourceFactory; @@ -51,8 +69,24 @@ public class PlayerDataSource { @NonNull final String userAgent, @NonNull final TransferListener transferListener) { continueLoadingCheckIntervalBytes = PlayerHelper.getProgressiveLoadIntervalBytes(context); + final File cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); + if (!cacheDir.exists()) { + //noinspection ResultOfMethodCallIgnored + cacheDir.mkdir(); + } + + if (cache == null) { + final LeastRecentlyUsedCacheEvictor evictor + = new LeastRecentlyUsedCacheEvictor(PlayerHelper.getPreferredCacheSize()); + cache = new SimpleCache(cacheDir, evictor, new StandaloneDatabaseProvider(context)); + Log.d(PlayerDataSource.class.getSimpleName(), "initExoPlayerCache: cacheDir = " + + cacheDir.getAbsolutePath()); + } + cacheDataSourceFactoryBuilder = new CacheFactory.Builder(context, userAgent, transferListener); + cacheDataSourceFactoryBuilder.setSimpleCache(cache); + cachelessDataSourceFactory = new DefaultDataSource.Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(userAgent)) .setTransferListener(transferListener); From ef20d9b91a7cca87d2dc1f4860d1ee3b80f98178 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sun, 8 May 2022 14:39:24 +0200 Subject: [PATCH 04/20] Move stream's cache key generation in PlaybackResolver and improve PlaybackResolver's code --- .../newpipe/player/helper/PlayerHelper.java | 50 ------------ .../resolver/AudioPlaybackResolver.java | 3 +- .../player/resolver/PlaybackResolver.java | 76 +++++++++++++++++++ .../resolver/VideoPlaybackResolver.java | 4 +- 4 files changed, 79 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java index d924f9314..2131861bf 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java @@ -3,8 +3,6 @@ package org.schabi.newpipe.player.helper; import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF; import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; -import static org.schabi.newpipe.extractor.stream.AudioStream.UNKNOWN_BITRATE; -import static org.schabi.newpipe.extractor.stream.VideoStream.RESOLUTION_UNKNOWN; import static org.schabi.newpipe.player.Player.IDLE_WINDOW_FLAGS; import static org.schabi.newpipe.player.Player.PLAYER_TYPE; import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_ALWAYS; @@ -47,11 +45,9 @@ import com.google.android.exoplayer2.util.MimeTypes; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; 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.StreamInfoItem; import org.schabi.newpipe.extractor.stream.SubtitlesStream; -import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.extractor.utils.Utils; import org.schabi.newpipe.player.MainPlayer; import org.schabi.newpipe.player.Player; @@ -197,52 +193,6 @@ public final class PlayerHelper { } } - @NonNull - public static String cacheKeyOf(@NonNull final StreamInfo info, - @NonNull final VideoStream videoStream) { - String cacheKey = info.getUrl() + " " + videoStream.getId(); - - final String resolution = videoStream.getResolution(); - final MediaFormat mediaFormat = videoStream.getFormat(); - if (resolution.equals(RESOLUTION_UNKNOWN) && mediaFormat == null) { - // The hash code is only used in the cache key in the case when the resolution and the - // media format are unknown - cacheKey += " " + videoStream.hashCode(); - } else { - if (mediaFormat != null) { - cacheKey += " " + videoStream.getFormat().getName(); - } - if (!resolution.equals(RESOLUTION_UNKNOWN)) { - cacheKey += " " + resolution; - } - } - - return cacheKey; - } - - @NonNull - public static String cacheKeyOf(@NonNull final StreamInfo info, - @NonNull final AudioStream audioStream) { - String cacheKey = info.getUrl() + " " + audioStream.getId(); - - final int averageBitrate = audioStream.getAverageBitrate(); - final MediaFormat mediaFormat = audioStream.getFormat(); - if (averageBitrate == UNKNOWN_BITRATE && mediaFormat == null) { - // The hash code is only used in the cache key in the case when the resolution and the - // media format are unknown - cacheKey += " " + audioStream.hashCode(); - } else { - if (mediaFormat != null) { - cacheKey += " " + audioStream.getFormat().getName(); - } - if (averageBitrate != UNKNOWN_BITRATE) { - cacheKey += " " + averageBitrate; - } - } - - return cacheKey; - } - /** * Given a {@link StreamInfo} and the existing queue items, * provide the {@link SinglePlayQueue} consisting of the next video for auto queueing. 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 765475b2f..85c15faf1 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 @@ -13,7 +13,6 @@ import com.google.android.exoplayer2.source.MediaSource; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.player.helper.PlayerDataSource; -import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.ListHelper; @@ -57,7 +56,7 @@ public class AudioPlaybackResolver implements PlaybackResolver { try { return PlaybackResolver.buildMediaSource( - dataSource, audio, info, PlayerHelper.cacheKeyOf(info, audio), tag); + dataSource, audio, info, PlaybackResolver.cacheKeyOf(info, audio), tag); } catch (final IOException e) { Log.e(TAG, "Unable to create audio source:", e); return null; 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 4c1b67dfc..3cbca7628 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 @@ -1,5 +1,7 @@ package org.schabi.newpipe.player.resolver; +import static org.schabi.newpipe.extractor.stream.AudioStream.UNKNOWN_BITRATE; +import static org.schabi.newpipe.extractor.stream.VideoStream.RESOLUTION_UNKNOWN; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import static org.schabi.newpipe.player.helper.PlayerDataSource.LIVE_STREAM_EDGE_GAP_MILLIS; @@ -20,6 +22,7 @@ import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifestParser; +import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.services.youtube.ItagItem; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.CreationException; @@ -49,6 +52,79 @@ import java.util.Objects; public interface PlaybackResolver extends Resolver { String TAG = PlaybackResolver.class.getSimpleName(); + @NonNull + private static StringBuilder commonCacheKeyOf(@NonNull final StreamInfo info, + @NonNull final Stream stream, + final boolean resolutionOrBitrateUnknown) { + // stream info service id + final StringBuilder cacheKey = new StringBuilder(info.getServiceId()); + + // stream info id + cacheKey.append(" "); + cacheKey.append(info.getId()); + + // stream id (even if unknown) + cacheKey.append(" "); + cacheKey.append(stream.getId()); + + // mediaFormat (if not null) + final MediaFormat mediaFormat = stream.getFormat(); + if (mediaFormat != null) { + cacheKey.append(" "); + cacheKey.append(mediaFormat.getName()); + } + + // content (only if other information is missing) + // If the media format and the resolution/bitrate are both missing, then we don't have + // enough information to distinguish this stream from other streams. + // So, only in that case, we use the content (i.e. url or manifest) to differentiate + // between streams. + // Note that if the content were used even when other information is present, then two + // streams with the same stats but with different contents (e.g. because the url was + // refreshed) will be considered different (i.e. with a different cacheKey), making the + // cache useless. + if (resolutionOrBitrateUnknown && mediaFormat == null) { + cacheKey.append(" "); + Objects.hash(stream.getContent(), stream.getManifestUrl()); + } + + return cacheKey; + } + + @NonNull + static String cacheKeyOf(@NonNull final StreamInfo info, + @NonNull final VideoStream videoStream) { + final boolean resolutionUnknown = videoStream.getResolution().equals(RESOLUTION_UNKNOWN); + final StringBuilder cacheKey = commonCacheKeyOf(info, videoStream, resolutionUnknown); + + // resolution (if known) + if (!resolutionUnknown) { + cacheKey.append(" "); + cacheKey.append(videoStream.getResolution()); + } + + // isVideoOnly + cacheKey.append(" "); + cacheKey.append(videoStream.isVideoOnly()); + + return cacheKey.toString(); + } + + @NonNull + static String cacheKeyOf(@NonNull final StreamInfo info, + @NonNull final AudioStream audioStream) { + final boolean averageBitrateUnknown = audioStream.getAverageBitrate() == UNKNOWN_BITRATE; + final StringBuilder cacheKey = commonCacheKeyOf(info, audioStream, averageBitrateUnknown); + + // averageBitrate (if known) + if (!averageBitrateUnknown) { + cacheKey.append(" "); + cacheKey.append(audioStream.getAverageBitrate()); + } + + return cacheKey.toString(); + } + @Nullable static MediaSource maybeBuildLiveMediaSource(@NonNull final PlayerDataSource dataSource, @NonNull final StreamInfo info) { 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 24ca2e63a..317c49fc9 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 @@ -95,7 +95,7 @@ public class VideoPlaybackResolver implements PlaybackResolver { if (video != null) { try { final MediaSource streamSource = PlaybackResolver.buildMediaSource( - dataSource, video, info, PlayerHelper.cacheKeyOf(info, video), tag); + dataSource, video, info, PlaybackResolver.cacheKeyOf(info, video), tag); mediaSources.add(streamSource); } catch (final IOException e) { Log.e(TAG, "Unable to create video source:", e); @@ -114,7 +114,7 @@ public class VideoPlaybackResolver implements PlaybackResolver { if (audio != null && (video == null || video.isVideoOnly())) { try { final MediaSource audioSource = PlaybackResolver.buildMediaSource( - dataSource, audio, info, PlayerHelper.cacheKeyOf(info, audio), tag); + dataSource, audio, info, PlaybackResolver.cacheKeyOf(info, audio), tag); mediaSources.add(audioSource); streamSourceType = SourceType.VIDEO_WITH_SEPARATED_AUDIO; } catch (final IOException e) { From 7ce2250d8523dd482f8d8df184ac38356fa48c17 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 21 May 2022 11:12:37 +0200 Subject: [PATCH 05/20] Improve CacheFactory and PlayerDataSource code --- .../newpipe/player/helper/CacheFactory.java | 85 ++--------- .../player/helper/PlayerDataSource.java | 140 ++++++++++-------- 2 files changed, 93 insertions(+), 132 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java index b09d8d643..d189616d1 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java @@ -3,107 +3,44 @@ package org.schabi.newpipe.player.helper; import android.content.Context; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSource; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; import com.google.android.exoplayer2.upstream.FileDataSource; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.upstream.cache.CacheDataSink; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.SimpleCache; -import org.schabi.newpipe.player.datasource.YoutubeHttpDataSource; - -/* package-private */ final class CacheFactory implements DataSource.Factory { +final class CacheFactory implements DataSource.Factory { private static final int CACHE_FLAGS = CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; - private final long maxFileSize; private final Context context; - private final String userAgent; private final TransferListener transferListener; private final DataSource.Factory upstreamDataSourceFactory; - private final SimpleCache simpleCache; + private final SimpleCache cache; - public static class Builder { - private final Context context; - private final String userAgent; - private final TransferListener transferListener; - private DataSource.Factory upstreamDataSourceFactory; - private SimpleCache simpleCache; - - Builder(@NonNull final Context context, - @NonNull final String userAgent, - @NonNull final TransferListener transferListener) { - this.context = context; - this.userAgent = userAgent; - this.transferListener = transferListener; - } - - public void setUpstreamDataSourceFactory( - @Nullable final DataSource.Factory upstreamDataSourceFactory) { - this.upstreamDataSourceFactory = upstreamDataSourceFactory; - } - - public void setSimpleCache(@NonNull final SimpleCache simpleCache) { - this.simpleCache = simpleCache; - } - - public CacheFactory build() { - if (simpleCache == null) { - throw new IllegalStateException("No SimpleCache instance has been specified. " - + "Please specify one with setSimpleCache"); - } - return new CacheFactory(context, userAgent, transferListener, simpleCache, - upstreamDataSourceFactory); - } - } - - private CacheFactory(@NonNull final Context context, - @NonNull final String userAgent, - @NonNull final TransferListener transferListener, - @NonNull final SimpleCache simpleCache, - @Nullable final DataSource.Factory upstreamDataSourceFactory) { + CacheFactory(final Context context, + final TransferListener transferListener, + final SimpleCache cache, + final DataSource.Factory upstreamDataSourceFactory) { this.context = context; - this.userAgent = userAgent; this.transferListener = transferListener; - this.simpleCache = simpleCache; + this.cache = cache; this.upstreamDataSourceFactory = upstreamDataSourceFactory; - - maxFileSize = PlayerHelper.getPreferredFileSize(); } @NonNull @Override public DataSource createDataSource() { - - final DataSource.Factory upstreamDataSourceFactoryToUse; - if (upstreamDataSourceFactory == null) { - upstreamDataSourceFactoryToUse = new DefaultHttpDataSource.Factory() - .setUserAgent(userAgent); - } else { - if (upstreamDataSourceFactory instanceof DefaultHttpDataSource.Factory) { - upstreamDataSourceFactoryToUse = - ((DefaultHttpDataSource.Factory) upstreamDataSourceFactory) - .setUserAgent(userAgent); - } else if (upstreamDataSourceFactory instanceof YoutubeHttpDataSource.Factory) { - upstreamDataSourceFactoryToUse = - ((YoutubeHttpDataSource.Factory) upstreamDataSourceFactory) - .setUserAgentForNonMobileStreams(userAgent); - } else { - upstreamDataSourceFactoryToUse = upstreamDataSourceFactory; - } - } - final DefaultDataSource dataSource = new DefaultDataSource.Factory(context, - upstreamDataSourceFactoryToUse) + upstreamDataSourceFactory) .setTransferListener(transferListener) .createDataSource(); final FileDataSource fileSource = new FileDataSource(); - final CacheDataSink dataSink = new CacheDataSink(simpleCache, maxFileSize); - return new CacheDataSource(simpleCache, dataSource, fileSource, dataSink, CACHE_FLAGS, - null); + final CacheDataSink dataSink + = new CacheDataSink(cache, PlayerHelper.getPreferredFileSize()); + return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java index 68c9223c9..f732e834f 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -3,7 +3,6 @@ package org.schabi.newpipe.player.helper; import android.content.Context; import android.util.Log; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.android.exoplayer2.database.StandaloneDatabaseProvider; @@ -31,6 +30,7 @@ import org.schabi.newpipe.player.datasource.YoutubeHttpDataSource; import java.io.File; public class PlayerDataSource { + public static final String TAG = PlayerDataSource.class.getSimpleName(); public static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000; @@ -47,7 +47,7 @@ public class PlayerDataSource { * {@link YoutubeProgressiveDashManifestCreator}, {@link YoutubeOtfDashManifestCreator} and * {@link YoutubePostLiveStreamDvrDashManifestCreator}. */ - private static final int MAXIMUM_SIZE_CACHED_GENERATED_MANIFESTS_PER_CACHE = 500; + private static final int MAX_MANIFEST_CACHE_SIZE = 500; /** * The folder name in which the ExoPlayer cache will be written. @@ -61,44 +61,53 @@ public class PlayerDataSource { */ private static SimpleCache cache; - private final int continueLoadingCheckIntervalBytes; - private final CacheFactory.Builder cacheDataSourceFactoryBuilder; + + private final int progressiveLoadIntervalBytes; + + // Generic Data Source Factories (without or with cache) private final DataSource.Factory cachelessDataSourceFactory; + private final CacheFactory cacheDataSourceFactory; - public PlayerDataSource(@NonNull final Context context, - @NonNull final String userAgent, - @NonNull final TransferListener transferListener) { - continueLoadingCheckIntervalBytes = PlayerHelper.getProgressiveLoadIntervalBytes(context); - final File cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); - if (!cacheDir.exists()) { - //noinspection ResultOfMethodCallIgnored - cacheDir.mkdir(); - } + // YouTube-specific Data Source Factories (with cache) + // They use YoutubeHttpDataSource.Factory, with different parameters each + private final CacheFactory ytHlsCacheDataSourceFactory; + private final CacheFactory ytDashCacheDataSourceFactory; + private final CacheFactory ytProgressiveDashCacheDataSourceFactory; - if (cache == null) { - final LeastRecentlyUsedCacheEvictor evictor - = new LeastRecentlyUsedCacheEvictor(PlayerHelper.getPreferredCacheSize()); - cache = new SimpleCache(cacheDir, evictor, new StandaloneDatabaseProvider(context)); - Log.d(PlayerDataSource.class.getSimpleName(), "initExoPlayerCache: cacheDir = " - + cacheDir.getAbsolutePath()); - } - cacheDataSourceFactoryBuilder = new CacheFactory.Builder(context, userAgent, - transferListener); - cacheDataSourceFactoryBuilder.setSimpleCache(cache); + public PlayerDataSource(final Context context, + final String userAgent, + final TransferListener transferListener) { + progressiveLoadIntervalBytes = PlayerHelper.getProgressiveLoadIntervalBytes(context); + + // make sure the static cache was created: needed by CacheFactories below + instantiateCacheIfNeeded(context); + + // generic data source factories use DefaultHttpDataSource.Factory cachelessDataSourceFactory = new DefaultDataSource.Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(userAgent)) .setTransferListener(transferListener); + cacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + new DefaultHttpDataSource.Factory().setUserAgent(userAgent)); - YoutubeProgressiveDashManifestCreator.getCache().setMaximumSize( - MAXIMUM_SIZE_CACHED_GENERATED_MANIFESTS_PER_CACHE); - YoutubeOtfDashManifestCreator.getCache().setMaximumSize( - MAXIMUM_SIZE_CACHED_GENERATED_MANIFESTS_PER_CACHE); + // YouTube-specific data source factories use getYoutubeHttpDataSourceFactory() + ytHlsCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + getYoutubeHttpDataSourceFactory(false, false, userAgent)); + ytDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + getYoutubeHttpDataSourceFactory(true, true, userAgent)); + ytProgressiveDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + getYoutubeHttpDataSourceFactory(false, true, userAgent)); + + // set the maximum size to manifest creators + YoutubeProgressiveDashManifestCreator.getCache().setMaximumSize(MAX_MANIFEST_CACHE_SIZE); + YoutubeOtfDashManifestCreator.getCache().setMaximumSize(MAX_MANIFEST_CACHE_SIZE); YoutubePostLiveStreamDvrDashManifestCreator.getCache().setMaximumSize( - MAXIMUM_SIZE_CACHED_GENERATED_MANIFESTS_PER_CACHE); + MAX_MANIFEST_CACHE_SIZE); } + + //region Live media source factories public SsMediaSource.Factory getLiveSsMediaSourceFactory() { return getSSMediaSourceFactory().setLivePresentationDelayMs(LIVE_STREAM_EDGE_GAP_MILLIS); } @@ -118,26 +127,26 @@ public class PlayerDataSource { getDefaultDashChunkSourceFactory(cachelessDataSourceFactory), cachelessDataSourceFactory); } + //endregion + + //region Generic media source factories public HlsMediaSource.Factory getHlsMediaSourceFactory( @Nullable final HlsPlaylistParserFactory hlsPlaylistParserFactory) { - final HlsMediaSource.Factory factory = new HlsMediaSource.Factory( - cacheDataSourceFactoryBuilder.build()); - if (hlsPlaylistParserFactory != null) { - factory.setPlaylistParserFactory(hlsPlaylistParserFactory); - } + final HlsMediaSource.Factory factory = new HlsMediaSource.Factory(cacheDataSourceFactory); + factory.setPlaylistParserFactory(hlsPlaylistParserFactory); return factory; } public DashMediaSource.Factory getDashMediaSourceFactory() { return new DashMediaSource.Factory( - getDefaultDashChunkSourceFactory(cacheDataSourceFactoryBuilder.build()), - cacheDataSourceFactoryBuilder.build()); + getDefaultDashChunkSourceFactory(cacheDataSourceFactory), + cacheDataSourceFactory); } public ProgressiveMediaSource.Factory getProgressiveMediaSourceFactory() { - return new ProgressiveMediaSource.Factory(cacheDataSourceFactoryBuilder.build()) - .setContinueLoadingCheckIntervalBytes(continueLoadingCheckIntervalBytes); + return new ProgressiveMediaSource.Factory(cacheDataSourceFactory) + .setContinueLoadingCheckIntervalBytes(progressiveLoadIntervalBytes); } public SsMediaSource.Factory getSSMediaSourceFactory() { @@ -147,42 +156,57 @@ public class PlayerDataSource { } public SingleSampleMediaSource.Factory getSingleSampleMediaSourceFactory() { - return new SingleSampleMediaSource.Factory(cacheDataSourceFactoryBuilder.build()); + return new SingleSampleMediaSource.Factory(cacheDataSourceFactory); + } + //endregion + + + //region YouTube media source factories + public HlsMediaSource.Factory getYoutubeHlsMediaSourceFactory() { + return new HlsMediaSource.Factory(ytHlsCacheDataSourceFactory); } public DashMediaSource.Factory getYoutubeDashMediaSourceFactory() { - cacheDataSourceFactoryBuilder.setUpstreamDataSourceFactory( - getYoutubeHttpDataSourceFactory(true, true)); return new DashMediaSource.Factory( - getDefaultDashChunkSourceFactory(cacheDataSourceFactoryBuilder.build()), - cacheDataSourceFactoryBuilder.build()); - } - - public HlsMediaSource.Factory getYoutubeHlsMediaSourceFactory() { - cacheDataSourceFactoryBuilder.setUpstreamDataSourceFactory( - getYoutubeHttpDataSourceFactory(false, false)); - return new HlsMediaSource.Factory(cacheDataSourceFactoryBuilder.build()); + getDefaultDashChunkSourceFactory(ytDashCacheDataSourceFactory), + ytDashCacheDataSourceFactory); } public ProgressiveMediaSource.Factory getYoutubeProgressiveMediaSourceFactory() { - cacheDataSourceFactoryBuilder.setUpstreamDataSourceFactory( - getYoutubeHttpDataSourceFactory(false, true)); - return new ProgressiveMediaSource.Factory(cacheDataSourceFactoryBuilder.build()) - .setContinueLoadingCheckIntervalBytes(continueLoadingCheckIntervalBytes); + return new ProgressiveMediaSource.Factory(ytProgressiveDashCacheDataSourceFactory) + .setContinueLoadingCheckIntervalBytes(progressiveLoadIntervalBytes); } + //endregion - @NonNull - private DefaultDashChunkSource.Factory getDefaultDashChunkSourceFactory( + + //region Static methods + private static DefaultDashChunkSource.Factory getDefaultDashChunkSourceFactory( final DataSource.Factory dataSourceFactory) { return new DefaultDashChunkSource.Factory(dataSourceFactory); } - @NonNull - private YoutubeHttpDataSource.Factory getYoutubeHttpDataSourceFactory( + private static YoutubeHttpDataSource.Factory getYoutubeHttpDataSourceFactory( final boolean rangeParameterEnabled, - final boolean rnParameterEnabled) { + final boolean rnParameterEnabled, + final String userAgent) { return new YoutubeHttpDataSource.Factory() .setRangeParameterEnabled(rangeParameterEnabled) - .setRnParameterEnabled(rnParameterEnabled); + .setRnParameterEnabled(rnParameterEnabled) + .setUserAgentForNonMobileStreams(userAgent); } + + private static void instantiateCacheIfNeeded(final Context context) { + if (cache == null) { + final File cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); + Log.d(TAG, "instantiateCacheIfNeeded: cacheDir = " + cacheDir.getAbsolutePath()); + if (!cacheDir.exists() && !cacheDir.mkdir()) { + Log.w(TAG, "instantiateCacheIfNeeded: could not create cache dir"); + } + + final LeastRecentlyUsedCacheEvictor evictor + = new LeastRecentlyUsedCacheEvictor(PlayerHelper.getPreferredCacheSize()); + cache = new SimpleCache(cacheDir, evictor, new StandaloneDatabaseProvider(context)); + } + } + //endregion } From fa46b7bf85caa29b94d9194e41a2ae2ace4db2c8 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 21 May 2022 11:27:14 +0200 Subject: [PATCH 06/20] Add comments and use downloader user agent in YT data source YoutubeHttpDataSource --- .../datasource/YoutubeHttpDataSource.java | 51 +++++++------------ .../player/helper/PlayerDataSource.java | 12 ++--- 2 files changed, 22 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java index acf9c6a47..c9abe65f6 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java @@ -44,6 +44,8 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.Sets; import com.google.common.net.HttpHeaders; +import org.schabi.newpipe.DownloaderImpl; + import java.io.IOException; import java.io.InputStream; import java.io.InterruptedIOException; @@ -69,6 +71,10 @@ import java.util.zip.GZIPInputStream; * (only where it's relevant) and also more parameters, such as {@code rn} and replaces the use of * the {@code Range} header by the corresponding parameter ({@code range}), if enabled. *

+ * + * There are many unused methods in this class because everything was copied from {@link + * com.google.android.exoplayer2.upstream.DefaultHttpDataSource} with as little changes as possible. + * SonarQube warnings were also suppressed for the same reason. */ @SuppressWarnings({"squid:S3011", "squid:S4738"}) public final class YoutubeHttpDataSource extends BaseDataSource implements HttpDataSource { @@ -89,8 +95,6 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD private boolean allowCrossProtocolRedirects; private boolean keepPostFor302Redirects; - @Nullable - private String userAgentForNonMobileStreams; private boolean rangeParameterEnabled; private boolean rnParameterEnabled; @@ -111,25 +115,6 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD return this; } - /** - * Sets the user agent that will be used, only for non-mobile streams. - * - *

- * The default is {@code null}, which causes the default user agent of the underlying - * platform to be used. - *

- * - * @param userAgentForNonMobileStreamsValue The user agent that will be used for non-mobile - * streams, or {@code null} to use the default - * user agent of the underlying platform. - * @return This factory. - */ - public Factory setUserAgentForNonMobileStreams( - @Nullable final String userAgentForNonMobileStreamsValue) { - userAgentForNonMobileStreams = userAgentForNonMobileStreamsValue; - return this; - } - /** * Sets the connect timeout, in milliseconds. * @@ -262,7 +247,6 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD @Override public YoutubeHttpDataSource createDataSource() { final YoutubeHttpDataSource dataSource = new YoutubeHttpDataSource( - userAgentForNonMobileStreams, connectTimeoutMs, readTimeoutMs, allowCrossProtocolRedirects, @@ -294,8 +278,6 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD private final int connectTimeoutMillis; private final int readTimeoutMillis; @Nullable - private final String userAgent; - @Nullable private final RequestProperties defaultRequestProperties; private final RequestProperties requestProperties; private final boolean keepPostFor302Redirects; @@ -316,8 +298,7 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD private long requestNumber; @SuppressWarnings("checkstyle:ParameterNumber") - private YoutubeHttpDataSource(@Nullable final String userAgent, - final int connectTimeoutMillis, + private YoutubeHttpDataSource(final int connectTimeoutMillis, final int readTimeoutMillis, final boolean allowCrossProtocolRedirects, final boolean rangeParameterEnabled, @@ -326,7 +307,6 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD @Nullable final Predicate contentTypePredicate, final boolean keepPostFor302Redirects) { super(true); - this.userAgent = userAgent; this.connectTimeoutMillis = connectTimeoutMillis; this.readTimeoutMillis = readTimeoutMillis; this.allowCrossProtocolRedirects = allowCrossProtocolRedirects; @@ -637,6 +617,8 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD final boolean allowGzip, final boolean followRedirects, final Map requestParameters) throws IOException { + // This is the method that contains breaking changes with respect to DefaultHttpDataSource! + String requestUrl = url.toString(); // Don't add the request number parameter if it has been already added (for instance in @@ -687,18 +669,19 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD httpURLConnection.setRequestProperty(HttpHeaders.TE, "trailers"); - final boolean isAnAndroidStreamingUrl = isAndroidStreamingUrl(requestUrl); - final boolean isAnIosStreamingUrl = isIosStreamingUrl(requestUrl); - if (isAnAndroidStreamingUrl) { + final boolean isAndroidStreamingUrl = isAndroidStreamingUrl(requestUrl); + final boolean isIosStreamingUrl = isIosStreamingUrl(requestUrl); + if (isAndroidStreamingUrl) { // Improvement which may be done: find the content country used to request YouTube // contents to add it in the user agent instead of using the default httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, getAndroidUserAgent(null)); - } else if (isAnIosStreamingUrl) { + } else if (isIosStreamingUrl) { httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, getIosUserAgent(null)); - } else if (userAgent != null) { - httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, userAgent); + } else { + // non-mobile user agent + httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, DownloaderImpl.USER_AGENT); } httpURLConnection.setRequestProperty(HttpHeaders.ACCEPT_ENCODING, @@ -707,7 +690,7 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD httpURLConnection.setDoOutput(httpBody != null); // Mobile clients uses POST requests to fetch contents - httpURLConnection.setRequestMethod(isAnAndroidStreamingUrl || isAnIosStreamingUrl + httpURLConnection.setRequestMethod(isAndroidStreamingUrl || isIosStreamingUrl ? "POST" : DataSpec.getStringForHttpMethod(httpMethod)); diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java index f732e834f..8b7689bac 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -93,11 +93,11 @@ public class PlayerDataSource { // YouTube-specific data source factories use getYoutubeHttpDataSourceFactory() ytHlsCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, - getYoutubeHttpDataSourceFactory(false, false, userAgent)); + getYoutubeHttpDataSourceFactory(false, false)); ytDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, - getYoutubeHttpDataSourceFactory(true, true, userAgent)); + getYoutubeHttpDataSourceFactory(true, true)); ytProgressiveDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, - getYoutubeHttpDataSourceFactory(false, true, userAgent)); + getYoutubeHttpDataSourceFactory(false, true)); // set the maximum size to manifest creators YoutubeProgressiveDashManifestCreator.getCache().setMaximumSize(MAX_MANIFEST_CACHE_SIZE); @@ -187,12 +187,10 @@ public class PlayerDataSource { private static YoutubeHttpDataSource.Factory getYoutubeHttpDataSourceFactory( final boolean rangeParameterEnabled, - final boolean rnParameterEnabled, - final String userAgent) { + final boolean rnParameterEnabled) { return new YoutubeHttpDataSource.Factory() .setRangeParameterEnabled(rangeParameterEnabled) - .setRnParameterEnabled(rnParameterEnabled) - .setUserAgentForNonMobileStreams(userAgent); + .setRnParameterEnabled(rnParameterEnabled); } private static void instantiateCacheIfNeeded(final Context context) { From 8445c381c5eba5d8b10077d0d0eea3e422b48964 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 21 May 2022 11:29:19 +0200 Subject: [PATCH 07/20] Use DownloaderImpl.USER_AGENT directly instead of passing it as a parameter --- app/src/main/java/org/schabi/newpipe/player/Player.java | 3 +-- .../org/schabi/newpipe/player/helper/PlayerDataSource.java | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) 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 d2aed7623..316b72a09 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -150,7 +150,6 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.squareup.picasso.Picasso; import com.squareup.picasso.Target; -import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.database.stream.model.StreamEntity; @@ -429,7 +428,7 @@ public final class Player implements setupBroadcastReceiver(); trackSelector = new DefaultTrackSelector(context, PlayerHelper.getQualitySelector()); - final PlayerDataSource dataSource = new PlayerDataSource(context, DownloaderImpl.USER_AGENT, + final PlayerDataSource dataSource = new PlayerDataSource(context, new DefaultBandwidthMeter.Builder(context).build()); loadController = new LoadController(); renderFactory = new DefaultRenderersFactory(context); diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java index 8b7689bac..8cb423b51 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -22,6 +22,7 @@ import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; +import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator; @@ -76,7 +77,6 @@ public class PlayerDataSource { public PlayerDataSource(final Context context, - final String userAgent, final TransferListener transferListener) { progressiveLoadIntervalBytes = PlayerHelper.getProgressiveLoadIntervalBytes(context); @@ -86,10 +86,10 @@ public class PlayerDataSource { // generic data source factories use DefaultHttpDataSource.Factory cachelessDataSourceFactory = new DefaultDataSource.Factory(context, - new DefaultHttpDataSource.Factory().setUserAgent(userAgent)) + new DefaultHttpDataSource.Factory().setUserAgent(DownloaderImpl.USER_AGENT)) .setTransferListener(transferListener); cacheDataSourceFactory = new CacheFactory(context, transferListener, cache, - new DefaultHttpDataSource.Factory().setUserAgent(userAgent)); + new DefaultHttpDataSource.Factory().setUserAgent(DownloaderImpl.USER_AGENT)); // YouTube-specific data source factories use getYoutubeHttpDataSourceFactory() ytHlsCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, From e5ffa2aa096697bac6e210fb7657ac3e79f691a0 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 21 May 2022 12:00:02 +0200 Subject: [PATCH 08/20] Add comments to PlaybackResolver and remove useless @NonNull --- .../player/resolver/PlaybackResolver.java | 183 ++++++++++-------- 1 file changed, 105 insertions(+), 78 deletions(-) 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 3cbca7628..3d11f0e44 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 @@ -8,6 +8,8 @@ import static org.schabi.newpipe.player.helper.PlayerDataSource.LIVE_STREAM_EDGE import android.net.Uri; import android.util.Log; +import androidx.annotation.Nullable; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.source.MediaSource; @@ -41,20 +43,23 @@ import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.StreamTypeUtil; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Objects; +/** + * This interface is just a shorthand for {@link Resolver} with {@link StreamInfo} as source and + * {@link MediaSource} as product. It contains many static methods that can be used by classes + * implementing this interface, and nothing else. + */ public interface PlaybackResolver extends Resolver { String TAG = PlaybackResolver.class.getSimpleName(); - @NonNull - private static StringBuilder commonCacheKeyOf(@NonNull final StreamInfo info, - @NonNull final Stream stream, + + //region Cache key generation + private static StringBuilder commonCacheKeyOf(final StreamInfo info, + final Stream stream, final boolean resolutionOrBitrateUnknown) { // stream info service id final StringBuilder cacheKey = new StringBuilder(info.getServiceId()); @@ -91,9 +96,20 @@ public interface PlaybackResolver extends Resolver { return cacheKey; } - @NonNull - static String cacheKeyOf(@NonNull final StreamInfo info, - @NonNull final VideoStream videoStream) { + /** + * Builds the cache key of a video stream. A cache key is unique to the features of the + * provided video stream, and when possible independent of transient parameters (such as + * the url of the stream). This ensures that there are no conflicts, but also that the cache is + * used as much as possible: the same cache should be used for two streams which have the same + * features but e.g. a different url, since the url might have been reloaded in the meantime, + * but the stream actually referenced by the url is still the same. + * + * @param info the stream info, to distinguish between streams with the same features but coming + * from different stream infos + * @param videoStream the video stream for which the cache key should be created + * @return a key to be used to store the cache of the provided video stream + */ + static String cacheKeyOf(final StreamInfo info, final VideoStream videoStream) { final boolean resolutionUnknown = videoStream.getResolution().equals(RESOLUTION_UNKNOWN); final StringBuilder cacheKey = commonCacheKeyOf(info, videoStream, resolutionUnknown); @@ -110,9 +126,20 @@ public interface PlaybackResolver extends Resolver { return cacheKey.toString(); } - @NonNull - static String cacheKeyOf(@NonNull final StreamInfo info, - @NonNull final AudioStream audioStream) { + /** + * Builds the cache key of an audio stream. A cache key is unique to the features of the + * provided audio stream, and when possible independent of transient parameters (such as + * the url of the stream). This ensures that there are no conflicts, but also that the cache is + * used as much as possible: the same cache should be used for two streams which have the same + * features but e.g. a different url, since the url might have been reloaded in the meantime, + * but the stream actually referenced by the url is still the same. + * + * @param info the stream info, to distinguish between streams with the same features but coming + * from different stream infos + * @param audioStream the audio stream for which the cache key should be created + * @return a key to be used to store the cache of the provided audio stream + */ + static String cacheKeyOf(final StreamInfo info, final AudioStream audioStream) { final boolean averageBitrateUnknown = audioStream.getAverageBitrate() == UNKNOWN_BITRATE; final StringBuilder cacheKey = commonCacheKeyOf(info, audioStream, averageBitrateUnknown); @@ -124,10 +151,13 @@ public interface PlaybackResolver extends Resolver { return cacheKey.toString(); } + //endregion + + //region Live media sources @Nullable - static MediaSource maybeBuildLiveMediaSource(@NonNull final PlayerDataSource dataSource, - @NonNull final StreamInfo info) { + static MediaSource maybeBuildLiveMediaSource(final PlayerDataSource dataSource, + final StreamInfo info) { final StreamType streamType = info.getStreamType(); if (!StreamTypeUtil.isLiveStream(streamType)) { return null; @@ -143,11 +173,10 @@ public interface PlaybackResolver extends Resolver { return null; } - @NonNull - static MediaSource buildLiveMediaSource(@NonNull final PlayerDataSource dataSource, - @NonNull final String sourceUrl, + static MediaSource buildLiveMediaSource(final PlayerDataSource dataSource, + final String sourceUrl, @C.ContentType final int type, - @NonNull final MediaItemTag metadata) { + final MediaItemTag metadata) { final MediaSource.Factory factory; switch (type) { case C.TYPE_SS: @@ -159,7 +188,7 @@ public interface PlaybackResolver extends Resolver { case C.TYPE_HLS: factory = dataSource.getLiveHlsMediaSourceFactory(); break; - default: + case C.TYPE_OTHER: case C.TYPE_RTSP: default: throw new IllegalStateException("Unsupported type: " + type); } @@ -173,13 +202,15 @@ public interface PlaybackResolver extends Resolver { .build()) .build()); } + //endregion - @NonNull - static MediaSource buildMediaSource(@NonNull final PlayerDataSource dataSource, - @NonNull final Stream stream, - @NonNull final StreamInfo streamInfo, - @NonNull final String cacheKey, - @NonNull final MediaItemTag metadata) + + //region Generic media sources + static MediaSource buildMediaSource(final PlayerDataSource dataSource, + final Stream stream, + final StreamInfo streamInfo, + final String cacheKey, + final MediaItemTag metadata) throws IOException { if (streamInfo.getService() == ServiceList.YouTube) { return createYoutubeMediaSource(stream, streamInfo, dataSource, cacheKey, metadata); @@ -201,12 +232,11 @@ public interface PlaybackResolver extends Resolver { } } - @NonNull - private static ProgressiveMediaSource buildProgressiveMediaSource( - @NonNull final PlayerDataSource dataSource, - @NonNull final T stream, - @NonNull final String cacheKey, - @NonNull final MediaItemTag metadata) throws IOException { + private static ProgressiveMediaSource buildProgressiveMediaSource( + final PlayerDataSource dataSource, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) throws IOException { final String url = stream.getContent(); if (isNullOrEmpty(url)) { @@ -223,12 +253,11 @@ public interface PlaybackResolver extends Resolver { } } - @NonNull - private static DashMediaSource buildDashMediaSource( - @NonNull final PlayerDataSource dataSource, - @NonNull final T stream, - @NonNull final String cacheKey, - @NonNull final MediaItemTag metadata) throws IOException { + private static DashMediaSource buildDashMediaSource(final PlayerDataSource dataSource, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) + throws IOException { final boolean isUrlStream = stream.isUrl(); if (isUrlStream && isNullOrEmpty(stream.getContent())) { throw new IOException("Try to generate a DASH media source from an empty string or " @@ -260,10 +289,8 @@ public interface PlaybackResolver extends Resolver { } } - @NonNull - private static DashManifest createDashManifest( - @NonNull final String manifestContent, - @NonNull final T stream) throws IOException { + private static DashManifest createDashManifest(final String manifestContent, + final Stream stream) throws IOException { try { final ByteArrayInputStream dashManifestInput = new ByteArrayInputStream( manifestContent.getBytes(StandardCharsets.UTF_8)); @@ -278,12 +305,11 @@ public interface PlaybackResolver extends Resolver { } } - @NonNull - private static HlsMediaSource buildHlsMediaSource( - @NonNull final PlayerDataSource dataSource, - @NonNull final T stream, - @NonNull final String cacheKey, - @NonNull final MediaItemTag metadata) throws IOException { + private static HlsMediaSource buildHlsMediaSource(final PlayerDataSource dataSource, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) + throws IOException { final boolean isUrlStream = stream.isUrl(); if (isUrlStream && isNullOrEmpty(stream.getContent())) { throw new IOException("Try to generate an HLS media source from an empty string or " @@ -324,12 +350,11 @@ public interface PlaybackResolver extends Resolver { } } - @NonNull - private static SsMediaSource buildSSMediaSource( - @NonNull final PlayerDataSource dataSource, - @NonNull final T stream, - @NonNull final String cacheKey, - @NonNull final MediaItemTag metadata) throws IOException { + private static SsMediaSource buildSSMediaSource(final PlayerDataSource dataSource, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) + throws IOException { final boolean isUrlStream = stream.isUrl(); if (isUrlStream && isNullOrEmpty(stream.getContent())) { throw new IOException("Try to generate an SmoothStreaming media source from an empty " @@ -370,13 +395,16 @@ public interface PlaybackResolver extends Resolver { .build()); } } + //endregion - private static MediaSource createYoutubeMediaSource( - final T stream, - final StreamInfo streamInfo, - final PlayerDataSource dataSource, - final String cacheKey, - final MediaItemTag metadata) throws IOException { + + //region YouTube media sources + private static MediaSource createYoutubeMediaSource(final Stream stream, + final StreamInfo streamInfo, + final PlayerDataSource dataSource, + final String cacheKey, + final MediaItemTag metadata) + throws IOException { if (!(stream instanceof AudioStream || stream instanceof VideoStream)) { throw new IOException("Try to generate a DASH manifest of a YouTube " + stream.getClass() + " " + stream.getContent()); @@ -414,12 +442,12 @@ public interface PlaybackResolver extends Resolver { } } - private static MediaSource createYoutubeMediaSourceOfVideoStreamType( - @NonNull final PlayerDataSource dataSource, - @NonNull final T stream, - @NonNull final StreamInfo streamInfo, - @NonNull final String cacheKey, - @NonNull final MediaItemTag metadata) throws IOException { + private static MediaSource createYoutubeMediaSourceOfVideoStreamType( + final PlayerDataSource dataSource, + final Stream stream, + final StreamInfo streamInfo, + final String cacheKey, + final MediaItemTag metadata) throws IOException { final DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); switch (deliveryMethod) { case PROGRESSIVE_HTTP: @@ -480,13 +508,12 @@ public interface PlaybackResolver extends Resolver { } } - @NonNull - private static DashMediaSource buildYoutubeManualDashMediaSource( - @NonNull final PlayerDataSource dataSource, - @NonNull final DashManifest dashManifest, - @NonNull final T stream, - @NonNull final String cacheKey, - @NonNull final MediaItemTag metadata) { + private static DashMediaSource buildYoutubeManualDashMediaSource( + final PlayerDataSource dataSource, + final DashManifest dashManifest, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) { return dataSource.getYoutubeDashMediaSourceFactory().createMediaSource(dashManifest, new MediaItem.Builder() .setTag(metadata) @@ -495,12 +522,11 @@ public interface PlaybackResolver extends Resolver { .build()); } - @NonNull - private static ProgressiveMediaSource buildYoutubeProgressiveMediaSource( - @NonNull final PlayerDataSource dataSource, - @NonNull final T stream, - @NonNull final String cacheKey, - @NonNull final MediaItemTag metadata) { + private static ProgressiveMediaSource buildYoutubeProgressiveMediaSource( + final PlayerDataSource dataSource, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) { return dataSource.getYoutubeProgressiveMediaSourceFactory() .createMediaSource(new MediaItem.Builder() .setTag(metadata) @@ -508,4 +534,5 @@ public interface PlaybackResolver extends Resolver { .setCustomCacheKey(cacheKey) .build()); } + //endregion } From 8dad6d7e1cace27428e71729d2b8cbf26ef1c5f1 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 21 May 2022 12:02:57 +0200 Subject: [PATCH 09/20] Code improvements here and there --- .../newpipe/download/DownloadDialog.java | 6 +-- .../newpipe/util/SecondaryStreamHelper.java | 50 ++++++++++--------- .../newpipe/util/StreamItemAdapter.java | 6 +-- 3 files changed, 31 insertions(+), 31 deletions(-) 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 73ba8c74a..9f46f7f6b 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -786,10 +786,8 @@ public class DownloadDialog extends DialogFragment if (format == MediaFormat.TTML) { filenameTmp += MediaFormat.SRT.suffix; - } else { - if (format != null) { - filenameTmp += format.suffix; - } + } else if (format != null) { + filenameTmp += format.suffix; } break; default: diff --git a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java index 96124da87..e7fd2d4a4 100644 --- a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java @@ -35,33 +35,35 @@ public class SecondaryStreamHelper { public static AudioStream getAudioStreamFor(@NonNull final List audioStreams, @NonNull final VideoStream videoStream) { final MediaFormat mediaFormat = videoStream.getFormat(); - if (mediaFormat != null) { - switch (mediaFormat) { - case WEBM: - case MPEG_4:// ¿is mpeg-4 DASH? - break; - default: - return null; - } + if (mediaFormat == null) { + return null; + } - final boolean m4v = (mediaFormat == MediaFormat.MPEG_4); - - for (final AudioStream audio : audioStreams) { - if (audio.getFormat() == (m4v ? MediaFormat.M4A : MediaFormat.WEBMA)) { - return audio; - } - } - - if (m4v) { + switch (mediaFormat) { + case WEBM: + case MPEG_4:// ¿is mpeg-4 DASH? + break; + default: return null; - } + } - // retry, but this time in reverse order - for (int i = audioStreams.size() - 1; i >= 0; i--) { - final AudioStream audio = audioStreams.get(i); - if (audio.getFormat() == MediaFormat.WEBMA_OPUS) { - return audio; - } + final boolean m4v = (mediaFormat == MediaFormat.MPEG_4); + + for (final AudioStream audio : audioStreams) { + if (audio.getFormat() == (m4v ? MediaFormat.M4A : MediaFormat.WEBMA)) { + return audio; + } + } + + if (m4v) { + return null; + } + + // retry, but this time in reverse order + for (int i = audioStreams.size() - 1; i >= 0; i--) { + final AudioStream audio = audioStreams.get(i); + if (audio.getFormat() == MediaFormat.WEBMA_OPUS) { + return audio; } } 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 11f982921..4b5e675c9 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java @@ -155,10 +155,10 @@ public class StreamItemAdapter extends BaseA qualityString += " (" + context.getString(R.string.caption_auto_generated) + ")"; } } else { - if (mediaFormat != null) { - qualityString = mediaFormat.getSuffix(); - } else { + if (mediaFormat == null) { qualityString = context.getString(R.string.unknown_quality); + } else { + qualityString = mediaFormat.getSuffix(); } } From 73855cacb730b77a987095a8b79a502a2b6adeb2 Mon Sep 17 00:00:00 2001 From: AudricV <74829229+AudricV@users.noreply.github.com> Date: Thu, 16 Jun 2022 11:13:54 +0200 Subject: [PATCH 10/20] Use StreamTypeUtil where possible and add isAudio and isVideo to this utility class --- .../newpipe/database/stream/dao/StreamDAO.kt | 6 ++-- .../info_list/dialog/InfoItemDialog.java | 8 ++--- .../holder/StreamMiniInfoItemHolder.java | 8 ++--- .../org/schabi/newpipe/player/Player.java | 21 ++++--------- .../schabi/newpipe/util/SparseItemUtil.java | 6 ++-- .../schabi/newpipe/util/StreamTypeUtil.java | 30 +++++++++++++++++-- 6 files changed, 44 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt index a22fd2bb9..d8c19c1e9 100644 --- a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt +++ b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt @@ -12,8 +12,7 @@ import org.schabi.newpipe.database.BasicDAO import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID import org.schabi.newpipe.extractor.stream.StreamType -import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM -import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM +import org.schabi.newpipe.util.StreamTypeUtil import java.time.OffsetDateTime @Dao @@ -91,8 +90,7 @@ abstract class StreamDAO : BasicDAO { ?: throw IllegalStateException("Stream cannot be null just after insertion.") newerStream.uid = existentMinimalStream.uid - val isNewerStreamLive = newerStream.streamType == AUDIO_LIVE_STREAM || newerStream.streamType == LIVE_STREAM - if (!isNewerStreamLive) { + if (!StreamTypeUtil.isLiveStream(newerStream.streamType)) { // Use the existent upload date if the newer stream does not have a better precision // (i.e. is an approximation). This is done to prevent unnecessary changes. diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java index 5a266c0a8..5afaea038 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java @@ -24,6 +24,7 @@ import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.player.helper.PlayerHolder; +import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.util.external_communication.KoreUtils; import java.util.ArrayList; @@ -269,8 +270,7 @@ public final class InfoItemDialog { */ public Builder addStartHereEntries() { addEntry(StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND); - if (infoItem.getStreamType() != StreamType.AUDIO_STREAM - && infoItem.getStreamType() != StreamType.AUDIO_LIVE_STREAM) { + if (!StreamTypeUtil.isAudio(infoItem.getStreamType())) { addEntry(StreamDialogDefaultEntry.START_HERE_ON_POPUP); } return this; @@ -285,9 +285,7 @@ public final class InfoItemDialog { final boolean isWatchHistoryEnabled = PreferenceManager .getDefaultSharedPreferences(context) .getBoolean(context.getString(R.string.enable_watch_history_key), false); - if (isWatchHistoryEnabled - && infoItem.getStreamType() != StreamType.LIVE_STREAM - && infoItem.getStreamType() != StreamType.AUDIO_LIVE_STREAM) { + if (isWatchHistoryEnabled && !StreamTypeUtil.isLiveStream(infoItem.getStreamType())) { addEntry(StreamDialogDefaultEntry.MARK_AS_WATCHED); } return this; diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java index 83211d4dd..54d31ca57 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java @@ -11,12 +11,12 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.views.AnimatedProgressBar; import java.util.concurrent.TimeUnit; @@ -70,8 +70,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { } else { itemProgressView.setVisibility(View.GONE); } - } else if (item.getStreamType() == StreamType.LIVE_STREAM - || item.getStreamType() == StreamType.AUDIO_LIVE_STREAM) { + } else if (StreamTypeUtil.isLiveStream(item.getStreamType())) { itemDurationView.setText(R.string.duration_live); itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), R.color.live_duration_background_color)); @@ -115,8 +114,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { final StreamStateEntity state = historyRecordManager.loadStreamState(infoItem).blockingGet()[0]; if (state != null && item.getDuration() > 0 - && item.getStreamType() != StreamType.LIVE_STREAM - && item.getStreamType() != StreamType.AUDIO_LIVE_STREAM) { + && !StreamTypeUtil.isLiveStream(item.getStreamType())) { itemProgressView.setMax((int) item.getDuration()); if (itemProgressView.getVisibility() == View.VISIBLE) { itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS 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 316b72a09..b2c8836e5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -4234,10 +4234,7 @@ public final class Player implements if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) { reloadPlayQueueManager(); } else { - final StreamType streamType = info.getStreamType(); - if (streamType == StreamType.AUDIO_STREAM - || streamType == StreamType.AUDIO_LIVE_STREAM - || streamType == StreamType.POST_LIVE_AUDIO_STREAM) { + if (StreamTypeUtil.isAudio(info.getStreamType())) { // Nothing to do more than setting the recovery position setRecovery(); return; @@ -4296,21 +4293,17 @@ public final class Player implements @NonNull final StreamInfo streamInfo, final int videoRendererIndex) { final StreamType streamType = streamInfo.getStreamType(); + final boolean isStreamTypeAudio = StreamTypeUtil.isAudio(streamType); - if (videoRendererIndex == RENDERER_UNAVAILABLE && streamType != StreamType.AUDIO_STREAM - && streamType != StreamType.AUDIO_LIVE_STREAM - && streamType != StreamType.POST_LIVE_AUDIO_STREAM) { + if (videoRendererIndex == RENDERER_UNAVAILABLE && !isStreamTypeAudio) { return true; } // The content is an audio stream, an audio live stream, or a live stream with a live // source: it's not needed to reload the play queue manager because the stream source will // be the same - if ((streamType == StreamType.AUDIO_STREAM - || streamType == StreamType.POST_LIVE_AUDIO_STREAM - || streamType == StreamType.AUDIO_LIVE_STREAM) - || (streamType == StreamType.LIVE_STREAM - && sourceType == SourceType.LIVE_STREAM)) { + if (isStreamTypeAudio || (streamType == StreamType.LIVE_STREAM + && sourceType == SourceType.LIVE_STREAM)) { return false; } @@ -4324,9 +4317,7 @@ public final class Player implements && 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 streamType != StreamType.VIDEO_STREAM - && streamType != StreamType.LIVE_STREAM - && streamType != StreamType.POST_LIVE_STREAM; + return !StreamTypeUtil.isVideo(streamType); } // Other cases: the play queue manager reload is needed diff --git a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java b/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java index b8cd4ef69..0c5f418b2 100644 --- a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java +++ b/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java @@ -1,7 +1,5 @@ package org.schabi.newpipe.util; -import static org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM; -import static org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import android.content.Context; @@ -49,8 +47,8 @@ public final class SparseItemUtil { public static void fetchItemInfoIfSparse(@NonNull final Context context, @NonNull final StreamInfoItem item, @NonNull final Consumer callback) { - if (((item.getStreamType() == LIVE_STREAM || item.getStreamType() == AUDIO_LIVE_STREAM) - || item.getDuration() >= 0) && !isNullOrEmpty(item.getUploaderUrl())) { + if ((StreamTypeUtil.isLiveStream(item.getStreamType()) || item.getDuration() >= 0) + && !isNullOrEmpty(item.getUploaderUrl())) { // if the duration is >= 0 (provided that the item is not a livestream) and there is an // uploader url, probably all info is already there, so there is no need to fetch it callback.accept(new SinglePlayQueue(item)); diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java b/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java index b0b6f4507..0cc0ecf1f 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java @@ -14,8 +14,34 @@ public final class StreamTypeUtil { * Check if the {@link StreamType} of a stream is a livestream. * * @param streamType the stream type of the stream - * @return true if the streamType is a - * {@link StreamType#LIVE_STREAM} or {@link StreamType#AUDIO_LIVE_STREAM} + * @return whether the stream type is {@link StreamType#AUDIO_STREAM}, + * {@link StreamType#AUDIO_LIVE_STREAM} or {@link StreamType#POST_LIVE_AUDIO_STREAM} + */ + public static boolean isAudio(final StreamType streamType) { + return streamType == StreamType.AUDIO_STREAM + || streamType == StreamType.AUDIO_LIVE_STREAM + || streamType == StreamType.POST_LIVE_AUDIO_STREAM; + } + + /** + * Check if the {@link StreamType} of a stream is a livestream. + * + * @param streamType the stream type of the stream + * @return whether the stream type is {@link StreamType#VIDEO_STREAM}, + * {@link StreamType#LIVE_STREAM} or {@link StreamType#POST_LIVE_STREAM} + */ + public static boolean isVideo(final StreamType streamType) { + return streamType == StreamType.VIDEO_STREAM + || streamType == StreamType.LIVE_STREAM + || streamType == StreamType.POST_LIVE_STREAM; + } + + /** + * Check if the {@link StreamType} of a stream is a livestream. + * + * @param streamType the stream type of the stream + * @return whether the stream type is {@link StreamType#LIVE_STREAM} or + * {@link StreamType#AUDIO_LIVE_STREAM} */ public static boolean isLiveStream(final StreamType streamType) { return streamType == StreamType.LIVE_STREAM From 036196a48747682bcad3831fae36db2916e9beb0 Mon Sep 17 00:00:00 2001 From: AudricV <74829229+AudricV@users.noreply.github.com> Date: Thu, 16 Jun 2022 11:14:02 +0200 Subject: [PATCH 11/20] Filter streams using Java 8 Stream's API instead of removing streams with list iterators and add a better toast when there is no audio stream for external players This ensures to not remove streams from the StreamInfo lists themselves, and so to not have to create list copies. The toast shown in RouterActivity, when there is no audio stream available for external players, is now shown, in the same case, when pressing the background button in VideoDetailFragment. --- .../newpipe/download/DownloadDialog.java | 24 +++--- .../fragments/detail/VideoDetailFragment.java | 26 ++++-- .../resolver/AudioPlaybackResolver.java | 6 +- .../resolver/VideoPlaybackResolver.java | 29 +++---- .../org/schabi/newpipe/util/ListHelper.java | 84 ++++++++---------- .../schabi/newpipe/util/NavigationHelper.java | 86 ++++++++++--------- 6 files changed, 123 insertions(+), 132 deletions(-) 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 9f46f7f6b..4fb47496b 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -69,7 +69,6 @@ import org.schabi.newpipe.util.ThemeHelper; import java.io.File; import java.io.IOException; -import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -84,7 +83,7 @@ import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; import us.shandian.giga.service.MissionState; -import static org.schabi.newpipe.util.ListHelper.keepStreamsWithDelivery; +import static org.schabi.newpipe.util.ListHelper.getStreamsOfSpecifiedDelivery; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; public class DownloadDialog extends DialogFragment @@ -149,25 +148,24 @@ public class DownloadDialog extends DialogFragment public static DownloadDialog newInstance(final Context context, @NonNull final StreamInfo info) { // TODO: Adapt this code when the downloader support other types of stream deliveries - final List videoStreams = new ArrayList<>(info.getVideoStreams()); final List progressiveHttpVideoStreams = - keepStreamsWithDelivery(videoStreams, DeliveryMethod.PROGRESSIVE_HTTP); + getStreamsOfSpecifiedDelivery(info.getVideoStreams(), + DeliveryMethod.PROGRESSIVE_HTTP); - final List videoOnlyStreams = new ArrayList<>(info.getVideoOnlyStreams()); final List progressiveHttpVideoOnlyStreams = - keepStreamsWithDelivery(videoOnlyStreams, DeliveryMethod.PROGRESSIVE_HTTP); + getStreamsOfSpecifiedDelivery(info.getVideoOnlyStreams(), + DeliveryMethod.PROGRESSIVE_HTTP); - final List audioStreams = new ArrayList<>(info.getAudioStreams()); final List progressiveHttpAudioStreams = - keepStreamsWithDelivery(audioStreams, DeliveryMethod.PROGRESSIVE_HTTP); + getStreamsOfSpecifiedDelivery(info.getAudioStreams(), + DeliveryMethod.PROGRESSIVE_HTTP); - final List subtitlesStreams = new ArrayList<>(info.getSubtitles()); final List progressiveHttpSubtitlesStreams = - keepStreamsWithDelivery(subtitlesStreams, DeliveryMethod.PROGRESSIVE_HTTP); + getStreamsOfSpecifiedDelivery(info.getSubtitles(), + DeliveryMethod.PROGRESSIVE_HTTP); - final List videoStreamsList = new ArrayList<>( - ListHelper.getSortedStreamVideosList(context, progressiveHttpVideoStreams, - progressiveHttpVideoOnlyStreams, false, false)); + final List videoStreamsList = ListHelper.getSortedStreamVideosList(context, + progressiveHttpVideoStreams, progressiveHttpVideoOnlyStreams, false, false); final DownloadDialog instance = new DownloadDialog(); instance.setInfo(info); 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 bb09681f5..ff2114b83 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 @@ -31,6 +31,7 @@ import android.view.WindowManager; import android.view.animation.DecelerateInterpolator; import android.widget.FrameLayout; import android.widget.RelativeLayout; +import android.widget.Toast; import androidx.annotation.AttrRes; import androidx.annotation.NonNull; @@ -122,7 +123,7 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientat import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired; import static org.schabi.newpipe.player.playqueue.PlayQueueItem.RECOVERY_UNSET; import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; -import static org.schabi.newpipe.util.ListHelper.removeNonUrlAndTorrentStreams; +import static org.schabi.newpipe.util.ListHelper.getNonUrlAndNonTorrentStreams; public final class VideoDetailFragment extends BaseStateFragment @@ -1092,9 +1093,6 @@ public final class VideoDetailFragment } private void openBackgroundPlayer(final boolean append) { - final AudioStream audioStream = currentInfo.getAudioStreams() - .get(ListHelper.getDefaultAudioFormat(activity, currentInfo.getAudioStreams())); - final boolean useExternalAudioPlayer = PreferenceManager .getDefaultSharedPreferences(activity) .getBoolean(activity.getString(R.string.use_external_audio_player_key), false); @@ -1109,7 +1107,17 @@ public final class VideoDetailFragment if (!useExternalAudioPlayer) { openNormalBackgroundPlayer(append); } else { - startOnExternalPlayer(activity, currentInfo, audioStream); + final List audioStreams = getNonUrlAndNonTorrentStreams( + 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)); } } @@ -2147,10 +2155,10 @@ public final class VideoDetailFragment return; } - final List videoStreams = removeNonUrlAndTorrentStreams( - new ArrayList<>(currentInfo.getVideoStreams())); - final List videoOnlyStreams = removeNonUrlAndTorrentStreams( - new ArrayList<>(currentInfo.getVideoOnlyStreams())); + final List videoStreams = getNonUrlAndNonTorrentStreams( + currentInfo.getVideoStreams()); + final List videoOnlyStreams = getNonUrlAndNonTorrentStreams( + currentInfo.getVideoOnlyStreams()); final AlertDialog.Builder builder = new AlertDialog.Builder(activity); builder.setTitle(R.string.select_quality_external_players); 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 85c15faf1..3e166c339 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,6 +1,6 @@ package org.schabi.newpipe.player.resolver; -import static org.schabi.newpipe.util.ListHelper.removeTorrentStreams; +import static org.schabi.newpipe.util.ListHelper.getNonTorrentStreams; import android.content.Context; import android.util.Log; @@ -18,7 +18,6 @@ import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.ListHelper; import java.io.IOException; -import java.util.ArrayList; import java.util.List; public class AudioPlaybackResolver implements PlaybackResolver { @@ -43,8 +42,7 @@ public class AudioPlaybackResolver implements PlaybackResolver { return liveSource; } - final List audioStreams = new ArrayList<>(info.getAudioStreams()); - removeTorrentStreams(audioStreams); + final List audioStreams = getNonTorrentStreams(info.getAudioStreams()); final int index = ListHelper.getDefaultAudioFormat(context, audioStreams); if (index < 0 || index >= info.getAudioStreams().size()) { 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 317c49fc9..fd00d0ed9 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 @@ -29,8 +29,8 @@ import java.util.List; import java.util.Optional; import static com.google.android.exoplayer2.C.TIME_UNSET; -import static org.schabi.newpipe.util.ListHelper.removeNonUrlAndTorrentStreams; -import static org.schabi.newpipe.util.ListHelper.removeTorrentStreams; +import static org.schabi.newpipe.util.ListHelper.getNonUrlAndNonTorrentStreams; +import static org.schabi.newpipe.util.ListHelper.getNonTorrentStreams; public class VideoPlaybackResolver implements PlaybackResolver { private static final String TAG = VideoPlaybackResolver.class.getSimpleName(); @@ -70,24 +70,21 @@ public class VideoPlaybackResolver implements PlaybackResolver { } final List mediaSources = new ArrayList<>(); - final List videoStreams = new ArrayList<>(info.getVideoStreams()); - final List videoOnlyStreams = new ArrayList<>(info.getVideoOnlyStreams()); - - removeTorrentStreams(videoStreams); - removeTorrentStreams(videoOnlyStreams); // Create video stream source - final List videos = ListHelper.getSortedStreamVideosList(context, - videoStreams, videoOnlyStreams, false, true); + final List videoStreamsList = ListHelper.getSortedStreamVideosList(context, + getNonTorrentStreams(info.getVideoStreams()), + getNonTorrentStreams(info.getVideoOnlyStreams()), false, true); final int index; - if (videos.isEmpty()) { + if (videoStreamsList.isEmpty()) { index = -1; } else if (playbackQuality == null) { - index = qualityResolver.getDefaultResolutionIndex(videos); + index = qualityResolver.getDefaultResolutionIndex(videoStreamsList); } else { - index = qualityResolver.getOverrideResolutionIndex(videos, getPlaybackQuality()); + index = qualityResolver.getOverrideResolutionIndex(videoStreamsList, + getPlaybackQuality()); } - final MediaItemTag tag = StreamInfoTag.of(info, videos, index); + final MediaItemTag tag = StreamInfoTag.of(info, videoStreamsList, index); @Nullable final VideoStream video = tag.getMaybeQuality() .map(MediaItemTag.Quality::getSelectedVideoStream) .orElse(null); @@ -104,8 +101,7 @@ public class VideoPlaybackResolver implements PlaybackResolver { } // Create optional audio stream source - final List audioStreams = info.getAudioStreams(); - removeTorrentStreams(audioStreams); + final List audioStreams = getNonTorrentStreams(info.getAudioStreams()); final AudioStream audio = audioStreams.isEmpty() ? null : audioStreams.get( ListHelper.getDefaultAudioFormat(context, audioStreams)); @@ -129,13 +125,14 @@ public class VideoPlaybackResolver implements PlaybackResolver { if (mediaSources.isEmpty()) { return null; } + // Below are auxiliary media sources // Create subtitle sources final List subtitlesStreams = info.getSubtitles(); if (subtitlesStreams != null) { // Torrent and non URL subtitles are not supported by ExoPlayer - final List nonTorrentAndUrlStreams = removeNonUrlAndTorrentStreams( + final List nonTorrentAndUrlStreams = getNonUrlAndNonTorrentStreams( subtitlesStreams); for (final SubtitlesStream subtitle : nonTorrentAndUrlStreams) { final MediaFormat mediaFormat = subtitle.getFormat(); 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 3a03e0b30..33c7a2f49 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java @@ -23,10 +23,10 @@ import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; -import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.function.Predicate; import java.util.stream.Collectors; public final class ListHelper { @@ -116,27 +116,17 @@ public final class ListHelper { * Return a {@link Stream} list which uses the given delivery method from a {@link Stream} * list. * - * @param streamList the original stream list - * @param deliveryMethod the delivery method + * @param streamList the original {@link Stream stream} list + * @param deliveryMethod the {@link DeliveryMethod delivery method} * @param the item type's class that extends {@link Stream} - * @return a stream list which uses the given delivery method + * @return a {@link Stream stream} list which uses the given delivery method */ @NonNull - public static List keepStreamsWithDelivery( - @NonNull final List streamList, + public static List getStreamsOfSpecifiedDelivery( + final List streamList, final DeliveryMethod deliveryMethod) { - if (streamList.isEmpty()) { - return Collections.emptyList(); - } - - final Iterator streamListIterator = streamList.iterator(); - while (streamListIterator.hasNext()) { - if (streamListIterator.next().getDeliveryMethod() != deliveryMethod) { - streamListIterator.remove(); - } - } - - return streamList; + return getFilteredStreamList(streamList, + stream -> stream.getDeliveryMethod() == deliveryMethod); } /** @@ -147,21 +137,10 @@ public final class ListHelper { * @return a stream list which only contains URL streams and non-torrent streams */ @NonNull - public static List removeNonUrlAndTorrentStreams( - @NonNull final List streamList) { - if (streamList.isEmpty()) { - return Collections.emptyList(); - } - - final Iterator streamListIterator = streamList.iterator(); - while (streamListIterator.hasNext()) { - final S stream = streamListIterator.next(); - if (!stream.isUrl() || stream.getDeliveryMethod() == DeliveryMethod.TORRENT) { - streamListIterator.remove(); - } - } - - return streamList; + public static List getNonUrlAndNonTorrentStreams( + final List streamList) { + return getFilteredStreamList(streamList, + stream -> stream.isUrl() && stream.getDeliveryMethod() != DeliveryMethod.TORRENT); } /** @@ -172,21 +151,10 @@ public final class ListHelper { * @return a stream list which only contains non-torrent streams */ @NonNull - public static List removeTorrentStreams( - @NonNull final List streamList) { - if (streamList.isEmpty()) { - return Collections.emptyList(); - } - - final Iterator streamListIterator = streamList.iterator(); - while (streamListIterator.hasNext()) { - final S stream = streamListIterator.next(); - if (stream.getDeliveryMethod() == DeliveryMethod.TORRENT) { - streamListIterator.remove(); - } - } - - return streamList; + public static List getNonTorrentStreams( + final List streamList) { + return getFilteredStreamList(streamList, + stream -> stream.getDeliveryMethod() != DeliveryMethod.TORRENT); } /** @@ -224,6 +192,26 @@ public final class ListHelper { // Utils //////////////////////////////////////////////////////////////////////////*/ + /** + * Get a filtered stream list, by using Java 8 Stream's API and the given predicate. + * + * @param streamList the stream list to filter + * @param streamListPredicate the predicate which will be used to filter streams + * @param the item type's class that extends {@link Stream} + * @return a new stream list filtered using the given predicate + */ + private static List getFilteredStreamList( + final List streamList, + final Predicate streamListPredicate) { + if (streamList == null) { + return Collections.emptyList(); + } + + return streamList.stream() + .filter(streamListPredicate) + .collect(Collectors.toList()); + } + private static String computeDefaultResolution(final Context context, final int key, final int value) { final SharedPreferences preferences diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index c3246857e..ffc7433a0 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -63,7 +63,7 @@ import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.List; -import static org.schabi.newpipe.util.ListHelper.removeNonUrlAndTorrentStreams; +import static org.schabi.newpipe.util.ListHelper.getNonUrlAndNonTorrentStreams; public final class NavigationHelper { public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; @@ -221,39 +221,42 @@ public final class NavigationHelper { public static void playOnExternalAudioPlayer(@NonNull final Context context, @NonNull final StreamInfo info) { final List audioStreams = info.getAudioStreams(); - if (audioStreams.isEmpty()) { + if (audioStreams == null || audioStreams.isEmpty()) { Toast.makeText(context, R.string.audio_streams_empty, Toast.LENGTH_SHORT).show(); return; } - final List audioStreamsForExternalPlayers = removeNonUrlAndTorrentStreams( - audioStreams); + + final List audioStreamsForExternalPlayers = + getNonUrlAndNonTorrentStreams(audioStreams); if (audioStreamsForExternalPlayers.isEmpty()) { Toast.makeText(context, R.string.no_audio_streams_available_for_external_players, Toast.LENGTH_SHORT).show(); return; } - final int index = ListHelper.getDefaultAudioFormat(context, - audioStreamsForExternalPlayers); + final int index = ListHelper.getDefaultAudioFormat(context, audioStreamsForExternalPlayers); final AudioStream audioStream = audioStreamsForExternalPlayers.get(index); + playOnExternalPlayer(context, info.getName(), info.getUploaderName(), audioStream); } public static void playOnExternalVideoPlayer(final Context context, @NonNull final StreamInfo info) { final List videoStreams = info.getVideoStreams(); - if (videoStreams.isEmpty()) { + if (videoStreams == null || videoStreams.isEmpty()) { Toast.makeText(context, R.string.video_streams_empty, Toast.LENGTH_SHORT).show(); return; } + final List videoStreamsForExternalPlayers = ListHelper.getSortedStreamVideosList(context, - removeNonUrlAndTorrentStreams(videoStreams), null, false, false); + getNonUrlAndNonTorrentStreams(videoStreams), null, false, false); if (videoStreamsForExternalPlayers.isEmpty()) { Toast.makeText(context, R.string.no_video_streams_available_for_external_players, Toast.LENGTH_SHORT).show(); return; } + final int index = ListHelper.getDefaultResolutionIndex(context, videoStreamsForExternalPlayers); @@ -267,42 +270,41 @@ public final class NavigationHelper { @NonNull final Stream stream) { final DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); final String mimeType; - if (deliveryMethod == DeliveryMethod.PROGRESSIVE_HTTP) { - if (stream.getFormat() != null) { - mimeType = stream.getFormat().getMimeType(); - } else { - if (stream instanceof AudioStream) { - mimeType = "audio/*"; - } else if (stream instanceof VideoStream) { - mimeType = "video/*"; + + if (!stream.isUrl() || deliveryMethod == DeliveryMethod.TORRENT) { + Toast.makeText(context, R.string.selected_stream_external_player_not_supported, + Toast.LENGTH_SHORT).show(); + return; + } + + switch (deliveryMethod) { + case PROGRESSIVE_HTTP: + if (stream.getFormat() == null) { + if (stream instanceof AudioStream) { + mimeType = "audio/*"; + } else if (stream instanceof VideoStream) { + mimeType = "video/*"; + } else { + // This should never be reached, because subtitles are not opened in + // external players + return; + } } else { - // This should never be reached, because subtitles are not opened in external - // players - return; + mimeType = stream.getFormat().getMimeType(); } - } - } else { - if (!stream.isUrl() || deliveryMethod == DeliveryMethod.TORRENT) { - Toast.makeText(context, R.string.selected_stream_external_player_not_supported, - Toast.LENGTH_SHORT).show(); - return; - } else { - switch (deliveryMethod) { - case HLS: - mimeType = "application/x-mpegURL"; - break; - case DASH: - mimeType = "application/dash+xml"; - break; - case SS: - mimeType = "application/vnd.ms-sstr+xml"; - break; - default: - // Progressive HTTP streams are handled above and torrents streams are not - // exposed to external players - mimeType = ""; - } - } + break; + case HLS: + mimeType = "application/x-mpegURL"; + break; + case DASH: + mimeType = "application/dash+xml"; + break; + case SS: + mimeType = "application/vnd.ms-sstr+xml"; + break; + default: + // Torrent streams are not exposed to external players + mimeType = ""; } final Intent intent = new Intent(); From 21c9530e8b9d44dd49260649184fe8cc8f200d97 Mon Sep 17 00:00:00 2001 From: AudricV <74829229+AudricV@users.noreply.github.com> Date: Thu, 16 Jun 2022 11:14:08 +0200 Subject: [PATCH 12/20] Throw a dedicated exception when errors occur in PlaybackResolver A new exception, ResolverException, a subclass of PlaybackResolver, is now thrown when errors occur in PlaybackResolver, instead of an IOException --- .../resolver/AudioPlaybackResolver.java | 5 +- .../player/resolver/PlaybackResolver.java | 184 ++++++++++-------- .../resolver/VideoPlaybackResolver.java | 9 +- 3 files changed, 112 insertions(+), 86 deletions(-) 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 3e166c339..934beba19 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 @@ -17,7 +17,6 @@ import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.ListHelper; -import java.io.IOException; import java.util.List; public class AudioPlaybackResolver implements PlaybackResolver { @@ -55,8 +54,8 @@ public class AudioPlaybackResolver implements PlaybackResolver { try { return PlaybackResolver.buildMediaSource( dataSource, audio, info, PlaybackResolver.cacheKeyOf(info, audio), tag); - } catch (final IOException e) { - Log.e(TAG, "Unable to create audio source:", e); + } catch (final ResolverException e) { + Log.e(TAG, "Unable to create audio source", e); return null; } } 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 3d11f0e44..d7f04774c 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 @@ -97,17 +97,22 @@ public interface PlaybackResolver extends Resolver { } /** - * Builds the cache key of a video stream. A cache key is unique to the features of the - * provided video stream, and when possible independent of transient parameters (such as - * the url of the stream). This ensures that there are no conflicts, but also that the cache is - * used as much as possible: the same cache should be used for two streams which have the same - * features but e.g. a different url, since the url might have been reloaded in the meantime, - * but the stream actually referenced by the url is still the same. + * Builds the cache key of a {@link VideoStream video stream}. * - * @param info the stream info, to distinguish between streams with the same features but coming - * from different stream infos - * @param videoStream the video stream for which the cache key should be created - * @return a key to be used to store the cache of the provided video stream + *

+ * A cache key is unique to the features of the provided video stream, and when possible + * independent of transient parameters (such as the URL of the stream). + * This ensures that there are no conflicts, but also that the cache is used as much as + * possible: the same cache should be used for two streams which have the same features but + * e.g. a different URL, since the URL might have been reloaded in the meantime, but the stream + * actually referenced by the URL is still the same. + *

+ * + * @param info the {@link StreamInfo stream info}, to distinguish between streams with + * the same features but coming from different stream infos + * @param videoStream the {@link VideoStream video stream} for which the cache key should be + * created + * @return a key to be used to store the cache of the provided {@link VideoStream video stream} */ static String cacheKeyOf(final StreamInfo info, final VideoStream videoStream) { final boolean resolutionUnknown = videoStream.getResolution().equals(RESOLUTION_UNKNOWN); @@ -127,17 +132,22 @@ public interface PlaybackResolver extends Resolver { } /** - * Builds the cache key of an audio stream. A cache key is unique to the features of the - * provided audio stream, and when possible independent of transient parameters (such as - * the url of the stream). This ensures that there are no conflicts, but also that the cache is - * used as much as possible: the same cache should be used for two streams which have the same - * features but e.g. a different url, since the url might have been reloaded in the meantime, - * but the stream actually referenced by the url is still the same. + * Builds the cache key of an audio stream. * - * @param info the stream info, to distinguish between streams with the same features but coming - * from different stream infos - * @param audioStream the audio stream for which the cache key should be created - * @return a key to be used to store the cache of the provided audio stream + *

+ * A cache key is unique to the features of the provided {@link AudioStream audio stream}, and + * when possible independent of transient parameters (such as the URL of the stream). + * This ensures that there are no conflicts, but also that the cache is used as much as + * possible: the same cache should be used for two streams which have the same features but + * e.g. a different URL, since the URL might have been reloaded in the meantime, but the stream + * actually referenced by the URL is still the same. + *

+ * + * @param info the {@link StreamInfo stream info}, to distinguish between streams with + * the same features but coming from different stream infos + * @param audioStream the {@link AudioStream audio stream} for which the cache key should be + * created + * @return a key to be used to store the cache of the provided {@link AudioStream audio stream} */ static String cacheKeyOf(final StreamInfo info, final AudioStream audioStream) { final boolean averageBitrateUnknown = audioStream.getAverageBitrate() == UNKNOWN_BITRATE; @@ -158,16 +168,20 @@ public interface PlaybackResolver extends Resolver { @Nullable static MediaSource maybeBuildLiveMediaSource(final PlayerDataSource dataSource, final StreamInfo info) { - final StreamType streamType = info.getStreamType(); - if (!StreamTypeUtil.isLiveStream(streamType)) { + if (!StreamTypeUtil.isLiveStream(info.getStreamType())) { return null; } - final StreamInfoTag tag = StreamInfoTag.of(info); - if (!info.getHlsUrl().isEmpty()) { - return buildLiveMediaSource(dataSource, info.getHlsUrl(), C.TYPE_HLS, tag); - } else if (!info.getDashMpdUrl().isEmpty()) { - return buildLiveMediaSource(dataSource, info.getDashMpdUrl(), C.TYPE_DASH, tag); + try { + final StreamInfoTag tag = StreamInfoTag.of(info); + if (!info.getHlsUrl().isEmpty()) { + return buildLiveMediaSource(dataSource, info.getHlsUrl(), C.TYPE_HLS, tag); + } else if (!info.getDashMpdUrl().isEmpty()) { + return buildLiveMediaSource(dataSource, info.getDashMpdUrl(), C.TYPE_DASH, tag); + } + } catch (final Exception e) { + Log.w(TAG, "Error when generating live media source, falling back to standard sources", + e); } return null; @@ -176,7 +190,7 @@ public interface PlaybackResolver extends Resolver { static MediaSource buildLiveMediaSource(final PlayerDataSource dataSource, final String sourceUrl, @C.ContentType final int type, - final MediaItemTag metadata) { + final MediaItemTag metadata) throws ResolverException { final MediaSource.Factory factory; switch (type) { case C.TYPE_SS: @@ -188,8 +202,10 @@ public interface PlaybackResolver extends Resolver { case C.TYPE_HLS: factory = dataSource.getLiveHlsMediaSourceFactory(); break; - case C.TYPE_OTHER: case C.TYPE_RTSP: default: - throw new IllegalStateException("Unsupported type: " + type); + case C.TYPE_OTHER: + case C.TYPE_RTSP: + default: + throw new ResolverException("Unsupported type: " + type); } return factory.createMediaSource( @@ -210,8 +226,7 @@ public interface PlaybackResolver extends Resolver { final Stream stream, final StreamInfo streamInfo, final String cacheKey, - final MediaItemTag metadata) - throws IOException { + final MediaItemTag metadata) throws ResolverException { if (streamInfo.getService() == ServiceList.YouTube) { return createYoutubeMediaSource(stream, streamInfo, dataSource, cacheKey, metadata); } @@ -228,7 +243,7 @@ public interface PlaybackResolver extends Resolver { return buildSSMediaSource(dataSource, stream, cacheKey, metadata); // Torrent streams are not supported by ExoPlayer default: - throw new IllegalArgumentException("Unsupported delivery type: " + deliveryMethod); + throw new ResolverException("Unsupported delivery type: " + deliveryMethod); } } @@ -236,11 +251,11 @@ public interface PlaybackResolver extends Resolver { final PlayerDataSource dataSource, final Stream stream, final String cacheKey, - final MediaItemTag metadata) throws IOException { + final MediaItemTag metadata) throws ResolverException { final String url = stream.getContent(); if (isNullOrEmpty(url)) { - throw new IOException( + throw new ResolverException( "Try to generate a progressive media source from an empty string or from a " + "null object"); } else { @@ -257,11 +272,11 @@ public interface PlaybackResolver extends Resolver { final Stream stream, final String cacheKey, final MediaItemTag metadata) - throws IOException { + throws ResolverException { final boolean isUrlStream = stream.isUrl(); if (isUrlStream && isNullOrEmpty(stream.getContent())) { - throw new IOException("Try to generate a DASH media source from an empty string or " - + "from a null object"); + throw new ResolverException( + "Could not build a DASH media source from an empty or a null URL content"); } if (isUrlStream) { @@ -279,41 +294,42 @@ public interface PlaybackResolver extends Resolver { final Uri uri = Uri.parse(baseUrl); - return dataSource.getDashMediaSourceFactory().createMediaSource( - createDashManifest(stream.getContent(), stream), - new MediaItem.Builder() - .setTag(metadata) - .setUri(uri) - .setCustomCacheKey(cacheKey) - .build()); + try { + return dataSource.getDashMediaSourceFactory().createMediaSource( + createDashManifest(stream.getContent(), stream), + new MediaItem.Builder() + .setTag(metadata) + .setUri(uri) + .setCustomCacheKey(cacheKey) + .build()); + } catch (final IOException e) { + throw new ResolverException( + "Could not create a DASH media source/manifest from the manifest text"); + } } } private static DashManifest createDashManifest(final String manifestContent, final Stream stream) throws IOException { - try { - final ByteArrayInputStream dashManifestInput = new ByteArrayInputStream( - manifestContent.getBytes(StandardCharsets.UTF_8)); - String baseUrl = stream.getManifestUrl(); - if (baseUrl == null) { - baseUrl = ""; - } - - return new DashManifestParser().parse(Uri.parse(baseUrl), dashManifestInput); - } catch (final IOException e) { - throw new IOException("Error when parsing manual DASH manifest", e); + final ByteArrayInputStream dashManifestInput = new ByteArrayInputStream( + manifestContent.getBytes(StandardCharsets.UTF_8)); + String baseUrl = stream.getManifestUrl(); + if (baseUrl == null) { + baseUrl = ""; } + + return new DashManifestParser().parse(Uri.parse(baseUrl), dashManifestInput); } private static HlsMediaSource buildHlsMediaSource(final PlayerDataSource dataSource, final Stream stream, final String cacheKey, final MediaItemTag metadata) - throws IOException { + throws ResolverException { final boolean isUrlStream = stream.isUrl(); if (isUrlStream && isNullOrEmpty(stream.getContent())) { - throw new IOException("Try to generate an HLS media source from an empty string or " - + "from a null object"); + throw new ResolverException( + "Could not build a HLS media source from an empty or a null URL content"); } if (isUrlStream) { @@ -337,7 +353,7 @@ public interface PlaybackResolver extends Resolver { stream.getContent().getBytes(StandardCharsets.UTF_8)); hlsPlaylist = new HlsPlaylistParser().parse(uri, hlsManifestInput); } catch (final IOException e) { - throw new IOException("Error when parsing manual HLS manifest", e); + throw new ResolverException("Error when parsing manual HLS manifest", e); } return dataSource.getHlsMediaSourceFactory( @@ -354,11 +370,11 @@ public interface PlaybackResolver extends Resolver { final Stream stream, final String cacheKey, final MediaItemTag metadata) - throws IOException { + throws ResolverException { final boolean isUrlStream = stream.isUrl(); if (isUrlStream && isNullOrEmpty(stream.getContent())) { - throw new IOException("Try to generate an SmoothStreaming media source from an empty " - + "string or from a null object"); + throw new ResolverException( + "Could not build a SS media source from an empty or a null URL content"); } if (isUrlStream) { @@ -383,7 +399,7 @@ public interface PlaybackResolver extends Resolver { smoothStreamingManifest = new SsManifestParser().parse(uri, smoothStreamingManifestInput); } catch (final IOException e) { - throw new IOException("Error when parsing manual SmoothStreaming manifest", e); + throw new ResolverException("Error when parsing manual SS manifest", e); } return dataSource.getSSMediaSourceFactory().createMediaSource( @@ -404,10 +420,10 @@ public interface PlaybackResolver extends Resolver { final PlayerDataSource dataSource, final String cacheKey, final MediaItemTag metadata) - throws IOException { + throws ResolverException { if (!(stream instanceof AudioStream || stream instanceof VideoStream)) { - throw new IOException("Try to generate a DASH manifest of a YouTube " - + stream.getClass() + " " + stream.getContent()); + throw new ResolverException("Generation of YouTube DASH manifest for " + + stream.getClass().getSimpleName() + " is not supported"); } final StreamType streamType = streamInfo.getStreamType(); @@ -430,15 +446,15 @@ public interface PlaybackResolver extends Resolver { return buildYoutubeManualDashMediaSource(dataSource, createDashManifest(manifestString, stream), stream, cacheKey, metadata); - } catch (final CreationException | NullPointerException e) { + } catch (final CreationException | IOException | NullPointerException e) { Log.e(TAG, "Error when generating the DASH manifest of YouTube ended live stream", e); - throw new IOException("Error when generating the DASH manifest of YouTube ended " - + "live stream " + stream.getContent(), e); + throw new ResolverException( + "Error when generating the DASH manifest of YouTube ended live stream", e); } } else { - throw new IllegalArgumentException("DASH manifest generation of YouTube livestreams is " - + "not supported"); + throw new ResolverException( + "DASH manifest generation of YouTube livestreams is not supported"); } } @@ -447,7 +463,7 @@ public interface PlaybackResolver extends Resolver { final Stream stream, final StreamInfo streamInfo, final String cacheKey, - final MediaItemTag metadata) throws IOException { + final MediaItemTag metadata) throws ResolverException { final DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); switch (deliveryMethod) { case PROGRESSIVE_HTTP: @@ -488,12 +504,11 @@ public interface PlaybackResolver extends Resolver { return buildYoutubeManualDashMediaSource(dataSource, createDashManifest(manifestString, stream), stream, cacheKey, metadata); - } catch (final CreationException | NullPointerException e) { + } catch (final CreationException | IOException | NullPointerException e) { Log.e(TAG, "Error when generating the DASH manifest of YouTube OTF stream", e); - throw new IOException( - "Error when generating the DASH manifest of YouTube OTF stream " - + stream.getContent(), e); + throw new ResolverException( + "Error when generating the DASH manifest of YouTube OTF stream", e); } case HLS: return dataSource.getYoutubeHlsMediaSourceFactory().createMediaSource( @@ -503,7 +518,7 @@ public interface PlaybackResolver extends Resolver { .setCustomCacheKey(cacheKey) .build()); default: - throw new IOException("Unsupported delivery method for YouTube contents: " + throw new ResolverException("Unsupported delivery method for YouTube contents: " + deliveryMethod); } } @@ -535,4 +550,17 @@ public interface PlaybackResolver extends Resolver { .build()); } //endregion + + + //region resolver exception + final class ResolverException extends Exception { + public ResolverException(final String message) { + super(message); + } + + public ResolverException(final String message, final Throwable cause) { + super(message, cause); + } + } + //endregion } 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 fd00d0ed9..6e18ee0cd 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 @@ -23,7 +23,6 @@ import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.ListHelper; -import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -94,8 +93,8 @@ public class VideoPlaybackResolver implements PlaybackResolver { final MediaSource streamSource = PlaybackResolver.buildMediaSource( dataSource, video, info, PlaybackResolver.cacheKeyOf(info, video), tag); mediaSources.add(streamSource); - } catch (final IOException e) { - Log.e(TAG, "Unable to create video source:", e); + } catch (final ResolverException e) { + Log.e(TAG, "Unable to create video source", e); return null; } } @@ -113,8 +112,8 @@ public class VideoPlaybackResolver implements PlaybackResolver { dataSource, audio, info, PlaybackResolver.cacheKeyOf(info, audio), tag); mediaSources.add(audioSource); streamSourceType = SourceType.VIDEO_WITH_SEPARATED_AUDIO; - } catch (final IOException e) { - Log.e(TAG, "Unable to create audio source:", e); + } catch (final ResolverException e) { + Log.e(TAG, "Unable to create audio source", e); return null; } } else { From e3c2aea3cc3abaaa86c36b0f8e8c6b6660a33795 Mon Sep 17 00:00:00 2001 From: AudricV <74829229+AudricV@users.noreply.github.com> Date: Thu, 16 Jun 2022 11:15:05 +0200 Subject: [PATCH 13/20] Fix playback of non-URI HLS streams A custom HlsPlaylistParserFactory cannot be used anymore to play HLS streams. This needs to be replaced by a custom HlsDataSourceFactory, which returns a ByteArrayDataSource (where the bytes of this DataSource correspond to the bytes of the playlist string) and a specified DataSource for other request types. This model has two limitations: - if media requests are relative, the URI from which the manifest comes from (either the manifest URI (preferred) or the master URI (if applicable)) must be returned, otherwise the content will be not playable, as it will be an invalid URL, or it may be treat as something unexpected, for instance as a file for DefaultDataSources; - if the playlist is a master playlist, endless loops should be encountered because the DataSources created for media playlists will use the master playlist response instead of fetching the corresponding playlist. With the current model of HlsDataSourceFactory, there is no possibility to distinguish the playlist type or the URI that is requested. If ExoPlayer provides a way to create HlsMediaSources with an HlsPlaylist in the future, it should be used instead of this solution. --- .../NonUriHlsDataSourceFactory.java | 136 ++++++++++++++++++ .../NonUriHlsPlaylistParserFactory.java | 50 ------- .../player/helper/PlayerDataSource.java | 13 +- .../player/resolver/PlaybackResolver.java | 30 ++-- 4 files changed, 153 insertions(+), 76 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java delete mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/NonUriHlsPlaylistParserFactory.java diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java b/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java new file mode 100644 index 000000000..676443a9c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java @@ -0,0 +1,136 @@ +package org.schabi.newpipe.player.datasource; + +import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; + +import androidx.annotation.NonNull; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.source.hls.HlsDataSourceFactory; +import com.google.android.exoplayer2.upstream.ByteArrayDataSource; +import com.google.android.exoplayer2.upstream.DataSource; + +import java.nio.charset.StandardCharsets; + +/** + * A {@link HlsDataSourceFactory} which allows playback of non-URI media HLS playlists for + * {@link com.google.android.exoplayer2.source.hls.HlsMediaSource HlsMediaSource}s. + * + *

+ * If media requests are relative, the URI from which the manifest comes from (either the + * manifest URI (preferred) or the master URI (if applicable)) must be returned, otherwise the + * content will be not playable, as it will be an invalid URL, or it may be treat as something + * unexpected, for instance as a file for + * {@link com.google.android.exoplayer2.upstream.DefaultDataSource DefaultDataSource}s. + *

+ * + *

+ * See {@link #createDataSource(int)} for changes and implementation details. + *

+ */ +public final class NonUriHlsDataSourceFactory implements HlsDataSourceFactory { + + /** + * Builder class of {@link NonUriHlsDataSourceFactory} instances. + */ + public static final class Builder { + private DataSource.Factory dataSourceFactory; + private String playlistString; + + /** + * Set the {@link DataSource.Factory} which will be used to create non manifest contents + * {@link DataSource}s. + * + * @param dataSourceFactoryForNonManifestContents the {@link DataSource.Factory} which will + * be used to create non manifest contents + * {@link DataSource}s, which cannot be null + */ + public void setDataSourceFactory( + @NonNull final DataSource.Factory dataSourceFactoryForNonManifestContents) { + this.dataSourceFactory = dataSourceFactoryForNonManifestContents; + } + + /** + * Set the HLS playlist which will be used for manifests requests. + * + * @param hlsPlaylistString the string which correspond to the response of the HLS + * manifest, which cannot be null or empty + */ + public void setPlaylistString(@NonNull final String hlsPlaylistString) { + this.playlistString = hlsPlaylistString; + } + + /** + * Create a new {@link NonUriHlsDataSourceFactory} with the given data source factory and + * the given HLS playlist. + * + * @return a {@link NonUriHlsDataSourceFactory} + * @throws IllegalArgumentException if the data source factory is null or if the HLS + * playlist string set is null or empty + */ + @NonNull + public NonUriHlsDataSourceFactory build() { + if (dataSourceFactory == null) { + throw new IllegalArgumentException( + "No DataSource.Factory valid instance has been specified."); + } + + if (isNullOrEmpty(playlistString)) { + throw new IllegalArgumentException("No HLS valid playlist has been specified."); + } + + return new NonUriHlsDataSourceFactory(dataSourceFactory, + playlistString.getBytes(StandardCharsets.UTF_8)); + } + } + + private final DataSource.Factory dataSourceFactory; + private final byte[] playlistStringByteArray; + + /** + * Create a {@link NonUriHlsDataSourceFactory} instance. + * + * @param dataSourceFactory the {@link DataSource.Factory} which will be used to build + * non manifests {@link DataSource}s, which must not be null + * @param playlistStringByteArray a byte array of the HLS playlist, which must not be null + */ + private NonUriHlsDataSourceFactory(@NonNull final DataSource.Factory dataSourceFactory, + @NonNull final byte[] playlistStringByteArray) { + this.dataSourceFactory = dataSourceFactory; + this.playlistStringByteArray = playlistStringByteArray; + } + + /** + * Create a {@link DataSource} for the given data type. + * + *

+ * Contrary to {@link com.google.android.exoplayer2.source.hls.DefaultHlsDataSourceFactory + * ExoPlayer's default implementation}, this implementation is not always using the + * {@link DataSource.Factory} passed to the + * {@link com.google.android.exoplayer2.source.hls.HlsMediaSource.Factory + * HlsMediaSource.Factory} constructor, only when it's not + * {@link C#DATA_TYPE_MANIFEST the manifest type}. + *

+ * + *

+ * This change allow playback of non-URI HLS contents, when the manifest is not a master + * manifest/playlist (otherwise, endless loops should be encountered because the + * {@link DataSource}s created for media playlists should use the master playlist response + * instead). + *

+ * + * @param dataType the data type for which the {@link DataSource} will be used, which is one of + * {@link C} {@code .DATA_TYPE_*} constants + * @return a {@link DataSource} for the given data type + */ + @NonNull + @Override + public DataSource createDataSource(final int dataType) { + // The manifest is already downloaded and provided with playlistStringByteArray, so we + // don't need to download it again and we can use a ByteArrayDataSource instead + if (dataType == C.DATA_TYPE_MANIFEST) { + return new ByteArrayDataSource(playlistStringByteArray); + } + + return dataSourceFactory.createDataSource(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/NonUriHlsPlaylistParserFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/NonUriHlsPlaylistParserFactory.java deleted file mode 100644 index a3a25fd1d..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/NonUriHlsPlaylistParserFactory.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.schabi.newpipe.player.helper; - -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; -import com.google.android.exoplayer2.source.hls.playlist.HlsMultivariantPlaylist; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParserFactory; -import com.google.android.exoplayer2.upstream.ParsingLoadable; - -import java.io.IOException; -import java.io.InputStream; - -/** - * A {@link HlsPlaylistParserFactory} for non-URI HLS sources. - */ -public final class NonUriHlsPlaylistParserFactory implements HlsPlaylistParserFactory { - - private final HlsPlaylist hlsPlaylist; - - public NonUriHlsPlaylistParserFactory(final HlsPlaylist hlsPlaylist) { - this.hlsPlaylist = hlsPlaylist; - } - - private final class NonUriHlsPlayListParser implements ParsingLoadable.Parser { - - @Override - public HlsPlaylist parse(final Uri uri, - final InputStream inputStream) throws IOException { - return hlsPlaylist; - } - } - - @NonNull - @Override - public ParsingLoadable.Parser createPlaylistParser() { - return new NonUriHlsPlayListParser(); - } - - @NonNull - @Override - public ParsingLoadable.Parser createPlaylistParser( - @NonNull final HlsMultivariantPlaylist multivariantPlaylist, - @Nullable final HlsMediaPlaylist previousMediaPlaylist) { - return new NonUriHlsPlayListParser(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java index 8cb423b51..82fb3a0e7 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -12,7 +12,6 @@ import com.google.android.exoplayer2.source.dash.DashMediaSource; import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource; import com.google.android.exoplayer2.source.hls.HlsMediaSource; import com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTracker; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParserFactory; import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.upstream.DataSource; @@ -26,6 +25,7 @@ import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator; +import org.schabi.newpipe.player.datasource.NonUriHlsDataSourceFactory; import org.schabi.newpipe.player.datasource.YoutubeHttpDataSource; import java.io.File; @@ -132,10 +132,13 @@ public class PlayerDataSource { //region Generic media source factories public HlsMediaSource.Factory getHlsMediaSourceFactory( - @Nullable final HlsPlaylistParserFactory hlsPlaylistParserFactory) { - final HlsMediaSource.Factory factory = new HlsMediaSource.Factory(cacheDataSourceFactory); - factory.setPlaylistParserFactory(hlsPlaylistParserFactory); - return factory; + @Nullable final NonUriHlsDataSourceFactory.Builder hlsDataSourceFactoryBuilder) { + if (hlsDataSourceFactoryBuilder != null) { + hlsDataSourceFactoryBuilder.setDataSourceFactory(cacheDataSourceFactory); + return new HlsMediaSource.Factory(hlsDataSourceFactoryBuilder.build()); + } + + return new HlsMediaSource.Factory(cacheDataSourceFactory); } public DashMediaSource.Factory getDashMediaSourceFactory() { 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 d7f04774c..763a67623 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 @@ -18,8 +18,6 @@ import com.google.android.exoplayer2.source.dash.DashMediaSource; import com.google.android.exoplayer2.source.dash.manifest.DashManifest; import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; import com.google.android.exoplayer2.source.hls.HlsMediaSource; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser; import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifestParser; @@ -37,7 +35,7 @@ import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.player.helper.NonUriHlsPlaylistParserFactory; +import org.schabi.newpipe.player.datasource.NonUriHlsDataSourceFactory; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediaitem.StreamInfoTag; @@ -340,27 +338,17 @@ public interface PlaybackResolver extends Resolver { .setCustomCacheKey(cacheKey) .build()); } else { - String baseUrl = stream.getManifestUrl(); - if (baseUrl == null) { - baseUrl = ""; + final NonUriHlsDataSourceFactory.Builder hlsDataSourceFactoryBuilder = + new NonUriHlsDataSourceFactory.Builder(); + hlsDataSourceFactoryBuilder.setPlaylistString(stream.getContent()); + String manifestUrl = stream.getManifestUrl(); + if (manifestUrl == null) { + manifestUrl = ""; } - - final Uri uri = Uri.parse(baseUrl); - - final HlsPlaylist hlsPlaylist; - try { - final ByteArrayInputStream hlsManifestInput = new ByteArrayInputStream( - stream.getContent().getBytes(StandardCharsets.UTF_8)); - hlsPlaylist = new HlsPlaylistParser().parse(uri, hlsManifestInput); - } catch (final IOException e) { - throw new ResolverException("Error when parsing manual HLS manifest", e); - } - - return dataSource.getHlsMediaSourceFactory( - new NonUriHlsPlaylistParserFactory(hlsPlaylist)) + return dataSource.getHlsMediaSourceFactory(hlsDataSourceFactoryBuilder) .createMediaSource(new MediaItem.Builder() .setTag(metadata) - .setUri(Uri.parse(stream.getContent())) + .setUri(Uri.parse(manifestUrl)) .setCustomCacheKey(cacheKey) .build()); } From 7ba79171c7c9e84fb0e9f4bc242d81ef321c36fa Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 18 Jun 2022 17:40:22 +0200 Subject: [PATCH 14/20] Refactor creation of DownloadDialog --- .../org/schabi/newpipe/RouterActivity.java | 5 +- .../newpipe/download/DownloadDialog.java | 148 +++++------------- .../fragments/detail/VideoDetailFragment.java | 6 +- 3 files changed, 39 insertions(+), 120 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index 96f8ff1bc..1fe6ce7ec 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -70,7 +70,6 @@ import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; @@ -676,9 +675,7 @@ public class RouterActivity extends AppCompatActivity { .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(result -> { - final DownloadDialog downloadDialog = DownloadDialog.newInstance(this, result); - downloadDialog.setSelectedVideoStream(ListHelper.getDefaultResolutionIndex( - this, downloadDialog.wrappedVideoStreams.getStreamsList())); + final DownloadDialog downloadDialog = new DownloadDialog(this, result); downloadDialog.setOnDismissListener(dialog -> finish()); final FragmentManager fm = getSupportFragmentManager(); 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 4fb47496b..e4adddc2a 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -48,7 +48,6 @@ import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.DeliveryMethod; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.SubtitlesStream; @@ -83,6 +82,7 @@ import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; import us.shandian.giga.service.MissionState; +import static org.schabi.newpipe.extractor.stream.DeliveryMethod.PROGRESSIVE_HTTP; import static org.schabi.newpipe.util.ListHelper.getStreamsOfSpecifiedDelivery; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; @@ -94,17 +94,17 @@ public class DownloadDialog extends DialogFragment @State StreamInfo currentInfo; @State - public StreamSizeWrapper wrappedAudioStreams = StreamSizeWrapper.empty(); + StreamSizeWrapper wrappedAudioStreams; @State - public StreamSizeWrapper wrappedVideoStreams = StreamSizeWrapper.empty(); + StreamSizeWrapper wrappedVideoStreams; @State - public StreamSizeWrapper wrappedSubtitleStreams = StreamSizeWrapper.empty(); + StreamSizeWrapper wrappedSubtitleStreams; @State - int selectedVideoIndex = 0; + int selectedVideoIndex; // set in the constructor @State - int selectedAudioIndex = 0; + int selectedAudioIndex = 0; // default to the first item @State - int selectedSubtitleIndex = 0; + int selectedSubtitleIndex = 0; // default to the first item @Nullable private OnDismissListener onDismissListener = null; @@ -140,116 +140,48 @@ public class DownloadDialog extends DialogFragment registerForActivityResult( new StartActivityForResult(), this::requestDownloadPickVideoFolderResult); + /*////////////////////////////////////////////////////////////////////////// // Instance creation //////////////////////////////////////////////////////////////////////////*/ - @NonNull - public static DownloadDialog newInstance(final Context context, - @NonNull final StreamInfo info) { - // TODO: Adapt this code when the downloader support other types of stream deliveries - final List progressiveHttpVideoStreams = - getStreamsOfSpecifiedDelivery(info.getVideoStreams(), - DeliveryMethod.PROGRESSIVE_HTTP); - - final List progressiveHttpVideoOnlyStreams = - getStreamsOfSpecifiedDelivery(info.getVideoOnlyStreams(), - DeliveryMethod.PROGRESSIVE_HTTP); - - final List progressiveHttpAudioStreams = - getStreamsOfSpecifiedDelivery(info.getAudioStreams(), - DeliveryMethod.PROGRESSIVE_HTTP); - - final List progressiveHttpSubtitlesStreams = - getStreamsOfSpecifiedDelivery(info.getSubtitles(), - DeliveryMethod.PROGRESSIVE_HTTP); - - final List videoStreamsList = ListHelper.getSortedStreamVideosList(context, - progressiveHttpVideoStreams, progressiveHttpVideoOnlyStreams, false, false); - - final DownloadDialog instance = new DownloadDialog(); - instance.setInfo(info); - instance.setVideoStreams(videoStreamsList); - instance.setAudioStreams(progressiveHttpAudioStreams); - instance.setSubtitleStreams(progressiveHttpSubtitlesStreams); - - return instance; - } - - - /*////////////////////////////////////////////////////////////////////////// - // Setters - //////////////////////////////////////////////////////////////////////////*/ - - private void setInfo(@NonNull final StreamInfo info) { + /** + * Create a new download dialog with the video, audio and subtitle streams from the provided + * stream info. Video streams and video-only streams will be put into a single list menu, + * sorted according to their resolution and the default video resolution will be selected. + * + * @param context the context to use just to obtain preferences and strings (will not be stored) + * @param info the info from which to obtain downloadable streams and other info (e.g. title) + */ + public DownloadDialog(final Context context, @NonNull final StreamInfo info) { this.currentInfo = info; - } - public void setAudioStreams(@NonNull final List audioStreams) { - this.wrappedAudioStreams = new StreamSizeWrapper<>(audioStreams, getContext()); - } + // 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 + ); - public void setVideoStreams(@NonNull final List videoStreams) { - this.wrappedVideoStreams = new StreamSizeWrapper<>(videoStreams, getContext()); - } + 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); - public void setSubtitleStreams(@NonNull final List subtitleStreams) { - this.wrappedSubtitleStreams = new StreamSizeWrapper<>(subtitleStreams, getContext()); + this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams); } /** - * Set the selected video stream, by using its index in the stream list. - * - * The index of the select video stream will be not set if this index is not in the bounds - * of the stream list. - * - * @param svi the index of the selected {@link VideoStream} + * @param onDismissListener the listener to call in {@link #onDismiss(DialogInterface)} */ - public void setSelectedVideoStream(final int svi) { - if (selectedStreamIsInBoundsOfWrappedStreams(svi, this.wrappedVideoStreams)) { - this.selectedVideoIndex = svi; - } - } - - /** - * Set the selected audio stream, by using its index in the stream list. - * - * The index of the select audio stream will be not set if this index is not in the bounds - * of the stream list. - * - * @param sai the index of the selected {@link AudioStream} - */ - public void setSelectedAudioStream(final int sai) { - if (selectedStreamIsInBoundsOfWrappedStreams(sai, this.wrappedAudioStreams)) { - this.selectedAudioIndex = sai; - } - } - - /** - * Set the selected subtitles stream, by using its index in the stream list. - * - * The index of the select subtitles stream will be not set if this index is not in the bounds - * of the stream list. - * - * @param ssi the index of the selected {@link SubtitlesStream} - */ - public void setSelectedSubtitleStream(final int ssi) { - if (selectedStreamIsInBoundsOfWrappedStreams(ssi, this.wrappedSubtitleStreams)) { - this.selectedSubtitleIndex = ssi; - } - } - - private boolean selectedStreamIsInBoundsOfWrappedStreams( - final int selectedIndexStream, - final StreamSizeWrapper wrappedStreams) { - return selectedIndexStream > 0 - && selectedIndexStream < wrappedStreams.getStreamsList().size(); - } - public void setOnDismissListener(@Nullable final OnDismissListener onDismissListener) { this.onDismissListener = onDismissListener; } + /*////////////////////////////////////////////////////////////////////////// // Android lifecycle //////////////////////////////////////////////////////////////////////////*/ @@ -754,13 +686,9 @@ public class DownloadDialog extends DialogFragment if (format == MediaFormat.WEBMA_OPUS) { mimeTmp = "audio/ogg"; filenameTmp += "opus"; - } else { - if (format != null) { - mimeTmp = format.mimeType; - } - if (format != null) { - filenameTmp += format.suffix; - } + } else if (format != null) { + mimeTmp = format.mimeType; + filenameTmp += format.suffix; } break; case R.id.video_button: @@ -769,8 +697,6 @@ public class DownloadDialog extends DialogFragment format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); if (format != null) { mimeTmp = format.mimeType; - } - if (format != null) { filenameTmp += format.suffix; } break; @@ -1085,7 +1011,7 @@ public class DownloadDialog extends DialogFragment new MissionRecoveryInfo(selectedStream) }; } else { - if (secondaryStream.getDeliveryMethod() != DeliveryMethod.PROGRESSIVE_HTTP) { + if (secondaryStream.getDeliveryMethod() != PROGRESSIVE_HTTP) { throw new IllegalArgumentException("Unsupported stream delivery format" + secondaryStream.getDeliveryMethod()); } 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 ff2114b83..100af41cc 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 @@ -1689,11 +1689,7 @@ public final class VideoDetailFragment } try { - final DownloadDialog downloadDialog = DownloadDialog.newInstance(activity, - currentInfo); - downloadDialog.setSelectedVideoStream(ListHelper.getDefaultResolutionIndex(activity, - downloadDialog.wrappedVideoStreams.getStreamsList())); - + final DownloadDialog downloadDialog = new DownloadDialog(activity, currentInfo); downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog"); } catch (final Exception e) { ErrorUtil.showSnackbar(activity, new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG, From 4863084fa2565f674a0eedcff429abdabda60185 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 18 Jun 2022 17:49:04 +0200 Subject: [PATCH 15/20] Improve code in VideoDetailFragment --- .../fragments/detail/VideoDetailFragment.java | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) 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 100af41cc..cbd8b05b4 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 @@ -1555,13 +1555,11 @@ public final class VideoDetailFragment binding.detailSubChannelThumbnailView.setImageDrawable(buddyDrawable); binding.detailUploaderThumbnailView.setImageDrawable(buddyDrawable); - final StreamType streamType = info.getStreamType(); - if (info.getViewCount() >= 0) { - if (streamType.equals(StreamType.AUDIO_LIVE_STREAM)) { + if (info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { binding.detailViewCountView.setText(Localization.listeningCount(activity, info.getViewCount())); - } else if (streamType.equals(StreamType.LIVE_STREAM)) { + } else if (info.getStreamType().equals(StreamType.LIVE_STREAM)) { binding.detailViewCountView.setText(Localization .localizeWatchingCount(activity, info.getViewCount())); } else { @@ -1648,7 +1646,7 @@ public final class VideoDetailFragment } binding.detailControlsDownload.setVisibility( - StreamTypeUtil.isLiveStream(streamType) ? View.GONE : View.VISIBLE); + StreamTypeUtil.isLiveStream(info.getStreamType()) ? View.GONE : View.VISIBLE); binding.detailControlsBackground.setVisibility(info.getAudioStreams().isEmpty() ? View.GONE : View.VISIBLE); @@ -2151,21 +2149,24 @@ public final class VideoDetailFragment return; } - final List videoStreams = getNonUrlAndNonTorrentStreams( - currentInfo.getVideoStreams()); - final List videoOnlyStreams = getNonUrlAndNonTorrentStreams( - currentInfo.getVideoOnlyStreams()); - final AlertDialog.Builder builder = new AlertDialog.Builder(activity); builder.setTitle(R.string.select_quality_external_players); builder.setNeutralButton(R.string.open_in_browser, (dialog, i) -> ShareUtils.openUrlInBrowser(requireActivity(), url)); + final List videoStreamsForExternalPlayers = - ListHelper.getSortedStreamVideosList(activity, videoStreams, videoOnlyStreams, - false, false); + ListHelper.getSortedStreamVideosList( + activity, + getNonUrlAndNonTorrentStreams(currentInfo.getVideoStreams()), + getNonUrlAndNonTorrentStreams(currentInfo.getVideoOnlyStreams()), + false, + false + ); + if (videoStreamsForExternalPlayers.isEmpty()) { builder.setMessage(R.string.no_video_streams_available_for_external_players); builder.setPositiveButton(R.string.ok, null); + } else { final int selectedVideoStreamIndexForExternalPlayers = ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers); From 1e076ea63d776f76c36934669545c7e4f2ec6838 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 18 Jun 2022 18:09:12 +0200 Subject: [PATCH 16/20] Wrap debug log in if(DEBUG) --- .../org/schabi/newpipe/player/helper/PlayerDataSource.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java index 82fb3a0e7..88f25e194 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.player.helper; +import static org.schabi.newpipe.MainActivity.DEBUG; + import android.content.Context; import android.util.Log; @@ -199,7 +201,9 @@ public class PlayerDataSource { private static void instantiateCacheIfNeeded(final Context context) { if (cache == null) { final File cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); - Log.d(TAG, "instantiateCacheIfNeeded: cacheDir = " + cacheDir.getAbsolutePath()); + if (DEBUG) { + Log.d(TAG, "instantiateCacheIfNeeded: cacheDir = " + cacheDir.getAbsolutePath()); + } if (!cacheDir.exists() && !cacheDir.mkdir()) { Log.w(TAG, "instantiateCacheIfNeeded: could not create cache dir"); } From 2019af831a7ad1bffb212a08f53c0bc72eb03874 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 18 Jun 2022 18:41:44 +0200 Subject: [PATCH 17/20] Refactor PlaybackResolver and fix cacheKeyOf In commonCacheKeyOf the result of an Objects.hash() was ignored --- .../player/resolver/PlaybackResolver.java | 181 ++++++++---------- 1 file changed, 76 insertions(+), 105 deletions(-) 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 763a67623..fb6dbc3bb 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 @@ -2,7 +2,6 @@ package org.schabi.newpipe.player.resolver; import static org.schabi.newpipe.extractor.stream.AudioStream.UNKNOWN_BITRATE; import static org.schabi.newpipe.extractor.stream.VideoStream.RESOLUTION_UNKNOWN; -import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import static org.schabi.newpipe.player.helper.PlayerDataSource.LIVE_STREAM_EDGE_GAP_MILLIS; import android.net.Uri; @@ -88,7 +87,7 @@ public interface PlaybackResolver extends Resolver { // cache useless. if (resolutionOrBitrateUnknown && mediaFormat == null) { cacheKey.append(" "); - Objects.hash(stream.getContent(), stream.getManifestUrl()); + cacheKey.append(Objects.hash(stream.getContent(), stream.getManifestUrl())); } return cacheKey; @@ -250,20 +249,13 @@ public interface PlaybackResolver extends Resolver { final Stream stream, final String cacheKey, final MediaItemTag metadata) throws ResolverException { - final String url = stream.getContent(); - - if (isNullOrEmpty(url)) { - throw new ResolverException( - "Try to generate a progressive media source from an empty string or from a " - + "null object"); - } else { - return dataSource.getProgressiveMediaSourceFactory().createMediaSource( - new MediaItem.Builder() - .setTag(metadata) - .setUri(Uri.parse(url)) - .setCustomCacheKey(cacheKey) - .build()); - } + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); + return dataSource.getProgressiveMediaSourceFactory().createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); } private static DashMediaSource buildDashMediaSource(final PlayerDataSource dataSource, @@ -271,52 +263,35 @@ public interface PlaybackResolver extends Resolver { final String cacheKey, final MediaItemTag metadata) throws ResolverException { - final boolean isUrlStream = stream.isUrl(); - if (isUrlStream && isNullOrEmpty(stream.getContent())) { - throw new ResolverException( - "Could not build a DASH media source from an empty or a null URL content"); - } - if (isUrlStream) { + if (stream.isUrl()) { + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); return dataSource.getDashMediaSourceFactory().createMediaSource( new MediaItem.Builder() .setTag(metadata) .setUri(Uri.parse(stream.getContent())) .setCustomCacheKey(cacheKey) .build()); - } else { - String baseUrl = stream.getManifestUrl(); - if (baseUrl == null) { - baseUrl = ""; - } + } - final Uri uri = Uri.parse(baseUrl); - - try { - return dataSource.getDashMediaSourceFactory().createMediaSource( - createDashManifest(stream.getContent(), stream), - new MediaItem.Builder() - .setTag(metadata) - .setUri(uri) - .setCustomCacheKey(cacheKey) - .build()); - } catch (final IOException e) { - throw new ResolverException( - "Could not create a DASH media source/manifest from the manifest text"); - } + try { + return dataSource.getDashMediaSourceFactory().createMediaSource( + createDashManifest(stream.getContent(), stream), + new MediaItem.Builder() + .setTag(metadata) + .setUri(manifestUrlToUri(stream.getManifestUrl())) + .setCustomCacheKey(cacheKey) + .build()); + } catch (final IOException e) { + throw new ResolverException( + "Could not create a DASH media source/manifest from the manifest text", e); } } private static DashManifest createDashManifest(final String manifestContent, final Stream stream) throws IOException { - final ByteArrayInputStream dashManifestInput = new ByteArrayInputStream( - manifestContent.getBytes(StandardCharsets.UTF_8)); - String baseUrl = stream.getManifestUrl(); - if (baseUrl == null) { - baseUrl = ""; - } - - return new DashManifestParser().parse(Uri.parse(baseUrl), dashManifestInput); + return new DashManifestParser().parse(manifestUrlToUri(stream.getManifestUrl()), + new ByteArrayInputStream(manifestContent.getBytes(StandardCharsets.UTF_8))); } private static HlsMediaSource buildHlsMediaSource(final PlayerDataSource dataSource, @@ -324,34 +299,26 @@ public interface PlaybackResolver extends Resolver { final String cacheKey, final MediaItemTag metadata) throws ResolverException { - final boolean isUrlStream = stream.isUrl(); - if (isUrlStream && isNullOrEmpty(stream.getContent())) { - throw new ResolverException( - "Could not build a HLS media source from an empty or a null URL content"); - } - - if (isUrlStream) { + if (stream.isUrl()) { + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); return dataSource.getHlsMediaSourceFactory(null).createMediaSource( new MediaItem.Builder() .setTag(metadata) .setUri(Uri.parse(stream.getContent())) .setCustomCacheKey(cacheKey) .build()); - } else { - final NonUriHlsDataSourceFactory.Builder hlsDataSourceFactoryBuilder = - new NonUriHlsDataSourceFactory.Builder(); - hlsDataSourceFactoryBuilder.setPlaylistString(stream.getContent()); - String manifestUrl = stream.getManifestUrl(); - if (manifestUrl == null) { - manifestUrl = ""; - } - return dataSource.getHlsMediaSourceFactory(hlsDataSourceFactoryBuilder) - .createMediaSource(new MediaItem.Builder() - .setTag(metadata) - .setUri(Uri.parse(manifestUrl)) - .setCustomCacheKey(cacheKey) - .build()); } + + final NonUriHlsDataSourceFactory.Builder hlsDataSourceFactoryBuilder = + new NonUriHlsDataSourceFactory.Builder(); + hlsDataSourceFactoryBuilder.setPlaylistString(stream.getContent()); + + return dataSource.getHlsMediaSourceFactory(hlsDataSourceFactoryBuilder) + .createMediaSource(new MediaItem.Builder() + .setTag(metadata) + .setUri(manifestUrlToUri(stream.getManifestUrl())) + .setCustomCacheKey(cacheKey) + .build()); } private static SsMediaSource buildSSMediaSource(final PlayerDataSource dataSource, @@ -359,45 +326,35 @@ public interface PlaybackResolver extends Resolver { final String cacheKey, final MediaItemTag metadata) throws ResolverException { - final boolean isUrlStream = stream.isUrl(); - if (isUrlStream && isNullOrEmpty(stream.getContent())) { - throw new ResolverException( - "Could not build a SS media source from an empty or a null URL content"); - } - - if (isUrlStream) { + if (stream.isUrl()) { + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); return dataSource.getSSMediaSourceFactory().createMediaSource( new MediaItem.Builder() .setTag(metadata) .setUri(Uri.parse(stream.getContent())) .setCustomCacheKey(cacheKey) .build()); - } else { - String baseUrl = stream.getManifestUrl(); - if (baseUrl == null) { - baseUrl = ""; - } - - final Uri uri = Uri.parse(baseUrl); - - final SsManifest smoothStreamingManifest; - try { - final ByteArrayInputStream smoothStreamingManifestInput = new ByteArrayInputStream( - stream.getContent().getBytes(StandardCharsets.UTF_8)); - smoothStreamingManifest = new SsManifestParser().parse(uri, - smoothStreamingManifestInput); - } catch (final IOException e) { - throw new ResolverException("Error when parsing manual SS manifest", e); - } - - return dataSource.getSSMediaSourceFactory().createMediaSource( - smoothStreamingManifest, - new MediaItem.Builder() - .setTag(metadata) - .setUri(uri) - .setCustomCacheKey(cacheKey) - .build()); } + + final Uri manifestUri = manifestUrlToUri(stream.getManifestUrl()); + + final SsManifest smoothStreamingManifest; + try { + final ByteArrayInputStream smoothStreamingManifestInput = new ByteArrayInputStream( + stream.getContent().getBytes(StandardCharsets.UTF_8)); + smoothStreamingManifest = new SsManifestParser().parse(manifestUri, + smoothStreamingManifestInput); + } catch (final IOException e) { + throw new ResolverException("Error when parsing manual SS manifest", e); + } + + return dataSource.getSSMediaSourceFactory().createMediaSource( + smoothStreamingManifest, + new MediaItem.Builder() + .setTag(metadata) + .setUri(manifestUri) + .setCustomCacheKey(cacheKey) + .build()); } //endregion @@ -435,8 +392,6 @@ public interface PlaybackResolver extends Resolver { createDashManifest(manifestString, stream), stream, cacheKey, metadata); } catch (final CreationException | IOException | NullPointerException e) { - Log.e(TAG, "Error when generating the DASH manifest of YouTube ended live stream", - e); throw new ResolverException( "Error when generating the DASH manifest of YouTube ended live stream", e); } @@ -540,7 +495,23 @@ public interface PlaybackResolver extends Resolver { //endregion - //region resolver exception + //region Utils + private static Uri manifestUrlToUri(final String manifestUrl) { + return Uri.parse(Objects.requireNonNullElse(manifestUrl, "")); + } + + private static void throwResolverExceptionIfUrlNullOrEmpty(@Nullable final String url) + throws ResolverException { + if (url == null) { + throw new ResolverException("Null stream url"); + } else if (url.isEmpty()) { + throw new ResolverException("Empty stream url"); + } + } + //endregion + + + //region Resolver exception final class ResolverException extends Exception { public ResolverException(final String message) { super(message); From 4e87f5aabcce455445bf1e50defedd8a07261df2 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 18 Jun 2022 18:52:32 +0200 Subject: [PATCH 18/20] Remove misleading first "Non" from getNonUrlAndNonTorrentStreams --- .../newpipe/fragments/detail/VideoDetailFragment.java | 8 ++++---- .../newpipe/player/resolver/VideoPlaybackResolver.java | 4 ++-- app/src/main/java/org/schabi/newpipe/util/ListHelper.java | 2 +- .../java/org/schabi/newpipe/util/NavigationHelper.java | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) 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 cbd8b05b4..5e19f558d 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 @@ -123,7 +123,7 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientat import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired; import static org.schabi.newpipe.player.playqueue.PlayQueueItem.RECOVERY_UNSET; import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; -import static org.schabi.newpipe.util.ListHelper.getNonUrlAndNonTorrentStreams; +import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; public final class VideoDetailFragment extends BaseStateFragment @@ -1107,7 +1107,7 @@ public final class VideoDetailFragment if (!useExternalAudioPlayer) { openNormalBackgroundPlayer(append); } else { - final List audioStreams = getNonUrlAndNonTorrentStreams( + final List audioStreams = getUrlAndNonTorrentStreams( currentInfo.getAudioStreams()); final int index = ListHelper.getDefaultAudioFormat(activity, audioStreams); @@ -2157,8 +2157,8 @@ public final class VideoDetailFragment final List videoStreamsForExternalPlayers = ListHelper.getSortedStreamVideosList( activity, - getNonUrlAndNonTorrentStreams(currentInfo.getVideoStreams()), - getNonUrlAndNonTorrentStreams(currentInfo.getVideoOnlyStreams()), + getUrlAndNonTorrentStreams(currentInfo.getVideoStreams()), + getUrlAndNonTorrentStreams(currentInfo.getVideoOnlyStreams()), false, false ); 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 6e18ee0cd..cf7d73558 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,7 +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.getNonUrlAndNonTorrentStreams; +import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; import static org.schabi.newpipe.util.ListHelper.getNonTorrentStreams; public class VideoPlaybackResolver implements PlaybackResolver { @@ -131,7 +131,7 @@ public class VideoPlaybackResolver implements PlaybackResolver { final List subtitlesStreams = info.getSubtitles(); if (subtitlesStreams != null) { // Torrent and non URL subtitles are not supported by ExoPlayer - final List nonTorrentAndUrlStreams = getNonUrlAndNonTorrentStreams( + final List nonTorrentAndUrlStreams = getUrlAndNonTorrentStreams( subtitlesStreams); for (final SubtitlesStream subtitle : nonTorrentAndUrlStreams) { final MediaFormat mediaFormat = subtitle.getFormat(); 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 33c7a2f49..eabac8330 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java @@ -137,7 +137,7 @@ public final class ListHelper { * @return a stream list which only contains URL streams and non-torrent streams */ @NonNull - public static List getNonUrlAndNonTorrentStreams( + public static List getUrlAndNonTorrentStreams( final List streamList) { return getFilteredStreamList(streamList, stream -> stream.isUrl() && stream.getDeliveryMethod() != DeliveryMethod.TORRENT); diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index ffc7433a0..c40b1a430 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -63,7 +63,7 @@ import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.List; -import static org.schabi.newpipe.util.ListHelper.getNonUrlAndNonTorrentStreams; +import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; public final class NavigationHelper { public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; @@ -227,7 +227,7 @@ public final class NavigationHelper { } final List audioStreamsForExternalPlayers = - getNonUrlAndNonTorrentStreams(audioStreams); + getUrlAndNonTorrentStreams(audioStreams); if (audioStreamsForExternalPlayers.isEmpty()) { Toast.makeText(context, R.string.no_audio_streams_available_for_external_players, Toast.LENGTH_SHORT).show(); @@ -250,7 +250,7 @@ public final class NavigationHelper { final List videoStreamsForExternalPlayers = ListHelper.getSortedStreamVideosList(context, - getNonUrlAndNonTorrentStreams(videoStreams), null, false, false); + getUrlAndNonTorrentStreams(videoStreams), null, false, false); if (videoStreamsForExternalPlayers.isEmpty()) { Toast.makeText(context, R.string.no_video_streams_available_for_external_players, Toast.LENGTH_SHORT).show(); From 0ad6b3b88ea46cc0e207a46ccdcc47827c5c6601 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 18 Jun 2022 19:16:36 +0200 Subject: [PATCH 19/20] Improve download_dialog.xml unsupported streams notice --- app/src/main/res/layout/download_dialog.xml | 12 ++++++------ app/src/main/res/values/strings.xml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/res/layout/download_dialog.xml b/app/src/main/res/layout/download_dialog.xml index 4a9c0711f..37bbf2b03 100644 --- a/app/src/main/res/layout/download_dialog.xml +++ b/app/src/main/res/layout/download_dialog.xml @@ -88,8 +88,8 @@ android:layout_below="@+id/threads_text_view" android:layout_marginLeft="24dp" android:layout_marginRight="24dp" - android:orientation="horizontal" - android:paddingBottom="12dp"> + android:layout_marginBottom="12dp" + android:orientation="horizontal"> + android:layout_marginBottom="12dp" + android:gravity="center" + android:text="@string/streams_not_yet_supported_removed" + android:textSize="12sp" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ab39d302..6a1c220f0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -740,7 +740,7 @@ You now subscribed to this channel , Toggle all - Note that streams which are not supported by the downloader yet have been removed + Streams which are not yet supported by the downloader are not shown 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 From cbd3308da643e2649e1e01c820cfa7a5fde7fcd1 Mon Sep 17 00:00:00 2001 From: AudricV <74829229+AudricV@users.noreply.github.com> Date: Sun, 19 Jun 2022 15:27:30 +0200 Subject: [PATCH 20/20] Ensure that progressive contents are URL contents for playback A ResolverException will be now thrown otherwise. --- .../schabi/newpipe/player/resolver/PlaybackResolver.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 fb6dbc3bb..34e7e9bd1 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 @@ -249,6 +249,9 @@ public interface PlaybackResolver extends Resolver { final Stream stream, final String cacheKey, final MediaItemTag metadata) throws ResolverException { + if (!stream.isUrl()) { + throw new ResolverException("Non URI progressive contents are not supported"); + } throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); return dataSource.getProgressiveMediaSourceFactory().createMediaSource( new MediaItem.Builder() @@ -503,9 +506,9 @@ public interface PlaybackResolver extends Resolver { private static void throwResolverExceptionIfUrlNullOrEmpty(@Nullable final String url) throws ResolverException { if (url == null) { - throw new ResolverException("Null stream url"); + throw new ResolverException("Null stream URL"); } else if (url.isEmpty()) { - throw new ResolverException("Empty stream url"); + throw new ResolverException("Empty stream URL"); } } //endregion