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 | 116 +- .../NonUriHlsPlaylistParserFactory.java | 50 + .../player/helper/PlayerDataSource.java | 127 +- .../newpipe/player/helper/PlayerHelper.java | 67 +- .../listeners/view/QualityClickListener.kt | 2 +- .../resolver/AudioPlaybackResolver.java | 27 +- .../player/resolver/PlaybackResolver.java | 407 ++++++- .../resolver/VideoPlaybackResolver.java | 89 +- .../org/schabi/newpipe/util/ListHelper.java | 130 ++- .../schabi/newpipe/util/NavigationHelper.java | 85 +- .../newpipe/util/SecondaryStreamHelper.java | 48 +- .../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, 2411 insertions(+), 533 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 44fd7512b50..995dae6ed5f 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 a9aa40d8273..016feb57645 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 cc89c0fed61..96f8ff1bceb 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.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 @@ private void openDownloadDialog() { .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 f5c22690836..73ba8c74a7b 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.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.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.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); + + final List videoOnlyStreams = new ArrayList<>(info.getVideoOnlyStreams()); + final List progressiveHttpVideoOnlyStreams = + keepStreamsWithDelivery(videoOnlyStreams, DeliveryMethod.PROGRESSIVE_HTTP); + + 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); - 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 videoStreamsList = new ArrayList<>( + ListHelper.getSortedStreamVideosList(context, progressiveHttpVideoStreams, + progressiveHttpVideoOnlyStreams, false, false)); - final DownloadDialog instance = newInstance(info); - instance.setVideoStreams(streamsList); - instance.setSelectedVideoStream(selectedStreamIndex); - instance.setAudioStreams(info.getAudioStreams()); - instance.setSubtitleStreams(info.getSubtitles()); + final DownloadDialog instance = new DownloadDialog(); + instance.setInfo(info); + instance.setVideoStreams(videoStreamsList); + instance.setAudioStreams(progressiveHttpAudioStreams); + instance.setSubtitleStreams(progressiveHttpSubtitlesStreams); return instance; } @@ -169,45 +183,69 @@ public static DownloadDialog newInstance(final Context context, final StreamInfo // 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(final StreamSizeWrapper was) { - this.wrappedAudioStreams = was; - } - - public void setVideoStreams(final List videoStreams) { - setVideoStreams(new StreamSizeWrapper<>(videoStreams, getContext())); + public void setAudioStreams(@NonNull final List audioStreams) { + this.wrappedAudioStreams = new StreamSizeWrapper<>(audioStreams, getContext()); } - public void setVideoStreams(final StreamSizeWrapper wvs) { - this.wrappedVideoStreams = wvs; + public void setVideoStreams(@NonNull final List videoStreams) { + this.wrappedVideoStreams = new StreamSizeWrapper<>(videoStreams, getContext()); } - 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 void onCreate(@Nullable final Bundle savedInstanceState) { .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 void onServiceDisconnected(final ComponentName name) { } @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 View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup } @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 void onViewCreated(@NonNull final View view, @Nullable final Bundle saved 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 @@ private void requestDownloadPickVideoFolderResult(final ActivityResult result) { 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 @@ private void requestDownloadSaveAsResult(final ActivityResult result) { 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 @@ private void requestDownloadSaveAsResult(final ActivityResult result) { 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 @@ private void requestDownloadPickFolderResult(final ActivityResult result, 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 void onCheckedChanged(final RadioGroup group, @IdRes final int checkedId) } @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 @@ protected void setupDownloadOptions() { 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 @@ private void setRadioButtonsState(final boolean enabled) { 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 @@ private int getSubtitleIndexBy(final List streams) { 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 @@ private void showFailedDialog(@StringRes final int msg) { } 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 @@ private void prepareSelectedDownload() { 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 @@ private void prepareSelectedDownload() { 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 @@ private void prepareSelectedDownload() { } 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 @@ private void continueSelectedDownload(@NonNull final StoredFileHelper storage) { 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 @@ private void continueSelectedDownload(@NonNull final StoredFileHelper storage) { } 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 @@ private void continueSelectedDownload(@NonNull final StoredFileHelper storage) { 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 @@ private void continueSelectedDownload(@NonNull final StoredFileHelper storage) { } 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 8c260461c2c..f5bd1f363e5 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.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.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 void handleResult(@NonNull final StreamInfo info) { 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 void handleResult(@NonNull final StreamInfo info) { 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 void handleResult(@NonNull final StreamInfo info) { } } - 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 void openDownloadDialog() { } 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 @@ private void updateProgressInfo(@NonNull final StreamInfo info) { 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 @@ private void showClearingQueueConfirmation(final Runnable onAllow) { } 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 79772a6a307..83211d4dd02 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 void updateFromItem(final InfoItem infoItem, 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 void updateState(final InfoItem infoItem, 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 217e3f3e3c5..96d395aa505 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 583da476464..d2aed76238c 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 @@ private void triggerProgressUpdate() { 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 @@ private void updateStreamRelatedViews() { 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 @@ private void updateStreamRelatedViews() { break; case VIDEO_STREAM: + case POST_LIVE_STREAM: if (currentMetadata == null || !currentMetadata.getMaybeQuality().isPresent() || (info.getVideoStreams().isEmpty() @@ -3484,10 +3471,10 @@ private void buildQualityMenu() { 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 boolean onMenuItemClick(@NonNull final MenuItem menuItem) { } 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 void onDismiss(@Nullable final PopupMenu menu) { } 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 @@ private void useVideoSource(final boolean videoEnabled) { } 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 @@ private void useVideoSource(final boolean videoEnabled) { * 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 @@ private boolean playQueueManagerReloadingNeeded(final SourceType sourceType, 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 @@ private boolean playQueueManagerReloadingNeeded(final SourceType sourceType, || (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 00000000000..acf9c6a4760 --- /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 98e04d4661d..47371533ab7 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 @@ 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.LeastRecentlyUsedCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; -import java.io.File; +import org.schabi.newpipe.player.datasource.YoutubeHttpDataSource; -import androidx.annotation.NonNull; +import java.io.File; -/* 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 static final int CACHE_FLAGS = CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; + private static SimpleCache cache; - private final DataSource.Factory dataSourceFactory; - private final File cacheDir; 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; + } - // 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 SimpleCache cache; + public void setUpstreamDataSourceFactory( + @Nullable final DataSource.Factory upstreamDataSourceFactory) { + this.upstreamDataSourceFactory = upstreamDataSourceFactory; + } - CacheFactory(@NonNull final Context context, - @NonNull final String userAgent, - @NonNull final TransferListener transferListener) { - this(context, userAgent, transferListener, PlayerHelper.getPreferredCacheSize(), - PlayerHelper.getPreferredFileSize()); + 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; - - dataSourceFactory = new DefaultDataSource - .Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(userAgent)) - .setTransferListener(transferListener); - cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); + @Nullable final DataSource.Factory upstreamDataSourceFactory) { + this.context = context; + this.userAgent = userAgent; + this.transferListener = transferListener; + this.upstreamDataSourceFactory = upstreamDataSourceFactory; + + final File cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); if (!cacheDir.exists()) { //noinspection ResultOfMethodCallIgnored cacheDir.mkdir(); @@ -60,37 +76,43 @@ private CacheFactory(@NonNull final Context context, 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 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; + 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; + } } - try { - for (final File file : cacheDir.listFiles()) { - final String filePath = file.getAbsolutePath(); - final boolean deleteSuccessful = file.delete(); + final DefaultDataSource dataSource = new DefaultDataSource.Factory(context, + upstreamDataSourceFactoryToUse) + .setTransferListener(transferListener) + .createDataSource(); - Log.d(TAG, "tryDeleteCacheFiles: " + filePath + " deleted = " + deleteSuccessful); - } - } catch (final Exception e) { - Log.e(TAG, "Failed to delete file.", e); - } + final FileDataSource fileSource = new FileDataSource(); + final CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize); + return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null); } } 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 00000000000..a3a25fd1df8 --- /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 405f6fd37b7..61d8baffcd8 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 @@ 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( + @Nullable final HlsPlaylistParserFactory hlsPlaylistParserFactory) { + final HlsMediaSource.Factory factory = new HlsMediaSource.Factory( + cacheDataSourceFactoryBuilder.build()); + if (hlsPlaylistParserFactory != null) { + factory.setPlaylistParserFactory(hlsPlaylistParserFactory); + } + return factory; } - public HlsMediaSource.Factory getHlsMediaSourceFactory() { - return new HlsMediaSource.Factory(cacheDataSourceFactory); + public DashMediaSource.Factory getDashMediaSourceFactory() { + return new DashMediaSource.Factory( + getDefaultDashChunkSourceFactory(cacheDataSourceFactoryBuilder.build()), + cacheDataSourceFactoryBuilder.build()); } - public DashMediaSource.Factory getDashMediaSourceFactory() { + public ProgressiveMediaSource.Factory getProgressiveMediaSourceFactory() { + return new ProgressiveMediaSource.Factory(cacheDataSourceFactoryBuilder.build()) + .setContinueLoadingCheckIntervalBytes(continueLoadingCheckIntervalBytes); + } + + 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(cacheDataSourceFactory), - cacheDataSourceFactory - ); + getDefaultDashChunkSourceFactory(cacheDataSourceFactoryBuilder.build()), + cacheDataSourceFactoryBuilder.build()); + } + + public HlsMediaSource.Factory getYoutubeHlsMediaSourceFactory() { + cacheDataSourceFactoryBuilder.setUpstreamDataSourceFactory( + getYoutubeHttpDataSourceFactory(false, false)); + return new HlsMediaSource.Factory(cacheDataSourceFactoryBuilder.build()); } - public ProgressiveMediaSource.Factory getExtractorMediaSourceFactory() { - return new ProgressiveMediaSource.Factory(cacheDataSourceFactory) - .setContinueLoadingCheckIntervalBytes(continueLoadingCheckIntervalBytes) - .setLoadErrorHandlingPolicy( - new DefaultLoadErrorHandlingPolicy(EXTRACTOR_MINIMUM_RETRY)); + 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); } - public SingleSampleMediaSource.Factory getSampleMediaSourceFactory() { - return new SingleSampleMediaSource.Factory(cacheDataSourceFactory); + @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 b73c6cf7f0b..d924f931476 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 @@ 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 static String getTimeString(final int milliSeconds) { ).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 static String resizeTypeOf(@NonNull final Context context, @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 static PlayQueue autoQueueOf(@NonNull final StreamInfo info, 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 static long getPreferredFileSize() { 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 static float getCaptionScale(@NonNull final Context context) { /** * @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 static int nextRepeatMode(@RepeatMode final int repeatMode) { 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 static WindowManager.LayoutParams retrievePopupLayoutParamsFromPrefs( 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 static WindowManager.LayoutParams retrievePopupLayoutParamsFromPrefs( 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 b103ac0e6c5..43e8288e605 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 9bded9331c7..765475b2faa 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.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 AudioPlaybackResolver(@NonNull final Context context, @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 90b38ed51da..4c1b67dfc6f 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 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 @@ default MediaSource maybeBuildLiveMediaSource(@NonNull final PlayerDataSource da } @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 @@ default MediaSource buildLiveMediaSource(@NonNull final PlayerDataSource dataSou .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); + 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); + } - 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; + 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 IllegalStateException("Unsupported type: " + type); + throw new IllegalArgumentException("Unsupported delivery type: " + deliveryMethod); } + } - return factory.createMediaSource( + @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 1aa7a5a18ab..24ca2e63a0e 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 @@ 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.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 VideoPlaybackResolver(@NonNull final Context context, @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 MediaSource resolve(@NonNull final StreamInfo info) { .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 MediaSource resolve(@NonNull final StreamInfo info) { // 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 c3ccef87c59..3a03e0b3023 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 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.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 static int getDefaultAudioFormat(final Context context, } } + /** + * 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 @@ private static String computeDefaultResolution(final Context context, final int 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 @@ static List getSortedStreamVideosList( .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 @@ private static int getAudioIndexByHighestRank(@Nullable final MediaFormat target * @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 @@ static int getVideoStreamIndex(final String targetResolution, final MediaFormat * @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 @@ private static int getDefaultResolutionWithDefaultFormat(final Context context, 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 @@ private static MediaFormat getDefaultFormat(final Context context, 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 @@ private static int compareAudioStreamBitrate(final AudioStream streamA, - 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 @@ private static boolean isLimitingDataUsage(final Context context) { * @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 @@ private static String getResolutionLimit(final Context context) { * @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 e55114a2dd4..c3246857e1c 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.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.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 static void enqueueNextOnPlayer(final Context context, final PlayQueue qu 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 static void playOnExternalPlayer(@NonNull final Context context, @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 8c697d32730..96124da8744 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 SecondaryStreamHelper(final StreamSizeWrapper streams, final T selecte * @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 MediaFormat mediaFormat = videoStream.getFormat(); + if (mediaFormat != null) { + switch (mediaFormat) { + case WEBM: + case MPEG_4:// ¿is mpeg-4 DASH? + break; + default: + return null; + } - final boolean m4v = videoStream.getFormat() == MediaFormat.MPEG_4; + final boolean m4v = (mediaFormat == MediaFormat.MPEG_4); - for (final AudioStream audio : audioStreams) { - if (audio.getFormat() == (m4v ? MediaFormat.M4A : MediaFormat.WEBMA)) { - return audio; + for (final AudioStream audio : audioStreams) { + if (audio.getFormat() == (m4v ? MediaFormat.M4A : MediaFormat.WEBMA)) { + return audio; + } } - } - if (m4v) { - return null; - } + 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; + // 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 03342a49770..11f982921fc 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.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 long getItemId(final int position) { } @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 View getView(final int position, final View convertView, final ViewGroup 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 @@ private View getCustomView(final int position, final View view, final ViewGroup 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 @@ private View getCustomView(final int position, final View view, final ViewGroup } } 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 @@ private View getCustomView(final int position, final View view, final ViewGroup 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 StreamSizeWrapper(final List sL, final Context context) { * @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 static Single fetchSizeForWrapper( } 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 87b3eed4f13..b0b6f4507ef 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 @@ 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 @@ private 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 90886b63c7b..e001c6f3fea 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.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 @@ private void resolveStream() throws IOException, ExtractionException, HttpError 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 11293a61063..c2f9dc9b27a 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 33e18c64a3f..4a9c0711f1b 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 80f79cfdd97..1ab39d30270 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 531837ea21d..c9d570c7d4d 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.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 void getSortedStreamVideosListTest() { 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 void getSortedStreamVideosListTest() { "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 void getSortedStreamVideosListWithPreferVideoOnlyStreamsTest() { 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 void getSortedStreamVideosListWithPreferVideoOnlyStreamsTest() { 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 void getSortedStreamVideosListWithPreferVideoOnlyStreamsTest() { 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 void getSortedStreamVideosExceptHighResolutionsTest() { "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 void getHighestQualityAudioFormatPreferredAbsent() { //////////////////////////////////////// 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 void getHighestQualityAudioFormatPreferredAbsent() { ////////////////////////////////////////////////////// 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 void getHighestQualityAudioFormatPreferredAbsent() { // 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 void getLowestQualityAudioFormatPreferredAbsent() { //////////////////////////////////////// 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 void getLowestQualityAudioFormatPreferredAbsent() { 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 void getLowestQualityAudioFormatPreferredAbsent() { ////////////////////////////////////////////////////// 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 void getLowestQualityAudioNull() { @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 void getVideoDefaultStreamIndexCombinations() { // 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 f5bd1f363e5..bb09681f537 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 void handleResult(@NonNull final StreamInfo info) { 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 @@ private void showExternalPlaybackDialog() { 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 @@ private void showExternalPlaybackDialog() { } 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 47371533ab7..b09d8d64346 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.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 @@ public void setUpstreamDataSourceFactory( 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 @@ public CacheFactory build() { 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 @@ public DataSource createDataSource() { .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 61d8baffcd8..68c9223c975 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.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 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(); + } + + 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 d924f931476..2131861bff6 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 @@ 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 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 static String resizeTypeOf(@NonNull final Context context, } } - @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 765475b2faa..85c15faf154 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 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 MediaSource resolve(@NonNull final StreamInfo info) { 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 4c1b67dfc6f..3cbca7628c5 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.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 @@ 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 24ca2e63a0e..317c49fc93a 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 MediaSource resolve(@NonNull final StreamInfo info) { 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 MediaSource resolve(@NonNull final StreamInfo info) { 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 | 142 ++++++++++-------- 2 files changed, 94 insertions(+), 133 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 b09d8d64346..d189616d193 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 @@ 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; - - 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; - } + private final SimpleCache cache; - 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 68c9223c975..f732e834f75 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 @@ 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 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); - - YoutubeProgressiveDashManifestCreator.getCache().setMaximumSize( - MAXIMUM_SIZE_CACHED_GENERATED_MANIFESTS_PER_CACHE); - YoutubeOtfDashManifestCreator.getCache().setMaximumSize( - MAXIMUM_SIZE_CACHED_GENERATED_MANIFESTS_PER_CACHE); + cacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + new DefaultHttpDataSource.Factory().setUserAgent(userAgent)); + + // 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 DashMediaSource.Factory getLiveDashMediaSourceFactory() { 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 SsMediaSource.Factory getSSMediaSourceFactory() { } public SingleSampleMediaSource.Factory getSingleSampleMediaSourceFactory() { - return new SingleSampleMediaSource.Factory(cacheDataSourceFactoryBuilder.build()); + return new SingleSampleMediaSource.Factory(cacheDataSourceFactory); } + //endregion - public DashMediaSource.Factory getYoutubeDashMediaSourceFactory() { - cacheDataSourceFactoryBuilder.setUpstreamDataSourceFactory( - getYoutubeHttpDataSourceFactory(true, true)); - return new DashMediaSource.Factory( - getDefaultDashChunkSourceFactory(cacheDataSourceFactoryBuilder.build()), - cacheDataSourceFactoryBuilder.build()); - } + //region YouTube media source factories public HlsMediaSource.Factory getYoutubeHlsMediaSourceFactory() { - cacheDataSourceFactoryBuilder.setUpstreamDataSourceFactory( - getYoutubeHttpDataSourceFactory(false, false)); - return new HlsMediaSource.Factory(cacheDataSourceFactoryBuilder.build()); + return new HlsMediaSource.Factory(ytHlsCacheDataSourceFactory); + } + + public DashMediaSource.Factory getYoutubeDashMediaSourceFactory() { + return new DashMediaSource.Factory( + 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 acf9c6a4760..c9abe65f62c 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.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 @@ * (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 static final class Factory implements HttpDataSource.Factory { private boolean allowCrossProtocolRedirects; private boolean keepPostFor302Redirects; - @Nullable - private String userAgentForNonMobileStreams; private boolean rangeParameterEnabled; private boolean rnParameterEnabled; @@ -111,25 +115,6 @@ public Factory setDefaultRequestProperties( 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 Factory setKeepPostFor302Redirects(final boolean keepPostFor302RedirectsV @Override public YoutubeHttpDataSource createDataSource() { final YoutubeHttpDataSource dataSource = new YoutubeHttpDataSource( - userAgentForNonMobileStreams, connectTimeoutMs, readTimeoutMs, allowCrossProtocolRedirects, @@ -294,8 +278,6 @@ public YoutubeHttpDataSource createDataSource() { 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 YoutubeHttpDataSource createDataSource() { 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 @@ private YoutubeHttpDataSource(@Nullable final String userAgent, @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 @@ private HttpURLConnection makeConnection( 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 @@ private HttpURLConnection makeConnection( 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 @@ private HttpURLConnection makeConnection( 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 f732e834f75..8b7689bac2e 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 PlayerDataSource(final Context context, // 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 @@ private static DefaultDashChunkSource.Factory getDefaultDashChunkSourceFactory( 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 d2aed76238c..316b72a0925 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.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 Player(@NonNull final MainPlayer service) { 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 8b7689bac2e..8cb423b5101 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.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 PlayerDataSource(final Context context, // 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 3cbca7628c5..3d11f0e448b 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 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.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 @@ private static StringBuilder commonCacheKeyOf(@NonNull final StreamInfo info, 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 @@ static String cacheKeyOf(@NonNull final StreamInfo info, 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 @@ static String cacheKeyOf(@NonNull final StreamInfo info, 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 @@ static MediaSource maybeBuildLiveMediaSource(@NonNull final PlayerDataSource dat 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 @@ static MediaSource buildLiveMediaSource(@NonNull final PlayerDataSource dataSour 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 @@ static MediaSource buildLiveMediaSource(@NonNull final PlayerDataSource dataSour .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 @@ static MediaSource buildMediaSource(@NonNull final PlayerDataSource dataSource, } } - @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 @@ private static ProgressiveMediaSource buildProgressiveMediaSo } } - @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 @@ private static DashMediaSource buildDashMediaSource( } } - @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 @@ private static DashManifest createDashManifest( } } - @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 @@ private static HlsMediaSource buildHlsMediaSource( } } - @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 @@ private static SsMediaSource buildSSMediaSource( .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 @@ private static MediaSource createYoutubeMediaSource( } } - 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 @@ private static MediaSource createYoutubeMediaSourceOfVideoStr } } - @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 @@ private static DashMediaSource buildYoutubeManualDashMediaSou .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 @@ private static ProgressiveMediaSource buildYoutubeProgressive .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 | 46 ++++++++++--------- .../newpipe/util/StreamItemAdapter.java | 6 +-- 3 files changed, 29 insertions(+), 29 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 73ba8c74a7b..9f46f7f6bf2 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 @@ private void prepareSelectedDownload() { 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 96124da8744..e7fd2d4a4bc 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 SecondaryStreamHelper(@NonNull final StreamSizeWrapper streams, 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); + switch (mediaFormat) { + case WEBM: + case MPEG_4:// ¿is mpeg-4 DASH? + break; + default: + return null; + } - for (final AudioStream audio : audioStreams) { - if (audio.getFormat() == (m4v ? MediaFormat.M4A : MediaFormat.WEBMA)) { - return audio; - } - } + final boolean m4v = (mediaFormat == MediaFormat.MPEG_4); - if (m4v) { - return null; + 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; - } + // 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 11f982921fc..4b5e675c917 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 @@ private View getCustomView(final int position, 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 a22fd2bb98d..d8c19c1e979 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 5a266c0a860..5afaea0384a 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.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 Builder addEnqueueEntriesIfNeeded() { */ 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 Builder addMarkAsWatchedEntryIfNeeded() { 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 83211d4dd02..54d31ca5735 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.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 void updateFromItem(final InfoItem infoItem, } 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 void updateState(final InfoItem infoItem, 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 316b72a0925..b2c8836e591 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 @@ private void useVideoSource(final boolean videoEnabled) { 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 @@ private boolean playQueueManagerReloadingNeeded(final SourceType sourceType, @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 @@ private boolean playQueueManagerReloadingNeeded(final SourceType sourceType, && 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 b8cd4ef6903..0c5f418b294 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 @@ private 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 b0b6f4507ef..0cc0ecf1fd3 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 @@ private 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 9f46f7f6bf2..4fb47496bea 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 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.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 bb09681f537..ff2114b83cb 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.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.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 @@ private void toggleFullscreenIfInFullscreenMode() { } 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 @@ private void openBackgroundPlayer(final boolean append) { 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 @@ private void showExternalPlaybackDialog() { 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 85c15faf154..3e166c3398c 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.util.ListHelper; import java.io.IOException; -import java.util.ArrayList; import java.util.List; public class AudioPlaybackResolver implements PlaybackResolver { @@ -43,8 +42,7 @@ public MediaSource resolve(@NonNull final StreamInfo info) { 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 317c49fc93a..fd00d0ed9ab 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.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 MediaSource resolve(@NonNull final StreamInfo info) { } 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 MediaSource resolve(@NonNull final StreamInfo info) { } // 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 MediaSource resolve(@NonNull final StreamInfo info) { 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 3a03e0b3023..33c7a2f49b6 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.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 static int getDefaultAudioFormat(final Context context, * 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 static List keepStreamsWithDelivery( * @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 static List removeNonUrlAndTorrentStreams( * @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 static List getSortedStreamVideosList( // 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 c3246857e1c..ffc7433a0ec 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 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 static void enqueueNextOnPlayer(final Context context, final PlayQueue qu 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 static void playOnExternalPlayer(@NonNull final Context context, @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 3e166c3398c..934beba196f 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.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 MediaSource resolve(@NonNull final StreamInfo info) { 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 3d11f0e448b..d7f04774c89 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 @@ private static StringBuilder commonCacheKeyOf(final StreamInfo info, } /** - * 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 @@ static String cacheKeyOf(final StreamInfo info, final VideoStream videoStream) { } /** - * 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. + * + *

+ * 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 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 + * @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 @@ static String cacheKeyOf(final StreamInfo info, final AudioStream audioStream) { @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 @@ static MediaSource maybeBuildLiveMediaSource(final PlayerDataSource dataSource, 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 @@ static MediaSource buildLiveMediaSource(final PlayerDataSource dataSource, 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 @@ static MediaSource buildMediaSource(final PlayerDataSource dataSource, 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 @@ static MediaSource buildMediaSource(final PlayerDataSource dataSource, 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 @@ private static ProgressiveMediaSource buildProgressiveMediaSource( 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 @@ private static DashMediaSource buildDashMediaSource(final PlayerDataSource dataS 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 @@ private static DashMediaSource buildDashMediaSource(final PlayerDataSource dataS 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 @@ private static HlsMediaSource buildHlsMediaSource(final PlayerDataSource dataSou 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 @@ private static SsMediaSource buildSSMediaSource(final PlayerDataSource dataSourc 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 @@ private static SsMediaSource buildSSMediaSource(final PlayerDataSource dataSourc 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 @@ private static MediaSource createYoutubeMediaSource(final Stream stream, 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 @@ private static MediaSource createYoutubeMediaSource(final Stream stream, 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 @@ private static MediaSource createYoutubeMediaSourceOfVideoStreamType( 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 @@ private static MediaSource createYoutubeMediaSourceOfVideoStreamType( 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 @@ private static MediaSource createYoutubeMediaSourceOfVideoStreamType( .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 @@ private static ProgressiveMediaSource buildYoutubeProgressiveMediaSource( .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 fd00d0ed9ab..6e18ee0cddd 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.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 MediaSource resolve(@NonNull final StreamInfo info) { 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 MediaSource resolve(@NonNull final StreamInfo info) { 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 00000000000..676443a9c78 --- /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 a3a25fd1df8..00000000000 --- 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 8cb423b5101..82fb3a0e78c 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.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.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 DashMediaSource.Factory getLiveDashMediaSourceFactory() { //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 d7f04774c89..763a676239c 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.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.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 @@ private static HlsMediaSource buildHlsMediaSource(final PlayerDataSource dataSou .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 ResolverException("Error when parsing manual HLS manifest", e); + final NonUriHlsDataSourceFactory.Builder hlsDataSourceFactoryBuilder = + new NonUriHlsDataSourceFactory.Builder(); + hlsDataSourceFactoryBuilder.setPlaylistString(stream.getContent()); + String manifestUrl = stream.getManifestUrl(); + if (manifestUrl == null) { + manifestUrl = ""; } - - 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 96f8ff1bceb..1fe6ce7ec7a 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.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 @@ private void openDownloadDialog() { .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 4fb47496bea..e4adddc2a44 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.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.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 + // Instance creation //////////////////////////////////////////////////////////////////////////*/ - private void setInfo(@NonNull final StreamInfo info) { - this.currentInfo = info; - } - - public void setAudioStreams(@NonNull final List audioStreams) { - this.wrappedAudioStreams = new StreamSizeWrapper<>(audioStreams, getContext()); - } - - public void setVideoStreams(@NonNull final List videoStreams) { - this.wrappedVideoStreams = new StreamSizeWrapper<>(videoStreams, getContext()); - } - - 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. + * 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 svi the index of the selected {@link VideoStream} + * @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 void setSelectedVideoStream(final int svi) { - if (selectedStreamIsInBoundsOfWrappedStreams(svi, this.wrappedVideoStreams)) { - this.selectedVideoIndex = svi; - } - } + public DownloadDialog(final Context context, @NonNull final StreamInfo info) { + this.currentInfo = info; - /** - * 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; - } + // 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 + ); + + 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); + + this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams); } /** - * 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} + * @param onDismissListener the listener to call in {@link #onDismiss(DialogInterface)} */ - 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 @@ private void prepareSelectedDownload() { 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 @@ private void prepareSelectedDownload() { format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); if (format != null) { mimeTmp = format.mimeType; - } - if (format != null) { filenameTmp += format.suffix; } break; @@ -1085,7 +1011,7 @@ private void continueSelectedDownload(@NonNull final StoredFileHelper storage) { 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 ff2114b83cb..100af41ccf1 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 void openDownloadDialog() { } 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 100af41ccf1..cbd8b05b4fc 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 void handleResult(@NonNull final StreamInfo info) { 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 void handleResult(@NonNull final StreamInfo info) { } 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 @@ private void showExternalPlaybackDialog() { 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 82fb3a0e78c..88f25e194ef 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 @@ private static YoutubeHttpDataSource.Factory getYoutubeHttpDataSourceFactory( 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 | 177 ++++++++---------- 1 file changed, 74 insertions(+), 103 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 763a676239c..fb6dbc3bb5b 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 @@ 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 @@ private static StringBuilder commonCacheKeyOf(final StreamInfo info, // 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 @@ private static ProgressiveMediaSource buildProgressiveMediaSource( 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 @@ private static DashMediaSource buildDashMediaSource(final PlayerDataSource dataS 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 @@ private static HlsMediaSource buildHlsMediaSource(final PlayerDataSource dataSou 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 @@ private static SsMediaSource buildSSMediaSource(final PlayerDataSource dataSourc 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); - } + final Uri manifestUri = manifestUrlToUri(stream.getManifestUrl()); - return dataSource.getSSMediaSourceFactory().createMediaSource( - smoothStreamingManifest, - new MediaItem.Builder() - .setTag(metadata) - .setUri(uri) - .setCustomCacheKey(cacheKey) - .build()); + 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 @@ private static MediaSource createYoutubeMediaSource(final Stream stream, 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 @@ private static ProgressiveMediaSource buildYoutubeProgressiveMediaSource( //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 cbd8b05b4fc..5e19f558d74 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.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 @@ private void openBackgroundPlayer(final boolean append) { 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 @@ private void showExternalPlaybackDialog() { 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 6e18ee0cddd..cf7d7355817 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.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 MediaSource resolve(@NonNull final StreamInfo info) { 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 33c7a2f49b6..eabac83304f 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 static List getStreamsOfSpecifiedDelivery( * @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 ffc7433a0ec..c40b1a43081 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 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 static void playOnExternalAudioPlayer(@NonNull final Context context, } 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 static void playOnExternalVideoPlayer(final Context context, 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 4a9c0711f1b..37bbf2b03d4 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 1ab39d30270..6a1c220f0d5 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 fb6dbc3bb5b..34e7e9bd1f1 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 @@ private static ProgressiveMediaSource buildProgressiveMediaSource( 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 @@ private static Uri manifestUrlToUri(final String manifestUrl) { 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