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..1fe6ce7ec7a 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; @@ -71,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; @@ -677,22 +675,13 @@ 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 = new DownloadDialog(this, result); + 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/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/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java index f5c22690836..e4adddc2a44 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -68,9 +68,9 @@ import java.io.File; import java.io.IOException; -import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Objects; import icepick.Icepick; import icepick.State; @@ -82,6 +82,8 @@ 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; public class DownloadDialog extends DialogFragment @@ -92,17 +94,17 @@ public class DownloadDialog extends DialogFragment @State StreamInfo currentInfo; @State - StreamSizeWrapper wrappedAudioStreams = StreamSizeWrapper.empty(); + StreamSizeWrapper wrappedAudioStreams; @State - StreamSizeWrapper wrappedVideoStreams = StreamSizeWrapper.empty(); + StreamSizeWrapper wrappedVideoStreams; @State - 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; @@ -143,77 +145,43 @@ public class DownloadDialog extends DialogFragment // Instance creation //////////////////////////////////////////////////////////////////////////*/ - public static DownloadDialog newInstance(final StreamInfo info) { - final DownloadDialog dialog = new DownloadDialog(); - dialog.setInfo(info); - return dialog; - } - - 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 DownloadDialog instance = newInstance(info); - instance.setVideoStreams(streamsList); - instance.setSelectedVideoStream(selectedStreamIndex); - instance.setAudioStreams(info.getAudioStreams()); - instance.setSubtitleStreams(info.getSubtitles()); - - return instance; - } - - - /*////////////////////////////////////////////////////////////////////////// - // Setters - //////////////////////////////////////////////////////////////////////////*/ - - private void setInfo(final StreamInfo info) { + /** + * Create a new download dialog with the video, audio and subtitle streams from the provided + * stream info. Video streams and video-only streams will be put into a single list menu, + * sorted according to their resolution and the default video resolution will be selected. + * + * @param context the context to use just to obtain preferences and strings (will not be stored) + * @param info the info from which to obtain downloadable streams and other info (e.g. title) + */ + public DownloadDialog(final Context context, @NonNull final StreamInfo info) { this.currentInfo = info; - } - - public void setAudioStreams(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 setVideoStreams(final StreamSizeWrapper wvs) { - this.wrappedVideoStreams = wvs; - } - - public void setSubtitleStreams(final List subtitleStreams) { - setSubtitleStreams(new StreamSizeWrapper<>(subtitleStreams, getContext())); - } - public void setSubtitleStreams( - final StreamSizeWrapper wss) { - this.wrappedSubtitleStreams = wss; - } - - public void setSelectedVideoStream(final int svi) { - this.selectedVideoIndex = svi; - } + // TODO: Adapt this code when the downloader support other types of stream deliveries + final List videoStreams = ListHelper.getSortedStreamVideosList( + context, + getStreamsOfSpecifiedDelivery(info.getVideoStreams(), PROGRESSIVE_HTTP), + getStreamsOfSpecifiedDelivery(info.getVideoOnlyStreams(), PROGRESSIVE_HTTP), + false, + false + ); - public void setSelectedAudioStream(final int sai) { - this.selectedAudioIndex = sai; - } + this.wrappedVideoStreams = new StreamSizeWrapper<>(videoStreams, context); + this.wrappedAudioStreams = new StreamSizeWrapper<>( + getStreamsOfSpecifiedDelivery(info.getAudioStreams(), PROGRESSIVE_HTTP), context); + this.wrappedSubtitleStreams = new StreamSizeWrapper<>( + getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context); - public void setSelectedSubtitleStream(final int ssi) { - this.selectedSubtitleIndex = ssi; + this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams); } + /** + * @param onDismissListener the listener to call in {@link #onDismiss(DialogInterface)} + */ public void setOnDismissListener(@Nullable final OnDismissListener onDismissListener) { this.onDismissListener = onDismissListener; } + /*////////////////////////////////////////////////////////////////////////// // Android lifecycle //////////////////////////////////////////////////////////////////////////*/ @@ -249,11 +217,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 +261,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 +273,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 +299,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 +445,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 +462,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 +474,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 +494,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 +536,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 +574,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 +619,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 +645,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 +664,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() { @@ -709,7 +686,7 @@ private void prepareSelectedDownload() { if (format == MediaFormat.WEBMA_OPUS) { mimeTmp = "audio/ogg"; filenameTmp += "opus"; - } else { + } else if (format != null) { mimeTmp = format.mimeType; filenameTmp += format.suffix; } @@ -718,22 +695,30 @@ private void prepareSelectedDownload() { 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; + 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 +752,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 +769,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 +931,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 +976,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 +993,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 +1004,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() != 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..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 @@ -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; @@ -94,6 +95,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 +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.getUrlAndNonTorrentStreams; public final class VideoDetailFragment extends BaseStateFragment @@ -186,8 +189,6 @@ public final class VideoDetailFragment @Nullable private Disposable positionSubscriber = null; - private List sortedVideoStreams; - private int selectedVideoStreamIndex = -1; private BottomSheetBehavior bottomSheetBehavior; private BroadcastReceiver broadcastReceiver; @@ -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 = getUrlAndNonTorrentStreams( + currentInfo.getAudioStreams()); + final int index = ListHelper.getDefaultAudioFormat(activity, audioStreams); + + if (index == -1) { + Toast.makeText(activity, R.string.no_audio_streams_available_for_external_players, + Toast.LENGTH_SHORT).show(); + return; + } + + startOnExternalPlayer(activity, currentInfo, audioStreams.get(index)); } } @@ -1612,14 +1620,6 @@ 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); updateProgressInfo(info); initThumbnailViews(info); showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, @@ -1645,8 +1645,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(info.getStreamType()) ? View.GONE : View.VISIBLE); binding.detailControlsBackground.setVisibility(info.getAudioStreams().isEmpty() ? View.GONE : View.VISIBLE); @@ -1687,12 +1687,7 @@ 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 = new DownloadDialog(activity, currentInfo); downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog"); } catch (final Exception e) { ErrorUtil.showSnackbar(activity, new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG, @@ -1722,8 +1717,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 +2145,52 @@ 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) + + 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, + getUrlAndNonTorrentStreams(currentInfo.getVideoStreams()), + getUrlAndNonTorrentStreams(currentInfo.getVideoOnlyStreams()), + false, + false ); - // Maybe there are no video streams available, show just `open in browser` button - if (resolutions.length > 0) { - builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndex, (dialog, i) -> { - dialog.dismiss(); - startOnExternalPlayer(activity, currentInfo, sortedVideoStreams.get(i)); - } - ); + + 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); + 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, + 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(); } 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 79772a6a307..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)); @@ -96,9 +95,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 +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) { + && !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/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..b2c8836e591 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); @@ -1744,24 +1743,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 +3383,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 +3402,7 @@ private void updateStreamRelatedViews() { break; case VIDEO_STREAM: + case POST_LIVE_STREAM: if (currentMetadata == null || !currentMetadata.getMaybeQuality().isPresent() || (info.getVideoStreams().isEmpty() @@ -3484,10 +3470,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 +3591,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 +3619,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); @@ -4248,9 +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) { + if (StreamTypeUtil.isAudio(info.getStreamType())) { // Nothing to do more than setting the recovery position setRecovery(); return; @@ -4285,13 +4269,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}. *
  • *
@@ -4307,18 +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) { + 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.AUDIO_LIVE_STREAM) - || (streamType == StreamType.LIVE_STREAM - && sourceType == SourceType.LIVE_STREAM)) { + if (isStreamTypeAudio || (streamType == StreamType.LIVE_STREAM + && sourceType == SourceType.LIVE_STREAM)) { return false; } @@ -4331,8 +4316,8 @@ 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 !StreamTypeUtil.isVideo(streamType); } // Other cases: the play queue manager reload is needed 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/datasource/YoutubeHttpDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java new file mode 100644 index 00000000000..c9abe65f62c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java @@ -0,0 +1,1014 @@ +/* + * 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 org.schabi.newpipe.DownloaderImpl; + +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. + *

+ * + * 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 { + + /** + * {@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; + + 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 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( + 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 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(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.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 { + // 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 + // 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 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 (isIosStreamingUrl) { + httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, + getIosUserAgent(null)); + } else { + // non-mobile user agent + httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, DownloaderImpl.USER_AGENT); + } + + 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(isAndroidStreamingUrl || isIosStreamingUrl + ? "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..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 @@ -1,96 +1,46 @@ package org.schabi.newpipe.player.helper; import android.content.Context; -import android.util.Log; -import com.google.android.exoplayer2.database.StandaloneDatabaseProvider; +import androidx.annotation.NonNull; + 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.LeastRecentlyUsedCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; -import java.io.File; - -import androidx.annotation.NonNull; - -/* package-private */ class CacheFactory implements DataSource.Factory { - private static final String TAG = "CacheFactory"; - - private static final String CACHE_FOLDER_NAME = "exoplayer"; - private static final int CACHE_FLAGS = CacheDataSource.FLAG_BLOCK_ON_CACHE - | CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; - - private final DataSource.Factory dataSourceFactory; - private final File cacheDir; - private final long maxFileSize; - - // Creating cache on every instance may cause problems with multiple players when - // sources are not ExtractorMediaSource - // see: https://stackoverflow.com/questions/28700391/using-cache-in-exoplayer - // todo: make this a singleton? - private static SimpleCache cache; - - CacheFactory(@NonNull final Context context, - @NonNull final String userAgent, - @NonNull final TransferListener transferListener) { - this(context, userAgent, transferListener, PlayerHelper.getPreferredCacheSize(), - PlayerHelper.getPreferredFileSize()); - } - - 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); - if (!cacheDir.exists()) { - //noinspection ResultOfMethodCallIgnored - cacheDir.mkdir(); - } - - if (cache == null) { - final LeastRecentlyUsedCacheEvictor evictor - = new LeastRecentlyUsedCacheEvictor(maxCacheSize); - cache = new SimpleCache(cacheDir, evictor, new StandaloneDatabaseProvider(context)); - } +final class CacheFactory implements DataSource.Factory { + private static final int CACHE_FLAGS = CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; + + private final Context context; + private final TransferListener transferListener; + private final DataSource.Factory upstreamDataSourceFactory; + private final SimpleCache cache; + + CacheFactory(final Context context, + final TransferListener transferListener, + final SimpleCache cache, + final DataSource.Factory upstreamDataSourceFactory) { + this.context = context; + this.transferListener = transferListener; + this.cache = cache; + this.upstreamDataSourceFactory = upstreamDataSourceFactory; } @NonNull @Override public DataSource createDataSource() { - Log.d(TAG, "initExoPlayerCache: cacheDir = " + cacheDir.getAbsolutePath()); + final DefaultDataSource dataSource = new DefaultDataSource.Factory(context, + upstreamDataSourceFactory) + .setTransferListener(transferListener) + .createDataSource(); - final DataSource dataSource = dataSourceFactory.createDataSource(); final FileDataSource fileSource = new FileDataSource(); - final CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize); - + final CacheDataSink dataSink + = new CacheDataSink(cache, PlayerHelper.getPreferredFileSize()); return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null); } - - public void tryDeleteCacheFiles() { - if (!cacheDir.exists() || !cacheDir.isDirectory()) { - return; - } - - try { - for (final File file : cacheDir.listFiles()) { - final String filePath = file.getAbsolutePath(); - final boolean deleteSuccessful = file.delete(); - - Log.d(TAG, "tryDeleteCacheFiles: " + filePath + " deleted = " + deleteSuccessful); - } - } catch (final Exception e) { - Log.e(TAG, "Failed to delete file.", e); - } - } } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java index 405f6fd37b7..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,7 +1,13 @@ package org.schabi.newpipe.player.helper; +import static org.schabi.newpipe.MainActivity.DEBUG; + import android.content.Context; +import android.util.Log; + +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; @@ -13,12 +19,21 @@ 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 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; +import org.schabi.newpipe.player.datasource.NonUriHlsDataSourceFactory; +import org.schabi.newpipe.player.datasource.YoutubeHttpDataSource; -import androidx.annotation.NonNull; +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; @@ -29,79 +44,174 @@ 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; - private final int continueLoadingCheckIntervalBytes; - private final DataSource.Factory cacheDataSourceFactory; + /** + * The maximum number of generated manifests per cache, in + * {@link YoutubeProgressiveDashManifestCreator}, {@link YoutubeOtfDashManifestCreator} and + * {@link YoutubePostLiveStreamDvrDashManifestCreator}. + */ + private static final int MAX_MANIFEST_CACHE_SIZE = 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 progressiveLoadIntervalBytes; + + // Generic Data Source Factories (without or with cache) private final DataSource.Factory cachelessDataSourceFactory; + private final CacheFactory cacheDataSourceFactory; + + // 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; + + + public PlayerDataSource(final Context context, + final TransferListener transferListener) { + + progressiveLoadIntervalBytes = PlayerHelper.getProgressiveLoadIntervalBytes(context); - 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)) + // 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(DownloaderImpl.USER_AGENT)) .setTransferListener(transferListener); + cacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + new DefaultHttpDataSource.Factory().setUserAgent(DownloaderImpl.USER_AGENT)); + + // YouTube-specific data source factories use getYoutubeHttpDataSourceFactory() + ytHlsCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + getYoutubeHttpDataSourceFactory(false, false)); + ytDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + getYoutubeHttpDataSourceFactory(true, true)); + ytProgressiveDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + getYoutubeHttpDataSourceFactory(false, true)); + + // set the maximum size to manifest creators + YoutubeProgressiveDashManifestCreator.getCache().setMaximumSize(MAX_MANIFEST_CACHE_SIZE); + YoutubeOtfDashManifestCreator.getCache().setMaximumSize(MAX_MANIFEST_CACHE_SIZE); + YoutubePostLiveStreamDvrDashManifestCreator.getCache().setMaximumSize( + MAX_MANIFEST_CACHE_SIZE); } + + //region Live media source factories 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); } + //endregion - private DefaultDashChunkSource.Factory getDefaultDashChunkSourceFactory( - final DataSource.Factory dataSourceFactory - ) { - return new DefaultDashChunkSource.Factory(dataSourceFactory); - } - public HlsMediaSource.Factory getHlsMediaSourceFactory() { + //region Generic media source factories + public HlsMediaSource.Factory getHlsMediaSourceFactory( + @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() { return new DashMediaSource.Factory( getDefaultDashChunkSourceFactory(cacheDataSourceFactory), - cacheDataSourceFactory - ); + cacheDataSourceFactory); } - public ProgressiveMediaSource.Factory getExtractorMediaSourceFactory() { + public ProgressiveMediaSource.Factory getProgressiveMediaSourceFactory() { return new ProgressiveMediaSource.Factory(cacheDataSourceFactory) - .setContinueLoadingCheckIntervalBytes(continueLoadingCheckIntervalBytes) - .setLoadErrorHandlingPolicy( - new DefaultLoadErrorHandlingPolicy(EXTRACTOR_MINIMUM_RETRY)); + .setContinueLoadingCheckIntervalBytes(progressiveLoadIntervalBytes); + } + + public SsMediaSource.Factory getSSMediaSourceFactory() { + return new SsMediaSource.Factory( + new DefaultSsChunkSource.Factory(cachelessDataSourceFactory), + cachelessDataSourceFactory); } - public SingleSampleMediaSource.Factory getSampleMediaSourceFactory() { + public SingleSampleMediaSource.Factory getSingleSampleMediaSourceFactory() { return new SingleSampleMediaSource.Factory(cacheDataSourceFactory); } + //endregion + + + //region YouTube media source factories + public HlsMediaSource.Factory getYoutubeHlsMediaSourceFactory() { + return new HlsMediaSource.Factory(ytHlsCacheDataSourceFactory); + } + + public DashMediaSource.Factory getYoutubeDashMediaSourceFactory() { + return new DashMediaSource.Factory( + getDefaultDashChunkSourceFactory(ytDashCacheDataSourceFactory), + ytDashCacheDataSourceFactory); + } + + public ProgressiveMediaSource.Factory getYoutubeProgressiveMediaSourceFactory() { + return new ProgressiveMediaSource.Factory(ytProgressiveDashCacheDataSourceFactory) + .setContinueLoadingCheckIntervalBytes(progressiveLoadIntervalBytes); + } + //endregion + + + //region Static methods + private static DefaultDashChunkSource.Factory getDefaultDashChunkSourceFactory( + final DataSource.Factory dataSourceFactory) { + return new DefaultDashChunkSource.Factory(dataSourceFactory); + } + + private static YoutubeHttpDataSource.Factory getYoutubeHttpDataSourceFactory( + final boolean rangeParameterEnabled, + final boolean rnParameterEnabled) { + return new YoutubeHttpDataSource.Factory() + .setRangeParameterEnabled(rangeParameterEnabled) + .setRnParameterEnabled(rnParameterEnabled); + } + + private static void instantiateCacheIfNeeded(final Context context) { + if (cache == null) { + final File cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); + if (DEBUG) { + 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 } 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..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 @@ -45,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; @@ -110,12 +108,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 +131,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; @@ -190,18 +193,6 @@ 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 - public static String cacheKeyOf(@NonNull final StreamInfo info, - @NonNull final AudioStream audio) { - return info.getUrl() + audio.getAverageBitrate() + audio.getFormat().getName(); - } - /** * Given a {@link StreamInfo} and the existing queue items, * provide the {@link SinglePlayQueue} consisting of the next video for auto queueing. @@ -233,7 +224,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 +326,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 +381,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 +472,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 +541,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 +557,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..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 @@ -1,22 +1,27 @@ package org.schabi.newpipe.player.resolver; +import static org.schabi.newpipe.util.ListHelper.getNonTorrentStreams; + 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; -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; +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 +36,27 @@ 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 = getNonTorrentStreams(info.getAudioStreams()); + + 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, PlaybackResolver.cacheKeyOf(info, audio), tag); + } 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 90b38ed51da..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 @@ -1,50 +1,193 @@ 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.player.helper.PlayerDataSource.LIVE_STREAM_EDGE_GAP_MILLIS; + import android.net.Uri; -import android.text.TextUtils; +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; -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.smoothstreaming.SsMediaSource; +import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest; +import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifestParser; +import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.extractor.services.youtube.ItagItem; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.CreationException; +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.datasource.NonUriHlsDataSourceFactory; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.StreamTypeUtil; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import 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; +/** + * 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(); + + + //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()); + + // 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(" "); + cacheKey.append(Objects.hash(stream.getContent(), stream.getManifestUrl())); + } + + return cacheKey; + } + + /** + * Builds the cache key of a {@link VideoStream 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); + 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(); + } + + /** + * 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 {@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; + final StringBuilder cacheKey = commonCacheKeyOf(info, audioStream, averageBitrateUnknown); + + // averageBitrate (if known) + if (!averageBitrateUnknown) { + cacheKey.append(" "); + cacheKey.append(audioStream.getAverageBitrate()); + } + return cacheKey.toString(); + } + //endregion + + + //region Live media sources @Nullable - default MediaSource maybeBuildLiveMediaSource(@NonNull final PlayerDataSource dataSource, - @NonNull final StreamInfo info) { - final StreamType streamType = info.getStreamType(); - if (!StreamTypeUtil.isLiveStream(streamType)) { + static MediaSource maybeBuildLiveMediaSource(final PlayerDataSource dataSource, + final StreamInfo info) { + 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; } - @NonNull - default MediaSource buildLiveMediaSource(@NonNull final PlayerDataSource dataSource, - @NonNull final String sourceUrl, - @C.ContentType final int type, - @NonNull final MediaItemTag metadata) { + static MediaSource buildLiveMediaSource(final PlayerDataSource dataSource, + final String sourceUrl, + @C.ContentType final int type, + final MediaItemTag metadata) throws ResolverException { final MediaSource.Factory factory; switch (type) { case C.TYPE_SS: @@ -56,8 +199,10 @@ default MediaSource buildLiveMediaSource(@NonNull final PlayerDataSource dataSou case C.TYPE_HLS: factory = dataSource.getLiveHlsMediaSourceFactory(); break; + case C.TYPE_OTHER: + case C.TYPE_RTSP: default: - throw new IllegalStateException("Unsupported type: " + type); + throw new ResolverException("Unsupported type: " + type); } return factory.createMediaSource( @@ -67,46 +212,317 @@ default MediaSource buildLiveMediaSource(@NonNull final PlayerDataSource dataSou .setLiveConfiguration( new MediaItem.LiveConfiguration.Builder() .setTargetOffsetMs(LIVE_STREAM_EDGE_GAP_MILLIS) - .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); + .build()) + .build()); + } + //endregion - 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; + + //region Generic media sources + static MediaSource buildMediaSource(final PlayerDataSource dataSource, + final Stream stream, + final StreamInfo streamInfo, + final String cacheKey, + final MediaItemTag metadata) throws ResolverException { + if (streamInfo.getService() == ServiceList.YouTube) { + return createYoutubeMediaSource(stream, streamInfo, dataSource, cacheKey, metadata); + } + + 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 ResolverException("Unsupported delivery type: " + deliveryMethod); } + } - return factory.createMediaSource( + private static ProgressiveMediaSource buildProgressiveMediaSource( + final PlayerDataSource dataSource, + 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() - .setTag(metadata) - .setUri(uri) - .setCustomCacheKey(cacheKey) - .build() - ); + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + + private static DashMediaSource buildDashMediaSource(final PlayerDataSource dataSource, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) + throws ResolverException { + + if (stream.isUrl()) { + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); + return dataSource.getDashMediaSourceFactory().createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + + 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 { + return new DashManifestParser().parse(manifestUrlToUri(stream.getManifestUrl()), + new ByteArrayInputStream(manifestContent.getBytes(StandardCharsets.UTF_8))); + } + + private static HlsMediaSource buildHlsMediaSource(final PlayerDataSource dataSource, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) + throws ResolverException { + if (stream.isUrl()) { + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); + return dataSource.getHlsMediaSourceFactory(null).createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .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, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) + throws ResolverException { + if (stream.isUrl()) { + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); + return dataSource.getSSMediaSourceFactory().createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + + final Uri manifestUri = manifestUrlToUri(stream.getManifestUrl()); + + final SsManifest smoothStreamingManifest; + try { + final ByteArrayInputStream smoothStreamingManifestInput = new ByteArrayInputStream( + stream.getContent().getBytes(StandardCharsets.UTF_8)); + smoothStreamingManifest = new SsManifestParser().parse(manifestUri, + smoothStreamingManifestInput); + } catch (final IOException e) { + throw new ResolverException("Error when parsing manual SS manifest", e); + } + + return dataSource.getSSMediaSourceFactory().createMediaSource( + smoothStreamingManifest, + new MediaItem.Builder() + .setTag(metadata) + .setUri(manifestUri) + .setCustomCacheKey(cacheKey) + .build()); + } + //endregion + + + //region YouTube media sources + private static MediaSource createYoutubeMediaSource(final Stream stream, + final StreamInfo streamInfo, + final PlayerDataSource dataSource, + final String cacheKey, + final MediaItemTag metadata) + throws ResolverException { + if (!(stream instanceof AudioStream || stream instanceof VideoStream)) { + throw new ResolverException("Generation of YouTube DASH manifest for " + + stream.getClass().getSimpleName() + " is not supported"); + } + + 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 | IOException | NullPointerException e) { + throw new ResolverException( + "Error when generating the DASH manifest of YouTube ended live stream", e); + } + } else { + throw new ResolverException( + "DASH manifest generation of YouTube livestreams is not supported"); + } + } + + private static MediaSource createYoutubeMediaSourceOfVideoStreamType( + final PlayerDataSource dataSource, + final Stream stream, + final StreamInfo streamInfo, + final String cacheKey, + final MediaItemTag metadata) throws ResolverException { + 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 | IOException | NullPointerException e) { + Log.e(TAG, + "Error when generating the DASH manifest of YouTube OTF stream", e); + throw new ResolverException( + "Error when generating the DASH manifest of YouTube OTF stream", e); + } + case HLS: + return dataSource.getYoutubeHlsMediaSourceFactory().createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + default: + throw new ResolverException("Unsupported delivery method for YouTube contents: " + + deliveryMethod); + } + } + + 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) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + + 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) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + //endregion + + + //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); + } + + 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 1aa7a5a18ab..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 @@ -2,6 +2,7 @@ import android.content.Context; import android.net.Uri; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -27,8 +28,12 @@ import java.util.Optional; import static com.google.android.exoplayer2.C.TIME_UNSET; +import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; +import static org.schabi.newpipe.util.ListHelper.getNonTorrentStreams; public class VideoPlaybackResolver implements PlaybackResolver { + private static final String TAG = VideoPlaybackResolver.class.getSimpleName(); + @NonNull private final Context context; @NonNull @@ -57,7 +62,7 @@ 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; @@ -66,40 +71,51 @@ public MediaSource resolve(@NonNull final StreamInfo info) { final List mediaSources = new ArrayList<>(); // Create video stream source - final List videos = ListHelper.getSortedStreamVideosList(context, - info.getVideoStreams(), info.getVideoOnlyStreams(), 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); 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, PlaybackResolver.cacheKeyOf(info, video), tag); + mediaSources.add(streamSource); + } catch (final ResolverException e) { + Log.e(TAG, "Unable to create video source", e); + return null; + } } // Create optional audio stream source - final List audioStreams = info.getAudioStreams(); + final List audioStreams = getNonTorrentStreams(info.getAudioStreams()); 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, PlaybackResolver.cacheKeyOf(info, audio), tag); + mediaSources.add(audioSource); + streamSourceType = SourceType.VIDEO_WITH_SEPARATED_AUDIO; + } catch (final ResolverException e) { + Log.e(TAG, "Unable to create audio source", e); + return null; + } } else { streamSourceType = SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY; } @@ -108,36 +124,39 @@ public MediaSource resolve(@NonNull final StreamInfo info) { if (mediaSources.isEmpty()) { return null; } + // 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 = getUrlAndNonTorrentStreams( + 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..eabac83304f 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; @@ -24,6 +26,7 @@ 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 { @@ -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,51 @@ 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 {@link Stream stream} list + * @param deliveryMethod the {@link DeliveryMethod delivery method} + * @param the item type's class that extends {@link Stream} + * @return a {@link Stream stream} list which uses the given delivery method + */ + @NonNull + public static List getStreamsOfSpecifiedDelivery( + final List streamList, + final DeliveryMethod deliveryMethod) { + return getFilteredStreamList(streamList, + stream -> stream.getDeliveryMethod() == deliveryMethod); + } + + /** + * 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 getUrlAndNonTorrentStreams( + final List streamList) { + return getFilteredStreamList(streamList, + stream -> stream.isUrl() && stream.getDeliveryMethod() != DeliveryMethod.TORRENT); + } + + /** + * 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 getNonTorrentStreams( + final List streamList) { + return getFilteredStreamList(streamList, + stream -> stream.getDeliveryMethod() != DeliveryMethod.TORRENT); + } + /** * Join the two lists of video streams (video_only and normal videos), * and sort them according with default format chosen by the user. @@ -145,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 @@ -177,7 +244,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 +300,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 +435,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 +498,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 +507,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 +527,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 +566,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 +614,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 +633,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..c40b1a43081 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.getUrlAndNonTorrentStreams; public final class NavigationHelper { public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; @@ -217,30 +220,47 @@ 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 == null || audioStreams.isEmpty()) { Toast.makeText(context, R.string.audio_streams_empty, Toast.LENGTH_SHORT).show(); return; } - final AudioStream audioStream = info.getAudioStreams().get(index); + final List audioStreamsForExternalPlayers = + getUrlAndNonTorrentStreams(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 = 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 == null || videoStreams.isEmpty()) { Toast.makeText(context, R.string.video_streams_empty, Toast.LENGTH_SHORT).show(); return; } - final VideoStream videoStream = videoStreamsList.get(index); + final List videoStreamsForExternalPlayers = + ListHelper.getSortedStreamVideosList(context, + getUrlAndNonTorrentStreams(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 = videoStreamsForExternalPlayers.get(index); playOnExternalPlayer(context, info.getName(), info.getUploaderName(), videoStream); } @@ -248,9 +268,48 @@ 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 (!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 { + mimeType = stream.getFormat().getMimeType(); + } + 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(); 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..e7fd2d4a4bc 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,9 +31,15 @@ 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()) { + final MediaFormat mediaFormat = videoStream.getFormat(); + if (mediaFormat == null) { + return null; + } + + switch (mediaFormat) { case WEBM: case MPEG_4:// ¿is mpeg-4 DASH? break; @@ -39,7 +47,7 @@ public static AudioStream getAudioStreamFor(@NonNull final List aud 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)) { 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/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java index 03342a49770..4b5e675c917 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 = context.getString(R.string.unknown_quality); + } else { + qualityString = mediaFormat.getSuffix(); + } } 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..0cc0ecf1fd3 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,11 +11,37 @@ 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 - * {@link StreamType#LIVE_STREAM} or {@link StreamType#AUDIO_LIVE_STREAM} + * @param streamType the stream type of the 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 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..37bbf2b03d4 100644 --- a/app/src/main/res/layout/download_dialog.xml +++ b/app/src/main/res/layout/download_dialog.xml @@ -82,13 +82,14 @@ android:text="@string/msg_threads" /> + android:layout_marginBottom="12dp" + android:orientation="horizontal"> + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 80f79cfdd97..6a1c220f0d5 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 + 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 + 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(); + } }