diff --git a/app/build.gradle b/app/build.gradle index c9bd8d003..ba6406d4b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -55,7 +55,7 @@ dependencies { exclude module: 'support-annotations' } - implementation 'com.github.TeamNewPipe:NewPipeExtractor:86db415b181' + implementation 'com.github.karyogamy:NewPipeExtractor:837dbd6b86' testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-core:1.10.19' 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 94a2f8ec0..b306721ba 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 @@ -56,6 +56,7 @@ import org.schabi.newpipe.extractor.services.youtube.YoutubeStreamExtractor; 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.StreamType; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.BaseStateFragment; @@ -1192,11 +1193,20 @@ public class VideoDetailFragment 0); } - if (info.video_streams.isEmpty() && info.video_only_streams.isEmpty()) { - detailControlsBackground.setVisibility(View.GONE); - detailControlsPopup.setVisibility(View.GONE); - spinnerToolbar.setVisibility(View.GONE); - thumbnailPlayButton.setImageResource(R.drawable.ic_headset_white_24dp); + switch (info.getStreamType()) { + case LIVE_STREAM: + case AUDIO_LIVE_STREAM: + detailControlsDownload.setVisibility(View.GONE); + spinnerToolbar.setVisibility(View.GONE); + break; + default: + if (!info.video_streams.isEmpty() || !info.video_only_streams.isEmpty()) break; + + detailControlsBackground.setVisibility(View.GONE); + detailControlsPopup.setVisibility(View.GONE); + spinnerToolbar.setVisibility(View.GONE); + thumbnailPlayButton.setImageResource(R.drawable.ic_headset_white_24dp); + break; } if (autoPlayEnabled) { @@ -1216,8 +1226,6 @@ public class VideoDetailFragment if (exception instanceof YoutubeStreamExtractor.GemaException) { onBlockedByGemaError(); - } else if (exception instanceof YoutubeStreamExtractor.LiveStreamException) { - showError(getString(R.string.live_streams_not_supported), false); } else if (exception instanceof ContentNotAvailableException) { showError(getString(R.string.content_not_available), false); } else { diff --git a/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java index fd47a7167..2fc3252a7 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java @@ -391,6 +391,9 @@ public final class BackgroundPlayer extends Service { @Override @Nullable public MediaSource sourceOf(final PlayQueueItem item, final StreamInfo info) { + final MediaSource liveSource = super.sourceOf(item, info); + if (liveSource != null) return liveSource; + final int index = ListHelper.getDefaultAudioFormat(context, info.audio_streams); if (index < 0 || index >= info.audio_streams.size()) return null; diff --git a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java index 9ee83427d..de86ea587 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -43,7 +43,6 @@ import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; import com.google.android.exoplayer2.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.SingleSampleMediaSource; @@ -54,21 +53,23 @@ import com.google.android.exoplayer2.source.hls.HlsMediaSource; import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; import com.google.android.exoplayer2.util.Util; import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.listener.SimpleImageLoadingListener; +import org.schabi.newpipe.Downloader; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.history.HistoryRecordManager; import org.schabi.newpipe.player.helper.AudioReactor; import org.schabi.newpipe.player.helper.CacheFactory; import org.schabi.newpipe.player.helper.LoadController; -import org.schabi.newpipe.player.playback.MediaSourceManagerAlt; +import org.schabi.newpipe.player.playback.CustomTrackSelector; +import org.schabi.newpipe.player.playback.MediaSourceManager; import org.schabi.newpipe.player.playback.PlaybackListener; import org.schabi.newpipe.playlist.PlayQueue; import org.schabi.newpipe.playlist.PlayQueueAdapter; @@ -125,7 +126,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen protected static final float[] PLAYBACK_SPEEDS = {0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f}; protected static final float[] PLAYBACK_PITCHES = {0.8f, 0.9f, 0.95f, 1f, 1.05f, 1.1f, 1.2f}; - protected MediaSourceManagerAlt playbackManager; + protected MediaSourceManager playbackManager; protected PlayQueue playQueue; protected StreamInfo currentInfo; @@ -147,9 +148,9 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen protected boolean isPrepared = false; - protected DefaultTrackSelector trackSelector; + protected CustomTrackSelector trackSelector; protected DataSource.Factory cacheDataSourceFactory; - protected DefaultExtractorsFactory extractorsFactory; + protected DataSource.Factory cachelessDataSourceFactory; protected SsMediaSource.Factory ssMediaSourceFactory; protected HlsMediaSource.Factory hlsMediaSourceFactory; @@ -190,23 +191,25 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen if (databaseUpdateReactor != null) databaseUpdateReactor.dispose(); databaseUpdateReactor = new CompositeDisposable(); + final String userAgent = Downloader.USER_AGENT; final DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(); - final AdaptiveTrackSelection.Factory trackSelectionFactory = new AdaptiveTrackSelection.Factory(bandwidthMeter); - final LoadControl loadControl = new LoadController(context); - final RenderersFactory renderFactory = new DefaultRenderersFactory(context); + final AdaptiveTrackSelection.Factory trackSelectionFactory = + new AdaptiveTrackSelection.Factory(bandwidthMeter); - trackSelector = new DefaultTrackSelector(trackSelectionFactory); - extractorsFactory = new DefaultExtractorsFactory(); - cacheDataSourceFactory = new CacheFactory(context); + trackSelector = new CustomTrackSelector(trackSelectionFactory); + cacheDataSourceFactory = new CacheFactory(context, userAgent, bandwidthMeter); + cachelessDataSourceFactory = new DefaultHttpDataSourceFactory(userAgent, bandwidthMeter); ssMediaSourceFactory = new SsMediaSource.Factory( - new DefaultSsChunkSource.Factory(cacheDataSourceFactory), cacheDataSourceFactory); - hlsMediaSourceFactory = new HlsMediaSource.Factory(cacheDataSourceFactory); + new DefaultSsChunkSource.Factory(cachelessDataSourceFactory), cachelessDataSourceFactory); + hlsMediaSourceFactory = new HlsMediaSource.Factory(cachelessDataSourceFactory); dashMediaSourceFactory = new DashMediaSource.Factory( - new DefaultDashChunkSource.Factory(cacheDataSourceFactory), cacheDataSourceFactory); + new DefaultDashChunkSource.Factory(cachelessDataSourceFactory), cachelessDataSourceFactory); extractorMediaSourceFactory = new ExtractorMediaSource.Factory(cacheDataSourceFactory); sampleMediaSourceFactory = new SingleSampleMediaSource.Factory(cacheDataSourceFactory); + final LoadControl loadControl = new LoadController(context); + final RenderersFactory renderFactory = new DefaultRenderersFactory(context); simpleExoPlayer = ExoPlayerFactory.newSimpleInstance(renderFactory, trackSelector, loadControl); audioReactor = new AudioReactor(context, simpleExoPlayer); @@ -262,7 +265,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen protected void initPlayback(final PlayQueue queue) { playQueue = queue; playQueue.init(); - playbackManager = new MediaSourceManagerAlt(this, playQueue); + playbackManager = new MediaSourceManager(this, playQueue); if (playQueueAdapter != null) playQueueAdapter.dispose(); playQueueAdapter = new PlayQueueAdapter(context, playQueue); @@ -316,6 +319,10 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen recordManager = null; } + public MediaSource buildMediaSource(String url) { + return buildMediaSource(url, ""); + } + public MediaSource buildMediaSource(String url, String overrideExtension) { if (DEBUG) { Log.d(TAG, "buildMediaSource() called with: url = [" + url + "], overrideExtension = [" + overrideExtension + "]"); @@ -360,7 +367,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen if (intent == null || intent.getAction() == null) return; switch (intent.getAction()) { case AudioManager.ACTION_AUDIO_BECOMING_NOISY: - if (isPlaying()) simpleExoPlayer.setPlayWhenReady(false); + if (isPlaying()) onVideoPlayPause(); break; } } @@ -721,6 +728,18 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen initThumbnail(info == null ? item.getThumbnailUrl() : info.thumbnail_url); } + @Nullable + @Override + public MediaSource sourceOf(PlayQueueItem item, StreamInfo info) { + if (!info.getHlsUrl().isEmpty()) { + return buildMediaSource(info.getHlsUrl()); + } else if (!info.getDashMpdUrl().isEmpty()) { + return buildMediaSource(info.getDashMpdUrl()); + } + + return null; + } + @Override public void shutdown() { if (DEBUG) Log.d(TAG, "Shutting down..."); diff --git a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java index f8844c15e..3e03bc207 100644 --- a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java @@ -53,7 +53,6 @@ import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MergingMediaSource; -import com.google.android.exoplayer2.source.SingleSampleMediaSource; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; @@ -65,6 +64,7 @@ import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.Subtitles; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.playlist.PlayQueueItem; @@ -305,8 +305,7 @@ public abstract class VideoPlayer extends BasePlayer captionItem.setOnMenuItemClickListener(menuItem -> { final int textRendererIndex = getRendererIndex(C.TRACK_TYPE_TEXT); if (trackSelector != null && textRendererIndex != RENDERER_UNAVAILABLE) { - trackSelector.setParameters(trackSelector.getParameters().buildUpon() - .setPreferredTextLanguage(captionLanguage).build()); + trackSelector.setPreferredTextLanguage(captionLanguage); trackSelector.setRendererDisabled(textRendererIndex, false); } return true; @@ -328,21 +327,32 @@ public abstract class VideoPlayer extends BasePlayer qualityTextView.setVisibility(View.GONE); playbackSpeedTextView.setVisibility(View.GONE); - if (info != null && info.video_streams.size() + info.video_only_streams.size() > 0) { - final List videos = ListHelper.getSortedStreamVideosList(context, - info.video_streams, info.video_only_streams, false); - availableStreams = new ArrayList<>(videos); - if (playbackQuality == null) { - selectedStreamIndex = getDefaultResolutionIndex(videos); - } else { - selectedStreamIndex = getOverrideResolutionIndex(videos, getPlaybackQuality()); - } + final StreamType streamType = info == null ? StreamType.NONE : info.getStreamType(); - buildQualityMenu(); - qualityTextView.setVisibility(View.VISIBLE); - surfaceView.setVisibility(View.VISIBLE); - } else { - surfaceView.setVisibility(View.GONE); + switch (streamType) { + case VIDEO_STREAM: + if (info.video_streams.size() + info.video_only_streams.size() == 0) break; + + final List videos = ListHelper.getSortedStreamVideosList(context, + info.video_streams, info.video_only_streams, false); + availableStreams = new ArrayList<>(videos); + if (playbackQuality == null) { + selectedStreamIndex = getDefaultResolutionIndex(videos); + } else { + selectedStreamIndex = getOverrideResolutionIndex(videos, getPlaybackQuality()); + } + + buildQualityMenu(); + qualityTextView.setVisibility(View.VISIBLE); + surfaceView.setVisibility(View.VISIBLE); + break; + + case AUDIO_STREAM: + case AUDIO_LIVE_STREAM: + surfaceView.setVisibility(View.GONE); + break; + default: + break; } buildPlaybackSpeedMenu(); @@ -352,6 +362,9 @@ public abstract class VideoPlayer extends BasePlayer @Override @Nullable public MediaSource sourceOf(final PlayQueueItem item, final StreamInfo info) { + final MediaSource liveSource = super.sourceOf(item, info); + if (liveSource != null) return liveSource; + List mediaSources = new ArrayList<>(); // Create video stream source @@ -529,26 +542,15 @@ public abstract class VideoPlayer extends BasePlayer } // Normalize mismatching language strings - final String preferredLanguage = trackSelector.getParameters().preferredTextLanguage; - // Because ExoPlayer normalizes the preferred language string but not the text track - // language strings, some preferred language string will have the language name in lowercase - String formattedPreferredLanguage = null; - if (preferredLanguage != null) { - for (final String language : availableLanguages) { - if (language.compareToIgnoreCase(preferredLanguage) == 0) { - formattedPreferredLanguage = language; - break; - } - } - } + final String preferredLanguage = trackSelector.getPreferredTextLanguage(); // Build UI buildCaptionMenu(availableLanguages); - if (trackSelector.getRendererDisabled(textRenderer) || formattedPreferredLanguage == null || - !availableLanguages.contains(formattedPreferredLanguage)) { + if (trackSelector.getRendererDisabled(textRenderer) || preferredLanguage == null || + !availableLanguages.contains(preferredLanguage)) { captionTextView.setText(R.string.caption_none); } else { - captionTextView.setText(formattedPreferredLanguage); + captionTextView.setText(preferredLanguage); } captionTextView.setVisibility(availableLanguages.isEmpty() ? View.GONE : View.VISIBLE); } 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 dce74ffb5..900f13ae9 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 @@ -4,11 +4,14 @@ import android.content.Context; import android.support.annotation.NonNull; import android.util.Log; +import com.google.android.exoplayer2.upstream.BandwidthMeter; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; import com.google.android.exoplayer2.upstream.DefaultDataSource; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +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; @@ -33,18 +36,21 @@ public class CacheFactory implements DataSource.Factory { // todo: make this a singleton? private static SimpleCache cache; - public CacheFactory(@NonNull final Context context) { - this(context, PlayerHelper.getPreferredCacheSize(context), PlayerHelper.getPreferredFileSize(context)); + public CacheFactory(@NonNull final Context context, + @NonNull final String userAgent, + @NonNull final TransferListener transferListener) { + this(context, userAgent, transferListener, PlayerHelper.getPreferredCacheSize(context), + PlayerHelper.getPreferredFileSize(context)); } - CacheFactory(@NonNull final Context context, final long maxCacheSize, final long maxFileSize) { - super(); + private CacheFactory(@NonNull final Context context, + @NonNull final String userAgent, + @NonNull final TransferListener transferListener, + final long maxCacheSize, + final long maxFileSize) { this.maxFileSize = maxFileSize; - final String userAgent = Downloader.USER_AGENT; - final DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(); - dataSourceFactory = new DefaultDataSourceFactory(context, userAgent, bandwidthMeter); - + dataSourceFactory = new DefaultDataSourceFactory(context, userAgent, transferListener); cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); if (!cacheDir.exists()) { //noinspection ResultOfMethodCallIgnored diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java b/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java new file mode 100644 index 000000000..d80ea5bae --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java @@ -0,0 +1,114 @@ +package org.schabi.newpipe.player.playback; + +import android.support.annotation.NonNull; +import android.text.TextUtils; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.FixedTrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; + +/** + * This class allows irregular text language labels for use when selecting text captions and + * is mostly a copy-paste from {@link DefaultTrackSelector}. + * + * This is a hack and should be removed once ExoPlayer fixes language normalization to accept + * a broader set of languages. + * */ +public class CustomTrackSelector extends DefaultTrackSelector { + private static final int WITHIN_RENDERER_CAPABILITIES_BONUS = 1000; + + private String preferredTextLanguage; + + public CustomTrackSelector(TrackSelection.Factory adaptiveTrackSelectionFactory) { + super(adaptiveTrackSelectionFactory); + } + + public String getPreferredTextLanguage() { + return preferredTextLanguage; + } + + public void setPreferredTextLanguage(@NonNull final String label) { + Assertions.checkNotNull(label); + if (!label.equals(preferredTextLanguage)) { + preferredTextLanguage = label; + invalidate(); + } + } + + /** @see DefaultTrackSelector#formatHasLanguage(Format, String)*/ + protected static boolean formatHasLanguage(Format format, String language) { + return language != null && TextUtils.equals(language, format.language); + } + + /** @see DefaultTrackSelector#formatHasNoLanguage(Format)*/ + protected static boolean formatHasNoLanguage(Format format) { + return TextUtils.isEmpty(format.language) || formatHasLanguage(format, C.LANGUAGE_UNDETERMINED); + } + + /** @see DefaultTrackSelector#selectTextTrack(TrackGroupArray, int[][], Parameters) */ + @Override + protected TrackSelection selectTextTrack(TrackGroupArray groups, int[][] formatSupport, + Parameters params) throws ExoPlaybackException { + TrackGroup selectedGroup = null; + int selectedTrackIndex = 0; + int selectedTrackScore = 0; + for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { + TrackGroup trackGroup = groups.get(groupIndex); + int[] trackFormatSupport = formatSupport[groupIndex]; + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + if (isSupported(trackFormatSupport[trackIndex], + params.exceedRendererCapabilitiesIfNecessary)) { + Format format = trackGroup.getFormat(trackIndex); + int maskedSelectionFlags = + format.selectionFlags & ~params.disabledTextTrackSelectionFlags; + boolean isDefault = (maskedSelectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; + boolean isForced = (maskedSelectionFlags & C.SELECTION_FLAG_FORCED) != 0; + int trackScore; + boolean preferredLanguageFound = formatHasLanguage(format, preferredTextLanguage); + if (preferredLanguageFound + || (params.selectUndeterminedTextLanguage && formatHasNoLanguage(format))) { + if (isDefault) { + trackScore = 8; + } else if (!isForced) { + // Prefer non-forced to forced if a preferred text language has been specified. Where + // both are provided the non-forced track will usually contain the forced subtitles as + // a subset. + trackScore = 6; + } else { + trackScore = 4; + } + trackScore += preferredLanguageFound ? 1 : 0; + } else if (isDefault) { + trackScore = 3; + } else if (isForced) { + if (formatHasLanguage(format, params.preferredAudioLanguage)) { + trackScore = 2; + } else { + trackScore = 1; + } + } else { + // Track should not be selected. + continue; + } + if (isSupported(trackFormatSupport[trackIndex], false)) { + trackScore += WITHIN_RENDERER_CAPABILITIES_BONUS; + } + if (trackScore > selectedTrackScore) { + selectedGroup = trackGroup; + selectedTrackIndex = trackIndex; + selectedTrackScore = trackScore; + } + } + } + } + return selectedGroup == null ? null + : new FixedTrackSelection(selectedGroup, selectedTrackIndex); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/DeferredMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/playback/DeferredMediaSource.java deleted file mode 100644 index 3ae744d18..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playback/DeferredMediaSource.java +++ /dev/null @@ -1,216 +0,0 @@ -package org.schabi.newpipe.player.playback; - -import android.support.annotation.NonNull; -import android.util.Log; - -import com.google.android.exoplayer2.ExoPlayer; -import com.google.android.exoplayer2.source.MediaPeriod; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.upstream.Allocator; - -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.playlist.PlayQueueItem; - -import java.io.IOException; - -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.Disposable; -import io.reactivex.functions.Consumer; -import io.reactivex.functions.Function; -import io.reactivex.schedulers.Schedulers; - -/** - * DeferredMediaSource is specifically designed to allow external control over when - * the source metadata are loaded while being compatible with ExoPlayer's playlists. - * - * This media source follows the structure of how NewPipeExtractor's - * {@link org.schabi.newpipe.extractor.stream.StreamInfoItem} is converted into - * {@link org.schabi.newpipe.extractor.stream.StreamInfo}. Once conversion is complete, - * this media source behaves identically as any other native media sources. - * */ -public final class DeferredMediaSource implements MediaSource { - private final String TAG = "DeferredMediaSource@" + Integer.toHexString(hashCode()); - - /** - * This state indicates the {@link DeferredMediaSource} has just been initialized or reset. - * The source must be prepared and loaded again before playback. - * */ - public final static int STATE_INIT = 0; - /** - * This state indicates the {@link DeferredMediaSource} has been prepared and is ready to load. - * */ - public final static int STATE_PREPARED = 1; - /** - * This state indicates the {@link DeferredMediaSource} has been loaded without errors and - * is ready for playback. - * */ - public final static int STATE_LOADED = 2; - - public interface Callback { - /** - * Player-specific {@link com.google.android.exoplayer2.source.MediaSource} resolution - * from a given StreamInfo. - * */ - MediaSource sourceOf(final PlayQueueItem item, final StreamInfo info); - } - - private PlayQueueItem stream; - private Callback callback; - private int state; - - private MediaSource mediaSource; - - /* Custom internal objects */ - private Disposable loader; - private ExoPlayer exoPlayer; - private Listener listener; - private Throwable error; - - public DeferredMediaSource(@NonNull final PlayQueueItem stream, - @NonNull final Callback callback) { - this.stream = stream; - this.callback = callback; - this.state = STATE_INIT; - } - - /** - * Returns the current state of the {@link DeferredMediaSource}. - * - * @see DeferredMediaSource#STATE_INIT - * @see DeferredMediaSource#STATE_PREPARED - * @see DeferredMediaSource#STATE_LOADED - * */ - public int state() { - return state; - } - - /** - * Parameters are kept in the class for delayed preparation. - * */ - @Override - public void prepareSource(ExoPlayer exoPlayer, boolean isTopLevelSource, Listener listener) { - this.exoPlayer = exoPlayer; - this.listener = listener; - this.state = STATE_PREPARED; - } - - /** - * Externally controlled loading. This method fully prepares the source to be used - * like any other native {@link com.google.android.exoplayer2.source.MediaSource}. - * - * Ideally, this should be called after this source has entered PREPARED state and - * called once only. - * - * If loading fails here, an error will be propagated out and result in an - * {@link com.google.android.exoplayer2.ExoPlaybackException ExoPlaybackException}, - * which is delegated to the player. - * */ - public synchronized void load() { - if (stream == null) { - Log.e(TAG, "Stream Info missing, media source loading terminated."); - return; - } - if (state != STATE_PREPARED || loader != null) return; - - Log.d(TAG, "Loading: [" + stream.getTitle() + "] with url: " + stream.getUrl()); - - loader = stream.getStream() - .map(streamInfo -> onStreamInfoReceived(stream, streamInfo)) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onMediaSourceReceived, this::onStreamInfoError); - } - - private MediaSource onStreamInfoReceived(@NonNull final PlayQueueItem item, - @NonNull final StreamInfo info) throws Exception { - if (callback == null) { - throw new Exception("No available callback for resolving stream info."); - } - - final MediaSource mediaSource = callback.sourceOf(item, info); - - if (mediaSource == null) { - throw new Exception("Unable to resolve source from stream info. URL: " + stream.getUrl() + - ", audio count: " + info.audio_streams.size() + - ", video count: " + info.video_only_streams.size() + info.video_streams.size()); - } - - return mediaSource; - } - - private void onMediaSourceReceived(final MediaSource mediaSource) throws Exception { - if (exoPlayer == null || listener == null || mediaSource == null) { - throw new Exception("MediaSource loading failed. URL: " + stream.getUrl()); - } - - Log.d(TAG, " Loaded: [" + stream.getTitle() + "] with url: " + stream.getUrl()); - state = STATE_LOADED; - - this.mediaSource = mediaSource; - this.mediaSource.prepareSource(exoPlayer, false, listener); - } - - private void onStreamInfoError(final Throwable throwable) { - Log.e(TAG, "Loading error:", throwable); - error = throwable; - state = STATE_LOADED; - } - - /** - * Delegate all errors to the player after {@link #load() load} is complete. - * - * Specifically, this method is called after an exception has occurred during loading or - * {@link com.google.android.exoplayer2.source.MediaSource#prepareSource(ExoPlayer, boolean, Listener) prepareSource}. - * */ - @Override - public void maybeThrowSourceInfoRefreshError() throws IOException { - if (error != null) { - throw new IOException(error); - } - - if (mediaSource != null) { - mediaSource.maybeThrowSourceInfoRefreshError(); - } - } - - @Override - public MediaPeriod createPeriod(MediaPeriodId mediaPeriodId, Allocator allocator) { - return mediaSource.createPeriod(mediaPeriodId, allocator); - } - - /** - * Releases the media period (buffers). - * - * This may be called after {@link #releaseSource releaseSource}. - * */ - @Override - public void releasePeriod(MediaPeriod mediaPeriod) { - mediaSource.releasePeriod(mediaPeriod); - } - - /** - * Cleans up all internal custom objects creating during loading. - * - * This method is called when the parent {@link com.google.android.exoplayer2.source.MediaSource} - * is released or when the player is stopped. - * - * This method should not release or set null the resources passed in through the constructor. - * This method should not set null the internal {@link com.google.android.exoplayer2.source.MediaSource}. - * */ - @Override - public void releaseSource() { - if (mediaSource != null) { - mediaSource.releaseSource(); - } - if (loader != null) { - loader.dispose(); - } - - /* Do not set mediaSource as null here as it may be called through releasePeriod */ - loader = null; - exoPlayer = null; - listener = null; - error = null; - - state = STATE_INIT; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java index 9dea4fdce..8d822fa54 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java @@ -1,13 +1,17 @@ package org.schabi.newpipe.player.playback; import android.support.annotation.Nullable; -import android.util.Log; import com.google.android.exoplayer2.source.DynamicConcatenatingMediaSource; +import com.google.android.exoplayer2.source.MediaSource; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.player.mediasource.FailedMediaSource; +import org.schabi.newpipe.player.mediasource.LoadedMediaSource; +import org.schabi.newpipe.player.mediasource.ManagedMediaSource; +import org.schabi.newpipe.player.mediasource.PlaceholderMediaSource; import org.schabi.newpipe.playlist.PlayQueue; import org.schabi.newpipe.playlist.PlayQueueItem; import org.schabi.newpipe.playlist.events.MoveEvent; @@ -15,18 +19,22 @@ import org.schabi.newpipe.playlist.events.PlayQueueEvent; import org.schabi.newpipe.playlist.events.RemoveEvent; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.concurrent.TimeUnit; +import io.reactivex.Single; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.annotations.NonNull; +import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; import io.reactivex.disposables.SerialDisposable; import io.reactivex.functions.Consumer; import io.reactivex.subjects.PublishSubject; public class MediaSourceManager { - private final String TAG = "MediaSourceManager@" + Integer.toHexString(hashCode()); // One-side rolling window size for default loading // Effectively loads windowSize * 2 + 1 streams per call to load, must be greater than 0 private final int windowSize; @@ -40,17 +48,17 @@ public class MediaSourceManager { private final PublishSubject debouncedLoadSignal; private final Disposable debouncedLoader; - private final DeferredMediaSource.Callback sourceBuilder; - private DynamicConcatenatingMediaSource sources; private Subscription playQueueReactor; - private SerialDisposable syncReactor; - - private PlayQueueItem syncedItem; + private CompositeDisposable loaderReactor; private boolean isBlocked; + private SerialDisposable syncReactor; + private PlayQueueItem syncedItem; + private Set loadingItems; + public MediaSourceManager(@NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue) { this(listener, playQueue, 1, 400L); @@ -61,7 +69,8 @@ public class MediaSourceManager { final int windowSize, final long loadDebounceMillis) { if (windowSize <= 0) { - throw new UnsupportedOperationException("MediaSourceManager window size must be greater than 0"); + throw new UnsupportedOperationException( + "MediaSourceManager window size must be greater than 0"); } this.playbackListener = listener; @@ -69,27 +78,20 @@ public class MediaSourceManager { this.windowSize = windowSize; this.loadDebounceMillis = loadDebounceMillis; - this.syncReactor = new SerialDisposable(); + this.loaderReactor = new CompositeDisposable(); this.debouncedLoadSignal = PublishSubject.create(); this.debouncedLoader = getDebouncedLoader(); - this.sourceBuilder = getSourceBuilder(); - this.sources = new DynamicConcatenatingMediaSource(); + this.syncReactor = new SerialDisposable(); + this.loadingItems = Collections.synchronizedSet(new HashSet<>()); + playQueue.getBroadcastReceiver() .observeOn(AndroidSchedulers.mainThread()) .subscribe(getReactor()); } - /*////////////////////////////////////////////////////////////////////////// - // DeferredMediaSource listener - //////////////////////////////////////////////////////////////////////////*/ - - private DeferredMediaSource.Callback getSourceBuilder() { - return playbackListener::sourceOf; - } - /*////////////////////////////////////////////////////////////////////////// // Exposed Methods //////////////////////////////////////////////////////////////////////////*/ @@ -100,10 +102,12 @@ public class MediaSourceManager { if (debouncedLoadSignal != null) debouncedLoadSignal.onComplete(); if (debouncedLoader != null) debouncedLoader.dispose(); if (playQueueReactor != null) playQueueReactor.cancel(); + if (loaderReactor != null) loaderReactor.dispose(); if (syncReactor != null) syncReactor.dispose(); if (sources != null) sources.releaseSource(); playQueueReactor = null; + loaderReactor = null; syncReactor = null; syncedItem = null; sources = null; @@ -121,7 +125,8 @@ public class MediaSourceManager { /** * Blocks the player and repopulate the sources. * - * Does not ensure the player is unblocked and should be done explicitly through {@link #load() load}. + * Does not ensure the player is unblocked and should be done explicitly + * through {@link #load() load}. * */ public void reset() { tryBlock(); @@ -210,41 +215,45 @@ public class MediaSourceManager { } /*////////////////////////////////////////////////////////////////////////// - // Internal Helpers + // Playback Locking //////////////////////////////////////////////////////////////////////////*/ private boolean isPlayQueueReady() { - return playQueue.isComplete() || playQueue.size() - playQueue.getIndex() > windowSize; + final boolean isWindowLoaded = playQueue.size() - playQueue.getIndex() > windowSize; + return playQueue.isComplete() || isWindowLoaded; } - private boolean tryBlock() { - if (!isBlocked) { - playbackListener.block(); - resetSources(); - isBlocked = true; - return true; - } - return false; + private boolean isPlaybackReady() { + return sources.getSize() > 0 && + sources.getMediaSource(playQueue.getIndex()) instanceof LoadedMediaSource; } - private boolean tryUnblock() { - if (isPlayQueueReady() && isBlocked && sources != null) { + private void tryBlock() { + if (isBlocked) return; + + playbackListener.block(); + resetSources(); + + isBlocked = true; + } + + private void tryUnblock() { + if (isPlayQueueReady() && isPlaybackReady() && isBlocked && sources != null) { isBlocked = false; playbackListener.unblock(sources); - return true; } - return false; } + /*////////////////////////////////////////////////////////////////////////// + // Metadata Synchronization TODO: maybe this should be a separate manager + //////////////////////////////////////////////////////////////////////////*/ + private void sync() { final PlayQueueItem currentItem = playQueue.getItem(); - if (currentItem == null) return; + if (isBlocked || currentItem == null) return; final Consumer onSuccess = info -> syncInternal(currentItem, info); - final Consumer onError = throwable -> { - Log.e(TAG, "Sync error:", throwable); - syncInternal(currentItem, null); - }; + final Consumer onError = throwable -> syncInternal(currentItem, null); if (syncedItem != currentItem) { syncedItem = currentItem; @@ -264,6 +273,17 @@ public class MediaSourceManager { } } + /*////////////////////////////////////////////////////////////////////////// + // MediaSource Loading + //////////////////////////////////////////////////////////////////////////*/ + + private Disposable getDebouncedLoader() { + return debouncedLoadSignal + .debounce(loadDebounceMillis, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(timestamp -> loadImmediate()); + } + private void loadDebounced() { debouncedLoadSignal.onNext(System.currentTimeMillis()); } @@ -279,76 +299,113 @@ public class MediaSourceManager { final int leftBound = Math.max(0, currentIndex - windowSize); final int rightLimit = currentIndex + windowSize + 1; final int rightBound = Math.min(playQueue.size(), rightLimit); - final List items = new ArrayList<>(playQueue.getStreams().subList(leftBound, rightBound)); + final List items = new ArrayList<>(playQueue.getStreams().subList(leftBound, + rightBound)); // Do a round robin final int excess = rightLimit - playQueue.size(); - if (excess >= 0) items.addAll(playQueue.getStreams().subList(0, Math.min(playQueue.size(), excess))); + if (excess >= 0) { + items.addAll(playQueue.getStreams().subList(0, Math.min(playQueue.size(), excess))); + } for (final PlayQueueItem item: items) loadItem(item); } private void loadItem(@Nullable final PlayQueueItem item) { - if (item == null) return; + if (sources == null || item == null) return; final int index = playQueue.indexOf(item); if (index > sources.getSize() - 1) return; - final DeferredMediaSource mediaSource = (DeferredMediaSource) sources.getMediaSource(playQueue.indexOf(item)); - if (mediaSource.state() == DeferredMediaSource.STATE_PREPARED) mediaSource.load(); + final Consumer onDone = mediaSource -> { + update(playQueue.indexOf(item), mediaSource); + loadingItems.remove(item); + tryUnblock(); + sync(); + }; + + if (!loadingItems.contains(item) && + ((ManagedMediaSource) sources.getMediaSource(index)).canReplace()) { + + loadingItems.add(item); + final Disposable loader = getLoadedMediaSource(item) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(onDone); + loaderReactor.add(loader); + } tryUnblock(); - if (!isBlocked) sync(); + sync(); } + private Single getLoadedMediaSource(@NonNull final PlayQueueItem stream) { + return stream.getStream().map(streamInfo -> { + if (playbackListener == null) { + return new FailedMediaSource(stream, new IllegalStateException( + "MediaSourceManager playback listener unavailable")); + } + + final MediaSource source = playbackListener.sourceOf(stream, streamInfo); + if (source == null) { + return new FailedMediaSource(stream, new IllegalStateException( + "MediaSource resolution is null")); + } + + final long expiration = System.currentTimeMillis() + + TimeUnit.MILLISECONDS.convert(2, TimeUnit.HOURS); + return new LoadedMediaSource(source, expiration); + }).onErrorReturn(throwable -> new FailedMediaSource(stream, throwable)); + } + + /*////////////////////////////////////////////////////////////////////////// + // MediaSource Playlist Helpers + //////////////////////////////////////////////////////////////////////////*/ + private void resetSources() { if (this.sources != null) this.sources.releaseSource(); this.sources = new DynamicConcatenatingMediaSource(); } private void populateSources() { - if (sources == null) return; + if (sources == null || sources.getSize() >= playQueue.size()) return; - for (final PlayQueueItem item : playQueue.getStreams()) { - insert(playQueue.indexOf(item), new DeferredMediaSource(item, sourceBuilder)); + for (int index = sources.getSize() - 1; index < playQueue.size(); index++) { + emplace(index, new PlaceholderMediaSource()); } } - private Disposable getDebouncedLoader() { - return debouncedLoadSignal - .debounce(loadDebounceMillis, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(timestamp -> loadImmediate()); - } /*////////////////////////////////////////////////////////////////////////// - // Media Source List Manipulation + // MediaSource Playlist Manipulation //////////////////////////////////////////////////////////////////////////*/ /** - * Inserts a source into {@link DynamicConcatenatingMediaSource} with position - * in respect to the play queue. - * - * If the play queue index already exists, then the insert is ignored. + * Places a {@link MediaSource} into the {@link DynamicConcatenatingMediaSource} + * with position * in respect to the play queue only if no {@link MediaSource} + * already exists at the given index. * */ - private void insert(final int queueIndex, final DeferredMediaSource source) { + private void emplace(final int index, final MediaSource source) { if (sources == null) return; - if (queueIndex < 0 || queueIndex < sources.getSize()) return; + if (index < 0 || index < sources.getSize()) return; - sources.addMediaSource(queueIndex, source); + sources.addMediaSource(index, source); } /** - * Removes a source from {@link DynamicConcatenatingMediaSource} with the given play queue index. - * - * If the play queue index does not exist, the removal is ignored. + * Removes a {@link MediaSource} from {@link DynamicConcatenatingMediaSource} + * at the given index. If this index is out of bound, then the removal is ignored. * */ - private void remove(final int queueIndex) { + private void remove(final int index) { if (sources == null) return; - if (queueIndex < 0 || queueIndex > sources.getSize()) return; + if (index < 0 || index > sources.getSize()) return; - sources.removeMediaSource(queueIndex); + sources.removeMediaSource(index); } + /** + * Moves a {@link MediaSource} in {@link DynamicConcatenatingMediaSource} + * from the given source index to the target index. If either index is out of bound, + * then the call is ignored. + * */ private void move(final int source, final int target) { if (sources == null) return; if (source < 0 || target < 0) return; @@ -356,4 +413,18 @@ public class MediaSourceManager { sources.moveMediaSource(source, target); } + + /** + * Updates the {@link MediaSource} in {@link DynamicConcatenatingMediaSource} + * at the given index with a given {@link MediaSource}. If the index is out of bound, + * then the replacement is ignored. + * */ + private void update(final int index, final MediaSource source) { + if (sources == null) return; + if (index < 0 || index >= sources.getSize()) return; + + sources.addMediaSource(index + 1, source, () -> { + if (sources != null) sources.removeMediaSource(index); + }); + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManagerAlt.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManagerAlt.java deleted file mode 100644 index 03b583e07..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManagerAlt.java +++ /dev/null @@ -1,422 +0,0 @@ -package org.schabi.newpipe.player.playback; - -import android.support.annotation.Nullable; - -import com.google.android.exoplayer2.source.DynamicConcatenatingMediaSource; -import com.google.android.exoplayer2.source.MediaSource; - -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.mediasource.FailedMediaSource; -import org.schabi.newpipe.player.mediasource.LoadedMediaSource; -import org.schabi.newpipe.player.mediasource.ManagedMediaSource; -import org.schabi.newpipe.player.mediasource.PlaceholderMediaSource; -import org.schabi.newpipe.playlist.PlayQueue; -import org.schabi.newpipe.playlist.PlayQueueItem; -import org.schabi.newpipe.playlist.events.MoveEvent; -import org.schabi.newpipe.playlist.events.PlayQueueEvent; -import org.schabi.newpipe.playlist.events.RemoveEvent; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -import io.reactivex.Single; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.annotations.NonNull; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.disposables.Disposable; -import io.reactivex.disposables.SerialDisposable; -import io.reactivex.functions.Consumer; -import io.reactivex.subjects.PublishSubject; - -public class MediaSourceManagerAlt { - // One-side rolling window size for default loading - // Effectively loads windowSize * 2 + 1 streams per call to load, must be greater than 0 - private final int windowSize; - private final PlaybackListener playbackListener; - private final PlayQueue playQueue; - - // Process only the last load order when receiving a stream of load orders (lessens I/O) - // The higher it is, the less loading occurs during rapid noncritical timeline changes - // Not recommended to go below 100ms - private final long loadDebounceMillis; - private final PublishSubject debouncedLoadSignal; - private final Disposable debouncedLoader; - - private DynamicConcatenatingMediaSource sources; - - private Subscription playQueueReactor; - private CompositeDisposable loaderReactor; - - private boolean isBlocked; - - private SerialDisposable syncReactor; - private PlayQueueItem syncedItem; - private Set loadingItems; - - public MediaSourceManagerAlt(@NonNull final PlaybackListener listener, - @NonNull final PlayQueue playQueue) { - this(listener, playQueue, 0, 400L); - } - - private MediaSourceManagerAlt(@NonNull final PlaybackListener listener, - @NonNull final PlayQueue playQueue, - final int windowSize, - final long loadDebounceMillis) { - if (windowSize < 0) { - throw new UnsupportedOperationException( - "MediaSourceManager window size must be greater than 0"); - } - - this.playbackListener = listener; - this.playQueue = playQueue; - this.windowSize = windowSize; - this.loadDebounceMillis = loadDebounceMillis; - - this.loaderReactor = new CompositeDisposable(); - this.debouncedLoadSignal = PublishSubject.create(); - this.debouncedLoader = getDebouncedLoader(); - - this.sources = new DynamicConcatenatingMediaSource(); - - this.syncReactor = new SerialDisposable(); - this.loadingItems = Collections.synchronizedSet(new HashSet<>()); - - playQueue.getBroadcastReceiver() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getReactor()); - } - - /*////////////////////////////////////////////////////////////////////////// - // Exposed Methods - //////////////////////////////////////////////////////////////////////////*/ - /** - * Dispose the manager and releases all message buses and loaders. - * */ - public void dispose() { - if (debouncedLoadSignal != null) debouncedLoadSignal.onComplete(); - if (debouncedLoader != null) debouncedLoader.dispose(); - if (playQueueReactor != null) playQueueReactor.cancel(); - if (loaderReactor != null) loaderReactor.dispose(); - if (syncReactor != null) syncReactor.dispose(); - if (sources != null) sources.releaseSource(); - - playQueueReactor = null; - loaderReactor = null; - syncReactor = null; - syncedItem = null; - sources = null; - } - - /** - * Loads the current playing stream and the streams within its windowSize bound. - * - * Unblocks the player once the item at the current index is loaded. - * */ - public void load() { - loadDebounced(); - } - - /** - * Blocks the player and repopulate the sources. - * - * Does not ensure the player is unblocked and should be done explicitly - * through {@link #load() load}. - * */ - public void reset() { - tryBlock(); - - syncedItem = null; - populateSources(); - } - /*////////////////////////////////////////////////////////////////////////// - // Event Reactor - //////////////////////////////////////////////////////////////////////////*/ - - private Subscriber getReactor() { - return new Subscriber() { - @Override - public void onSubscribe(@NonNull Subscription d) { - if (playQueueReactor != null) playQueueReactor.cancel(); - playQueueReactor = d; - playQueueReactor.request(1); - } - - @Override - public void onNext(@NonNull PlayQueueEvent playQueueMessage) { - if (playQueueReactor != null) onPlayQueueChanged(playQueueMessage); - } - - @Override - public void onError(@NonNull Throwable e) {} - - @Override - public void onComplete() {} - }; - } - - private void onPlayQueueChanged(final PlayQueueEvent event) { - if (playQueue.isEmpty() && playQueue.isComplete()) { - playbackListener.shutdown(); - return; - } - - // Event specific action - switch (event.type()) { - case INIT: - case REORDER: - case ERROR: - reset(); - break; - case APPEND: - populateSources(); - break; - case REMOVE: - final RemoveEvent removeEvent = (RemoveEvent) event; - remove(removeEvent.getRemoveIndex()); - break; - case MOVE: - final MoveEvent moveEvent = (MoveEvent) event; - move(moveEvent.getFromIndex(), moveEvent.getToIndex()); - break; - case SELECT: - case RECOVERY: - default: - break; - } - - // Loading and Syncing - switch (event.type()) { - case INIT: - case REORDER: - case ERROR: - loadImmediate(); // low frequency, critical events - break; - case APPEND: - case REMOVE: - case SELECT: - case MOVE: - case RECOVERY: - default: - loadDebounced(); // high frequency or noncritical events - break; - } - - if (!isPlayQueueReady()) { - tryBlock(); - playQueue.fetch(); - } - if (playQueueReactor != null) playQueueReactor.request(1); - } - - /*////////////////////////////////////////////////////////////////////////// - // Playback Locking - //////////////////////////////////////////////////////////////////////////*/ - - private boolean isPlayQueueReady() { - final boolean isWindowLoaded = playQueue.size() - playQueue.getIndex() > windowSize; - return playQueue.isComplete() || isWindowLoaded; - } - - private boolean isPlaybackReady() { - return sources.getSize() > 0 && - sources.getMediaSource(playQueue.getIndex()) instanceof LoadedMediaSource; - } - - private void tryBlock() { - if (isBlocked) return; - - playbackListener.block(); - - if (this.sources != null) this.sources.releaseSource(); - this.sources = new DynamicConcatenatingMediaSource(); - - isBlocked = true; - } - - private void tryUnblock() { - if (isPlayQueueReady() && isPlaybackReady() && isBlocked && sources != null) { - isBlocked = false; - playbackListener.unblock(sources); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Metadata Synchronization TODO: maybe this should be a separate manager - //////////////////////////////////////////////////////////////////////////*/ - - private void sync() { - final PlayQueueItem currentItem = playQueue.getItem(); - if (isBlocked || currentItem == null) return; - - final Consumer onSuccess = info -> syncInternal(currentItem, info); - final Consumer onError = throwable -> syncInternal(currentItem, null); - - if (syncedItem != currentItem) { - syncedItem = currentItem; - final Disposable sync = currentItem.getStream() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(onSuccess, onError); - syncReactor.set(sync); - } - } - - private void syncInternal(@android.support.annotation.NonNull final PlayQueueItem item, - @Nullable final StreamInfo info) { - if (playQueue == null || playbackListener == null) return; - // Ensure the current item is up to date with the play queue - if (playQueue.getItem() == item && playQueue.getItem() == syncedItem) { - playbackListener.sync(syncedItem,info); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // MediaSource Loading - //////////////////////////////////////////////////////////////////////////*/ - - private Disposable getDebouncedLoader() { - return debouncedLoadSignal - .debounce(loadDebounceMillis, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(timestamp -> loadImmediate()); - } - - private void populateSources() { - if (sources == null || sources.getSize() >= playQueue.size()) return; - - for (int index = sources.getSize() - 1; index < playQueue.size(); index++) { - emplace(index, new PlaceholderMediaSource()); - } - } - - private void loadDebounced() { - debouncedLoadSignal.onNext(System.currentTimeMillis()); - } - - private void loadImmediate() { - // The current item has higher priority - final int currentIndex = playQueue.getIndex(); - final PlayQueueItem currentItem = playQueue.getItem(currentIndex); - if (currentItem == null) return; - loadItem(currentItem); - - // The rest are just for seamless playback - final int leftBound = Math.max(0, currentIndex - windowSize); - final int rightLimit = currentIndex + windowSize + 1; - final int rightBound = Math.min(playQueue.size(), rightLimit); - final List items = new ArrayList<>(playQueue.getStreams().subList(leftBound, - rightBound)); - - // Do a round robin - final int excess = rightLimit - playQueue.size(); - if (excess >= 0) { - items.addAll(playQueue.getStreams().subList(0, Math.min(playQueue.size(), excess))); - } - - for (final PlayQueueItem item: items) loadItem(item); - } - - private void loadItem(@Nullable final PlayQueueItem item) { - if (sources == null || item == null) return; - - final int index = playQueue.indexOf(item); - if (index > sources.getSize() - 1) return; - - final Consumer onDone = mediaSource -> { - update(playQueue.indexOf(item), mediaSource); - loadingItems.remove(item); - tryUnblock(); - sync(); - }; - - if (!loadingItems.contains(item) && - ((ManagedMediaSource) sources.getMediaSource(index)).canReplace()) { - - loadingItems.add(item); - final Disposable loader = getLoadedMediaSource(item) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(onDone); - loaderReactor.add(loader); - } - - tryUnblock(); - sync(); - } - - private Single getLoadedMediaSource(@NonNull final PlayQueueItem stream) { - return stream.getStream().map(streamInfo -> { - if (playbackListener == null) { - return new FailedMediaSource(stream, new IllegalStateException( - "MediaSourceManager playback listener unavailable")); - } - - final MediaSource source = playbackListener.sourceOf(stream, streamInfo); - if (source == null) { - return new FailedMediaSource(stream, new IllegalStateException( - "MediaSource resolution is null")); - } - - final long expiration = System.currentTimeMillis() + - TimeUnit.MILLISECONDS.convert(2, TimeUnit.HOURS); - return new LoadedMediaSource(source, expiration); - }).onErrorReturn(throwable -> new FailedMediaSource(stream, throwable)); - } - - /*////////////////////////////////////////////////////////////////////////// - // Media Source List Manipulation - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Places a {@link MediaSource} into the {@link DynamicConcatenatingMediaSource} - * with position * in respect to the play queue only if no {@link MediaSource} - * already exists at the given index. - * */ - private void emplace(final int index, final MediaSource source) { - if (sources == null) return; - if (index < 0 || index < sources.getSize()) return; - - sources.addMediaSource(index, source); - } - - /** - * Removes a {@link MediaSource} from {@link DynamicConcatenatingMediaSource} - * at the given index. If this index is out of bound, then the removal is ignored. - * */ - private void remove(final int index) { - if (sources == null) return; - if (index < 0 || index > sources.getSize()) return; - - sources.removeMediaSource(index); - } - - /** - * Moves a {@link MediaSource} in {@link DynamicConcatenatingMediaSource} - * from the given source index to the target index. If either index is out of bound, - * then the call is ignored. - * */ - private void move(final int source, final int target) { - if (sources == null) return; - if (source < 0 || target < 0) return; - if (source >= sources.getSize() || target >= sources.getSize()) return; - - sources.moveMediaSource(source, target); - } - - /** - * Updates the {@link MediaSource} in {@link DynamicConcatenatingMediaSource} - * at the given index with a given {@link MediaSource}. If the index is out of bound, - * then the replacement is ignored. - * */ - private void update(final int index, final MediaSource source) { - if (sources == null) return; - if (index < 0 || index >= sources.getSize()) return; - - sources.addMediaSource(index + 1, source); - sources.removeMediaSource(index); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java index 1148171d7..78d6a6318 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -224,8 +224,6 @@ public final class ExtractorHelper { Toast.makeText(context, R.string.network_error, Toast.LENGTH_LONG).show(); } else if (exception instanceof YoutubeStreamExtractor.GemaException) { Toast.makeText(context, R.string.blocked_by_gema, Toast.LENGTH_LONG).show(); - } else if (exception instanceof YoutubeStreamExtractor.LiveStreamException) { - Toast.makeText(context, R.string.live_streams_not_supported, Toast.LENGTH_LONG).show(); } else if (exception instanceof ContentNotAvailableException) { Toast.makeText(context, R.string.content_not_available, Toast.LENGTH_LONG).show(); } else {