From 8803b60b28e0cc1ef292610bead89a115d76f1ea Mon Sep 17 00:00:00 2001 From: John Zhen Mo Date: Thu, 22 Feb 2018 13:30:48 -0800 Subject: [PATCH 01/16] -Updated Exoplayer to 2.7.0. -PoC for new seamless stream loading mechanism. --- app/build.gradle | 2 +- .../fragments/list/search/SearchFragment.java | 59 +-- .../org/schabi/newpipe/player/BasePlayer.java | 31 +- .../schabi/newpipe/player/VideoPlayer.java | 8 +- .../newpipe/player/helper/LoadController.java | 33 +- .../player/mediasource/FailedMediaSource.java | 72 ++++ .../player/mediasource/LoadedMediaSource.java | 75 ++++ .../mediasource/ManagedMediaSource.java | 7 + .../mediasource/PlaceholderMediaSource.java | 22 ++ .../player/playback/MediaSourceManager.java | 1 - .../playback/MediaSourceManagerAlt.java | 369 ++++++++++++++++++ 11 files changed, 617 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManagerAlt.java diff --git a/app/build.gradle b/app/build.gradle index c5887faed..c9bd8d003 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -73,7 +73,7 @@ dependencies { implementation 'de.hdodenhof:circleimageview:2.2.0' implementation 'com.github.nirhart:ParallaxScroll:dd53d1f9d1' implementation 'com.nononsenseapps:filepicker:3.0.1' - implementation 'com.google.android.exoplayer:exoplayer:2.6.0' + implementation 'com.google.android.exoplayer:exoplayer:2.7.0' debugImplementation 'com.facebook.stetho:stetho:1.5.0' debugImplementation 'com.facebook.stetho:stetho-urlconnection:1.5.0' diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java index 9c9aeb080..1ad31d06c 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java @@ -527,23 +527,26 @@ public class SearchFragment extends BaseListFragment suggestionPublisher - .onNext(searchEditText.getText().toString()), - - throwable -> showSnackBarError(throwable, - UserAction.SOMETHING_ELSE, "none", - "Deleting item failed", R.string.general_error) - ); - + if (activity == null || historyRecordManager == null || suggestionPublisher == null || + searchEditText == null || disposables == null) return; + final String query = item.query; new AlertDialog.Builder(activity) - .setTitle(item.query) + .setTitle(query) .setMessage(R.string.delete_item_search_history) .setCancelable(true) .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.delete, (dialog, which) -> disposables.add(onDelete)) + .setPositiveButton(R.string.delete, (dialog, which) -> { + final Disposable onDelete = historyRecordManager.deleteSearchHistory(query) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + howManyDeleted -> suggestionPublisher + .onNext(searchEditText.getText().toString()), + throwable -> showSnackBarError(throwable, + UserAction.SOMETHING_ELSE, "none", + "Deleting item failed", R.string.general_error) + ); + disposables.add(onDelete); + }) .show(); } @@ -701,19 +704,8 @@ public class SearchFragment extends BaseListFragment() { - @Override - public void accept(@NonNull SearchResult result) throws Exception { - isLoading.set(false); - handleResult(result); - } - }, new Consumer() { - @Override - public void accept(@NonNull Throwable throwable) throws Exception { - isLoading.set(false); - onError(throwable); - } - }); + .doOnEvent((searchResult, throwable) -> isLoading.set(false)) + .subscribe(this::handleResult, this::onError); } @Override @@ -725,19 +717,8 @@ public class SearchFragment extends BaseListFragment() { - @Override - public void accept(@NonNull ListExtractor.InfoItemPage result) throws Exception { - isLoading.set(false); - handleNextItems(result); - } - }, new Consumer() { - @Override - public void accept(@NonNull Throwable throwable) throws Exception { - isLoading.set(false); - onError(throwable); - } - }); + .doOnEvent((nextItemsResult, throwable) -> isLoading.set(false)) + .subscribe(this::handleNextItems, this::onError); } @Override 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 222f0fad8..9ee83427d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -46,6 +46,7 @@ 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; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.dash.DashMediaSource; import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource; @@ -67,7 +68,7 @@ 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.MediaSourceManager; +import org.schabi.newpipe.player.playback.MediaSourceManagerAlt; import org.schabi.newpipe.player.playback.PlaybackListener; import org.schabi.newpipe.playlist.PlayQueue; import org.schabi.newpipe.playlist.PlayQueueAdapter; @@ -124,7 +125,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 MediaSourceManager playbackManager; + protected MediaSourceManagerAlt playbackManager; protected PlayQueue playQueue; protected StreamInfo currentInfo; @@ -150,6 +151,12 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen protected DataSource.Factory cacheDataSourceFactory; protected DefaultExtractorsFactory extractorsFactory; + protected SsMediaSource.Factory ssMediaSourceFactory; + protected HlsMediaSource.Factory hlsMediaSourceFactory; + protected DashMediaSource.Factory dashMediaSourceFactory; + protected ExtractorMediaSource.Factory extractorMediaSourceFactory; + protected SingleSampleMediaSource.Factory sampleMediaSourceFactory; + protected Disposable progressUpdateReactor; protected CompositeDisposable databaseUpdateReactor; @@ -192,6 +199,14 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen extractorsFactory = new DefaultExtractorsFactory(); cacheDataSourceFactory = new CacheFactory(context); + ssMediaSourceFactory = new SsMediaSource.Factory( + new DefaultSsChunkSource.Factory(cacheDataSourceFactory), cacheDataSourceFactory); + hlsMediaSourceFactory = new HlsMediaSource.Factory(cacheDataSourceFactory); + dashMediaSourceFactory = new DashMediaSource.Factory( + new DefaultDashChunkSource.Factory(cacheDataSourceFactory), cacheDataSourceFactory); + extractorMediaSourceFactory = new ExtractorMediaSource.Factory(cacheDataSourceFactory); + sampleMediaSourceFactory = new SingleSampleMediaSource.Factory(cacheDataSourceFactory); + simpleExoPlayer = ExoPlayerFactory.newSimpleInstance(renderFactory, trackSelector, loadControl); audioReactor = new AudioReactor(context, simpleExoPlayer); @@ -247,7 +262,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen protected void initPlayback(final PlayQueue queue) { playQueue = queue; playQueue.init(); - playbackManager = new MediaSourceManager(this, playQueue); + playbackManager = new MediaSourceManagerAlt(this, playQueue); if (playQueueAdapter != null) playQueueAdapter.dispose(); playQueueAdapter = new PlayQueueAdapter(context, playQueue); @@ -310,16 +325,16 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen MediaSource mediaSource; switch (type) { case C.TYPE_SS: - mediaSource = new SsMediaSource(uri, cacheDataSourceFactory, new DefaultSsChunkSource.Factory(cacheDataSourceFactory), null, null); + mediaSource = ssMediaSourceFactory.createMediaSource(uri); break; case C.TYPE_DASH: - mediaSource = new DashMediaSource(uri, cacheDataSourceFactory, new DefaultDashChunkSource.Factory(cacheDataSourceFactory), null, null); + mediaSource = dashMediaSourceFactory.createMediaSource(uri); break; case C.TYPE_HLS: - mediaSource = new HlsMediaSource(uri, cacheDataSourceFactory, null, null); + mediaSource = hlsMediaSourceFactory.createMediaSource(uri); break; case C.TYPE_OTHER: - mediaSource = new ExtractorMediaSource(uri, cacheDataSourceFactory, extractorsFactory, null, null); + mediaSource = extractorMediaSourceFactory.createMediaSource(uri); break; default: { throw new IllegalStateException("Unsupported type: " + type); @@ -489,7 +504,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen } @Override - public void onTimelineChanged(Timeline timeline, Object manifest) { + public void onTimelineChanged(Timeline timeline, Object manifest, int reason) { if (DEBUG) Log.d(TAG, "onTimelineChanged(), timeline size = " + timeline.getWindowCount()); if (playbackManager != null) { 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 40b7df2dc..f8844c15e 100644 --- a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java @@ -305,8 +305,8 @@ 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() - .withPreferredTextLanguage(captionLanguage)); + trackSelector.setParameters(trackSelector.getParameters().buildUpon() + .setPreferredTextLanguage(captionLanguage).build()); trackSelector.setRendererDisabled(textRendererIndex, false); } return true; @@ -395,8 +395,8 @@ public abstract class VideoPlayer extends BasePlayer final Format textFormat = Format.createTextSampleFormat(null, mimeType, SELECTION_FLAG_AUTOSELECT, PlayerHelper.captionLanguageOf(context, subtitle)); - final MediaSource textSource = new SingleSampleMediaSource( - Uri.parse(subtitle.getURL()), cacheDataSourceFactory, textFormat, TIME_UNSET); + final MediaSource textSource = sampleMediaSourceFactory.createMediaSource( + Uri.parse(subtitle.getURL()), textFormat, TIME_UNSET); mediaSources.add(textSource); } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java index be7b8efde..15668be90 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java @@ -12,6 +12,8 @@ import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DefaultAllocator; import static com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS; +import static com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS; +import static com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_TARGET_BUFFER_BYTES; public class LoadController implements LoadControl { @@ -29,14 +31,14 @@ public class LoadController implements LoadControl { PlayerHelper.getBufferForPlaybackMs(context)); } - public LoadController(final int minBufferMs, - final int maxBufferMs, - final int bufferForPlaybackMs) { + private LoadController(final int minBufferMs, final int maxBufferMs, + final int bufferForPlaybackMs) { final DefaultAllocator allocator = new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE); internalLoadControl = new DefaultLoadControl(allocator, minBufferMs, maxBufferMs, - bufferForPlaybackMs, DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS); + bufferForPlaybackMs, DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, + DEFAULT_TARGET_BUFFER_BYTES, DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS); } /*////////////////////////////////////////////////////////////////////////// @@ -49,7 +51,8 @@ public class LoadController implements LoadControl { } @Override - public void onTracksSelected(Renderer[] renderers, TrackGroupArray trackGroupArray, TrackSelectionArray trackSelectionArray) { + public void onTracksSelected(Renderer[] renderers, TrackGroupArray trackGroupArray, + TrackSelectionArray trackSelectionArray) { internalLoadControl.onTracksSelected(renderers, trackGroupArray, trackSelectionArray); } @@ -69,12 +72,24 @@ public class LoadController implements LoadControl { } @Override - public boolean shouldStartPlayback(long l, boolean b) { - return internalLoadControl.shouldStartPlayback(l, b); + public long getBackBufferDurationUs() { + return internalLoadControl.getBackBufferDurationUs(); } @Override - public boolean shouldContinueLoading(long l) { - return internalLoadControl.shouldContinueLoading(l); + public boolean retainBackBufferFromKeyframe() { + return internalLoadControl.retainBackBufferFromKeyframe(); + } + + @Override + public boolean shouldContinueLoading(long bufferedDurationUs, float playbackSpeed) { + return internalLoadControl.shouldContinueLoading(bufferedDurationUs, playbackSpeed); + } + + @Override + public boolean shouldStartPlayback(long bufferedDurationUs, float playbackSpeed, + boolean rebuffering) { + return internalLoadControl.shouldStartPlayback(bufferedDurationUs, playbackSpeed, + rebuffering); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java new file mode 100644 index 000000000..c4a44f503 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java @@ -0,0 +1,72 @@ +package org.schabi.newpipe.player.mediasource; + +import android.support.annotation.NonNull; + +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; + +public class FailedMediaSource implements ManagedMediaSource { + + private final PlayQueueItem playQueueItem; + private final Throwable error; + + private final long retryTimestamp; + + public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem, + @NonNull final Throwable error, + final long retryTimestamp) { + this.playQueueItem = playQueueItem; + this.error = error; + this.retryTimestamp = retryTimestamp; + } + + public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem, + @NonNull final Throwable error) { + this.playQueueItem = playQueueItem; + this.error = error; + this.retryTimestamp = Long.MAX_VALUE; + } + + public PlayQueueItem getPlayQueueItem() { + return playQueueItem; + } + + public Throwable getError() { + return error; + } + + public boolean canRetry() { + return System.currentTimeMillis() >= retryTimestamp; + } + + @Override + public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) {} + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + throw new IOException(error); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + return null; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) {} + + @Override + public void releaseSource() {} + + @Override + public boolean canReplace() { + return canRetry(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java new file mode 100644 index 000000000..45a079d2b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java @@ -0,0 +1,75 @@ +package org.schabi.newpipe.player.mediasource; + +import android.support.annotation.NonNull; + +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; + +public class LoadedMediaSource implements ManagedMediaSource { + + private final PlayQueueItem playQueueItem; + private final StreamInfo streamInfo; + private final MediaSource source; + + private final long expireTimestamp; + + public LoadedMediaSource(@NonNull final PlayQueueItem playQueueItem, + @NonNull final StreamInfo streamInfo, + @NonNull final MediaSource source, + final long expireTimestamp) { + this.playQueueItem = playQueueItem; + this.streamInfo = streamInfo; + this.source = source; + + this.expireTimestamp = expireTimestamp; + } + + public PlayQueueItem getPlayQueueItem() { + return playQueueItem; + } + + public StreamInfo getStreamInfo() { + return streamInfo; + } + + public boolean isExpired() { + return System.currentTimeMillis() >= expireTimestamp; + } + + @Override + public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { + source.prepareSource(player, isTopLevelSource, listener); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + source.maybeThrowSourceInfoRefreshError(); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + return source.createPeriod(id, allocator); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + source.releasePeriod(mediaPeriod); + } + + @Override + public void releaseSource() { + source.releaseSource(); + } + + @Override + public boolean canReplace() { + return isExpired(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java new file mode 100644 index 000000000..5ac07c9f0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java @@ -0,0 +1,7 @@ +package org.schabi.newpipe.player.mediasource; + +import com.google.android.exoplayer2.source.MediaSource; + +public interface ManagedMediaSource extends MediaSource { + boolean canReplace(); +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java new file mode 100644 index 000000000..0a389a9d9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java @@ -0,0 +1,22 @@ +package org.schabi.newpipe.player.mediasource; + +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 java.io.IOException; + +public class PlaceholderMediaSource implements ManagedMediaSource { + // Do nothing, so this will stall the playback + @Override public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) {} + @Override public void maybeThrowSourceInfoRefreshError() throws IOException {} + @Override public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { return null; } + @Override public void releasePeriod(MediaPeriod mediaPeriod) {} + @Override public void releaseSource() {} + + @Override + public boolean canReplace() { + return true; + } +} 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 54eb4078a..9dea4fdce 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 @@ -20,7 +20,6 @@ import java.util.concurrent.TimeUnit; 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; 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 new file mode 100644 index 000000000..a306ff859 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManagerAlt.java @@ -0,0 +1,369 @@ +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.List; +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.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 PlayQueueItem syncedItem; + + private boolean isBlocked; + + public MediaSourceManagerAlt(@NonNull final PlaybackListener listener, + @NonNull final PlayQueue playQueue) { + this(listener, playQueue, 1, 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(); + + 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 (sources != null) sources.releaseSource(); + + playQueueReactor = null; + loaderReactor = 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); + } + + /*////////////////////////////////////////////////////////////////////////// + // Internal Helpers + //////////////////////////////////////////////////////////////////////////*/ + + private boolean isPlayQueueReady() { + 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 tryUnblock() { + if (isPlayQueueReady() && isBlocked && sources != null) { + isBlocked = false; + playbackListener.unblock(sources); + return true; + } + return false; + } + + private void sync(final PlayQueueItem item, final StreamInfo info) { + final PlayQueueItem currentItem = playQueue.getItem(); + if (currentItem != item || syncedItem == item || playbackListener == null) return; + + syncedItem = currentItem; + // Ensure the current item is up to date with the play queue + if (playQueue.getItem() == currentItem && playQueue.getItem() == syncedItem) { + playbackListener.sync(syncedItem, info); + } + } + + 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; + + if (((ManagedMediaSource) sources.getMediaSource(index)).canReplace()) { + final Disposable loader = getMediaSource(item) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(mediaSource -> update(playQueue.indexOf(item), mediaSource)); + loaderReactor.add(loader); + } + + tryUnblock(); + if (!isBlocked) { + final MediaSource mediaSource = sources.getMediaSource(playQueue.indexOf(item)); + final StreamInfo info = mediaSource instanceof LoadedMediaSource ? + ((LoadedMediaSource) mediaSource).getStreamInfo() : null; + sync(item, info); + } + } + + private void resetSources() { + if (this.sources != null) this.sources.releaseSource(); + this.sources = new DynamicConcatenatingMediaSource(); + } + + private void populateSources() { + if (sources == null) return; + + for (final PlayQueueItem item : playQueue.getStreams()) { + insert(playQueue.indexOf(item), new PlaceholderMediaSource()); + } + } + + private Disposable getDebouncedLoader() { + return debouncedLoadSignal + .debounce(loadDebounceMillis, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(timestamp -> loadImmediate()); + } + + private Single getMediaSource(@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")); + } + + return new LoadedMediaSource(stream, streamInfo, source, + TimeUnit.MILLISECONDS.convert(2, TimeUnit.HOURS)); + }).onErrorReturn(throwable -> new FailedMediaSource(stream, throwable)); + } + /*////////////////////////////////////////////////////////////////////////// + // Media Source List Manipulation + //////////////////////////////////////////////////////////////////////////*/ + + private void update(final int queueIndex, final MediaSource source) { + if (sources == null) return; + if (queueIndex < 0 || queueIndex < sources.getSize()) return; + + sources.addMediaSource(queueIndex + 1, source); + sources.removeMediaSource(queueIndex); + } + + /** + * 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. + * */ + private void insert(final int queueIndex, final PlaceholderMediaSource source) { + if (sources == null) return; + if (queueIndex < 0 || queueIndex < sources.getSize()) return; + + sources.addMediaSource(queueIndex, 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. + * */ + private void remove(final int queueIndex) { + if (sources == null) return; + if (queueIndex < 0 || queueIndex > sources.getSize()) return; + + sources.removeMediaSource(queueIndex); + } + + 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); + } +} From 19cbcd0c1d02e30a4011db7b169e1f32e2e2a321 Mon Sep 17 00:00:00 2001 From: John Zhen Mo Date: Fri, 23 Feb 2018 19:00:08 -0800 Subject: [PATCH 02/16] -Fixed media source update index check. -Fixed media source manager excessive loading. -Remove unneeded fields in loaded media source. --- .../player/mediasource/LoadedMediaSource.java | 23 +- .../playback/MediaSourceManagerAlt.java | 213 +++++++++++------- 2 files changed, 135 insertions(+), 101 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java index 45a079d2b..ddc78bd77 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java @@ -14,35 +14,16 @@ import java.io.IOException; public class LoadedMediaSource implements ManagedMediaSource { - private final PlayQueueItem playQueueItem; - private final StreamInfo streamInfo; private final MediaSource source; private final long expireTimestamp; - public LoadedMediaSource(@NonNull final PlayQueueItem playQueueItem, - @NonNull final StreamInfo streamInfo, - @NonNull final MediaSource source, - final long expireTimestamp) { - this.playQueueItem = playQueueItem; - this.streamInfo = streamInfo; + public LoadedMediaSource(@NonNull final MediaSource source, final long expireTimestamp) { this.source = source; this.expireTimestamp = expireTimestamp; } - public PlayQueueItem getPlayQueueItem() { - return playQueueItem; - } - - public StreamInfo getStreamInfo() { - return streamInfo; - } - - public boolean isExpired() { - return System.currentTimeMillis() >= expireTimestamp; - } - @Override public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { source.prepareSource(player, isTopLevelSource, listener); @@ -70,6 +51,6 @@ public class LoadedMediaSource implements ManagedMediaSource { @Override public boolean canReplace() { - return isExpired(); + return System.currentTimeMillis() >= expireTimestamp; } } 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 index a306ff859..03b583e07 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManagerAlt.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManagerAlt.java @@ -19,7 +19,10 @@ 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; @@ -27,6 +30,8 @@ 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 { @@ -48,21 +53,24 @@ public class MediaSourceManagerAlt { private Subscription playQueueReactor; private CompositeDisposable loaderReactor; - private PlayQueueItem syncedItem; - 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, 1, 400L); + 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"); + if (windowSize < 0) { + throw new UnsupportedOperationException( + "MediaSourceManager window size must be greater than 0"); } this.playbackListener = listener; @@ -76,6 +84,9 @@ public class MediaSourceManagerAlt { this.sources = new DynamicConcatenatingMediaSource(); + this.syncReactor = new SerialDisposable(); + this.loadingItems = Collections.synchronizedSet(new HashSet<>()); + playQueue.getBroadcastReceiver() .observeOn(AndroidSchedulers.mainThread()) .subscribe(getReactor()); @@ -92,10 +103,12 @@ public class MediaSourceManagerAlt { 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; } @@ -112,7 +125,8 @@ public class MediaSourceManagerAlt { /** * 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(); @@ -201,7 +215,7 @@ public class MediaSourceManagerAlt { } /*////////////////////////////////////////////////////////////////////////// - // Internal Helpers + // Playback Locking //////////////////////////////////////////////////////////////////////////*/ private boolean isPlayQueueReady() { @@ -209,33 +223,74 @@ public class MediaSourceManagerAlt { 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(); + + 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); - return true; } - return false; } - private void sync(final PlayQueueItem item, final StreamInfo info) { - final PlayQueueItem currentItem = playQueue.getItem(); - if (currentItem != item || syncedItem == item || playbackListener == null) return; + /*////////////////////////////////////////////////////////////////////////// + // Metadata Synchronization TODO: maybe this should be a separate manager + //////////////////////////////////////////////////////////////////////////*/ - syncedItem = currentItem; + 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() == currentItem && playQueue.getItem() == syncedItem) { - playbackListener.sync(syncedItem, info); + 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()); } } @@ -254,11 +309,14 @@ public class MediaSourceManagerAlt { 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); } @@ -269,43 +327,28 @@ public class MediaSourceManagerAlt { final int index = playQueue.indexOf(item); if (index > sources.getSize() - 1) return; - if (((ManagedMediaSource) sources.getMediaSource(index)).canReplace()) { - final Disposable loader = getMediaSource(item) + 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(mediaSource -> update(playQueue.indexOf(item), mediaSource)); + .subscribe(onDone); loaderReactor.add(loader); } tryUnblock(); - if (!isBlocked) { - final MediaSource mediaSource = sources.getMediaSource(playQueue.indexOf(item)); - final StreamInfo info = mediaSource instanceof LoadedMediaSource ? - ((LoadedMediaSource) mediaSource).getStreamInfo() : null; - sync(item, info); - } + sync(); } - private void resetSources() { - if (this.sources != null) this.sources.releaseSource(); - this.sources = new DynamicConcatenatingMediaSource(); - } - - private void populateSources() { - if (sources == null) return; - - for (final PlayQueueItem item : playQueue.getStreams()) { - insert(playQueue.indexOf(item), new PlaceholderMediaSource()); - } - } - - private Disposable getDebouncedLoader() { - return debouncedLoadSignal - .debounce(loadDebounceMillis, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(timestamp -> loadImmediate()); - } - - private Single getMediaSource(@NonNull final PlayQueueItem stream) { + private Single getLoadedMediaSource(@NonNull final PlayQueueItem stream) { return stream.getStream().map(streamInfo -> { if (playbackListener == null) { return new FailedMediaSource(stream, new IllegalStateException( @@ -318,47 +361,44 @@ public class MediaSourceManagerAlt { "MediaSource resolution is null")); } - return new LoadedMediaSource(stream, streamInfo, source, - TimeUnit.MILLISECONDS.convert(2, TimeUnit.HOURS)); + 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 //////////////////////////////////////////////////////////////////////////*/ - private void update(final int queueIndex, final MediaSource source) { + /** + * 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 (queueIndex < 0 || queueIndex < sources.getSize()) return; + if (index < 0 || index < sources.getSize()) return; - sources.addMediaSource(queueIndex + 1, source); - sources.removeMediaSource(queueIndex); + sources.addMediaSource(index, source); } /** - * 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. + * 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 insert(final int queueIndex, final PlaceholderMediaSource source) { + 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.addMediaSource(queueIndex, source); + sources.removeMediaSource(index); } /** - * Removes a source from {@link DynamicConcatenatingMediaSource} with the given play queue index. - * - * If the play queue index does not exist, the removal is ignored. + * 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 remove(final int queueIndex) { - if (sources == null) return; - if (queueIndex < 0 || queueIndex > sources.getSize()) return; - - sources.removeMediaSource(queueIndex); - } - private void move(final int source, final int target) { if (sources == null) return; if (source < 0 || target < 0) return; @@ -366,4 +406,17 @@ public class MediaSourceManagerAlt { 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); + } } From 563a4137bd9d21a1f2ee0efaefcd5dc0b33256bf Mon Sep 17 00:00:00 2001 From: John Zhen Mo Date: Sun, 25 Feb 2018 15:10:11 -0800 Subject: [PATCH 03/16] -Fixed inconsistent audio focus state when audio becomes noisy (e.g. headset unplugged). -Fixed live media sources failing when using cached data source by introducing cacheless data sources. -Added custom track selector to circumvent ExoPlayer's language normalization NPE. -Updated Extractor to correctly load live streams. -Removed deprecated deferred media source and media source manager. -Removed Livestream exceptions. --- app/build.gradle | 2 +- .../fragments/detail/VideoDetailFragment.java | 22 +- .../newpipe/player/BackgroundPlayer.java | 3 + .../org/schabi/newpipe/player/BasePlayer.java | 53 ++- .../schabi/newpipe/player/VideoPlayer.java | 66 +-- .../newpipe/player/helper/CacheFactory.java | 22 +- .../player/playback/CustomTrackSelector.java | 114 +++++ .../player/playback/DeferredMediaSource.java | 216 --------- .../player/playback/MediaSourceManager.java | 207 ++++++--- .../playback/MediaSourceManagerAlt.java | 422 ------------------ .../schabi/newpipe/util/ExtractorHelper.java | 2 - 11 files changed, 356 insertions(+), 773 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playback/DeferredMediaSource.java delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManagerAlt.java 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 { From ac431e3ece282bc941ce5330d7e83bd8963dd50c Mon Sep 17 00:00:00 2001 From: John Zhen Mo Date: Sun, 25 Feb 2018 15:32:25 -0800 Subject: [PATCH 04/16] -Fixed failed media source not treated as ready. --- .../newpipe/player/mediasource/FailedMediaSource.java | 2 -- .../schabi/newpipe/player/playback/MediaSourceManager.java | 7 +++++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java index c4a44f503..2454c022c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java @@ -4,10 +4,8 @@ import android.support.annotation.NonNull; 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; 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 8d822fa54..50b485cb5 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 @@ -219,13 +219,16 @@ public class MediaSourceManager { //////////////////////////////////////////////////////////////////////////*/ private boolean isPlayQueueReady() { + if (playQueue == null) return false; + final boolean isWindowLoaded = playQueue.size() - playQueue.getIndex() > windowSize; return playQueue.isComplete() || isWindowLoaded; } + // Checks if the current playback media source is a placeholder, if so, then it is not ready private boolean isPlaybackReady() { - return sources.getSize() > 0 && - sources.getMediaSource(playQueue.getIndex()) instanceof LoadedMediaSource; + return sources != null && playQueue != null && sources.getSize() > playQueue.getIndex() && + !(sources.getMediaSource(playQueue.getIndex()) instanceof PlaceholderMediaSource); } private void tryBlock() { From 1444fe5468e3e6a691b916cb0fa80aecd7c628cd Mon Sep 17 00:00:00 2001 From: John Zhen Mo Date: Sun, 25 Feb 2018 20:12:20 -0800 Subject: [PATCH 05/16] -Fixed potential NPE when obtaining broadcast receiver. -Extracted expiration time in media source manager. -Re-enabled long click on live stream info items. -Fixed dash source building to use mpd instead of extractor. --- .../holder/StreamMiniInfoItemHolder.java | 26 +++++------- .../org/schabi/newpipe/player/BasePlayer.java | 40 +++++++++---------- .../player/mediasource/FailedMediaSource.java | 4 ++ .../player/playback/MediaSourceManager.java | 24 +++++++---- .../schabi/newpipe/playlist/PlayQueue.java | 3 +- .../newpipe/playlist/PlayQueueAdapter.java | 4 +- 6 files changed, 54 insertions(+), 47 deletions(-) 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 48dc470d0..594a85582 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 @@ -59,23 +59,20 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { itemBuilder.getImageLoader() .displayImage(item.thumbnail_url, itemThumbnailView, StreamInfoItemHolder.DISPLAY_THUMBNAIL_OPTIONS); - itemView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - if (itemBuilder.getOnStreamSelectedListener() != null) { - itemBuilder.getOnStreamSelectedListener().selected(item); - } + itemView.setOnClickListener(view -> { + if (itemBuilder.getOnStreamSelectedListener() != null) { + itemBuilder.getOnStreamSelectedListener().selected(item); } }); switch (item.stream_type) { case AUDIO_STREAM: case VIDEO_STREAM: - case FILE: - enableLongClick(item); - break; case LIVE_STREAM: case AUDIO_LIVE_STREAM: + enableLongClick(item); + break; + case FILE: case NONE: default: disableLongClick(); @@ -85,14 +82,11 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { private void enableLongClick(final StreamInfoItem item) { itemView.setLongClickable(true); - itemView.setOnLongClickListener(new View.OnLongClickListener() { - @Override - public boolean onLongClick(View view) { - if (itemBuilder.getOnStreamSelectedListener() != null) { - itemBuilder.getOnStreamSelectedListener().held(item); - } - return true; + itemView.setOnLongClickListener(view -> { + if (itemBuilder.getOnStreamSelectedListener() != null) { + itemBuilder.getOnStreamSelectedListener().held(item); } + return true; }); } 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 de86ea587..3e136dbde 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -319,35 +319,27 @@ 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 + "]"); + Log.d(TAG, "buildMediaSource() called with: url = [" + url + + "], overrideExtension = [" + overrideExtension + "]"); } Uri uri = Uri.parse(url); - int type = TextUtils.isEmpty(overrideExtension) ? Util.inferContentType(uri) : Util.inferContentType("." + overrideExtension); - MediaSource mediaSource; + int type = TextUtils.isEmpty(overrideExtension) ? Util.inferContentType(uri) : + Util.inferContentType("." + overrideExtension); switch (type) { case C.TYPE_SS: - mediaSource = ssMediaSourceFactory.createMediaSource(uri); - break; + return ssMediaSourceFactory.createMediaSource(uri); case C.TYPE_DASH: - mediaSource = dashMediaSourceFactory.createMediaSource(uri); - break; + return dashMediaSourceFactory.createMediaSource(uri); case C.TYPE_HLS: - mediaSource = hlsMediaSourceFactory.createMediaSource(uri); - break; + return hlsMediaSourceFactory.createMediaSource(uri); case C.TYPE_OTHER: - mediaSource = extractorMediaSourceFactory.createMediaSource(uri); - break; + return extractorMediaSourceFactory.createMediaSource(uri); default: { throw new IllegalStateException("Unsupported type: " + type); } } - return mediaSource; } /*////////////////////////////////////////////////////////////////////////// @@ -514,8 +506,13 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen public void onTimelineChanged(Timeline timeline, Object manifest, int reason) { if (DEBUG) Log.d(TAG, "onTimelineChanged(), timeline size = " + timeline.getWindowCount()); - if (playbackManager != null) { - playbackManager.load(); + switch (reason) { + case Player.TIMELINE_CHANGE_REASON_PREPARED: + case Player.TIMELINE_CHANGE_REASON_RESET: + case Player.TIMELINE_CHANGE_REASON_DYNAMIC: + default: + if (playbackManager != null) playbackManager.load(); + break; } } @@ -526,7 +523,8 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen @Override public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { - if (DEBUG) Log.d(TAG, "playbackParameters(), speed: " + playbackParameters.speed + ", pitch: " + playbackParameters.pitch); + if (DEBUG) Log.d(TAG, "playbackParameters(), speed: " + playbackParameters.speed + + ", pitch: " + playbackParameters.pitch); } @Override @@ -732,9 +730,9 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen @Override public MediaSource sourceOf(PlayQueueItem item, StreamInfo info) { if (!info.getHlsUrl().isEmpty()) { - return buildMediaSource(info.getHlsUrl()); + return buildMediaSource(info.getHlsUrl(), "m3u8"); } else if (!info.getDashMpdUrl().isEmpty()) { - return buildMediaSource(info.getDashMpdUrl()); + return buildMediaSource(info.getDashMpdUrl(), "mpd"); } return null; diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java index 2454c022c..a73c9975e 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java @@ -25,6 +25,10 @@ public class FailedMediaSource implements ManagedMediaSource { this.retryTimestamp = retryTimestamp; } + /** + * Permanently fail the play queue item associated with this source, with no hope of retrying. + * The error will always be propagated to ExoPlayer. + * */ public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem, @NonNull final Throwable error) { this.playQueueItem = playQueueItem; 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 50b485cb5..3b068f730 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 @@ -40,6 +40,8 @@ public class MediaSourceManager { private final int windowSize; private final PlaybackListener playbackListener; private final PlayQueue playQueue; + private final long expirationTimeMillis; + private final TimeUnit expirationTimeUnit; // 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 @@ -61,13 +63,15 @@ public class MediaSourceManager { public MediaSourceManager(@NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue) { - this(listener, playQueue, 1, 400L); + this(listener, playQueue, 1, 400L, 2, TimeUnit.HOURS); } private MediaSourceManager(@NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue, final int windowSize, - final long loadDebounceMillis) { + final long loadDebounceMillis, + final long expirationTimeMillis, + @NonNull final TimeUnit expirationTimeUnit) { if (windowSize <= 0) { throw new UnsupportedOperationException( "MediaSourceManager window size must be greater than 0"); @@ -77,6 +81,8 @@ public class MediaSourceManager { this.playQueue = playQueue; this.windowSize = windowSize; this.loadDebounceMillis = loadDebounceMillis; + this.expirationTimeMillis = expirationTimeMillis; + this.expirationTimeUnit = expirationTimeUnit; this.loaderReactor = new CompositeDisposable(); this.debouncedLoadSignal = PublishSubject.create(); @@ -87,9 +93,11 @@ public class MediaSourceManager { this.syncReactor = new SerialDisposable(); this.loadingItems = Collections.synchronizedSet(new HashSet<>()); - playQueue.getBroadcastReceiver() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getReactor()); + if (playQueue.getBroadcastReceiver() != null) { + playQueue.getBroadcastReceiver() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getReactor()); + } } /*////////////////////////////////////////////////////////////////////////// @@ -225,7 +233,7 @@ public class MediaSourceManager { return playQueue.isComplete() || isWindowLoaded; } - // Checks if the current playback media source is a placeholder, if so, then it is not ready + // Checks if the current playback media source is a placeholder, if so, then it is not ready. private boolean isPlaybackReady() { return sources != null && playQueue != null && sources.getSize() > playQueue.getIndex() && !(sources.getMediaSource(playQueue.getIndex()) instanceof PlaceholderMediaSource); @@ -351,11 +359,11 @@ public class MediaSourceManager { final MediaSource source = playbackListener.sourceOf(stream, streamInfo); if (source == null) { return new FailedMediaSource(stream, new IllegalStateException( - "MediaSource resolution is null")); + "MediaSource cannot be resolved")); } final long expiration = System.currentTimeMillis() + - TimeUnit.MILLISECONDS.convert(2, TimeUnit.HOURS); + TimeUnit.MILLISECONDS.convert(expirationTimeMillis, expirationTimeUnit); return new LoadedMediaSource(source, expiration); }).onErrorReturn(throwable -> new FailedMediaSource(stream, throwable)); } diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java index 3daa58bb7..cd507c2bf 100644 --- a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.playlist; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.util.Log; import org.reactivestreams.Subscriber; @@ -170,7 +171,7 @@ public abstract class PlayQueue implements Serializable { * Returns the play queue's update broadcast. * May be null if the play queue message bus is not initialized. * */ - @NonNull + @Nullable public Flowable getBroadcastReceiver() { return broadcastReceiver; } diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java index cd833c1ab..7c701a637 100644 --- a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java @@ -99,7 +99,9 @@ public class PlayQueueAdapter extends RecyclerView.Adapter Date: Mon, 26 Feb 2018 19:57:59 -0800 Subject: [PATCH 06/16] -Improved player queue stability by using more aggressive synchronization policy. -Added sync buttons on live streams to allow seeking to live edge. -Added custom cache key for extractor sources to allow more persistent reuse. -Refactored player data source factories into own class and separating live and non-live data sources. --- .../newpipe/player/BackgroundPlayer.java | 4 +- .../org/schabi/newpipe/player/BasePlayer.java | 108 ++++++++++-------- .../newpipe/player/ServicePlayerActivity.java | 19 +++ .../schabi/newpipe/player/VideoPlayer.java | 39 +++++-- .../newpipe/player/helper/CacheFactory.java | 2 +- .../player/helper/PlayerDataSource.java | 76 ++++++++++++ .../newpipe/player/helper/PlayerHelper.java | 14 ++- .../player/mediasource/FailedMediaSource.java | 14 ++- .../player/mediasource/LoadedMediaSource.java | 21 +++- .../mediasource/ManagedMediaSource.java | 6 +- .../mediasource/PlaceholderMediaSource.java | 7 +- .../player/playback/MediaSourceManager.java | 52 +++++++-- .../activity_player_queue_control.xml | 10 ++ .../main/res/layout/activity_main_player.xml | 11 ++ .../layout/activity_player_queue_control.xml | 10 ++ app/src/main/res/layout/player_popup.xml | 11 ++ app/src/main/res/values/strings.xml | 2 + 17 files changed, 320 insertions(+), 86 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java 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 2fc3252a7..7e5e612d6 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java @@ -46,6 +46,7 @@ import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.player.event.PlayerEventListener; import org.schabi.newpipe.player.helper.LockManager; +import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.playlist.PlayQueueItem; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; @@ -398,7 +399,8 @@ public final class BackgroundPlayer extends Service { if (index < 0 || index >= info.audio_streams.size()) return null; final AudioStream audio = info.audio_streams.get(index); - return buildMediaSource(audio.getUrl(), MediaFormat.getSuffixById(audio.getFormatId())); + return buildMediaSource(audio.getUrl(), PlayerHelper.cacheKeyOf(info, audio), + MediaFormat.getSuffixById(audio.getFormatId())); } @Override 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 3e136dbde..1854e9a01 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -43,20 +43,11 @@ 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.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.SingleSampleMediaSource; import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.source.dash.DashMediaSource; -import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource; -import com.google.android.exoplayer2.source.hls.HlsMediaSource; -import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; -import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; 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; @@ -66,8 +57,8 @@ 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.helper.PlayerDataSource; import org.schabi.newpipe.player.playback.CustomTrackSelector; import org.schabi.newpipe.player.playback.MediaSourceManager; import org.schabi.newpipe.player.playback.PlaybackListener; @@ -149,14 +140,8 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen protected boolean isPrepared = false; protected CustomTrackSelector trackSelector; - protected DataSource.Factory cacheDataSourceFactory; - protected DataSource.Factory cachelessDataSourceFactory; - protected SsMediaSource.Factory ssMediaSourceFactory; - protected HlsMediaSource.Factory hlsMediaSourceFactory; - protected DashMediaSource.Factory dashMediaSourceFactory; - protected ExtractorMediaSource.Factory extractorMediaSourceFactory; - protected SingleSampleMediaSource.Factory sampleMediaSourceFactory; + protected PlayerDataSource dataSource; protected Disposable progressUpdateReactor; protected CompositeDisposable databaseUpdateReactor; @@ -193,20 +178,11 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen final String userAgent = Downloader.USER_AGENT; final DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(); + dataSource = new PlayerDataSource(context, userAgent, bandwidthMeter); + final AdaptiveTrackSelection.Factory trackSelectionFactory = new AdaptiveTrackSelection.Factory(bandwidthMeter); - trackSelector = new CustomTrackSelector(trackSelectionFactory); - cacheDataSourceFactory = new CacheFactory(context, userAgent, bandwidthMeter); - cachelessDataSourceFactory = new DefaultHttpDataSourceFactory(userAgent, bandwidthMeter); - - ssMediaSourceFactory = new SsMediaSource.Factory( - new DefaultSsChunkSource.Factory(cachelessDataSourceFactory), cachelessDataSourceFactory); - hlsMediaSourceFactory = new HlsMediaSource.Factory(cachelessDataSourceFactory); - dashMediaSourceFactory = new DashMediaSource.Factory( - 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); @@ -319,26 +295,56 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen recordManager = null; } - public MediaSource buildMediaSource(String url, String overrideExtension) { + /*////////////////////////////////////////////////////////////////////////// + // MediaSource Building + //////////////////////////////////////////////////////////////////////////*/ + + public MediaSource buildLiveMediaSource(@NonNull final String sourceUrl, + @C.ContentType final int type) { if (DEBUG) { - Log.d(TAG, "buildMediaSource() called with: url = [" + url + - "], overrideExtension = [" + overrideExtension + "]"); + Log.d(TAG, "buildLiveMediaSource() called with: url = [" + sourceUrl + + "], content type = [" + type + "]"); } - Uri uri = Uri.parse(url); - int type = TextUtils.isEmpty(overrideExtension) ? Util.inferContentType(uri) : - Util.inferContentType("." + overrideExtension); + if (dataSource == null) return null; + + final Uri uri = Uri.parse(sourceUrl); switch (type) { case C.TYPE_SS: - return ssMediaSourceFactory.createMediaSource(uri); + return dataSource.getLiveSsMediaSourceFactory().createMediaSource(uri); case C.TYPE_DASH: - return dashMediaSourceFactory.createMediaSource(uri); + return dataSource.getLiveDashMediaSourceFactory().createMediaSource(uri); case C.TYPE_HLS: - return hlsMediaSourceFactory.createMediaSource(uri); - case C.TYPE_OTHER: - return extractorMediaSourceFactory.createMediaSource(uri); - default: { + return dataSource.getLiveHlsMediaSourceFactory().createMediaSource(uri); + default: + throw new IllegalStateException("Unsupported type: " + type); + } + } + + public MediaSource buildMediaSource(@NonNull final String sourceUrl, + @NonNull final String cacheKey, + @NonNull final String overrideExtension) { + if (DEBUG) { + Log.d(TAG, "buildMediaSource() called with: url = [" + sourceUrl + + "], cacheKey = [" + cacheKey + "]" + + "], overrideExtension = [" + overrideExtension + "]"); + } + if (dataSource == null) return null; + + final Uri uri = Uri.parse(sourceUrl); + @C.ContentType final int type = TextUtils.isEmpty(overrideExtension) ? + Util.inferContentType(uri) : Util.inferContentType("." + overrideExtension); + + switch (type) { + case C.TYPE_SS: + return dataSource.getLiveSsMediaSourceFactory().createMediaSource(uri); + case C.TYPE_DASH: + return dataSource.getDashMediaSourceFactory().createMediaSource(uri); + case C.TYPE_HLS: + return dataSource.getHlsMediaSourceFactory().createMediaSource(uri); + case C.TYPE_OTHER: + return dataSource.getExtractorMediaSourceFactory(cacheKey).createMediaSource(uri); + default: throw new IllegalStateException("Unsupported type: " + type); - } } } @@ -478,7 +484,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen // ExoPlayer Listener //////////////////////////////////////////////////////////////////////////*/ - private void recover() { + private void maybeRecover() { final int currentSourceIndex = playQueue.getIndex(); final PlayQueueItem currentSourceItem = playQueue.getItem(); @@ -554,7 +560,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen } break; case Player.STATE_READY: //3 - recover(); + maybeRecover(); if (!isPrepared) { isPrepared = true; onPrepared(playWhenReady); @@ -566,7 +572,8 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen case Player.STATE_ENDED: // 4 // Ensure the current window has actually ended // since single windows that are still loading may produce an ended state - if (isCurrentWindowValid() && simpleExoPlayer.getCurrentPosition() >= simpleExoPlayer.getDuration()) { + if (isCurrentWindowValid() && + simpleExoPlayer.getCurrentPosition() >= simpleExoPlayer.getDuration()) { changeState(STATE_COMPLETED); isPrepared = false; } @@ -730,9 +737,9 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen @Override public MediaSource sourceOf(PlayQueueItem item, StreamInfo info) { if (!info.getHlsUrl().isEmpty()) { - return buildMediaSource(info.getHlsUrl(), "m3u8"); + return buildLiveMediaSource(info.getHlsUrl(), C.TYPE_HLS); } else if (!info.getDashMpdUrl().isEmpty()) { - return buildMediaSource(info.getDashMpdUrl(), "mpd"); + return buildLiveMediaSource(info.getDashMpdUrl(), C.TYPE_DASH); } return null; @@ -852,8 +859,11 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen public void seekBy(int milliSeconds) { if (DEBUG) Log.d(TAG, "seekBy() called with: milliSeconds = [" + milliSeconds + "]"); - if (simpleExoPlayer == null || (isCompleted() && milliSeconds > 0) || ((milliSeconds < 0 && simpleExoPlayer.getCurrentPosition() == 0))) + if (simpleExoPlayer == null || (isCompleted() && milliSeconds > 0) || + ((milliSeconds < 0 && simpleExoPlayer.getCurrentPosition() == 0))) { return; + } + int progress = (int) (simpleExoPlayer.getCurrentPosition() + milliSeconds); if (progress < 0) progress = 0; simpleExoPlayer.seekTo(progress); @@ -864,6 +874,10 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen && simpleExoPlayer.getCurrentPosition() >= 0; } + public void seekToDefault() { + if (simpleExoPlayer != null) simpleExoPlayer.seekToDefaultPosition(); + } + /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ diff --git a/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java index 1378d9a80..d9c04b796 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java @@ -76,6 +76,7 @@ public abstract class ServicePlayerActivity extends AppCompatActivity private SeekBar progressSeekBar; private TextView progressCurrentTime; private TextView progressEndTime; + private TextView progressLiveSync; private TextView seekDisplay; private ImageButton repeatButton; @@ -294,9 +295,11 @@ public abstract class ServicePlayerActivity extends AppCompatActivity progressCurrentTime = rootView.findViewById(R.id.current_time); progressSeekBar = rootView.findViewById(R.id.seek_bar); progressEndTime = rootView.findViewById(R.id.end_time); + progressLiveSync = rootView.findViewById(R.id.live_sync); seekDisplay = rootView.findViewById(R.id.seek_display); progressSeekBar.setOnSeekBarChangeListener(this); + progressLiveSync.setOnClickListener(this); } private void buildControls() { @@ -513,6 +516,9 @@ public abstract class ServicePlayerActivity extends AppCompatActivity } else if (view.getId() == metadata.getId()) { scrollToSelected(); + } else if (view.getId() == progressLiveSync.getId()) { + player.seekToDefault(); + } } @@ -574,6 +580,19 @@ public abstract class ServicePlayerActivity extends AppCompatActivity if (info != null) { metadataTitle.setText(info.getName()); metadataArtist.setText(info.uploader_name); + + progressEndTime.setVisibility(View.GONE); + progressLiveSync.setVisibility(View.GONE); + switch (info.getStreamType()) { + case LIVE_STREAM: + case AUDIO_LIVE_STREAM: + progressLiveSync.setVisibility(View.VISIBLE); + break; + default: + progressEndTime.setVisibility(View.VISIBLE); + break; + } + scrollToSelected(); } } 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 3e03bc207..eca6415f6 100644 --- a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java @@ -50,7 +50,6 @@ import android.widget.TextView; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; 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.TrackGroup; @@ -58,6 +57,7 @@ import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; import com.google.android.exoplayer2.ui.SubtitleView; +import com.google.android.exoplayer2.video.VideoListener; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.MediaFormat; @@ -87,7 +87,7 @@ import static org.schabi.newpipe.util.AnimationUtils.animateView; */ @SuppressWarnings({"WeakerAccess", "unused"}) public abstract class VideoPlayer extends BasePlayer - implements SimpleExoPlayer.VideoListener, + implements VideoListener, SeekBar.OnSeekBarChangeListener, View.OnClickListener, Player.EventListener, @@ -131,6 +131,7 @@ public abstract class VideoPlayer extends BasePlayer private SeekBar playbackSeekBar; private TextView playbackCurrentTime; private TextView playbackEndTime; + private TextView playbackLiveSync; private TextView playbackSpeedTextView; private View topControlsRoot; @@ -180,6 +181,7 @@ public abstract class VideoPlayer extends BasePlayer this.playbackSeekBar = rootView.findViewById(R.id.playbackSeekBar); this.playbackCurrentTime = rootView.findViewById(R.id.playbackCurrentTime); this.playbackEndTime = rootView.findViewById(R.id.playbackEndTime); + this.playbackLiveSync = rootView.findViewById(R.id.playbackLiveSync); this.playbackSpeedTextView = rootView.findViewById(R.id.playbackSpeed); this.bottomControlsRoot = rootView.findViewById(R.id.bottomControls); this.topControlsRoot = rootView.findViewById(R.id.topControls); @@ -221,6 +223,7 @@ public abstract class VideoPlayer extends BasePlayer qualityTextView.setOnClickListener(this); captionTextView.setOnClickListener(this); resizeView.setOnClickListener(this); + playbackLiveSync.setOnClickListener(this); } @Override @@ -261,7 +264,8 @@ public abstract class VideoPlayer extends BasePlayer qualityPopupMenu.getMenu().removeGroup(qualityPopupMenuGroupId); for (int i = 0; i < availableStreams.size(); i++) { VideoStream videoStream = availableStreams.get(i); - qualityPopupMenu.getMenu().add(qualityPopupMenuGroupId, i, Menu.NONE, MediaFormat.getNameById(videoStream.format) + " " + videoStream.resolution); + qualityPopupMenu.getMenu().add(qualityPopupMenuGroupId, i, Menu.NONE, + MediaFormat.getNameById(videoStream.getFormatId()) + " " + videoStream.resolution); } if (getSelectedVideoStream() != null) { qualityTextView.setText(getSelectedVideoStream().resolution); @@ -327,9 +331,22 @@ public abstract class VideoPlayer extends BasePlayer qualityTextView.setVisibility(View.GONE); playbackSpeedTextView.setVisibility(View.GONE); + playbackEndTime.setVisibility(View.GONE); + playbackLiveSync.setVisibility(View.GONE); + final StreamType streamType = info == null ? StreamType.NONE : info.getStreamType(); switch (streamType) { + case AUDIO_STREAM: + surfaceView.setVisibility(View.GONE); + break; + + case AUDIO_LIVE_STREAM: + surfaceView.setVisibility(View.GONE); + case LIVE_STREAM: + playbackLiveSync.setVisibility(View.VISIBLE); + break; + case VIDEO_STREAM: if (info.video_streams.size() + info.video_only_streams.size() == 0) break; @@ -344,14 +361,10 @@ public abstract class VideoPlayer extends BasePlayer buildQualityMenu(); qualityTextView.setVisibility(View.VISIBLE); - surfaceView.setVisibility(View.VISIBLE); - break; - case AUDIO_STREAM: - case AUDIO_LIVE_STREAM: - surfaceView.setVisibility(View.GONE); - break; + surfaceView.setVisibility(View.VISIBLE); default: + playbackEndTime.setVisibility(View.VISIBLE); break; } @@ -381,6 +394,7 @@ public abstract class VideoPlayer extends BasePlayer final VideoStream video = index >= 0 && index < videos.size() ? videos.get(index) : null; if (video != null) { final MediaSource streamSource = buildMediaSource(video.getUrl(), + PlayerHelper.cacheKeyOf(info, video), MediaFormat.getSuffixById(video.getFormatId())); mediaSources.add(streamSource); } @@ -393,6 +407,7 @@ public abstract class VideoPlayer extends BasePlayer // Merge with audio stream in case if video does not contain audio if (audio != null && ((video != null && video.isVideoOnly) || video == null)) { final MediaSource audioSource = buildMediaSource(audio.getUrl(), + PlayerHelper.cacheKeyOf(info, audio), MediaFormat.getSuffixById(audio.getFormatId())); mediaSources.add(audioSource); } @@ -408,8 +423,8 @@ public abstract class VideoPlayer extends BasePlayer final Format textFormat = Format.createTextSampleFormat(null, mimeType, SELECTION_FLAG_AUTOSELECT, PlayerHelper.captionLanguageOf(context, subtitle)); - final MediaSource textSource = sampleMediaSourceFactory.createMediaSource( - Uri.parse(subtitle.getURL()), textFormat, TIME_UNSET); + final MediaSource textSource = dataSource.getSampleMediaSourceFactory() + .createMediaSource(Uri.parse(subtitle.getURL()), textFormat, TIME_UNSET); mediaSources.add(textSource); } @@ -635,6 +650,8 @@ public abstract class VideoPlayer extends BasePlayer onResizeClicked(); } else if (v.getId() == captionTextView.getId()) { onCaptionClicked(); + } else if (v.getId() == playbackLiveSync.getId()) { + seekToDefault(); } } 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 900f13ae9..ec7813056 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 @@ -21,7 +21,7 @@ import org.schabi.newpipe.Downloader; import java.io.File; -public class CacheFactory implements DataSource.Factory { +/* 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; 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 new file mode 100644 index 000000000..40548aa6c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -0,0 +1,76 @@ +package org.schabi.newpipe.player.helper; + +import android.content.Context; +import android.support.annotation.NonNull; + +import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.SingleSampleMediaSource; +import com.google.android.exoplayer2.source.dash.DashMediaSource; +import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource; +import com.google.android.exoplayer2.source.hls.HlsMediaSource; +import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; +import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.TransferListener; + +public class PlayerDataSource { + private static final int MANIFEST_MINIMUM_RETRY = 5; + private static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000; + + private final DataSource.Factory cacheDataSourceFactory; + private final DataSource.Factory cachelessDataSourceFactory; + + public PlayerDataSource(@NonNull final Context context, + @NonNull final String userAgent, + @NonNull final TransferListener transferListener) { + cacheDataSourceFactory = new CacheFactory(context, userAgent, transferListener); + cachelessDataSourceFactory = new DefaultDataSourceFactory(context, userAgent, transferListener); + } + + public SsMediaSource.Factory getLiveSsMediaSourceFactory() { + return new SsMediaSource.Factory(new DefaultSsChunkSource.Factory( + cachelessDataSourceFactory), cachelessDataSourceFactory) + .setMinLoadableRetryCount(MANIFEST_MINIMUM_RETRY) + .setLivePresentationDelayMs(LIVE_STREAM_EDGE_GAP_MILLIS); + } + + public HlsMediaSource.Factory getLiveHlsMediaSourceFactory() { + return new HlsMediaSource.Factory(cachelessDataSourceFactory) + .setAllowChunklessPreparation(true) + .setMinLoadableRetryCount(MANIFEST_MINIMUM_RETRY); + } + + public DashMediaSource.Factory getLiveDashMediaSourceFactory() { + return new DashMediaSource.Factory(new DefaultDashChunkSource.Factory( + cachelessDataSourceFactory), cachelessDataSourceFactory) + .setMinLoadableRetryCount(MANIFEST_MINIMUM_RETRY) + .setLivePresentationDelayMs(LIVE_STREAM_EDGE_GAP_MILLIS); + } + + public SsMediaSource.Factory getSsMediaSourceFactory() { + return new SsMediaSource.Factory(new DefaultSsChunkSource.Factory( + cacheDataSourceFactory), cacheDataSourceFactory); + } + + public HlsMediaSource.Factory getHlsMediaSourceFactory() { + return new HlsMediaSource.Factory(cacheDataSourceFactory); + } + + public DashMediaSource.Factory getDashMediaSourceFactory() { + return new DashMediaSource.Factory(new DefaultDashChunkSource.Factory( + cacheDataSourceFactory), cacheDataSourceFactory); + } + + public ExtractorMediaSource.Factory getExtractorMediaSourceFactory() { + return new ExtractorMediaSource.Factory(cacheDataSourceFactory); + } + + public ExtractorMediaSource.Factory getExtractorMediaSourceFactory(@NonNull final String key) { + return new ExtractorMediaSource.Factory(cacheDataSourceFactory).setCustomCacheKey(key); + } + + public SingleSampleMediaSource.Factory getSampleMediaSourceFactory() { + return new SingleSampleMediaSource.Factory(cacheDataSourceFactory); + } +} 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 ea3f73a17..6e2ff0ac9 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 @@ -10,7 +10,10 @@ import com.google.android.exoplayer2.util.MimeTypes; import org.schabi.newpipe.R; 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.SubtitlesFormat; +import org.schabi.newpipe.extractor.stream.VideoStream; import java.text.DecimalFormat; import java.text.NumberFormat; @@ -69,8 +72,7 @@ public class PlayerHelper { public static String captionLanguageOf(@NonNull final Context context, @NonNull final Subtitles subtitles) { final String displayName = subtitles.getLocale().getDisplayName(subtitles.getLocale()); - return displayName + (subtitles.isAutoGenerated() ? - " (" + context.getString(R.string.caption_auto_generated)+ ")" : ""); + return displayName + (subtitles.isAutoGenerated() ? " (" + context.getString(R.string.caption_auto_generated)+ ")" : ""); } public static String resizeTypeOf(@NonNull final Context context, @@ -83,6 +85,14 @@ public class PlayerHelper { } } + public static String cacheKeyOf(@NonNull final StreamInfo info, @NonNull VideoStream video) { + return info.getUrl() + video.getResolution() + video.getFormat().getName(); + } + + public static String cacheKeyOf(@NonNull final StreamInfo info, @NonNull AudioStream audio) { + return info.getUrl() + audio.getAverageBitrate() + audio.getFormat().getName(); + } + public static boolean isResumeAfterAudioFocusGain(@NonNull final Context context) { return isResumeAfterAudioFocusGain(context, false); } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java index a73c9975e..d88385d2d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.player.mediasource; import android.support.annotation.NonNull; +import android.util.Log; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.source.MediaPeriod; @@ -11,6 +12,7 @@ import org.schabi.newpipe.playlist.PlayQueueItem; import java.io.IOException; public class FailedMediaSource implements ManagedMediaSource { + private final String TAG = "ManagedMediaSource@" + Integer.toHexString(hashCode()); private final PlayQueueItem playQueueItem; private final Throwable error; @@ -36,7 +38,7 @@ public class FailedMediaSource implements ManagedMediaSource { this.retryTimestamp = Long.MAX_VALUE; } - public PlayQueueItem getPlayQueueItem() { + public PlayQueueItem getStream() { return playQueueItem; } @@ -44,12 +46,14 @@ public class FailedMediaSource implements ManagedMediaSource { return error; } - public boolean canRetry() { + private boolean canRetry() { return System.currentTimeMillis() >= retryTimestamp; } @Override - public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) {} + public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { + Log.e(TAG, "Loading failed source: ", error); + } @Override public void maybeThrowSourceInfoRefreshError() throws IOException { @@ -68,7 +72,7 @@ public class FailedMediaSource implements ManagedMediaSource { public void releaseSource() {} @Override - public boolean canReplace() { - return canRetry(); + public boolean canReplace(@NonNull final PlayQueueItem newIdentity) { + return newIdentity != playQueueItem || canRetry(); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java index ddc78bd77..f523667f9 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java @@ -7,7 +7,6 @@ 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; @@ -15,15 +14,25 @@ import java.io.IOException; public class LoadedMediaSource implements ManagedMediaSource { private final MediaSource source; - + private final PlayQueueItem stream; private final long expireTimestamp; - public LoadedMediaSource(@NonNull final MediaSource source, final long expireTimestamp) { + public LoadedMediaSource(@NonNull final MediaSource source, + @NonNull final PlayQueueItem stream, + final long expireTimestamp) { this.source = source; - + this.stream = stream; this.expireTimestamp = expireTimestamp; } + public PlayQueueItem getStream() { + return stream; + } + + private boolean isExpired() { + return System.currentTimeMillis() >= expireTimestamp; + } + @Override public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { source.prepareSource(player, isTopLevelSource, listener); @@ -50,7 +59,7 @@ public class LoadedMediaSource implements ManagedMediaSource { } @Override - public boolean canReplace() { - return System.currentTimeMillis() >= expireTimestamp; + public boolean canReplace(@NonNull final PlayQueueItem newIdentity) { + return newIdentity != stream || isExpired(); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java index 5ac07c9f0..3bb7ca429 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java @@ -1,7 +1,11 @@ package org.schabi.newpipe.player.mediasource; +import android.support.annotation.NonNull; + import com.google.android.exoplayer2.source.MediaSource; +import org.schabi.newpipe.playlist.PlayQueueItem; + public interface ManagedMediaSource extends MediaSource { - boolean canReplace(); + boolean canReplace(@NonNull final PlayQueueItem newIdentity); } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java index 0a389a9d9..0d3436a01 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java @@ -1,10 +1,13 @@ package org.schabi.newpipe.player.mediasource; +import android.support.annotation.NonNull; + 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.playlist.PlayQueueItem; + import java.io.IOException; public class PlaceholderMediaSource implements ManagedMediaSource { @@ -16,7 +19,7 @@ public class PlaceholderMediaSource implements ManagedMediaSource { @Override public void releaseSource() {} @Override - public boolean canReplace() { + public boolean canReplace(@NonNull final PlayQueueItem newIdentity) { return true; } } 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 3b068f730..2a2a4ccb4 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,6 +1,7 @@ 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; @@ -34,7 +35,11 @@ import io.reactivex.disposables.SerialDisposable; import io.reactivex.functions.Consumer; import io.reactivex.subjects.PublishSubject; +import static org.schabi.newpipe.playlist.PlayQueue.DEBUG; + public class MediaSourceManager { + private final String TAG = "MediaSourceManager"; + // 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; @@ -233,10 +238,16 @@ public class MediaSourceManager { return playQueue.isComplete() || isWindowLoaded; } - // Checks if the current playback media source is a placeholder, if so, then it is not ready. private boolean isPlaybackReady() { - return sources != null && playQueue != null && sources.getSize() > playQueue.getIndex() && - !(sources.getMediaSource(playQueue.getIndex()) instanceof PlaceholderMediaSource); + if (sources == null || playQueue == null || sources.getSize() != playQueue.size()) { + return false; + } + + final MediaSource mediaSource = sources.getMediaSource(playQueue.getIndex()); + if (!(mediaSource instanceof LoadedMediaSource)) return false; + + final PlayQueueItem playQueueItem = playQueue.getItem(); + return playQueueItem == ((LoadedMediaSource) mediaSource).getStream(); } private void tryBlock() { @@ -280,7 +291,7 @@ public class MediaSourceManager { 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); + playbackListener.sync(syncedItem, info); } } @@ -329,14 +340,19 @@ public class MediaSourceManager { if (index > sources.getSize() - 1) return; final Consumer onDone = mediaSource -> { - update(playQueue.indexOf(item), mediaSource); + if (DEBUG) Log.d(TAG, " Loaded: [" + item.getTitle() + + "] with url: " + item.getUrl()); + + if (isCorrectionNeeded(item)) update(playQueue.indexOf(item), mediaSource); + loadingItems.remove(item); tryUnblock(); sync(); }; - if (!loadingItems.contains(item) && - ((ManagedMediaSource) sources.getMediaSource(index)).canReplace()) { + if (!loadingItems.contains(item) && isCorrectionNeeded(item)) { + if (DEBUG) Log.d(TAG, "Loading: [" + item.getTitle() + + "] with url: " + item.getUrl()); loadingItems.add(item); final Disposable loader = getLoadedMediaSource(item) @@ -358,16 +374,32 @@ public class MediaSourceManager { final MediaSource source = playbackListener.sourceOf(stream, streamInfo); if (source == null) { - return new FailedMediaSource(stream, new IllegalStateException( - "MediaSource cannot be resolved")); + final Exception exception = new IllegalStateException( + "Unable to resolve source from stream info." + + " URL: " + stream.getUrl() + + ", audio count: " + streamInfo.audio_streams.size() + + ", video count: " + streamInfo.video_only_streams.size() + + streamInfo.video_streams.size()); + return new FailedMediaSource(stream, new IllegalStateException(exception)); } final long expiration = System.currentTimeMillis() + TimeUnit.MILLISECONDS.convert(expirationTimeMillis, expirationTimeUnit); - return new LoadedMediaSource(source, expiration); + return new LoadedMediaSource(source, stream, expiration); }).onErrorReturn(throwable -> new FailedMediaSource(stream, throwable)); } + private boolean isCorrectionNeeded(@NonNull final PlayQueueItem item) { + if (playQueue == null || sources == null) return false; + + final int index = playQueue.indexOf(item); + if (index == -1 || index >= sources.getSize()) return false; + + final MediaSource mediaSource = sources.getMediaSource(index); + return !(mediaSource instanceof ManagedMediaSource) || + ((ManagedMediaSource) mediaSource).canReplace(item); + } + /*////////////////////////////////////////////////////////////////////////// // MediaSource Playlist Helpers //////////////////////////////////////////////////////////////////////////*/ diff --git a/app/src/main/res/layout-land/activity_player_queue_control.xml b/app/src/main/res/layout-land/activity_player_queue_control.xml index a577b7fe0..c3480c547 100644 --- a/app/src/main/res/layout-land/activity_player_queue_control.xml +++ b/app/src/main/res/layout-land/activity_player_queue_control.xml @@ -296,5 +296,15 @@ android:textColor="?attr/colorAccent" tools:ignore="HardcodedText" tools:text="1:23:49"/> + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main_player.xml b/app/src/main/res/layout/activity_main_player.xml index 80c67974f..be778e7c8 100644 --- a/app/src/main/res/layout/activity_main_player.xml +++ b/app/src/main/res/layout/activity_main_player.xml @@ -397,6 +397,17 @@ android:textColor="@android:color/white" tools:ignore="HardcodedText" tools:text="1:23:49"/> + + diff --git a/app/src/main/res/layout/activity_player_queue_control.xml b/app/src/main/res/layout/activity_player_queue_control.xml index a59e5ba2e..639a8037c 100644 --- a/app/src/main/res/layout/activity_player_queue_control.xml +++ b/app/src/main/res/layout/activity_player_queue_control.xml @@ -146,6 +146,16 @@ android:textColor="?attr/colorAccent" tools:ignore="HardcodedText" tools:text="1:23:49"/> + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 495842092..9dd5dcddb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -413,6 +413,8 @@ Normal Font Larger Font + SYNC + Enable LeakCanary Memory leak monitoring may cause app to become unresponsive when heap dumping From 77da40e507ecebc8c0b361d1ed846cf0fa3ca356 Mon Sep 17 00:00:00 2001 From: John Zhen Mo Date: Mon, 26 Feb 2018 22:37:19 -0800 Subject: [PATCH 07/16] -Added perpetual extractor source loading on network failures. -Fixed play queue playlist desynchronization caused by media source manager window loading expansion on sublist prior to current item. -Fixed failed media source not treated as ready for playback. --- .../player/helper/PlayerDataSource.java | 6 ++- .../player/playback/MediaSourceManager.java | 41 ++++++++++--------- 2 files changed, 25 insertions(+), 22 deletions(-) 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 40548aa6c..133121269 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 @@ -16,6 +16,7 @@ import com.google.android.exoplayer2.upstream.TransferListener; public class PlayerDataSource { private static final int MANIFEST_MINIMUM_RETRY = 5; + private static final int EXTRACTOR_MINIMUM_RETRY = Integer.MAX_VALUE; private static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000; private final DataSource.Factory cacheDataSourceFactory; @@ -63,11 +64,12 @@ public class PlayerDataSource { } public ExtractorMediaSource.Factory getExtractorMediaSourceFactory() { - return new ExtractorMediaSource.Factory(cacheDataSourceFactory); + return new ExtractorMediaSource.Factory(cacheDataSourceFactory) + .setMinLoadableRetryCount(EXTRACTOR_MINIMUM_RETRY); } public ExtractorMediaSource.Factory getExtractorMediaSourceFactory(@NonNull final String key) { - return new ExtractorMediaSource.Factory(cacheDataSourceFactory).setCustomCacheKey(key); + return getExtractorMediaSourceFactory().setCustomCacheKey(key); } public SingleSampleMediaSource.Factory getSampleMediaSourceFactory() { 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 2a2a4ccb4..0f30169a7 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 @@ -38,11 +38,12 @@ import io.reactivex.subjects.PublishSubject; import static org.schabi.newpipe.playlist.PlayQueue.DEBUG; public class MediaSourceManager { - private final String TAG = "MediaSourceManager"; + private final static String TAG = "MediaSourceManager"; + + // WINDOW_SIZE determines how many streams AFTER the current stream should be loaded. + // The default value (1) ensures seamless playback under typical network settings. + private final static int WINDOW_SIZE = 1; - // 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; private final long expirationTimeMillis; @@ -68,23 +69,19 @@ public class MediaSourceManager { public MediaSourceManager(@NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue) { - this(listener, playQueue, 1, 400L, 2, TimeUnit.HOURS); + this(listener, playQueue, + /*loadDebounceMillis=*/400L, + /*expirationTimeMillis=*/2, + /*expirationTimeUnit=*/TimeUnit.HOURS); } private MediaSourceManager(@NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue, - final int windowSize, final long loadDebounceMillis, final long expirationTimeMillis, @NonNull final TimeUnit expirationTimeUnit) { - 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.expirationTimeMillis = expirationTimeMillis; this.expirationTimeUnit = expirationTimeUnit; @@ -234,7 +231,7 @@ public class MediaSourceManager { private boolean isPlayQueueReady() { if (playQueue == null) return false; - final boolean isWindowLoaded = playQueue.size() - playQueue.getIndex() > windowSize; + final boolean isWindowLoaded = playQueue.size() - playQueue.getIndex() > WINDOW_SIZE; return playQueue.isComplete() || isWindowLoaded; } @@ -244,10 +241,14 @@ public class MediaSourceManager { } final MediaSource mediaSource = sources.getMediaSource(playQueue.getIndex()); - if (!(mediaSource instanceof LoadedMediaSource)) return false; - final PlayQueueItem playQueueItem = playQueue.getItem(); - return playQueueItem == ((LoadedMediaSource) mediaSource).getStream(); + + if (mediaSource instanceof LoadedMediaSource) { + return playQueueItem == ((LoadedMediaSource) mediaSource).getStream(); + } else if (mediaSource instanceof FailedMediaSource) { + return playQueueItem == ((FailedMediaSource) mediaSource).getStream(); + } + return false; } private void tryBlock() { @@ -318,11 +319,11 @@ public class MediaSourceManager { 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 leftBound = currentIndex + 1; + final int rightLimit = leftBound + WINDOW_SIZE; 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(); From b4668367c6640986cd78f2ac06436520fddb9399 Mon Sep 17 00:00:00 2001 From: John Zhen Mo Date: Wed, 28 Feb 2018 17:45:05 -0800 Subject: [PATCH 08/16] -Added better assertions and documentations to new mechanism in MediaSourceManager. -Modified LoadController to allow fast playback start and increased buffer zigzag window. -Removed unnecessary loading on timeline changes. -Changed select message in MediaSourceManager to cause immediate load. -Reduced default expiration time in MediaSourceManager. -Fixed main video player not showing end time on audio-only streams. -Fixed live stream has player view disabled after transitioning from audio stream. -Fixed inconsistent progress bar height between live and non-live video on main player. --- app/build.gradle | 2 +- .../org/schabi/newpipe/player/BasePlayer.java | 20 ++-- .../schabi/newpipe/player/VideoPlayer.java | 5 + .../newpipe/player/helper/LoadController.java | 29 +++-- .../newpipe/player/helper/PlayerHelper.java | 23 +++- .../player/mediasource/FailedMediaSource.java | 2 +- .../player/playback/MediaSourceManager.java | 104 +++++++++++------- .../main/res/layout/activity_main_player.xml | 4 +- 8 files changed, 117 insertions(+), 72 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index ba6406d4b..bfc22c76b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -55,7 +55,7 @@ dependencies { exclude module: 'support-annotations' } - implementation 'com.github.karyogamy:NewPipeExtractor:837dbd6b86' + implementation 'com.github.karyogamy:NewPipeExtractor:4cf4ee394f' testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-core:1.10.19' 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 1854e9a01..a449b4a36 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -511,15 +511,6 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen @Override public void onTimelineChanged(Timeline timeline, Object manifest, int reason) { if (DEBUG) Log.d(TAG, "onTimelineChanged(), timeline size = " + timeline.getWindowCount()); - - switch (reason) { - case Player.TIMELINE_CHANGE_REASON_PREPARED: - case Player.TIMELINE_CHANGE_REASON_RESET: - case Player.TIMELINE_CHANGE_REASON_DYNAMIC: - default: - if (playbackManager != null) playbackManager.load(); - break; - } } @Override @@ -654,6 +645,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen } else { playQueue.offsetIndex(+1); } + playbackManager.load(); break; case DISCONTINUITY_REASON_SEEK: case DISCONTINUITY_REASON_SEEK_ADJUSTMENT: @@ -661,7 +653,6 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen default: break; } - playbackManager.load(); } @Override @@ -724,8 +715,9 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen "], queue index=[" + playQueue.getIndex() + "]"); } else if (simpleExoPlayer.getCurrentPeriodIndex() != currentSourceIndex || !isPlaying()) { final long startPos = info != null ? info.start_position : 0; - if (DEBUG) Log.d(TAG, "Rewinding to correct window: " + currentSourceIndex + - " at: " + getTimeString((int)startPos)); + if (DEBUG) Log.d(TAG, "Rewinding to correct window=[" + currentSourceIndex + "]," + + " at=[" + getTimeString((int)startPos) + "]," + + " from=[" + simpleExoPlayer.getCurrentPeriodIndex() + "]."); simpleExoPlayer.seekTo(currentSourceIndex, startPos); } @@ -974,7 +966,9 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen } public boolean isPlaying() { - return simpleExoPlayer.getPlaybackState() == Player.STATE_READY && simpleExoPlayer.getPlayWhenReady(); + final int state = simpleExoPlayer.getPlaybackState(); + return (state == Player.STATE_READY || state == Player.STATE_BUFFERING) + && simpleExoPlayer.getPlayWhenReady(); } public int getRepeatMode() { 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 eca6415f6..5a7a9a462 100644 --- a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java @@ -339,11 +339,16 @@ public abstract class VideoPlayer extends BasePlayer switch (streamType) { case AUDIO_STREAM: surfaceView.setVisibility(View.GONE); + playbackEndTime.setVisibility(View.VISIBLE); break; case AUDIO_LIVE_STREAM: surfaceView.setVisibility(View.GONE); + playbackLiveSync.setVisibility(View.VISIBLE); + break; + case LIVE_STREAM: + surfaceView.setVisibility(View.VISIBLE); playbackLiveSync.setVisibility(View.VISIBLE); break; diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java index 15668be90..7670deb98 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java @@ -11,7 +11,6 @@ import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DefaultAllocator; -import static com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS; import static com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS; import static com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_TARGET_BUFFER_BYTES; @@ -19,6 +18,7 @@ public class LoadController implements LoadControl { public static final String TAG = "LoadController"; + private final long initialPlaybackBufferUs; private final LoadControl internalLoadControl; /*////////////////////////////////////////////////////////////////////////// @@ -26,18 +26,24 @@ public class LoadController implements LoadControl { //////////////////////////////////////////////////////////////////////////*/ public LoadController(final Context context) { - this(PlayerHelper.getMinBufferMs(context), - PlayerHelper.getMaxBufferMs(context), - PlayerHelper.getBufferForPlaybackMs(context)); + this(PlayerHelper.getPlaybackStartBufferMs(context), + PlayerHelper.getPlaybackMinimumBufferMs(context), + PlayerHelper.getPlaybackOptimalBufferMs(context)); } - private LoadController(final int minBufferMs, final int maxBufferMs, - final int bufferForPlaybackMs) { + private LoadController(final int initialPlaybackBufferMs, + final int minimumPlaybackbufferMs, + final int optimalPlaybackBufferMs) { + this.initialPlaybackBufferUs = initialPlaybackBufferMs * 1000; + final DefaultAllocator allocator = new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE); - internalLoadControl = new DefaultLoadControl(allocator, minBufferMs, maxBufferMs, - bufferForPlaybackMs, DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, + internalLoadControl = new DefaultLoadControl(allocator, + /*minBufferMs=*/minimumPlaybackbufferMs, + /*maxBufferMs=*/optimalPlaybackBufferMs, + /*bufferForPlaybackMs=*/initialPlaybackBufferMs, + /*bufferForPlaybackAfterRebufferMs=*/initialPlaybackBufferMs, DEFAULT_TARGET_BUFFER_BYTES, DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS); } @@ -89,7 +95,10 @@ public class LoadController implements LoadControl { @Override public boolean shouldStartPlayback(long bufferedDurationUs, float playbackSpeed, boolean rebuffering) { - return internalLoadControl.shouldStartPlayback(bufferedDurationUs, playbackSpeed, - rebuffering); + final boolean isInitialPlaybackBufferFilled = bufferedDurationUs >= + this.initialPlaybackBufferUs * playbackSpeed; + final boolean isInternalStartingPlayback = internalLoadControl.shouldStartPlayback( + bufferedDurationUs, playbackSpeed, rebuffering); + return isInitialPlaybackBufferFilled || isInternalStartingPlayback; } } 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 6e2ff0ac9..813c69c22 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 @@ -117,16 +117,27 @@ public class PlayerHelper { return 512 * 1024L; } - public static int getMinBufferMs(@NonNull final Context context) { - return 15000; + /** + * Returns the number of milliseconds the player buffers for before starting playback. + * */ + public static int getPlaybackStartBufferMs(@NonNull final Context context) { + return 500; } - public static int getMaxBufferMs(@NonNull final Context context) { - return 30000; + /** + * Returns the minimum number of milliseconds the player always buffers to after starting + * playback. + * */ + public static int getPlaybackMinimumBufferMs(@NonNull final Context context) { + return 25000; } - public static int getBufferForPlaybackMs(@NonNull final Context context) { - return 2500; + /** + * Returns the maximum/optimal number of milliseconds the player will buffer to once the buffer + * hits the point of {@link #getPlaybackMinimumBufferMs(Context)}. + * */ + public static int getPlaybackOptimalBufferMs(@NonNull final Context context) { + return 60000; } public static boolean isUsingDSP(@NonNull final Context context) { diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java index d88385d2d..d07baf2a7 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java @@ -12,7 +12,7 @@ import org.schabi.newpipe.playlist.PlayQueueItem; import java.io.IOException; public class FailedMediaSource implements ManagedMediaSource { - private final String TAG = "ManagedMediaSource@" + Integer.toHexString(hashCode()); + private final String TAG = "FailedMediaSource@" + Integer.toHexString(hashCode()); private final PlayQueueItem playQueueItem; private final Throwable error; 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 0f30169a7..8f91e53c2 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,5 +1,6 @@ package org.schabi.newpipe.player.playback; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.Log; @@ -28,7 +29,6 @@ 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; @@ -38,23 +38,26 @@ import io.reactivex.subjects.PublishSubject; import static org.schabi.newpipe.playlist.PlayQueue.DEBUG; public class MediaSourceManager { - private final static String TAG = "MediaSourceManager"; + @NonNull private final static String TAG = "MediaSourceManager"; // WINDOW_SIZE determines how many streams AFTER the current stream should be loaded. // The default value (1) ensures seamless playback under typical network settings. private final static int WINDOW_SIZE = 1; - private final PlaybackListener playbackListener; - private final PlayQueue playQueue; + @NonNull private final PlaybackListener playbackListener; + @NonNull private final PlayQueue playQueue; + + // Once a MediaSource item has been detected to be expired, the manager will immediately + // trigger a reload on the associated PlayQueueItem, which may disrupt playback, + // if the item is being played private final long expirationTimeMillis; - private final TimeUnit expirationTimeUnit; // 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; + @NonNull private final Disposable debouncedLoader; + @NonNull private final PublishSubject debouncedSignal; private DynamicConcatenatingMediaSource sources; @@ -71,23 +74,20 @@ public class MediaSourceManager { @NonNull final PlayQueue playQueue) { this(listener, playQueue, /*loadDebounceMillis=*/400L, - /*expirationTimeMillis=*/2, - /*expirationTimeUnit=*/TimeUnit.HOURS); + /*expirationTimeMillis=*/TimeUnit.MILLISECONDS.convert(30, TimeUnit.MINUTES)); } private MediaSourceManager(@NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue, final long loadDebounceMillis, - final long expirationTimeMillis, - @NonNull final TimeUnit expirationTimeUnit) { + final long expirationTimeMillis) { this.playbackListener = listener; this.playQueue = playQueue; this.loadDebounceMillis = loadDebounceMillis; this.expirationTimeMillis = expirationTimeMillis; - this.expirationTimeUnit = expirationTimeUnit; this.loaderReactor = new CompositeDisposable(); - this.debouncedLoadSignal = PublishSubject.create(); + this.debouncedSignal = PublishSubject.create(); this.debouncedLoader = getDebouncedLoader(); this.sources = new DynamicConcatenatingMediaSource(); @@ -109,8 +109,11 @@ public class MediaSourceManager { * Dispose the manager and releases all message buses and loaders. * */ public void dispose() { - if (debouncedLoadSignal != null) debouncedLoadSignal.onComplete(); - if (debouncedLoader != null) debouncedLoader.dispose(); + if (DEBUG) Log.d(TAG, "dispose() called."); + + debouncedSignal.onComplete(); + debouncedLoader.dispose(); + if (playQueueReactor != null) playQueueReactor.cancel(); if (loaderReactor != null) loaderReactor.dispose(); if (syncReactor != null) syncReactor.dispose(); @@ -129,6 +132,7 @@ public class MediaSourceManager { * Unblocks the player once the item at the current index is loaded. * */ public void load() { + if (DEBUG) Log.d(TAG, "load() called."); loadDebounced(); } @@ -139,6 +143,8 @@ public class MediaSourceManager { * through {@link #load() load}. * */ public void reset() { + if (DEBUG) Log.d(TAG, "reset() called."); + tryBlock(); syncedItem = null; @@ -205,11 +211,11 @@ public class MediaSourceManager { case INIT: case REORDER: case ERROR: + case SELECT: loadImmediate(); // low frequency, critical events break; case APPEND: case REMOVE: - case SELECT: case MOVE: case RECOVERY: default: @@ -229,16 +235,12 @@ public class MediaSourceManager { //////////////////////////////////////////////////////////////////////////*/ private boolean isPlayQueueReady() { - if (playQueue == null) return false; - final boolean isWindowLoaded = playQueue.size() - playQueue.getIndex() > WINDOW_SIZE; return playQueue.isComplete() || isWindowLoaded; } private boolean isPlaybackReady() { - if (sources == null || playQueue == null || sources.getSize() != playQueue.size()) { - return false; - } + if (sources == null || sources.getSize() != playQueue.size()) return false; final MediaSource mediaSource = sources.getMediaSource(playQueue.getIndex()); final PlayQueueItem playQueueItem = playQueue.getItem(); @@ -252,6 +254,8 @@ public class MediaSourceManager { } private void tryBlock() { + if (DEBUG) Log.d(TAG, "tryBlock() called."); + if (isBlocked) return; playbackListener.block(); @@ -261,6 +265,8 @@ public class MediaSourceManager { } private void tryUnblock() { + if (DEBUG) Log.d(TAG, "tryUnblock() called."); + if (isPlayQueueReady() && isPlaybackReady() && isBlocked && sources != null) { isBlocked = false; playbackListener.unblock(sources); @@ -272,6 +278,8 @@ public class MediaSourceManager { //////////////////////////////////////////////////////////////////////////*/ private void sync() { + if (DEBUG) Log.d(TAG, "sync() called."); + final PlayQueueItem currentItem = playQueue.getItem(); if (isBlocked || currentItem == null) return; @@ -289,7 +297,6 @@ public class MediaSourceManager { 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); @@ -301,14 +308,14 @@ public class MediaSourceManager { //////////////////////////////////////////////////////////////////////////*/ private Disposable getDebouncedLoader() { - return debouncedLoadSignal + return debouncedSignal .debounce(loadDebounceMillis, TimeUnit.MILLISECONDS) .observeOn(AndroidSchedulers.mainThread()) .subscribe(timestamp -> loadImmediate()); } private void loadDebounced() { - debouncedLoadSignal.onNext(System.currentTimeMillis()); + debouncedSignal.onNext(System.currentTimeMillis()); } private void loadImmediate() { @@ -316,7 +323,7 @@ public class MediaSourceManager { final int currentIndex = playQueue.getIndex(); final PlayQueueItem currentItem = playQueue.getItem(currentIndex); if (currentItem == null) return; - loadItem(currentItem); + maybeLoadItem(currentItem); // The rest are just for seamless playback final int leftBound = currentIndex + 1; @@ -331,10 +338,14 @@ public class MediaSourceManager { items.addAll(playQueue.getStreams().subList(0, Math.min(playQueue.size(), excess))); } - for (final PlayQueueItem item: items) loadItem(item); + for (final PlayQueueItem item : items) { + maybeLoadItem(item); + } } - private void loadItem(@Nullable final PlayQueueItem item) { + private void maybeLoadItem(@Nullable final PlayQueueItem item) { + if (DEBUG) Log.d(TAG, "maybeLoadItem() called."); + if (sources == null || item == null) return; final int index = playQueue.indexOf(item); @@ -368,11 +379,6 @@ public class MediaSourceManager { 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) { final Exception exception = new IllegalStateException( @@ -384,21 +390,34 @@ public class MediaSourceManager { return new FailedMediaSource(stream, new IllegalStateException(exception)); } - final long expiration = System.currentTimeMillis() + - TimeUnit.MILLISECONDS.convert(expirationTimeMillis, expirationTimeUnit); + final long expiration = System.currentTimeMillis() + expirationTimeMillis; return new LoadedMediaSource(source, stream, expiration); }).onErrorReturn(throwable -> new FailedMediaSource(stream, throwable)); } + /** + * Checks if the corresponding MediaSource in {@link DynamicConcatenatingMediaSource} + * for a given {@link PlayQueueItem} needs replacement, either due to gapless playback + * readiness or playlist desynchronization. + *

+ * If the given {@link PlayQueueItem} is currently being played and is already loaded, + * then correction is not only needed if the playlist is desynchronized. Otherwise, the + * check depends on the status (e.g. expiration or placeholder) of the + * {@link ManagedMediaSource}. + * */ private boolean isCorrectionNeeded(@NonNull final PlayQueueItem item) { - if (playQueue == null || sources == null) return false; + if (sources == null) return false; final int index = playQueue.indexOf(item); if (index == -1 || index >= sources.getSize()) return false; - final MediaSource mediaSource = sources.getMediaSource(index); - return !(mediaSource instanceof ManagedMediaSource) || - ((ManagedMediaSource) mediaSource).canReplace(item); + final ManagedMediaSource mediaSource = (ManagedMediaSource) sources.getMediaSource(index); + + if (index == playQueue.getIndex() && mediaSource instanceof LoadedMediaSource) { + return item != ((LoadedMediaSource) mediaSource).getStream(); + } else { + return mediaSource.canReplace(item); + } } /*////////////////////////////////////////////////////////////////////////// @@ -406,11 +425,14 @@ public class MediaSourceManager { //////////////////////////////////////////////////////////////////////////*/ private void resetSources() { + if (DEBUG) Log.d(TAG, "resetSources() called."); + if (this.sources != null) this.sources.releaseSource(); this.sources = new DynamicConcatenatingMediaSource(); } private void populateSources() { + if (DEBUG) Log.d(TAG, "populateSources() called."); if (sources == null || sources.getSize() >= playQueue.size()) return; for (int index = sources.getSize() - 1; index < playQueue.size(); index++) { @@ -462,8 +484,12 @@ public class MediaSourceManager { * 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. + *

+ * Not recommended to use on indices LESS THAN the currently playing index, since + * this will modify the playback timeline prior to the index and cause desynchronization + * on the playing item between {@link PlayQueue} and {@link DynamicConcatenatingMediaSource}. * */ - private void update(final int index, final MediaSource source) { + private synchronized void update(final int index, final MediaSource source) { if (sources == null) return; if (index < 0 || index >= sources.getSize()) return; diff --git a/app/src/main/res/layout/activity_main_player.xml b/app/src/main/res/layout/activity_main_player.xml index be778e7c8..9abe43715 100644 --- a/app/src/main/res/layout/activity_main_player.xml +++ b/app/src/main/res/layout/activity_main_player.xml @@ -401,8 +401,8 @@ Date: Wed, 28 Feb 2018 17:47:12 -0800 Subject: [PATCH 09/16] -Added serialized cache for transferring serializable objects too large for intent transactions. -Fixed potential transaction too large exceptions for player intents. --- .../org/schabi/newpipe/player/BasePlayer.java | 12 +- .../playlist/AbstractInfoPlayQueue.java | 1 + .../schabi/newpipe/playlist/PlayQueue.java | 1 + .../schabi/newpipe/util/NavigationHelper.java | 38 +++--- .../schabi/newpipe/util/SerializedCache.java | 112 ++++++++++++++++++ 5 files changed, 141 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/util/SerializedCache.java 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 a449b4a36..6a867110a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -65,8 +65,8 @@ import org.schabi.newpipe.player.playback.PlaybackListener; import org.schabi.newpipe.playlist.PlayQueue; import org.schabi.newpipe.playlist.PlayQueueAdapter; import org.schabi.newpipe.playlist.PlayQueueItem; +import org.schabi.newpipe.util.SerializedCache; -import java.io.Serializable; import java.util.concurrent.TimeUnit; import io.reactivex.Observable; @@ -106,7 +106,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen public static final String PLAYBACK_PITCH = "playback_pitch"; public static final String PLAYBACK_SPEED = "playback_speed"; public static final String PLAYBACK_QUALITY = "playback_quality"; - public static final String PLAY_QUEUE = "play_queue"; + public static final String PLAY_QUEUE_KEY = "play_queue_key"; public static final String APPEND_ONLY = "append_only"; public static final String SELECT_ON_APPEND = "select_on_append"; @@ -207,10 +207,10 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen if (intent == null) return; // Resolve play queue - if (!intent.hasExtra(PLAY_QUEUE)) return; - final Serializable playQueueCandidate = intent.getSerializableExtra(PLAY_QUEUE); - if (!(playQueueCandidate instanceof PlayQueue)) return; - final PlayQueue queue = (PlayQueue) playQueueCandidate; + if (!intent.hasExtra(PLAY_QUEUE_KEY)) return; + final String intentCacheKey = intent.getStringExtra(PLAY_QUEUE_KEY); + final PlayQueue queue = SerializedCache.getInstance().take(intentCacheKey, PlayQueue.class); + if (queue == null) return; // Resolve append intents if (intent.getBooleanExtra(APPEND_ONLY, false) && playQueue != null) { diff --git a/app/src/main/java/org/schabi/newpipe/playlist/AbstractInfoPlayQueue.java b/app/src/main/java/org/schabi/newpipe/playlist/AbstractInfoPlayQueue.java index fc1fabfc4..6e63a3aaa 100644 --- a/app/src/main/java/org/schabi/newpipe/playlist/AbstractInfoPlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/playlist/AbstractInfoPlayQueue.java @@ -118,6 +118,7 @@ abstract class AbstractInfoPlayQueue ext public void dispose() { super.dispose(); if (fetchReactor != null) fetchReactor.dispose(); + fetchReactor = null; } private static List extractListItems(final List infos) { diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java index cd507c2bf..9005ef8d2 100644 --- a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java @@ -84,6 +84,7 @@ public abstract class PlayQueue implements Serializable { if (eventBroadcast != null) eventBroadcast.onComplete(); if (reportingReactor != null) reportingReactor.cancel(); + eventBroadcast = null; broadcastReceiver = null; reportingReactor = 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 2039dcc8e..da1019f99 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -7,6 +7,8 @@ import android.content.Intent; import android.net.Uri; import android.os.Build; import android.preference.PreferenceManager; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentManager; import android.support.v7.app.AlertDialog; @@ -33,9 +35,9 @@ import org.schabi.newpipe.fragments.list.feed.FeedFragment; import org.schabi.newpipe.fragments.list.kiosk.KioskFragment; import org.schabi.newpipe.fragments.list.playlist.PlaylistFragment; import org.schabi.newpipe.fragments.list.search.SearchFragment; +import org.schabi.newpipe.fragments.local.bookmark.LastPlayedFragment; import org.schabi.newpipe.fragments.local.bookmark.LocalPlaylistFragment; import org.schabi.newpipe.fragments.local.bookmark.MostPlayedFragment; -import org.schabi.newpipe.fragments.local.bookmark.LastPlayedFragment; import org.schabi.newpipe.history.HistoryActivity; import org.schabi.newpipe.player.BackgroundPlayer; import org.schabi.newpipe.player.BackgroundPlayerActivity; @@ -59,39 +61,41 @@ public class NavigationHelper { // Players //////////////////////////////////////////////////////////////////////////*/ - public static Intent getPlayerIntent(final Context context, - final Class targetClazz, - final PlayQueue playQueue, - final String quality) { - Intent intent = new Intent(context, targetClazz) - .putExtra(VideoPlayer.PLAY_QUEUE, playQueue); + public static Intent getPlayerIntent(@NonNull final Context context, + @NonNull final Class targetClazz, + @NonNull final PlayQueue playQueue, + @Nullable final String quality) { + Intent intent = new Intent(context, targetClazz); + + final String cacheKey = SerializedCache.getInstance().put(playQueue, PlayQueue.class); + if (cacheKey != null) intent.putExtra(VideoPlayer.PLAY_QUEUE_KEY, cacheKey); if (quality != null) intent.putExtra(VideoPlayer.PLAYBACK_QUALITY, quality); return intent; } - public static Intent getPlayerIntent(final Context context, - final Class targetClazz, - final PlayQueue playQueue) { + public static Intent getPlayerIntent(@NonNull final Context context, + @NonNull final Class targetClazz, + @NonNull final PlayQueue playQueue) { return getPlayerIntent(context, targetClazz, playQueue, null); } - public static Intent getPlayerEnqueueIntent(final Context context, - final Class targetClazz, - final PlayQueue playQueue, + public static Intent getPlayerEnqueueIntent(@NonNull final Context context, + @NonNull final Class targetClazz, + @NonNull final PlayQueue playQueue, final boolean selectOnAppend) { return getPlayerIntent(context, targetClazz, playQueue) .putExtra(BasePlayer.APPEND_ONLY, true) .putExtra(BasePlayer.SELECT_ON_APPEND, selectOnAppend); } - public static Intent getPlayerIntent(final Context context, - final Class targetClazz, - final PlayQueue playQueue, + public static Intent getPlayerIntent(@NonNull final Context context, + @NonNull final Class targetClazz, + @NonNull final PlayQueue playQueue, final int repeatMode, final float playbackSpeed, final float playbackPitch, - final String playbackQuality) { + @Nullable final String playbackQuality) { return getPlayerIntent(context, targetClazz, playQueue, playbackQuality) .putExtra(BasePlayer.REPEAT_MODE, repeatMode) .putExtra(BasePlayer.PLAYBACK_SPEED, playbackSpeed) diff --git a/app/src/main/java/org/schabi/newpipe/util/SerializedCache.java b/app/src/main/java/org/schabi/newpipe/util/SerializedCache.java new file mode 100644 index 000000000..02871aff5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/SerializedCache.java @@ -0,0 +1,112 @@ +package org.schabi.newpipe.util; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.util.LruCache; +import android.util.Log; + +import org.schabi.newpipe.MainActivity; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.UUID; + +public class SerializedCache { + private static final boolean DEBUG = MainActivity.DEBUG; + private final String TAG = getClass().getSimpleName(); + + private static final SerializedCache instance = new SerializedCache(); + private static final int MAX_ITEMS_ON_CACHE = 5; + + private static final LruCache lruCache = + new LruCache<>(MAX_ITEMS_ON_CACHE); + + private SerializedCache() { + //no instance + } + + public static SerializedCache getInstance() { + return instance; + } + + @Nullable + public T take(@NonNull final String key, @NonNull final Class type) { + if (DEBUG) Log.d(TAG, "take() called with: key = [" + key + "]"); + synchronized (lruCache) { + return lruCache.get(key) != null ? getItem(lruCache.remove(key), type) : null; + } + } + + @Nullable + public T get(@NonNull final String key, @NonNull final Class type) { + if (DEBUG) Log.d(TAG, "get() called with: key = [" + key + "]"); + synchronized (lruCache) { + final CacheData data = lruCache.get(key); + return data != null ? getItem(data, type) : null; + } + } + + @Nullable + public String put(@NonNull T item, @NonNull final Class type) { + final String key = UUID.randomUUID().toString(); + return put(key, item, type) ? key : null; + } + + public boolean put(@NonNull final String key, @NonNull T item, + @NonNull final Class type) { + if (DEBUG) Log.d(TAG, "put() called with: key = [" + key + "], item = [" + item + "]"); + synchronized (lruCache) { + try { + lruCache.put(key, new CacheData<>(clone(item, type), type)); + return true; + } catch (final Exception error) { + Log.e(TAG, "Serialization failed for: ", error); + } + } + return false; + } + + public void clear() { + if (DEBUG) Log.d(TAG, "clear() called"); + synchronized (lruCache) { + lruCache.evictAll(); + } + } + + public long size() { + synchronized (lruCache) { + return lruCache.size(); + } + } + + @Nullable + private T getItem(@NonNull final CacheData data, @NonNull final Class type) { + return type.isAssignableFrom(data.type) ? type.cast(data.item) : null; + } + + @NonNull + private T clone(@NonNull T item, + @NonNull final Class type) throws Exception { + final ByteArrayOutputStream bytesOutput = new ByteArrayOutputStream(); + try (final ObjectOutputStream objectOutput = new ObjectOutputStream(bytesOutput)) { + objectOutput.writeObject(item); + objectOutput.flush(); + } + final Object clone = new ObjectInputStream( + new ByteArrayInputStream(bytesOutput.toByteArray())).readObject(); + return type.cast(clone); + } + + final private static class CacheData { + private final T item; + private final Class type; + + private CacheData(@NonNull final T item, @NonNull Class type) { + this.item = item; + this.type = type; + } + } +} From 9ea08c8a4b1e02beb7b08db301aced50c9534e82 Mon Sep 17 00:00:00 2001 From: John Zhen Mo Date: Wed, 28 Feb 2018 23:25:45 -0800 Subject: [PATCH 10/16] -Re-added loading for items prior to current index in MediaSourceManager to allow faster access time. -Added some null checks annotation. --- .../player/playback/MediaSourceManager.java | 21 ++++++++++++------- .../schabi/newpipe/playlist/PlayQueue.java | 10 ++++----- .../schabi/newpipe/util/NavigationHelper.java | 4 ++++ 3 files changed, 22 insertions(+), 13 deletions(-) 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 8f91e53c2..439885e58 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 @@ -326,8 +326,10 @@ public class MediaSourceManager { maybeLoadItem(currentItem); // The rest are just for seamless playback - final int leftBound = currentIndex + 1; - final int rightLimit = leftBound + WINDOW_SIZE; + // Although timeline is not updated prior to the current index, these sources are still + // loaded into the cache for faster retrieval at a potentially later time. + final int leftBound = Math.max(0, currentIndex - WINDOW_SIZE); + final int rightLimit = currentIndex + WINDOW_SIZE + 1; final int rightBound = Math.min(playQueue.size(), rightLimit); final List items = new ArrayList<>( playQueue.getStreams().subList(leftBound,rightBound)); @@ -343,10 +345,9 @@ public class MediaSourceManager { } } - private void maybeLoadItem(@Nullable final PlayQueueItem item) { + private void maybeLoadItem(@NonNull final PlayQueueItem item) { if (DEBUG) Log.d(TAG, "maybeLoadItem() called."); - - if (sources == null || item == null) return; + if (sources == null) return; final int index = playQueue.indexOf(item); if (index > sources.getSize() - 1) return; @@ -355,7 +356,11 @@ public class MediaSourceManager { if (DEBUG) Log.d(TAG, " Loaded: [" + item.getTitle() + "] with url: " + item.getUrl()); - if (isCorrectionNeeded(item)) update(playQueue.indexOf(item), mediaSource); + final int itemIndex = playQueue.indexOf(item); + // Only update the playlist timeline for items at the current index or after. + if (itemIndex >= playQueue.getIndex() && isCorrectionNeeded(item)) { + update(itemIndex, mediaSource); + } loadingItems.remove(item); tryUnblock(); @@ -449,7 +454,7 @@ public class MediaSourceManager { * 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) { + private void emplace(final int index, @NonNull final MediaSource source) { if (sources == null) return; if (index < 0 || index < sources.getSize()) return; @@ -489,7 +494,7 @@ public class MediaSourceManager { * this will modify the playback timeline prior to the index and cause desynchronization * on the playing item between {@link PlayQueue} and {@link DynamicConcatenatingMediaSource}. * */ - private synchronized void update(final int index, final MediaSource source) { + private synchronized void update(final int index, @NonNull final MediaSource source) { if (sources == null) return; if (index < 0 || index >= sources.getSize()) return; diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java index 9005ef8d2..8f4c5913d 100644 --- a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java @@ -45,7 +45,7 @@ public abstract class PlayQueue implements Serializable { private ArrayList backup; private ArrayList streams; - private final AtomicInteger queueIndex; + @NonNull private final AtomicInteger queueIndex; private transient BehaviorSubject eventBroadcast; private transient Flowable broadcastReceiver; @@ -133,7 +133,7 @@ public abstract class PlayQueue implements Serializable { * Returns the index of the given item using referential equality. * May be null despite play queue contains identical item. * */ - public int indexOf(final PlayQueueItem item) { + public int indexOf(@NonNull final PlayQueueItem item) { // referential equality, can't think of a better way to do this // todo: better than this return streams.indexOf(item); @@ -213,7 +213,7 @@ public abstract class PlayQueue implements Serializable { * * @see #append(List items) * */ - public synchronized void append(final PlayQueueItem... items) { + public synchronized void append(@NonNull final PlayQueueItem... items) { append(Arrays.asList(items)); } @@ -225,7 +225,7 @@ public abstract class PlayQueue implements Serializable { * * Will emit a {@link AppendEvent} on any given context. * */ - public synchronized void append(final List items) { + public synchronized void append(@NonNull final List items) { List itemList = new ArrayList<>(items); if (isShuffled()) { @@ -393,7 +393,7 @@ public abstract class PlayQueue implements Serializable { // Rx Broadcast //////////////////////////////////////////////////////////////////////////*/ - private void broadcast(final PlayQueueEvent event) { + private void broadcast(@NonNull final PlayQueueEvent event) { if (eventBroadcast != null) { eventBroadcast.onNext(event); } 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 da1019f99..4fc854416 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -61,6 +61,7 @@ public class NavigationHelper { // Players //////////////////////////////////////////////////////////////////////////*/ + @NonNull public static Intent getPlayerIntent(@NonNull final Context context, @NonNull final Class targetClazz, @NonNull final PlayQueue playQueue, @@ -74,12 +75,14 @@ public class NavigationHelper { return intent; } + @NonNull public static Intent getPlayerIntent(@NonNull final Context context, @NonNull final Class targetClazz, @NonNull final PlayQueue playQueue) { return getPlayerIntent(context, targetClazz, playQueue, null); } + @NonNull public static Intent getPlayerEnqueueIntent(@NonNull final Context context, @NonNull final Class targetClazz, @NonNull final PlayQueue playQueue, @@ -89,6 +92,7 @@ public class NavigationHelper { .putExtra(BasePlayer.SELECT_ON_APPEND, selectOnAppend); } + @NonNull public static Intent getPlayerIntent(@NonNull final Context context, @NonNull final Class targetClazz, @NonNull final PlayQueue playQueue, From 0c17f0825b6f8b23ccba2c845fa5275754b29848 Mon Sep 17 00:00:00 2001 From: John Zhen Mo Date: Sat, 3 Mar 2018 11:42:23 -0800 Subject: [PATCH 11/16] -Added loader eviction to avoid spawning too many threads in MediaSourceManager. -Added nonnull and final constraints to variables in MediaSourceManager. -Added nonnull and final constraints on context related objects in BasePlayer. -Fixed Hls livestreams crashing player when behind live window for too long. -Fixed cache miss when InfoCache key mismatch between StreamInfo and StreamInfoItem. --- app/build.gradle | 2 +- .../fragments/detail/VideoDetailFragment.java | 2 +- .../newpipe/player/BackgroundPlayer.java | 11 +- .../org/schabi/newpipe/player/BasePlayer.java | 363 +++++++++++------- .../newpipe/player/PopupVideoPlayer.java | 10 +- .../schabi/newpipe/player/VideoPlayer.java | 7 +- .../player/playback/MediaSourceManager.java | 209 +++++----- .../newpipe/playlist/PlayQueueAdapter.java | 29 +- .../schabi/newpipe/util/ExtractorHelper.java | 2 +- .../org/schabi/newpipe/util/InfoCache.java | 37 +- 10 files changed, 395 insertions(+), 277 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index bfc22c76b..74a005ce3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -55,7 +55,7 @@ dependencies { exclude module: 'support-annotations' } - implementation 'com.github.karyogamy:NewPipeExtractor:4cf4ee394f' + implementation 'com.github.karyogamy:NewPipeExtractor:b4206479cb' 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 b306721ba..6d505b00e 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 @@ -322,7 +322,7 @@ public class VideoDetailFragment if (serializable instanceof StreamInfo) { //noinspection unchecked currentInfo = (StreamInfo) serializable; - InfoCache.getInstance().putInfo(currentInfo); + InfoCache.getInstance().putInfo(serviceId, url, currentInfo); } serializable = savedState.getSerializable(STACK_KEY); 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 7e5e612d6..f002115f8 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java @@ -33,6 +33,7 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.NotificationCompat; import android.util.Log; +import android.view.View; import android.widget.RemoteViews; import com.google.android.exoplayer2.PlaybackParameters; @@ -292,15 +293,15 @@ public final class BackgroundPlayer extends Service { } @Override - public void onThumbnailReceived(Bitmap thumbnail) { - super.onThumbnailReceived(thumbnail); + public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) { + super.onLoadingComplete(imageUri, view, loadedImage); - if (thumbnail != null) { + if (loadedImage != null) { // rebuild notification here since remote view does not release bitmaps, causing memory leaks resetNotification(); - if (notRemoteView != null) notRemoteView.setImageViewBitmap(R.id.notificationCover, thumbnail); - if (bigNotRemoteView != null) bigNotRemoteView.setImageViewBitmap(R.id.notificationCover, thumbnail); + if (notRemoteView != null) notRemoteView.setImageViewBitmap(R.id.notificationCover, loadedImage); + if (bigNotRemoteView != null) bigNotRemoteView.setImageViewBitmap(R.id.notificationCover, loadedImage); updateNotification(-1); } 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 6a867110a..86a4d1234 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -43,6 +43,7 @@ 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.source.BehindLiveWindowException; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; @@ -50,7 +51,8 @@ import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; import com.google.android.exoplayer2.util.Util; import com.nostra13.universalimageloader.core.ImageLoader; -import com.nostra13.universalimageloader.core.listener.SimpleImageLoadingListener; +import com.nostra13.universalimageloader.core.assist.FailReason; +import com.nostra13.universalimageloader.core.listener.ImageLoadingListener; import org.schabi.newpipe.Downloader; import org.schabi.newpipe.R; @@ -67,6 +69,8 @@ import org.schabi.newpipe.playlist.PlayQueueAdapter; import org.schabi.newpipe.playlist.PlayQueueItem; import org.schabi.newpipe.util.SerializedCache; +import java.io.IOException; +import java.net.UnknownHostException; import java.util.concurrent.TimeUnit; import io.reactivex.Observable; @@ -86,17 +90,18 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; * @author mauriciocolli */ @SuppressWarnings({"WeakerAccess"}) -public abstract class BasePlayer implements Player.EventListener, PlaybackListener { +public abstract class BasePlayer implements + Player.EventListener, PlaybackListener, ImageLoadingListener { public static final boolean DEBUG = true; - public static final String TAG = "BasePlayer"; + @NonNull public static final String TAG = "BasePlayer"; - protected Context context; + @NonNull final protected Context context; - protected BroadcastReceiver broadcastReceiver; - protected IntentFilter intentFilter; + @NonNull final protected BroadcastReceiver broadcastReceiver; + @NonNull final protected IntentFilter intentFilter; - protected PlayQueueAdapter playQueueAdapter; + @NonNull final protected HistoryRecordManager recordManager; /*////////////////////////////////////////////////////////////////////////// // Intent @@ -117,8 +122,10 @@ 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 MediaSourceManager playbackManager; protected PlayQueue playQueue; + protected PlayQueueAdapter playQueueAdapter; + + protected MediaSourceManager playbackManager; protected StreamInfo currentInfo; protected PlayQueueItem currentItem; @@ -134,23 +141,20 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen protected final static int PROGRESS_LOOP_INTERVAL = 500; protected final static int RECOVERY_SKIP_THRESHOLD = 3000; // 3 seconds + protected CustomTrackSelector trackSelector; + protected PlayerDataSource dataSource; + protected SimpleExoPlayer simpleExoPlayer; protected AudioReactor audioReactor; protected boolean isPrepared = false; - protected CustomTrackSelector trackSelector; - - protected PlayerDataSource dataSource; - protected Disposable progressUpdateReactor; protected CompositeDisposable databaseUpdateReactor; - protected HistoryRecordManager recordManager; - //////////////////////////////////////////////////////////////////////////*/ - public BasePlayer(Context context) { + public BasePlayer(@NonNull final Context context) { this.context = context; this.broadcastReceiver = new BroadcastReceiver() { @@ -162,6 +166,8 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen this.intentFilter = new IntentFilter(); setupBroadcastReceiver(intentFilter); context.registerReceiver(broadcastReceiver, intentFilter); + + this.recordManager = new HistoryRecordManager(context); } public void setup() { @@ -172,7 +178,6 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen public void initPlayer() { if (DEBUG) Log.d(TAG, "initPlayer() called with: context = [" + context + "]"); - if (recordManager == null) recordManager = new HistoryRecordManager(context); if (databaseUpdateReactor != null) databaseUpdateReactor.dispose(); databaseUpdateReactor = new CompositeDisposable(); @@ -195,13 +200,6 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen public void initListeners() {} - private Disposable getProgressReactor() { - return Observable.interval(PROGRESS_LOOP_INTERVAL, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .filter(ignored -> isProgressLoopRunning()) - .subscribe(ignored -> triggerProgressUpdate()); - } - public void handleIntent(Intent intent) { if (DEBUG) Log.d(TAG, "handleIntent() called with: intent = [" + intent + "]"); if (intent == null) return; @@ -217,7 +215,8 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen int sizeBeforeAppend = playQueue.size(); playQueue.append(queue.getStreams()); - if (intent.getBooleanExtra(SELECT_ON_APPEND, false) && queue.getStreams().size() > 0) { + if (intent.getBooleanExtra(SELECT_ON_APPEND, false) && + queue.getStreams().size() > 0) { playQueue.setIndex(sizeBeforeAppend); } @@ -247,24 +246,6 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen playQueueAdapter = new PlayQueueAdapter(context, playQueue); } - public void initThumbnail(final String url) { - if (DEBUG) Log.d(TAG, "initThumbnail() called"); - if (url == null || url.isEmpty()) return; - ImageLoader.getInstance().resume(); - ImageLoader.getInstance().loadImage(url, new SimpleImageLoadingListener() { - @Override - public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) { - if (simpleExoPlayer == null) return; - if (DEBUG) Log.d(TAG, "onLoadingComplete() called with: imageUri = [" + imageUri + "], view = [" + view + "], loadedImage = [" + loadedImage + "]"); - onThumbnailReceived(loadedImage); - } - }); - } - - public void onThumbnailReceived(Bitmap thumbnail) { - if (DEBUG) Log.d(TAG, "onThumbnailReceived() called with: thumbnail = [" + thumbnail + "]"); - } - public void destroyPlayer() { if (DEBUG) Log.d(TAG, "destroyPlayer() called"); if (simpleExoPlayer != null) { @@ -292,7 +273,46 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen trackSelector = null; simpleExoPlayer = null; - recordManager = null; + } + + /*////////////////////////////////////////////////////////////////////////// + // Thumbnail Loading + //////////////////////////////////////////////////////////////////////////*/ + + public void initThumbnail(final String url) { + if (DEBUG) Log.d(TAG, "Thumbnail - initThumbnail() called"); + if (url == null || url.isEmpty()) return; + ImageLoader.getInstance().resume(); + ImageLoader.getInstance().loadImage(url, this); + } + + @Override + public void onLoadingStarted(String imageUri, View view) { + if (DEBUG) Log.d(TAG, "Thumbnail - onLoadingStarted() called on: " + + "imageUri = [" + imageUri + "], view = [" + view + "]"); + } + + @Override + public void onLoadingFailed(String imageUri, View view, FailReason failReason) { + Log.e(TAG, "Thumbnail - onLoadingFailed() called on imageUri = [" + imageUri + "]", + failReason.getCause()); + } + + @Override + public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) { + if (DEBUG) Log.d(TAG, "Thumbnail - onLoadingComplete() called with: " + + "imageUri = [" + imageUri + "], view = [" + view + "], " + + "loadedImage = [" + loadedImage + "]"); + } + + @Override + public void onLoadingCancelled(String imageUri, View view) { + if (DEBUG) Log.d(TAG, "Thumbnail - onLoadingCancelled() called with: " + + "imageUri = [" + imageUri + "], view = [" + view + "]"); + } + + protected void clearThumbnailCache() { + ImageLoader.getInstance().clearMemoryCache(); } /*////////////////////////////////////////////////////////////////////////// @@ -371,9 +391,10 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen } public void unregisterBroadcastReceiver() { - if (broadcastReceiver != null && context != null) { + try { context.unregisterReceiver(broadcastReceiver); - broadcastReceiver = null; + } catch (final IllegalArgumentException unregisteredException) { + Log.e(TAG, "Broadcast receiver already unregistered.", unregisteredException); } } @@ -423,6 +444,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen public void onPlaying() { if (DEBUG) Log.d(TAG, "onPlaying() called"); if (!isProgressLoopRunning()) startProgressLoop(); + if (!isCurrentWindowValid()) seekToDefault(); } public void onBuffering() { @@ -480,64 +502,95 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen } } + /*////////////////////////////////////////////////////////////////////////// + // Progress Updates + //////////////////////////////////////////////////////////////////////////*/ + + public abstract void onUpdateProgress(int currentProgress, int duration, int bufferPercent); + + protected void startProgressLoop() { + if (progressUpdateReactor != null) progressUpdateReactor.dispose(); + progressUpdateReactor = getProgressReactor(); + } + + protected void stopProgressLoop() { + if (progressUpdateReactor != null) progressUpdateReactor.dispose(); + progressUpdateReactor = null; + } + + public void triggerProgressUpdate() { + onUpdateProgress( + (int) simpleExoPlayer.getCurrentPosition(), + (int) simpleExoPlayer.getDuration(), + simpleExoPlayer.getBufferedPercentage() + ); + } + + + private Disposable getProgressReactor() { + return Observable.interval(PROGRESS_LOOP_INTERVAL, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .filter(ignored -> isProgressLoopRunning()) + .subscribe(ignored -> triggerProgressUpdate()); + } + /*////////////////////////////////////////////////////////////////////////// // ExoPlayer Listener //////////////////////////////////////////////////////////////////////////*/ - private void maybeRecover() { - final int currentSourceIndex = playQueue.getIndex(); - final PlayQueueItem currentSourceItem = playQueue.getItem(); + @Override + public void onTimelineChanged(Timeline timeline, Object manifest, + @Player.TimelineChangeReason final int reason) { + if (DEBUG) Log.d(TAG, "ExoPlayer - onTimelineChanged() called with " + + (manifest == null ? "no manifest" : "available manifest") + ", " + + "timeline size = [" + timeline.getWindowCount() + "], " + + "reason = [" + reason + "]"); - // Check if already playing correct window - final boolean isCurrentPeriodCorrect = - simpleExoPlayer.getCurrentPeriodIndex() == currentSourceIndex; - - // Check if recovering - if (isCurrentPeriodCorrect && currentSourceItem != null) { - /* Recovering with sub-second position may cause a long buffer delay in ExoPlayer, - * rounding this position to the nearest second will help alleviate this.*/ - final long position = currentSourceItem.getRecoveryPosition(); - - /* Skip recovering if the recovery position is not set.*/ - if (position == PlayQueueItem.RECOVERY_UNSET) return; - - if (DEBUG) Log.d(TAG, "Rewinding to recovery window: " + currentSourceIndex + - " at: " + getTimeString((int)position)); - simpleExoPlayer.seekTo(currentSourceItem.getRecoveryPosition()); - playQueue.unsetRecovery(currentSourceIndex); + switch (reason) { + case Player.TIMELINE_CHANGE_REASON_RESET: // called after #block + case Player.TIMELINE_CHANGE_REASON_PREPARED: // called after #unblock + case Player.TIMELINE_CHANGE_REASON_DYNAMIC: // called after playlist changes + if (playQueue != null && playbackManager != null && + // ensures MediaSourceManager#update is complete + timeline.getWindowCount() == playQueue.size()) { + playbackManager.load(); + } } } - @Override - public void onTimelineChanged(Timeline timeline, Object manifest, int reason) { - if (DEBUG) Log.d(TAG, "onTimelineChanged(), timeline size = " + timeline.getWindowCount()); - } - @Override public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { - if (DEBUG) Log.d(TAG, "onTracksChanged(), track group size = " + trackGroups.length); + if (DEBUG) Log.d(TAG, "ExoPlayer - onTracksChanged(), " + + "track group size = " + trackGroups.length); } @Override public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { - if (DEBUG) Log.d(TAG, "playbackParameters(), speed: " + playbackParameters.speed + - ", pitch: " + playbackParameters.pitch); + if (DEBUG) Log.d(TAG, "ExoPlayer - playbackParameters(), " + + "speed: " + playbackParameters.speed + ", " + + "pitch: " + playbackParameters.pitch); } @Override - public void onLoadingChanged(boolean isLoading) { - if (DEBUG) Log.d(TAG, "onLoadingChanged() called with: isLoading = [" + isLoading + "]"); + public void onLoadingChanged(final boolean isLoading) { + if (DEBUG) Log.d(TAG, "ExoPlayer - onLoadingChanged() called with: " + + "isLoading = [" + isLoading + "]"); - if (!isLoading && getCurrentState() == STATE_PAUSED && isProgressLoopRunning()) stopProgressLoop(); - else if (isLoading && !isProgressLoopRunning()) startProgressLoop(); + if (!isLoading && getCurrentState() == STATE_PAUSED && isProgressLoopRunning()) { + stopProgressLoop(); + } else if (isLoading && !isProgressLoopRunning()) { + startProgressLoop(); + } } @Override public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { - if (DEBUG) - Log.d(TAG, "onPlayerStateChanged() called with: playWhenReady = [" + playWhenReady + "], playbackState = [" + playbackState + "]"); + if (DEBUG) Log.d(TAG, "ExoPlayer - onPlayerStateChanged() called with: " + + "playWhenReady = [" + playWhenReady + "], " + + "playbackState = [" + playbackState + "]"); + if (getCurrentState() == STATE_PAUSED_SEEK) { - if (DEBUG) Log.d(TAG, "onPlayerStateChanged() is currently blocked"); + if (DEBUG) Log.d(TAG, "ExoPlayer - onPlayerStateChanged() is currently blocked"); return; } @@ -572,24 +625,35 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen } } + private void maybeRecover() { + final int currentSourceIndex = playQueue.getIndex(); + final PlayQueueItem currentSourceItem = playQueue.getItem(); + + // Check if already playing correct window + final boolean isCurrentPeriodCorrect = + simpleExoPlayer.getCurrentPeriodIndex() == currentSourceIndex; + + // Check if recovering + if (isCurrentPeriodCorrect && currentSourceItem != null) { + /* Recovering with sub-second position may cause a long buffer delay in ExoPlayer, + * rounding this position to the nearest second will help alleviate this.*/ + final long position = currentSourceItem.getRecoveryPosition(); + + /* Skip recovering if the recovery position is not set.*/ + if (position == PlayQueueItem.RECOVERY_UNSET) return; + + if (DEBUG) Log.d(TAG, "Rewinding to recovery window: " + currentSourceIndex + + " at: " + getTimeString((int)position)); + simpleExoPlayer.seekTo(currentSourceItem.getRecoveryPosition()); + playQueue.unsetRecovery(currentSourceIndex); + } + } + /** * Processes the exceptions produced by {@link com.google.android.exoplayer2.ExoPlayer ExoPlayer}. * There are multiple types of errors:

* * {@link ExoPlaybackException#TYPE_SOURCE TYPE_SOURCE}:

- * If the current {@link com.google.android.exoplayer2.Timeline.Window window} is valid, - * then we know the error is produced by transitioning into a bad window, therefore we report - * an error to the play queue based on if the current error can be skipped. - * - * This is done because ExoPlayer reports the source exceptions before window is - * transitioned on seamless playback. Because player error causes ExoPlayer to go - * back to {@link Player#STATE_IDLE STATE_IDLE}, we reset and prepare the media source - * again to resume playback. - * - * In the event that this error is produced during a valid stream playback, we save the - * current position so the playback may be recovered and resumed manually by the user. This - * happens only if the playback is {@link #RECOVERY_SKIP_THRESHOLD} milliseconds until complete. - *

* * {@link ExoPlaybackException#TYPE_UNEXPECTED TYPE_UNEXPECTED}:

* If a runtime error occurred, then we can try to recover it by restarting the playback @@ -598,11 +662,13 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen * {@link ExoPlaybackException#TYPE_RENDERER TYPE_RENDERER}:

* If the renderer failed, treat the error as unrecoverable. * + * @see #processSourceError(IOException) * @see Player.EventListener#onPlayerError(ExoPlaybackException) * */ @Override public void onPlayerError(ExoPlaybackException error) { - if (DEBUG) Log.d(TAG, "onPlayerError() called with: error = [" + error + "]"); + if (DEBUG) Log.d(TAG, "ExoPlayer - onPlayerError() called with: " + + "error = [" + error + "]"); if (errorToast != null) { errorToast.cancel(); errorToast = null; @@ -612,11 +678,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen switch (error.type) { case ExoPlaybackException.TYPE_SOURCE: - if (simpleExoPlayer.getCurrentPosition() < - simpleExoPlayer.getDuration() - RECOVERY_SKIP_THRESHOLD) { - setRecovery(); - } - playQueue.error(isCurrentWindowValid()); + processSourceError(error.getSourceException()); showStreamError(error); break; case ExoPlaybackException.TYPE_UNEXPECTED: @@ -631,9 +693,48 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen } } + /** + * Processes {@link ExoPlaybackException} tagged with {@link ExoPlaybackException#TYPE_SOURCE}. + *

+ * If the current {@link com.google.android.exoplayer2.Timeline.Window window} is valid, + * then we know the error is produced by transitioning into a bad window, therefore we report + * an error to the play queue based on if the current error can be skipped. + *

+ * This is done because ExoPlayer reports the source exceptions before window is + * transitioned on seamless playback. Because player error causes ExoPlayer to go + * back to {@link Player#STATE_IDLE STATE_IDLE}, we reset and prepare the media source + * again to resume playback. + *

+ * In the event that this error is produced during a valid stream playback, we save the + * current position so the playback may be recovered and resumed manually by the user. This + * happens only if the playback is {@link #RECOVERY_SKIP_THRESHOLD} milliseconds until complete. + *

+ * In the event of livestreaming being lagged behind for any reason, most notably pausing for + * too long, a {@link BehindLiveWindowException} will be produced. This will trigger a reload + * instead of skipping or removal. + * */ + private void processSourceError(final IOException error) { + if (simpleExoPlayer == null || playQueue == null) return; + + if (simpleExoPlayer.getCurrentPosition() < + simpleExoPlayer.getDuration() - RECOVERY_SKIP_THRESHOLD) { + setRecovery(); + } + + final Throwable cause = error.getCause(); + if (cause instanceof BehindLiveWindowException) { + reload(); + } else if (cause instanceof UnknownHostException) { + playQueue.error(/*isNetworkProblem=*/true); + } else { + playQueue.error(isCurrentWindowValid()); + } + } + @Override - public void onPositionDiscontinuity(int reason) { - if (DEBUG) Log.d(TAG, "onPositionDiscontinuity() called with reason = [" + reason + "]"); + public void onPositionDiscontinuity(@Player.DiscontinuityReason final int reason) { + if (DEBUG) Log.d(TAG, "ExoPlayer - onPositionDiscontinuity() called with " + + "reason = [" + reason + "]"); // Refresh the playback if there is a transition to the next video final int newPeriodIndex = simpleExoPlayer.getCurrentPeriodIndex(); @@ -645,30 +746,28 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen } else { playQueue.offsetIndex(+1); } - playbackManager.load(); - break; case DISCONTINUITY_REASON_SEEK: case DISCONTINUITY_REASON_SEEK_ADJUSTMENT: case DISCONTINUITY_REASON_INTERNAL: - default: break; } } @Override - public void onRepeatModeChanged(int i) { - if (DEBUG) Log.d(TAG, "onRepeatModeChanged() called with: mode = [" + i + "]"); + public void onRepeatModeChanged(@Player.RepeatMode final int reason) { + if (DEBUG) Log.d(TAG, "ExoPlayer - onRepeatModeChanged() called with: " + + "mode = [" + reason + "]"); } @Override - public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { - if (DEBUG) Log.d(TAG, "onShuffleModeEnabledChanged() called with: " + + public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { + if (DEBUG) Log.d(TAG, "ExoPlayer - onShuffleModeEnabledChanged() called with: " + "mode = [" + shuffleModeEnabled + "]"); } @Override public void onSeekProcessed() { - if (DEBUG) Log.d(TAG, "onSeekProcessed() called"); + if (DEBUG) Log.d(TAG, "ExoPlayer - onSeekProcessed() called"); } /*////////////////////////////////////////////////////////////////////////// // Playback Listener @@ -677,7 +776,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen @Override public void block() { if (simpleExoPlayer == null) return; - if (DEBUG) Log.d(TAG, "Blocking..."); + if (DEBUG) Log.d(TAG, "Playback - block() called"); currentItem = null; currentInfo = null; @@ -690,12 +789,12 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen @Override public void unblock(final MediaSource mediaSource) { if (simpleExoPlayer == null) return; - if (DEBUG) Log.d(TAG, "Unblocking..."); + if (DEBUG) Log.d(TAG, "Playback - unblock() called"); if (getCurrentState() == STATE_BLOCKED) changeState(STATE_BUFFERING); simpleExoPlayer.prepare(mediaSource); - simpleExoPlayer.seekToDefaultPosition(); + seekToDefault(); } @Override @@ -705,7 +804,9 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen currentItem = item; currentInfo = info; - if (DEBUG) Log.d(TAG, "Syncing..."); + if (DEBUG) Log.d(TAG, "Playback - sync() called with " + + (info == null ? "available" : "null") + " info, " + + "item=[" + item.getTitle() + "], url=[" + item.getUrl() + "]"); if (simpleExoPlayer == null) return; // Check if on wrong window @@ -781,8 +882,6 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen changeState(playWhenReady ? STATE_PLAYING : STATE_PAUSED); } - public abstract void onUpdateProgress(int currentProgress, int duration, int bufferPercent); - public void onVideoPlayPause() { if (DEBUG) Log.d(TAG, "onVideoPlayPause() called"); @@ -794,7 +893,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen if (getCurrentState() == STATE_COMPLETED) { if (playQueue.getIndex() == 0) { - simpleExoPlayer.seekToDefaultPosition(); + seekToDefault(); } else { playQueue.setIndex(0); } @@ -839,11 +938,13 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen } public void onSelected(final PlayQueueItem item) { + if (playQueue == null || simpleExoPlayer == null) return; + final int index = playQueue.indexOf(item); if (index == -1) return; - if (playQueue.getIndex() == index) { - simpleExoPlayer.seekToDefaultPosition(); + if (playQueue.getIndex() == index && simpleExoPlayer.getCurrentWindowIndex() == index) { + seekToDefault(); } else { playQueue.setIndex(index); } @@ -875,7 +976,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen //////////////////////////////////////////////////////////////////////////*/ private void registerView() { - if (databaseUpdateReactor == null || recordManager == null || currentInfo == null) return; + if (databaseUpdateReactor == null || currentInfo == null) return; databaseUpdateReactor.add(recordManager.onViewed(currentInfo).onErrorComplete() .subscribe( ignored -> {/* successful */}, @@ -890,30 +991,8 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen } } - protected void clearThumbnailCache() { - ImageLoader.getInstance().clearMemoryCache(); - } - - protected void startProgressLoop() { - if (progressUpdateReactor != null) progressUpdateReactor.dispose(); - progressUpdateReactor = getProgressReactor(); - } - - protected void stopProgressLoop() { - if (progressUpdateReactor != null) progressUpdateReactor.dispose(); - progressUpdateReactor = null; - } - - public void triggerProgressUpdate() { - onUpdateProgress( - (int) simpleExoPlayer.getCurrentPosition(), - (int) simpleExoPlayer.getDuration(), - simpleExoPlayer.getBufferedPercentage() - ); - } - protected void savePlaybackState(final StreamInfo info, final long progress) { - if (context == null || info == null || databaseUpdateReactor == null) return; + if (info == null || databaseUpdateReactor == null) return; final Disposable stateSaver = recordManager.saveStreamState(info, progress) .observeOn(AndroidSchedulers.mainThread()) .onErrorComplete() diff --git a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java index f4e7a0d6a..6263541bb 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java @@ -419,13 +419,15 @@ public final class PopupVideoPlayer extends Service { } @Override - public void onThumbnailReceived(Bitmap thumbnail) { - super.onThumbnailReceived(thumbnail); - if (thumbnail != null) { + public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) { + super.onLoadingComplete(imageUri, view, loadedImage); + if (loadedImage != null) { // rebuild notification here since remote view does not release bitmaps, causing memory leaks notBuilder = createNotification(); - if (notRemoteView != null) notRemoteView.setImageViewBitmap(R.id.notificationCover, thumbnail); + if (notRemoteView != null) { + notRemoteView.setImageViewBitmap(R.id.notificationCover, loadedImage); + } updateNotification(-1); } 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 5a7a9a462..58de44130 100644 --- a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java @@ -160,7 +160,6 @@ public abstract class VideoPlayer extends BasePlayer public VideoPlayer(String debugTag, Context context) { super(context); this.TAG = debugTag; - this.context = context; } public void setup(View rootView) { @@ -617,9 +616,9 @@ public abstract class VideoPlayer extends BasePlayer } @Override - public void onThumbnailReceived(Bitmap thumbnail) { - super.onThumbnailReceived(thumbnail); - if (thumbnail != null) endScreen.setImageBitmap(thumbnail); + public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) { + super.onLoadingComplete(imageUri, view, loadedImage); + if (loadedImage != null) endScreen.setImageBitmap(loadedImage); } protected void onFullScreenButtonClicked() { 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 439885e58..bc7f92b42 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 @@ -26,6 +26,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import io.reactivex.Single; import io.reactivex.android.schedulers.AndroidSchedulers; @@ -33,6 +34,7 @@ import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; import io.reactivex.disposables.SerialDisposable; import io.reactivex.functions.Consumer; +import io.reactivex.internal.subscriptions.EmptySubscription; import io.reactivex.subjects.PublishSubject; import static org.schabi.newpipe.playlist.PlayQueue.DEBUG; @@ -40,66 +42,105 @@ import static org.schabi.newpipe.playlist.PlayQueue.DEBUG; public class MediaSourceManager { @NonNull private final static String TAG = "MediaSourceManager"; - // WINDOW_SIZE determines how many streams AFTER the current stream should be loaded. - // The default value (1) ensures seamless playback under typical network settings. + /** + * Determines how many streams before and after the current stream should be loaded. + * The default value (1) ensures seamless playback under typical network settings. + *

+ * The streams after the current will be loaded into the playlist timeline while the + * streams before will only be cached for future usage. + * + * @see #onMediaSourceReceived(PlayQueueItem, ManagedMediaSource) + * @see #update(int, MediaSource) + * */ private final static int WINDOW_SIZE = 1; @NonNull private final PlaybackListener playbackListener; @NonNull private final PlayQueue playQueue; - // Once a MediaSource item has been detected to be expired, the manager will immediately - // trigger a reload on the associated PlayQueueItem, which may disrupt playback, - // if the item is being played - private final long expirationTimeMillis; + /** + * Determines how long NEIGHBOURING {@link LoadedMediaSource} window of a currently playing + * {@link MediaSource} is allowed to stay in the playlist timeline. This is to ensure + * the {@link StreamInfo} used in subsequent playback is up-to-date. + *

+ * Once a {@link LoadedMediaSource} has expired, a new source will be reloaded to + * replace the expired one on whereupon {@link #loadImmediate()} is called. + * + * @see #loadImmediate() + * @see #isCorrectionNeeded(PlayQueueItem) + * */ + private final long windowRefreshTimeMillis; - // 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 + /** + * 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. + * + * @see #loadDebounced() + * */ private final long loadDebounceMillis; @NonNull private final Disposable debouncedLoader; @NonNull private final PublishSubject debouncedSignal; - private DynamicConcatenatingMediaSource sources; + @NonNull private Subscription playQueueReactor; - private Subscription playQueueReactor; - private CompositeDisposable loaderReactor; + /** + * Determines the maximum number of disposables allowed in the {@link #loaderReactor}. + * Once exceeded, new calls to {@link #loadImmediate()} will evict all disposables in the + * {@link #loaderReactor} in order to load a new set of items. + * + * @see #loadImmediate() + * @see #maybeLoadItem(PlayQueueItem) + * */ + private final static int MAXIMUM_LOADER_SIZE = WINDOW_SIZE * 2 + 1; + @NonNull private final CompositeDisposable loaderReactor; + @NonNull private Set loadingItems; + @NonNull private final SerialDisposable syncReactor; - private boolean isBlocked; + @NonNull private final AtomicBoolean isBlocked; - private SerialDisposable syncReactor; - private PlayQueueItem syncedItem; - private Set loadingItems; + @NonNull private DynamicConcatenatingMediaSource sources; + + @Nullable private PlayQueueItem syncedItem; public MediaSourceManager(@NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue) { this(listener, playQueue, /*loadDebounceMillis=*/400L, - /*expirationTimeMillis=*/TimeUnit.MILLISECONDS.convert(30, TimeUnit.MINUTES)); + /*windowRefreshTimeMillis=*/TimeUnit.MILLISECONDS.convert(10, TimeUnit.MINUTES)); } private MediaSourceManager(@NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue, final long loadDebounceMillis, - final long expirationTimeMillis) { + final long windowRefreshTimeMillis) { + if (playQueue.getBroadcastReceiver() == null) { + throw new IllegalArgumentException("Play Queue has not been initialized."); + } + this.playbackListener = listener; this.playQueue = playQueue; - this.loadDebounceMillis = loadDebounceMillis; - this.expirationTimeMillis = expirationTimeMillis; - this.loaderReactor = new CompositeDisposable(); + this.windowRefreshTimeMillis = windowRefreshTimeMillis; + + this.loadDebounceMillis = loadDebounceMillis; this.debouncedSignal = PublishSubject.create(); this.debouncedLoader = getDebouncedLoader(); + this.playQueueReactor = EmptySubscription.INSTANCE; + this.loaderReactor = new CompositeDisposable(); + this.syncReactor = new SerialDisposable(); + + this.isBlocked = new AtomicBoolean(false); + this.sources = new DynamicConcatenatingMediaSource(); - this.syncReactor = new SerialDisposable(); this.loadingItems = Collections.synchronizedSet(new HashSet<>()); - if (playQueue.getBroadcastReceiver() != null) { - playQueue.getBroadcastReceiver() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getReactor()); - } + playQueue.getBroadcastReceiver() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getReactor()); } /*////////////////////////////////////////////////////////////////////////// @@ -114,16 +155,12 @@ public class MediaSourceManager { debouncedSignal.onComplete(); debouncedLoader.dispose(); - if (playQueueReactor != null) playQueueReactor.cancel(); - if (loaderReactor != null) loaderReactor.dispose(); - if (syncReactor != null) syncReactor.dispose(); - if (sources != null) sources.releaseSource(); + playQueueReactor.cancel(); + loaderReactor.dispose(); + syncReactor.dispose(); + sources.releaseSource(); - playQueueReactor = null; - loaderReactor = null; - syncReactor = null; syncedItem = null; - sources = null; } /** @@ -158,14 +195,14 @@ public class MediaSourceManager { return new Subscriber() { @Override public void onSubscribe(@NonNull Subscription d) { - if (playQueueReactor != null) playQueueReactor.cancel(); + playQueueReactor.cancel(); playQueueReactor = d; playQueueReactor.request(1); } @Override public void onNext(@NonNull PlayQueueEvent playQueueMessage) { - if (playQueueReactor != null) onPlayQueueChanged(playQueueMessage); + onPlayQueueChanged(playQueueMessage); } @Override @@ -227,7 +264,7 @@ public class MediaSourceManager { tryBlock(); playQueue.fetch(); } - if (playQueueReactor != null) playQueueReactor.request(1); + playQueueReactor.request(1); } /*////////////////////////////////////////////////////////////////////////// @@ -240,7 +277,7 @@ public class MediaSourceManager { } private boolean isPlaybackReady() { - if (sources == null || sources.getSize() != playQueue.size()) return false; + if (sources.getSize() != playQueue.size()) return false; final MediaSource mediaSource = sources.getMediaSource(playQueue.getIndex()); final PlayQueueItem playQueueItem = playQueue.getItem(); @@ -256,19 +293,19 @@ public class MediaSourceManager { private void tryBlock() { if (DEBUG) Log.d(TAG, "tryBlock() called."); - if (isBlocked) return; + if (isBlocked.get()) return; playbackListener.block(); resetSources(); - isBlocked = true; + isBlocked.set(true); } private void tryUnblock() { if (DEBUG) Log.d(TAG, "tryUnblock() called."); - if (isPlayQueueReady() && isPlaybackReady() && isBlocked && sources != null) { - isBlocked = false; + if (isPlayQueueReady() && isPlaybackReady() && isBlocked.get()) { + isBlocked.set(false); playbackListener.unblock(sources); } } @@ -281,7 +318,7 @@ public class MediaSourceManager { if (DEBUG) Log.d(TAG, "sync() called."); final PlayQueueItem currentItem = playQueue.getItem(); - if (isBlocked || currentItem == null) return; + if (isBlocked.get() || currentItem == null) return; final Consumer onSuccess = info -> syncInternal(currentItem, info); final Consumer onError = throwable -> syncInternal(currentItem, null); @@ -295,11 +332,11 @@ public class MediaSourceManager { } } - private void syncInternal(@android.support.annotation.NonNull final PlayQueueItem item, + private void syncInternal(@NonNull final PlayQueueItem item, @Nullable final StreamInfo info) { // Ensure the current item is up to date with the play queue if (playQueue.getItem() == item && playQueue.getItem() == syncedItem) { - playbackListener.sync(syncedItem, info); + playbackListener.sync(item, info); } } @@ -323,6 +360,12 @@ public class MediaSourceManager { final int currentIndex = playQueue.getIndex(); final PlayQueueItem currentItem = playQueue.getItem(currentIndex); if (currentItem == null) return; + + // Evict the items being loaded to free up memory + if (!loadingItems.contains(currentItem) && loaderReactor.size() > MAXIMUM_LOADER_SIZE) { + loaderReactor.clear(); + loadingItems.clear(); + } maybeLoadItem(currentItem); // The rest are just for seamless playback @@ -347,34 +390,17 @@ public class MediaSourceManager { private void maybeLoadItem(@NonNull final PlayQueueItem item) { if (DEBUG) Log.d(TAG, "maybeLoadItem() called."); - if (sources == null) return; - - final int index = playQueue.indexOf(item); - if (index > sources.getSize() - 1) return; - - final Consumer onDone = mediaSource -> { - if (DEBUG) Log.d(TAG, " Loaded: [" + item.getTitle() + - "] with url: " + item.getUrl()); - - final int itemIndex = playQueue.indexOf(item); - // Only update the playlist timeline for items at the current index or after. - if (itemIndex >= playQueue.getIndex() && isCorrectionNeeded(item)) { - update(itemIndex, mediaSource); - } - - loadingItems.remove(item); - tryUnblock(); - sync(); - }; + if (playQueue.indexOf(item) >= sources.getSize()) return; if (!loadingItems.contains(item) && isCorrectionNeeded(item)) { - if (DEBUG) Log.d(TAG, "Loading: [" + item.getTitle() + + if (DEBUG) Log.d(TAG, "MediaSource - Loading: [" + item.getTitle() + "] with url: " + item.getUrl()); loadingItems.add(item); final Disposable loader = getLoadedMediaSource(item) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(onDone); + /* No exception handling since getLoadedMediaSource guarantees nonnull return */ + .subscribe(mediaSource -> onMediaSourceReceived(item, mediaSource)); loaderReactor.add(loader); } @@ -392,14 +418,32 @@ public class MediaSourceManager { ", audio count: " + streamInfo.audio_streams.size() + ", video count: " + streamInfo.video_only_streams.size() + streamInfo.video_streams.size()); - return new FailedMediaSource(stream, new IllegalStateException(exception)); + return new FailedMediaSource(stream, exception); } - final long expiration = System.currentTimeMillis() + expirationTimeMillis; + final long expiration = System.currentTimeMillis() + windowRefreshTimeMillis; return new LoadedMediaSource(source, stream, expiration); }).onErrorReturn(throwable -> new FailedMediaSource(stream, throwable)); } + private void onMediaSourceReceived(@NonNull final PlayQueueItem item, + @NonNull final ManagedMediaSource mediaSource) { + if (DEBUG) Log.d(TAG, "MediaSource - Loaded: [" + item.getTitle() + + "] with url: " + item.getUrl()); + + final int itemIndex = playQueue.indexOf(item); + // Only update the playlist timeline for items at the current index or after. + if (itemIndex >= playQueue.getIndex() && isCorrectionNeeded(item)) { + if (DEBUG) Log.d(TAG, "MediaSource - Updating: [" + item.getTitle() + + "] with url: " + item.getUrl()); + update(itemIndex, mediaSource); + } + + loadingItems.remove(item); + tryUnblock(); + sync(); + } + /** * Checks if the corresponding MediaSource in {@link DynamicConcatenatingMediaSource} * for a given {@link PlayQueueItem} needs replacement, either due to gapless playback @@ -411,8 +455,6 @@ public class MediaSourceManager { * {@link ManagedMediaSource}. * */ private boolean isCorrectionNeeded(@NonNull final PlayQueueItem item) { - if (sources == null) return false; - final int index = playQueue.indexOf(item); if (index == -1 || index >= sources.getSize()) return false; @@ -432,13 +474,13 @@ public class MediaSourceManager { private void resetSources() { if (DEBUG) Log.d(TAG, "resetSources() called."); - if (this.sources != null) this.sources.releaseSource(); + this.sources.releaseSource(); this.sources = new DynamicConcatenatingMediaSource(); } private void populateSources() { if (DEBUG) Log.d(TAG, "populateSources() called."); - if (sources == null || sources.getSize() >= playQueue.size()) return; + if (sources.getSize() >= playQueue.size()) return; for (int index = sources.getSize() - 1; index < playQueue.size(); index++) { emplace(index, new PlaceholderMediaSource()); @@ -451,12 +493,11 @@ public class MediaSourceManager { /** * Places a {@link MediaSource} into the {@link DynamicConcatenatingMediaSource} - * with position * in respect to the play queue only if no {@link MediaSource} + * 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, @NonNull final MediaSource source) { - if (sources == null) return; - if (index < 0 || index < sources.getSize()) return; + private synchronized void emplace(final int index, @NonNull final MediaSource source) { + if (index < sources.getSize()) return; sources.addMediaSource(index, source); } @@ -465,8 +506,7 @@ public class MediaSourceManager { * 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; + private synchronized void remove(final int index) { if (index < 0 || index > sources.getSize()) return; sources.removeMediaSource(index); @@ -477,8 +517,7 @@ public class MediaSourceManager { * 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; + private synchronized void move(final int source, final int target) { if (source < 0 || target < 0) return; if (source >= sources.getSize() || target >= sources.getSize()) return; @@ -491,15 +530,13 @@ public class MediaSourceManager { * then the replacement is ignored. *

* Not recommended to use on indices LESS THAN the currently playing index, since - * this will modify the playback timeline prior to the index and cause desynchronization + * this will modify the playback timeline prior to the index and may cause desynchronization * on the playing item between {@link PlayQueue} and {@link DynamicConcatenatingMediaSource}. * */ private synchronized void update(final int index, @NonNull 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); - }); + sources.addMediaSource(index + 1, source, () -> + sources.removeMediaSource(index)); } } diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java index 7c701a637..dd320c2bc 100644 --- a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java @@ -63,22 +63,18 @@ public class PlayQueueAdapter extends RecyclerView.Adapter observer = new Observer() { + private Observer getReactor() { + return new Observer() { @Override public void onSubscribe(@NonNull Disposable d) { if (playQueueReactor != null) playQueueReactor.dispose(); @@ -99,9 +95,6 @@ public class PlayQueueAdapter extends RecyclerView.Adapter loadFromNetwork) { checkServiceId(serviceId); - loadFromNetwork = loadFromNetwork.doOnSuccess((@NonNull I i) -> cache.putInfo(i)); + loadFromNetwork = loadFromNetwork.doOnSuccess(info -> cache.putInfo(serviceId, url, info)); Single load; if (forceLoad) { diff --git a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java index 0f082cc11..47c45e82a 100644 --- a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java +++ b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java @@ -20,6 +20,7 @@ package org.schabi.newpipe.util; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.v4.util.LruCache; import android.util.Log; @@ -29,6 +30,8 @@ import org.schabi.newpipe.extractor.Info; import java.util.Map; import java.util.concurrent.TimeUnit; +import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; + public final class InfoCache { private static final boolean DEBUG = MainActivity.DEBUG; @@ -52,6 +55,7 @@ public final class InfoCache { return instance; } + @Nullable public Info getFromKey(int serviceId, @NonNull String url) { if (DEBUG) Log.d(TAG, "getFromKey() called with: serviceId = [" + serviceId + "], url = [" + url + "]"); synchronized (lruCache) { @@ -59,18 +63,19 @@ public final class InfoCache { } } - public void putInfo(@NonNull Info info) { + public void putInfo(int serviceId, @NonNull String url, @NonNull Info info) { if (DEBUG) Log.d(TAG, "putInfo() called with: info = [" + info + "]"); - synchronized (lruCache) { - final CacheData data = new CacheData(info, DEFAULT_TIMEOUT_HOURS, TimeUnit.HOURS); - lruCache.put(keyOf(info), data); - } - } - public void removeInfo(@NonNull Info info) { - if (DEBUG) Log.d(TAG, "removeInfo() called with: info = [" + info + "]"); + final long expirationMillis; + if (info.getServiceId() == SoundCloud.getServiceId()) { + expirationMillis = TimeUnit.MILLISECONDS.convert(15, TimeUnit.MINUTES); + } else { + expirationMillis = TimeUnit.MILLISECONDS.convert(DEFAULT_TIMEOUT_HOURS, TimeUnit.HOURS); + } + synchronized (lruCache) { - lruCache.remove(keyOf(info)); + final CacheData data = new CacheData(info, expirationMillis); + lruCache.put(keyOf(serviceId, url), data); } } @@ -102,10 +107,7 @@ public final class InfoCache { } } - private static String keyOf(@NonNull final Info info) { - return keyOf(info.getServiceId(), info.getUrl()); - } - + @NonNull private static String keyOf(final int serviceId, @NonNull final String url) { return serviceId + url; } @@ -119,6 +121,7 @@ public final class InfoCache { } } + @Nullable private static Info getInfo(@NonNull final LruCache cache, @NonNull final String key) { final CacheData data = cache.get(key); @@ -136,12 +139,8 @@ public final class InfoCache { final private long expireTimestamp; final private Info info; - private CacheData(@NonNull final Info info, - final long timeout, - @NonNull final TimeUnit timeUnit) { - this.expireTimestamp = System.currentTimeMillis() + - TimeUnit.MILLISECONDS.convert(timeout, timeUnit); - + private CacheData(@NonNull final Info info, final long timeoutMillis) { + this.expireTimestamp = System.currentTimeMillis() + timeoutMillis; this.info = info; } From a88e19a8edfdf02d14736571f5ec785c72d0f3b9 Mon Sep 17 00:00:00 2001 From: John Zhen Mo Date: Sat, 3 Mar 2018 14:24:21 -0800 Subject: [PATCH 12/16] -Added toggle to enable fast inexact seek in players. -Improved player sync calls to recognize different metadata updates. -Changed MediaSourceManager to synchronize only after timeline changes and reenabled multiple sync calls to player. -Renamed listener and synchronization methods related to MediaSourceManager. --- .../newpipe/player/BackgroundPlayer.java | 13 ++-- .../org/schabi/newpipe/player/BasePlayer.java | 58 ++++++++++------ .../newpipe/player/MainVideoPlayer.java | 39 +++++------ .../newpipe/player/PopupVideoPlayer.java | 16 +++-- .../schabi/newpipe/player/VideoPlayer.java | 7 +- .../newpipe/player/helper/PlayerHelper.java | 11 +++ .../player/playback/MediaSourceManager.java | 68 +++++++++---------- .../player/playback/PlaybackListener.java | 12 ++-- app/src/main/res/values/settings_keys.xml | 1 + app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/video_audio_settings.xml | 5 ++ 11 files changed, 132 insertions(+), 100 deletions(-) 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 f002115f8..f688b3aa8 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java @@ -380,11 +380,10 @@ public final class BackgroundPlayer extends Service { // Playback Listener //////////////////////////////////////////////////////////////////////////*/ - @Override - public void sync(@NonNull final PlayQueueItem item, @Nullable final StreamInfo info) { - if (currentItem == item && currentInfo == info) return; - super.sync(item, info); - + protected void onMetadataChanged(@NonNull final PlayQueueItem item, + @Nullable final StreamInfo info, + final int newPlayQueueIndex, + final boolean hasPlayQueueItemChanged) { resetNotification(); updateNotification(-1); updateMetadata(); @@ -405,8 +404,8 @@ public final class BackgroundPlayer extends Service { } @Override - public void shutdown() { - super.shutdown(); + public void onPlaybackShutdown() { + super.onPlaybackShutdown(); onClose(); } 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 86a4d1234..bf661c897 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -61,6 +61,7 @@ import org.schabi.newpipe.history.HistoryRecordManager; import org.schabi.newpipe.player.helper.AudioReactor; import org.schabi.newpipe.player.helper.LoadController; import org.schabi.newpipe.player.helper.PlayerDataSource; +import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.playback.CustomTrackSelector; import org.schabi.newpipe.player.playback.MediaSourceManager; import org.schabi.newpipe.player.playback.PlaybackListener; @@ -196,6 +197,7 @@ public abstract class BasePlayer implements simpleExoPlayer.addListener(this); simpleExoPlayer.setPlayWhenReady(true); + simpleExoPlayer.setSeekParameters(PlayerHelper.getSeekParameters(context)); } public void initListeners() {} @@ -688,7 +690,7 @@ public abstract class BasePlayer implements break; default: showUnrecoverableError(error); - shutdown(); + onPlaybackShutdown(); break; } } @@ -774,9 +776,9 @@ public abstract class BasePlayer implements //////////////////////////////////////////////////////////////////////////*/ @Override - public void block() { + public void onPlaybackBlock() { if (simpleExoPlayer == null) return; - if (DEBUG) Log.d(TAG, "Playback - block() called"); + if (DEBUG) Log.d(TAG, "Playback - onPlaybackBlock() called"); currentItem = null; currentInfo = null; @@ -787,9 +789,9 @@ public abstract class BasePlayer implements } @Override - public void unblock(final MediaSource mediaSource) { + public void onPlaybackUnblock(final MediaSource mediaSource) { if (simpleExoPlayer == null) return; - if (DEBUG) Log.d(TAG, "Playback - unblock() called"); + if (DEBUG) Log.d(TAG, "Playback - onPlaybackUnblock() called"); if (getCurrentState() == STATE_BLOCKED) changeState(STATE_BUFFERING); @@ -798,34 +800,50 @@ public abstract class BasePlayer implements } @Override - public void sync(@NonNull final PlayQueueItem item, - @Nullable final StreamInfo info) { - if (currentItem == item && currentInfo == info) return; - currentItem = item; - currentInfo = info; - - if (DEBUG) Log.d(TAG, "Playback - sync() called with " + + public void onPlaybackSynchronize(@NonNull final PlayQueueItem item, + @Nullable final StreamInfo info) { + if (DEBUG) Log.d(TAG, "Playback - onPlaybackSynchronize() called with " + (info == null ? "available" : "null") + " info, " + "item=[" + item.getTitle() + "], url=[" + item.getUrl() + "]"); - if (simpleExoPlayer == null) return; + + final boolean hasPlayQueueItemChanged = currentItem != item; + final boolean hasStreamInfoChanged = currentInfo != info; + if (!hasPlayQueueItemChanged && !hasStreamInfoChanged) { + return; // Nothing to synchronize + } + + currentItem = item; + currentInfo = info; + if (hasPlayQueueItemChanged) { + // updates only to the stream info should not trigger another view count + registerView(); + initThumbnail(info == null ? item.getThumbnailUrl() : info.getThumbnailUrl()); + } + + final int currentSourceIndex = playQueue.indexOf(item); + onMetadataChanged(item, info, currentSourceIndex, hasPlayQueueItemChanged); // Check if on wrong window - final int currentSourceIndex = playQueue.indexOf(item); + if (simpleExoPlayer == null) return; if (currentSourceIndex != playQueue.getIndex()) { Log.e(TAG, "Play Queue may be desynchronized: item index=[" + currentSourceIndex + "], queue index=[" + playQueue.getIndex() + "]"); - } else if (simpleExoPlayer.getCurrentPeriodIndex() != currentSourceIndex || !isPlaying()) { + + // on metadata changed + } else if (simpleExoPlayer.getCurrentWindowIndex() != currentSourceIndex || !isPlaying()) { final long startPos = info != null ? info.start_position : 0; if (DEBUG) Log.d(TAG, "Rewinding to correct window=[" + currentSourceIndex + "]," + " at=[" + getTimeString((int)startPos) + "]," + - " from=[" + simpleExoPlayer.getCurrentPeriodIndex() + "]."); + " from=[" + simpleExoPlayer.getCurrentWindowIndex() + "]."); simpleExoPlayer.seekTo(currentSourceIndex, startPos); } - - registerView(); - initThumbnail(info == null ? item.getThumbnailUrl() : info.thumbnail_url); } + abstract protected void onMetadataChanged(@NonNull final PlayQueueItem item, + @Nullable final StreamInfo info, + final int newPlayQueueIndex, + final boolean hasPlayQueueItemChanged); + @Nullable @Override public MediaSource sourceOf(PlayQueueItem item, StreamInfo info) { @@ -839,7 +857,7 @@ public abstract class BasePlayer implements } @Override - public void shutdown() { + public void onPlaybackShutdown() { if (DEBUG) Log.d(TAG, "Shutting down..."); destroy(); } diff --git a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java index d48994d0f..04d93bbdc 100644 --- a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java @@ -391,31 +391,32 @@ public final class MainVideoPlayer extends Activity { updatePlaybackButtons(); } - /*////////////////////////////////////////////////////////////////////////// - // Playback Listener - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void shutdown() { - super.shutdown(); - finish(); - } - - @Override - public void sync(@NonNull final PlayQueueItem item, @Nullable final StreamInfo info) { - super.sync(item, info); - titleTextView.setText(getVideoTitle()); - channelTextView.setText(getUploaderName()); - - //playPauseButton.setImageResource(R.drawable.ic_pause_white); - } - @Override public void onShuffleClicked() { super.onShuffleClicked(); updatePlaybackButtons(); } + /*////////////////////////////////////////////////////////////////////////// + // Playback Listener + //////////////////////////////////////////////////////////////////////////*/ + + protected void onMetadataChanged(@NonNull final PlayQueueItem item, + @Nullable final StreamInfo info, + final int newPlayQueueIndex, + final boolean hasPlayQueueItemChanged) { + super.onMetadataChanged(item, info, newPlayQueueIndex, false); + + titleTextView.setText(getVideoTitle()); + channelTextView.setText(getUploaderName()); + } + + @Override + public void onPlaybackShutdown() { + super.onPlaybackShutdown(); + finish(); + } + /*////////////////////////////////////////////////////////////////////////// // Player Overrides //////////////////////////////////////////////////////////////////////////*/ diff --git a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java index 6263541bb..80063e547 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java @@ -535,7 +535,8 @@ public final class PopupVideoPlayer extends Service { private void updatePlayback() { if (activityListener != null && simpleExoPlayer != null && playQueue != null) { - activityListener.onPlaybackUpdate(currentState, getRepeatMode(), playQueue.isShuffled(), simpleExoPlayer.getPlaybackParameters()); + activityListener.onPlaybackUpdate(currentState, getRepeatMode(), + playQueue.isShuffled(), simpleExoPlayer.getPlaybackParameters()); } } @@ -574,16 +575,17 @@ public final class PopupVideoPlayer extends Service { // Playback Listener //////////////////////////////////////////////////////////////////////////*/ - @Override - public void sync(@NonNull PlayQueueItem item, @Nullable StreamInfo info) { - if (currentItem == item && currentInfo == info) return; - super.sync(item, info); + protected void onMetadataChanged(@NonNull final PlayQueueItem item, + @Nullable final StreamInfo info, + final int newPlayQueueIndex, + final boolean hasPlayQueueItemChanged) { + super.onMetadataChanged(item, info, newPlayQueueIndex, false); updateMetadata(); } @Override - public void shutdown() { - super.shutdown(); + public void onPlaybackShutdown() { + super.onPlaybackShutdown(); onClose(); } 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 58de44130..5b4a1f80b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java @@ -324,9 +324,10 @@ public abstract class VideoPlayer extends BasePlayer protected abstract int getOverrideResolutionIndex(final List sortedVideos, final String playbackQuality); - @Override - public void sync(@NonNull final PlayQueueItem item, @Nullable final StreamInfo info) { - super.sync(item, info); + protected void onMetadataChanged(@NonNull final PlayQueueItem item, + @Nullable final StreamInfo info, + final int newPlayQueueIndex, + final boolean hasPlayQueueItemChanged) { qualityTextView.setVisibility(View.GONE); playbackSpeedTextView.setVisibility(View.GONE); 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 813c69c22..553163d21 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 @@ -5,6 +5,7 @@ import android.content.SharedPreferences; import android.preference.PreferenceManager; import android.support.annotation.NonNull; +import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; import com.google.android.exoplayer2.util.MimeTypes; @@ -109,6 +110,12 @@ public class PlayerHelper { return isRememberingPopupDimensions(context, true); } + @NonNull + public static SeekParameters getSeekParameters(@NonNull final Context context) { + return isUsingInexactSeek(context, false) ? + SeekParameters.CLOSEST_SYNC : SeekParameters.EXACT; + } + public static long getPreferredCacheSize(@NonNull final Context context) { return 64 * 1024 * 1024L; } @@ -176,4 +183,8 @@ public class PlayerHelper { private static boolean isRememberingPopupDimensions(@Nonnull final Context context, final boolean b) { return getPreferences(context).getBoolean(context.getString(R.string.popup_remember_size_pos_key), b); } + + private static boolean isUsingInexactSeek(@NonNull final Context context, final boolean b) { + return getPreferences(context).getBoolean(context.getString(R.string.use_inexact_seek_key), b); + } } 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 bc7f92b42..3f7a8522c 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 @@ -50,7 +50,7 @@ public class MediaSourceManager { * streams before will only be cached for future usage. * * @see #onMediaSourceReceived(PlayQueueItem, ManagedMediaSource) - * @see #update(int, MediaSource) + * @see #update(int, MediaSource, Runnable) * */ private final static int WINDOW_SIZE = 1; @@ -95,15 +95,13 @@ public class MediaSourceManager { * */ private final static int MAXIMUM_LOADER_SIZE = WINDOW_SIZE * 2 + 1; @NonNull private final CompositeDisposable loaderReactor; - @NonNull private Set loadingItems; + @NonNull private final Set loadingItems; @NonNull private final SerialDisposable syncReactor; @NonNull private final AtomicBoolean isBlocked; @NonNull private DynamicConcatenatingMediaSource sources; - @Nullable private PlayQueueItem syncedItem; - public MediaSourceManager(@NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue) { this(listener, playQueue, @@ -159,8 +157,6 @@ public class MediaSourceManager { loaderReactor.dispose(); syncReactor.dispose(); sources.releaseSource(); - - syncedItem = null; } /** @@ -182,9 +178,7 @@ public class MediaSourceManager { public void reset() { if (DEBUG) Log.d(TAG, "reset() called."); - tryBlock(); - - syncedItem = null; + maybeBlock(); populateSources(); } /*////////////////////////////////////////////////////////////////////////// @@ -215,7 +209,7 @@ public class MediaSourceManager { private void onPlayQueueChanged(final PlayQueueEvent event) { if (playQueue.isEmpty() && playQueue.isComplete()) { - playbackListener.shutdown(); + playbackListener.onPlaybackShutdown(); return; } @@ -261,7 +255,7 @@ public class MediaSourceManager { } if (!isPlayQueueReady()) { - tryBlock(); + maybeBlock(); playQueue.fetch(); } playQueueReactor.request(1); @@ -290,23 +284,23 @@ public class MediaSourceManager { return false; } - private void tryBlock() { - if (DEBUG) Log.d(TAG, "tryBlock() called."); + private void maybeBlock() { + if (DEBUG) Log.d(TAG, "maybeBlock() called."); if (isBlocked.get()) return; - playbackListener.block(); + playbackListener.onPlaybackBlock(); resetSources(); isBlocked.set(true); } - private void tryUnblock() { - if (DEBUG) Log.d(TAG, "tryUnblock() called."); + private void maybeUnblock() { + if (DEBUG) Log.d(TAG, "maybeUnblock() called."); if (isPlayQueueReady() && isPlaybackReady() && isBlocked.get()) { isBlocked.set(false); - playbackListener.unblock(sources); + playbackListener.onPlaybackUnblock(sources); } } @@ -314,8 +308,8 @@ public class MediaSourceManager { // Metadata Synchronization TODO: maybe this should be a separate manager //////////////////////////////////////////////////////////////////////////*/ - private void sync() { - if (DEBUG) Log.d(TAG, "sync() called."); + private void maybeSync() { + if (DEBUG) Log.d(TAG, "onPlaybackSynchronize() called."); final PlayQueueItem currentItem = playQueue.getItem(); if (isBlocked.get() || currentItem == null) return; @@ -323,23 +317,25 @@ public class MediaSourceManager { 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); - } + final Disposable sync = currentItem.getStream() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(onSuccess, onError); + syncReactor.set(sync); } private void syncInternal(@NonNull final PlayQueueItem item, @Nullable final StreamInfo info) { // Ensure the current item is up to date with the play queue - if (playQueue.getItem() == item && playQueue.getItem() == syncedItem) { - playbackListener.sync(item, info); + if (playQueue.getItem() == item) { + playbackListener.onPlaybackSynchronize(item, info); } } + private void maybeSynchronizePlayer() { + maybeUnblock(); + maybeSync(); + } + /*////////////////////////////////////////////////////////////////////////// // MediaSource Loading //////////////////////////////////////////////////////////////////////////*/ @@ -404,8 +400,7 @@ public class MediaSourceManager { loaderReactor.add(loader); } - tryUnblock(); - sync(); + maybeSynchronizePlayer(); } private Single getLoadedMediaSource(@NonNull final PlayQueueItem stream) { @@ -431,17 +426,15 @@ public class MediaSourceManager { if (DEBUG) Log.d(TAG, "MediaSource - Loaded: [" + item.getTitle() + "] with url: " + item.getUrl()); + loadingItems.remove(item); + final int itemIndex = playQueue.indexOf(item); // Only update the playlist timeline for items at the current index or after. if (itemIndex >= playQueue.getIndex() && isCorrectionNeeded(item)) { if (DEBUG) Log.d(TAG, "MediaSource - Updating: [" + item.getTitle() + "] with url: " + item.getUrl()); - update(itemIndex, mediaSource); + update(itemIndex, mediaSource, this::maybeSynchronizePlayer); } - - loadingItems.remove(item); - tryUnblock(); - sync(); } /** @@ -533,10 +526,11 @@ public class MediaSourceManager { * this will modify the playback timeline prior to the index and may cause desynchronization * on the playing item between {@link PlayQueue} and {@link DynamicConcatenatingMediaSource}. * */ - private synchronized void update(final int index, @NonNull final MediaSource source) { + private synchronized void update(final int index, @NonNull final MediaSource source, + @Nullable final Runnable finalizingAction) { if (index < 0 || index >= sources.getSize()) return; sources.addMediaSource(index + 1, source, () -> - sources.removeMediaSource(index)); + sources.removeMediaSource(index, finalizingAction)); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java b/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java index c6fdde656..b37a269e2 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java @@ -18,7 +18,7 @@ public interface PlaybackListener { * * May be called at any time. * */ - void block(); + void onPlaybackBlock(); /** * Called when the stream at the current queue index is ready. @@ -26,18 +26,16 @@ public interface PlaybackListener { * * May be called only when the player is blocked. * */ - void unblock(final MediaSource mediaSource); + void onPlaybackUnblock(final MediaSource mediaSource); /** * Called when the queue index is refreshed. * Signals to the listener to synchronize the player's window to the manager's * window. * - * Occurs once only per play queue item change. - * - * May be called only after unblock is called. + * May be called anytime at any amount once unblock is called. * */ - void sync(@NonNull final PlayQueueItem item, @Nullable final StreamInfo info); + void onPlaybackSynchronize(@NonNull final PlayQueueItem item, @Nullable final StreamInfo info); /** * Requests the listener to resolve a stream info into a media source @@ -55,5 +53,5 @@ public interface PlaybackListener { * * May be called at any time. * */ - void shutdown(); + void onPlaybackShutdown(); } diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index fc31ee02c..c207ed712 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -20,6 +20,7 @@ player_gesture_controls resume_on_audio_focus_gain popup_remember_size_pos_key + use_inexact_seek_key default_resolution 360p diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9dd5dcddb..6a8123bde 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -72,6 +72,8 @@ Black Remember popup size and position Remember last size and position of popup + Use fast inexact seek + Inexact seek allows the player to seek to positions faster with reduced precision Player gesture controls Use gestures to control the brightness and volume of the player Search suggestions diff --git a/app/src/main/res/xml/video_audio_settings.xml b/app/src/main/res/xml/video_audio_settings.xml index 7551834a2..e17b46ebc 100644 --- a/app/src/main/res/xml/video_audio_settings.xml +++ b/app/src/main/res/xml/video_audio_settings.xml @@ -100,5 +100,10 @@ android:summary="@string/popup_remember_size_pos_summary" android:title="@string/popup_remember_size_pos_title"/> + From 59558efed12f19cd99dd607d5d60901717b0d3b2 Mon Sep 17 00:00:00 2001 From: John Zhen Mo Date: Sat, 3 Mar 2018 20:58:06 -0800 Subject: [PATCH 13/16] -Added seamless shuffling. -Reenabled full window loading in MediaSourceManager. --- .../newpipe/player/BackgroundPlayer.java | 11 +++++--- .../org/schabi/newpipe/player/BasePlayer.java | 18 ++++++------- .../player/playback/MediaSourceManager.java | 26 ++++++++++++------- .../schabi/newpipe/playlist/PlayQueue.java | 6 +++-- .../newpipe/playlist/events/ReorderEvent.java | 14 +++++++++- 5 files changed, 50 insertions(+), 25 deletions(-) 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 f688b3aa8..a43f434ff 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java @@ -384,9 +384,11 @@ public final class BackgroundPlayer extends Service { @Nullable final StreamInfo info, final int newPlayQueueIndex, final boolean hasPlayQueueItemChanged) { - resetNotification(); - updateNotification(-1); - updateMetadata(); + if (shouldUpdateOnProgress || hasPlayQueueItemChanged) { + resetNotification(); + updateNotification(-1); + updateMetadata(); + } } @Override @@ -434,7 +436,8 @@ public final class BackgroundPlayer extends Service { private void updatePlayback() { if (activityListener != null && simpleExoPlayer != null && playQueue != null) { - activityListener.onPlaybackUpdate(currentState, getRepeatMode(), playQueue.isShuffled(), getPlaybackParameters()); + activityListener.onPlaybackUpdate(currentState, getRepeatMode(), + playQueue.isShuffled(), getPlaybackParameters()); } } 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 bf661c897..f89bd2630 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -494,14 +494,8 @@ public abstract class BasePlayer implements public void onShuffleClicked() { if (DEBUG) Log.d(TAG, "onShuffleClicked() called"); - if (playQueue == null) return; - - setRecovery(); - if (playQueue.isShuffled()) { - playQueue.unshuffle(); - } else { - playQueue.shuffle(); - } + if (simpleExoPlayer == null) return; + simpleExoPlayer.setShuffleModeEnabled(!simpleExoPlayer.getShuffleModeEnabled()); } /*////////////////////////////////////////////////////////////////////////// @@ -765,6 +759,12 @@ public abstract class BasePlayer implements public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { if (DEBUG) Log.d(TAG, "ExoPlayer - onShuffleModeEnabledChanged() called with: " + "mode = [" + shuffleModeEnabled + "]"); + if (playQueue == null) return; + if (shuffleModeEnabled) { + playQueue.shuffle(); + } else { + playQueue.unshuffle(); + } } @Override @@ -803,7 +803,7 @@ public abstract class BasePlayer implements public void onPlaybackSynchronize(@NonNull final PlayQueueItem item, @Nullable final StreamInfo info) { if (DEBUG) Log.d(TAG, "Playback - onPlaybackSynchronize() called with " + - (info == null ? "available" : "null") + " info, " + + (info != null ? "available" : "null") + " info, " + "item=[" + item.getTitle() + "], url=[" + item.getUrl() + "]"); final boolean hasPlayQueueItemChanged = currentItem != item; 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 3f7a8522c..cb803dcd1 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 @@ -6,6 +6,7 @@ import android.util.Log; import com.google.android.exoplayer2.source.DynamicConcatenatingMediaSource; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ShuffleOrder; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; @@ -19,6 +20,7 @@ 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 org.schabi.newpipe.playlist.events.ReorderEvent; import java.util.ArrayList; import java.util.Collections; @@ -216,7 +218,6 @@ public class MediaSourceManager { // Event specific action switch (event.type()) { case INIT: - case REORDER: case ERROR: reset(); break; @@ -231,6 +232,12 @@ public class MediaSourceManager { final MoveEvent moveEvent = (MoveEvent) event; move(moveEvent.getFromIndex(), moveEvent.getToIndex()); break; + case REORDER: + // Need to move to ensure the playing index from play queue matches that of + // the source timeline, and then window correction can take care of the rest + final ReorderEvent reorderEvent = (ReorderEvent) event; + move(reorderEvent.getFromSelectedIndex(), reorderEvent.getToSelectedIndex()); + break; case SELECT: case RECOVERY: default: @@ -305,7 +312,7 @@ public class MediaSourceManager { } /*////////////////////////////////////////////////////////////////////////// - // Metadata Synchronization TODO: maybe this should be a separate manager + // Metadata Synchronization //////////////////////////////////////////////////////////////////////////*/ private void maybeSync() { @@ -389,8 +396,8 @@ public class MediaSourceManager { if (playQueue.indexOf(item) >= sources.getSize()) return; if (!loadingItems.contains(item) && isCorrectionNeeded(item)) { - if (DEBUG) Log.d(TAG, "MediaSource - Loading: [" + item.getTitle() + - "] with url: " + item.getUrl()); + if (DEBUG) Log.d(TAG, "MediaSource - Loading=[" + item.getTitle() + + "] with url=[" + item.getUrl() + "]"); loadingItems.add(item); final Disposable loader = getLoadedMediaSource(item) @@ -423,16 +430,16 @@ public class MediaSourceManager { private void onMediaSourceReceived(@NonNull final PlayQueueItem item, @NonNull final ManagedMediaSource mediaSource) { - if (DEBUG) Log.d(TAG, "MediaSource - Loaded: [" + item.getTitle() + - "] with url: " + item.getUrl()); + if (DEBUG) Log.d(TAG, "MediaSource - Loaded=[" + item.getTitle() + + "] with url=[" + item.getUrl() + "]"); loadingItems.remove(item); final int itemIndex = playQueue.indexOf(item); // Only update the playlist timeline for items at the current index or after. if (itemIndex >= playQueue.getIndex() && isCorrectionNeeded(item)) { - if (DEBUG) Log.d(TAG, "MediaSource - Updating: [" + item.getTitle() + - "] with url: " + item.getUrl()); + if (DEBUG) Log.d(TAG, "MediaSource - Updating index=[" + itemIndex + "] with " + + "title=[" + item.getTitle() + "] at url=[" + item.getUrl() + "]"); update(itemIndex, mediaSource, this::maybeSynchronizePlayer); } } @@ -468,7 +475,8 @@ public class MediaSourceManager { if (DEBUG) Log.d(TAG, "resetSources() called."); this.sources.releaseSource(); - this.sources = new DynamicConcatenatingMediaSource(); + this.sources = new DynamicConcatenatingMediaSource(false, + new ShuffleOrder.UnshuffledShuffleOrder(0)); } private void populateSources() { diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java index 8f4c5913d..19e6dc63d 100644 --- a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueue.java @@ -351,6 +351,7 @@ public abstract class PlayQueue implements Serializable { if (backup == null) { backup = new ArrayList<>(streams); } + final int originIndex = getIndex(); final PlayQueueItem current = getItem(); Collections.shuffle(streams); @@ -360,7 +361,7 @@ public abstract class PlayQueue implements Serializable { } queueIndex.set(0); - broadcast(new ReorderEvent()); + broadcast(new ReorderEvent(originIndex, queueIndex.get())); } /** @@ -373,6 +374,7 @@ public abstract class PlayQueue implements Serializable { * */ public synchronized void unshuffle() { if (backup == null) return; + final int originIndex = getIndex(); final PlayQueueItem current = getItem(); streams.clear(); @@ -386,7 +388,7 @@ public abstract class PlayQueue implements Serializable { queueIndex.set(0); } - broadcast(new ReorderEvent()); + broadcast(new ReorderEvent(originIndex, queueIndex.get())); } /*////////////////////////////////////////////////////////////////////////// diff --git a/app/src/main/java/org/schabi/newpipe/playlist/events/ReorderEvent.java b/app/src/main/java/org/schabi/newpipe/playlist/events/ReorderEvent.java index f1d09d457..19bb632d8 100644 --- a/app/src/main/java/org/schabi/newpipe/playlist/events/ReorderEvent.java +++ b/app/src/main/java/org/schabi/newpipe/playlist/events/ReorderEvent.java @@ -1,12 +1,24 @@ package org.schabi.newpipe.playlist.events; public class ReorderEvent implements PlayQueueEvent { + private final int fromSelectedIndex; + private final int toSelectedIndex; + @Override public PlayQueueEventType type() { return PlayQueueEventType.REORDER; } - public ReorderEvent() { + public ReorderEvent(final int fromSelectedIndex, final int toSelectedIndex) { + this.fromSelectedIndex = fromSelectedIndex; + this.toSelectedIndex = toSelectedIndex; + } + public int getFromSelectedIndex() { + return fromSelectedIndex; + } + + public int getToSelectedIndex() { + return toSelectedIndex; } } From 7f068b691b7516629feda0e149e759d303b106e9 Mon Sep 17 00:00:00 2001 From: John Zhen Mo Date: Sat, 3 Mar 2018 20:58:53 -0800 Subject: [PATCH 14/16] -Removed system ui on main player for Kitkat or above. -[#1151] Hide video player UI on playing to avoid unnecessary interruptions after pause, seek and resize. --- .../org/schabi/newpipe/player/BasePlayer.java | 6 +- .../newpipe/player/MainVideoPlayer.java | 114 ++++++++++++------ .../newpipe/player/PopupVideoPlayer.java | 9 +- .../schabi/newpipe/player/VideoPlayer.java | 32 ++--- .../main/res/layout/activity_main_player.xml | 1 + 5 files changed, 99 insertions(+), 63 deletions(-) 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 f89bd2630..58047639c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -449,15 +449,13 @@ public abstract class BasePlayer implements if (!isCurrentWindowValid()) seekToDefault(); } - public void onBuffering() { - } + public void onBuffering() {} public void onPaused() { if (isProgressLoopRunning()) stopProgressLoop(); } - public void onPausedSeek() { - } + public void onPausedSeek() {} public void onCompleted() { if (DEBUG) Log.d(TAG, "onCompleted() called"); diff --git a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java index 04d93bbdc..0bfdcd32a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java @@ -69,6 +69,9 @@ import org.schabi.newpipe.util.ThemeHelper; import java.util.List; +import static org.schabi.newpipe.player.BasePlayer.STATE_PLAYING; +import static org.schabi.newpipe.player.VideoPlayer.DEFAULT_CONTROLS_DURATION; +import static org.schabi.newpipe.player.VideoPlayer.DEFAULT_CONTROLS_HIDE_TIME; import static org.schabi.newpipe.util.AnimationUtils.animateView; /** @@ -114,7 +117,7 @@ public final class MainVideoPlayer extends Activity { return; } - showSystemUi(); + changeSystemUi(); setContentView(R.layout.activity_main_player); playerImpl = new VideoPlayerImpl(this); playerImpl.setup(findViewById(android.R.id.content)); @@ -206,31 +209,53 @@ public final class MainVideoPlayer extends Activity { // Utils //////////////////////////////////////////////////////////////////////////*/ + /** + * Prior to Kitkat, hiding system ui causes the player view to be overlaid and require two + * clicks to get rid of that invisible overlay. By showing the system UI on actions/events, + * that overlay is removed and the player view is put to the foreground. + * + * Post Kitkat, navbar and status bar can be pulled out by swiping the edge of + * screen, therefore, we can do nothing or hide the UI on actions/events. + * */ + private void changeSystemUi() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + showSystemUi(); + } else { + hideSystemUi(); + } + } + private void showSystemUi() { if (DEBUG) Log.d(TAG, "showSystemUi() called"); if (playerImpl != null && playerImpl.queueVisible) return; + + final int visibility; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { - getWindow().getDecorView().setSystemUiVisibility( - View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - ); - } else getWindow().getDecorView().setSystemUiVisibility(0); + visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION; + } else { + visibility = View.STATUS_BAR_VISIBLE; + } + getWindow().getDecorView().setSystemUiVisibility(visibility); getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); } private void hideSystemUi() { if (DEBUG) Log.d(TAG, "hideSystemUi() called"); - if (android.os.Build.VERSION.SDK_INT >= 16) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_FULLSCREEN - | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN; - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) visibility |= View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + | View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + visibility |= View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + } getWindow().getDecorView().setSystemUiVisibility(visibility); } - getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); + getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN); } private void toggleOrientation() { @@ -307,6 +332,7 @@ public final class MainVideoPlayer extends Activity { private ImageButton switchPopupButton; private ImageButton switchBackgroundButton; + private RelativeLayout windowRootLayout; private View secondaryControls; VideoPlayerImpl(final Context context) { @@ -334,6 +360,19 @@ public final class MainVideoPlayer extends Activity { this.switchBackgroundButton = rootView.findViewById(R.id.switchBackground); this.switchPopupButton = rootView.findViewById(R.id.switchPopup); + this.queueLayout = findViewById(R.id.playQueuePanel); + this.itemsListCloseButton = findViewById(R.id.playQueueClose); + this.itemsList = findViewById(R.id.playQueue); + + this.windowRootLayout = rootView.findViewById(R.id.playbackWindowRoot); + // Prior to Kitkat, there is no way of setting translucent navbar programmatically. + // Thus, fit system windows is opted instead. + // See https://stackoverflow.com/questions/29069070/completely-transparent-status-bar-and-navigation-bar-on-lollipop + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + windowRootLayout.setFitsSystemWindows(false); + windowRootLayout.invalidate(); + } + titleTextView.setSelected(true); channelTextView.setSelected(true); @@ -509,9 +548,9 @@ public final class MainVideoPlayer extends Activity { if (getCurrentState() != STATE_COMPLETED) { getControlsVisibilityHandler().removeCallbacksAndMessages(null); - animateView(getControlsRoot(), true, 300, 0, () -> { + animateView(getControlsRoot(), true, DEFAULT_CONTROLS_DURATION, 0, () -> { if (getCurrentState() == STATE_PLAYING && !isSomePopupMenuVisible()) { - hideControls(300, DEFAULT_CONTROLS_HIDE_TIME); + hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); } }); } @@ -547,7 +586,7 @@ public final class MainVideoPlayer extends Activity { R.drawable.ic_expand_less_white_24dp)); animateView(secondaryControls, true, 200); } - showControls(300); + showControls(DEFAULT_CONTROLS_DURATION); } private void onScreenRotationClicked() { @@ -559,15 +598,13 @@ public final class MainVideoPlayer extends Activity { @Override public void onStopTrackingTouch(SeekBar seekBar) { super.onStopTrackingTouch(seekBar); - if (wasPlaying()) { - hideControls(100, 0); - } + if (wasPlaying()) showControlsThenHide(); } @Override public void onDismiss(PopupMenu menu) { super.onDismiss(menu); - if (isPlaying()) hideControls(300, 0); + if (isPlaying()) hideControls(DEFAULT_CONTROLS_DURATION, 0); } @Override @@ -625,7 +662,8 @@ public final class MainVideoPlayer extends Activity { playPauseButton.setImageResource(R.drawable.ic_pause_white); animatePlayButtons(true, 200); }); - showSystemUi(); + + changeSystemUi(); getRootView().setKeepScreenOn(true); } @@ -637,7 +675,7 @@ public final class MainVideoPlayer extends Activity { animatePlayButtons(true, 200); }); - showSystemUi(); + changeSystemUi(); getRootView().setKeepScreenOn(false); } @@ -651,10 +689,9 @@ public final class MainVideoPlayer extends Activity { @Override public void onCompleted() { - showSystemUi(); animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, false, 0, 0, () -> { playPauseButton.setImageResource(R.drawable.ic_replay_white); - animatePlayButtons(true, 300); + animatePlayButtons(true, DEFAULT_CONTROLS_DURATION); }); getRootView().setKeepScreenOn(false); @@ -684,8 +721,9 @@ public final class MainVideoPlayer extends Activity { if (DEBUG) Log.d(TAG, "hideControls() called with: delay = [" + delay + "]"); getControlsVisibilityHandler().removeCallbacksAndMessages(null); getControlsVisibilityHandler().postDelayed(() -> - animateView(getControlsRoot(), false, duration, 0, MainVideoPlayer.this::hideSystemUi), - delay + animateView(getControlsRoot(), false, duration, 0, + MainVideoPlayer.this::hideSystemUi), + /*delayMillis=*/delay ); } @@ -698,11 +736,6 @@ public final class MainVideoPlayer extends Activity { } private void buildQueue() { - queueLayout = findViewById(R.id.playQueuePanel); - - itemsListCloseButton = findViewById(R.id.playQueueClose); - - itemsList = findViewById(R.id.playQueue); itemsList.setAdapter(playQueueAdapter); itemsList.setClickable(true); itemsList.setLongClickable(true); @@ -831,10 +864,11 @@ public final class MainVideoPlayer extends Activity { if (DEBUG) Log.d(TAG, "onSingleTapConfirmed() called with: e = [" + e + "]"); if (playerImpl.getCurrentState() == BasePlayer.STATE_BLOCKED) return true; - if (playerImpl.isControlsVisible()) playerImpl.hideControls(150, 0); - else { + if (playerImpl.isControlsVisible()) { + playerImpl.hideControls(150, 0); + } else { playerImpl.showControlsThenHide(); - showSystemUi(); + changeSystemUi(); } return true; } @@ -917,11 +951,15 @@ public final class MainVideoPlayer extends Activity { eventsNum = 0; /* if (playerImpl.getVolumeTextView().getVisibility() == View.VISIBLE) playerImpl.getVolumeTextView().setVisibility(View.GONE); if (playerImpl.getBrightnessTextView().getVisibility() == View.VISIBLE) playerImpl.getBrightnessTextView().setVisibility(View.GONE);*/ - if (playerImpl.getVolumeTextView().getVisibility() == View.VISIBLE) animateView(playerImpl.getVolumeTextView(), false, 200, 200); - if (playerImpl.getBrightnessTextView().getVisibility() == View.VISIBLE) animateView(playerImpl.getBrightnessTextView(), false, 200, 200); + if (playerImpl.getVolumeTextView().getVisibility() == View.VISIBLE) { + animateView(playerImpl.getVolumeTextView(), false, 200, 200); + } + if (playerImpl.getBrightnessTextView().getVisibility() == View.VISIBLE) { + animateView(playerImpl.getBrightnessTextView(), false, 200, 200); + } - if (playerImpl.isControlsVisible() && playerImpl.getCurrentState() == BasePlayer.STATE_PLAYING) { - playerImpl.hideControls(300, VideoPlayer.DEFAULT_CONTROLS_HIDE_TIME); + if (playerImpl.isControlsVisible() && playerImpl.getCurrentState() == STATE_PLAYING) { + playerImpl.hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java index 80063e547..123fbfee3 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java @@ -70,6 +70,9 @@ import org.schabi.newpipe.util.ThemeHelper; import java.util.List; +import static org.schabi.newpipe.player.BasePlayer.STATE_PLAYING; +import static org.schabi.newpipe.player.VideoPlayer.DEFAULT_CONTROLS_DURATION; +import static org.schabi.newpipe.player.VideoPlayer.DEFAULT_CONTROLS_HIDE_TIME; import static org.schabi.newpipe.player.helper.PlayerHelper.isUsingOldPlayer; import static org.schabi.newpipe.util.AnimationUtils.animateView; @@ -650,6 +653,8 @@ public final class PopupVideoPlayer extends Service { super.onPlaying(); updateNotification(R.drawable.ic_pause_white); lockManager.acquireWifiAndCpu(); + + hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); } @Override @@ -782,8 +787,8 @@ public final class PopupVideoPlayer extends Service { private void onScrollEnd() { if (DEBUG) Log.d(TAG, "onScrollEnd() called"); if (playerImpl == null) return; - if (playerImpl.isControlsVisible() && playerImpl.getCurrentState() == BasePlayer.STATE_PLAYING) { - playerImpl.hideControls(300, VideoPlayer.DEFAULT_CONTROLS_HIDE_TIME); + if (playerImpl.isControlsVisible() && playerImpl.getCurrentState() == STATE_PLAYING) { + playerImpl.hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); } } 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 5b4a1f80b..aa90b7b88 100644 --- a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java @@ -101,6 +101,7 @@ public abstract class VideoPlayer extends BasePlayer //////////////////////////////////////////////////////////////////////////*/ protected static final int RENDERER_UNAVAILABLE = -1; + public static final int DEFAULT_CONTROLS_DURATION = 300; // 300 millis public static final int DEFAULT_CONTROLS_HIDE_TIME = 2000; // 2 Seconds private ArrayList availableStreams; @@ -450,7 +451,7 @@ public abstract class VideoPlayer extends BasePlayer super.onBlocked(); controlsVisibilityHandler.removeCallbacksAndMessages(null); - animateView(controlsRoot, false, 300); + animateView(controlsRoot, false, DEFAULT_CONTROLS_DURATION); playbackSeekBar.setEnabled(false); // Bug on lower api, disabling and enabling the seekBar resets the thumb color -.-, so sets the color again @@ -475,7 +476,7 @@ public abstract class VideoPlayer extends BasePlayer playbackSeekBar.getThumb().setColorFilter(Color.RED, PorterDuff.Mode.SRC_IN); loadingPanel.setVisibility(View.GONE); - showControlsThenHide(); + animateView(currentDisplaySeek, AnimationUtils.Type.SCALE_AND_ALPHA, false, 200); animateView(endScreen, false, 0); } @@ -707,7 +708,7 @@ public abstract class VideoPlayer extends BasePlayer if (DEBUG) Log.d(TAG, "onQualitySelectorClicked() called"); qualityPopupMenu.show(); isSomePopupMenuVisible = true; - showControls(300); + showControls(DEFAULT_CONTROLS_DURATION); final VideoStream videoStream = getSelectedVideoStream(); if (videoStream != null) { @@ -723,14 +724,14 @@ public abstract class VideoPlayer extends BasePlayer if (DEBUG) Log.d(TAG, "onPlaybackSpeedClicked() called"); playbackSpeedPopupMenu.show(); isSomePopupMenuVisible = true; - showControls(300); + showControls(DEFAULT_CONTROLS_DURATION); } private void onCaptionClicked() { if (DEBUG) Log.d(TAG, "onCaptionClicked() called"); captionPopupMenu.show(); isSomePopupMenuVisible = true; - showControls(300); + showControls(DEFAULT_CONTROLS_DURATION); } private void onResizeClicked() { @@ -763,7 +764,8 @@ public abstract class VideoPlayer extends BasePlayer if (isPlaying()) simpleExoPlayer.setPlayWhenReady(false); showControls(0); - animateView(currentDisplaySeek, AnimationUtils.Type.SCALE_AND_ALPHA, true, 300); + animateView(currentDisplaySeek, AnimationUtils.Type.SCALE_AND_ALPHA, true, + DEFAULT_CONTROLS_DURATION); } @Override @@ -819,7 +821,7 @@ public abstract class VideoPlayer extends BasePlayer PropertyValuesHolder.ofFloat(View.ALPHA, 1f, 0f), PropertyValuesHolder.ofFloat(View.SCALE_X, 1.4f, 1f), PropertyValuesHolder.ofFloat(View.SCALE_Y, 1.4f, 1f) - ).setDuration(300); + ).setDuration(DEFAULT_CONTROLS_DURATION); controlViewAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { @@ -861,12 +863,8 @@ public abstract class VideoPlayer extends BasePlayer public void showControlsThenHide() { if (DEBUG) Log.d(TAG, "showControlsThenHide() called"); - animateView(controlsRoot, true, 300, 0, new Runnable() { - @Override - public void run() { - hideControls(300, DEFAULT_CONTROLS_HIDE_TIME); - } - }); + animateView(controlsRoot, true, DEFAULT_CONTROLS_DURATION, 0, + () -> hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME)); } public void showControls(long duration) { @@ -878,12 +876,8 @@ public abstract class VideoPlayer extends BasePlayer public void hideControls(final long duration, long delay) { if (DEBUG) Log.d(TAG, "hideControls() called with: delay = [" + delay + "]"); controlsVisibilityHandler.removeCallbacksAndMessages(null); - controlsVisibilityHandler.postDelayed(new Runnable() { - @Override - public void run() { - animateView(controlsRoot, false, duration); - } - }, delay); + controlsVisibilityHandler.postDelayed( + () -> animateView(controlsRoot, false, duration), delay); } /*////////////////////////////////////////////////////////////////////////// diff --git a/app/src/main/res/layout/activity_main_player.xml b/app/src/main/res/layout/activity_main_player.xml index 9abe43715..e7d337c17 100644 --- a/app/src/main/res/layout/activity_main_player.xml +++ b/app/src/main/res/layout/activity_main_player.xml @@ -134,6 +134,7 @@ tools:visibility="visible"> From d01aeab2425c45471d1f4e03ada84887f64992cd Mon Sep 17 00:00:00 2001 From: John Zhen Mo Date: Sun, 4 Mar 2018 20:16:38 -0800 Subject: [PATCH 15/16] -Added auto-queuing to allow next or related streams to queue up when the last item on play queue is playing. -Added toggle to enable auto-queuing. -Modified main video player to only pause the video onPause. -Fixed main video player not saving play queue state onStop. --- .../org/schabi/newpipe/player/BasePlayer.java | 31 ++++++--- .../newpipe/player/MainVideoPlayer.java | 36 ++++++----- .../newpipe/player/helper/PlayerHelper.java | 64 +++++++++++++++++++ app/src/main/res/values/settings_keys.xml | 1 + app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/content_settings.xml | 7 ++ 6 files changed, 115 insertions(+), 26 deletions(-) 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 58047639c..73d0b9180 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -68,6 +68,7 @@ import org.schabi.newpipe.player.playback.PlaybackListener; import org.schabi.newpipe.playlist.PlayQueue; import org.schabi.newpipe.playlist.PlayQueueAdapter; import org.schabi.newpipe.playlist.PlayQueueItem; +import org.schabi.newpipe.playlist.SinglePlayQueue; import org.schabi.newpipe.util.SerializedCache; import java.io.IOException; @@ -818,22 +819,32 @@ public abstract class BasePlayer implements initThumbnail(info == null ? item.getThumbnailUrl() : info.getThumbnailUrl()); } - final int currentSourceIndex = playQueue.indexOf(item); - onMetadataChanged(item, info, currentSourceIndex, hasPlayQueueItemChanged); + final int currentPlayQueueIndex = playQueue.indexOf(item); + onMetadataChanged(item, info, currentPlayQueueIndex, hasPlayQueueItemChanged); - // Check if on wrong window if (simpleExoPlayer == null) return; - if (currentSourceIndex != playQueue.getIndex()) { - Log.e(TAG, "Play Queue may be desynchronized: item index=[" + currentSourceIndex + - "], queue index=[" + playQueue.getIndex() + "]"); + final int currentPlaylistIndex = simpleExoPlayer.getCurrentWindowIndex(); + // Check if on wrong window + if (currentPlayQueueIndex != playQueue.getIndex()) { + Log.e(TAG, "Play Queue may be desynchronized: item " + + "index=[" + currentPlayQueueIndex + "], " + + "queue index=[" + playQueue.getIndex() + "]"); // on metadata changed - } else if (simpleExoPlayer.getCurrentWindowIndex() != currentSourceIndex || !isPlaying()) { - final long startPos = info != null ? info.start_position : 0; - if (DEBUG) Log.d(TAG, "Rewinding to correct window=[" + currentSourceIndex + "]," + + } else if (currentPlaylistIndex != currentPlayQueueIndex || !isPlaying()) { + final long startPos = info != null ? info.start_position : C.TIME_UNSET; + if (DEBUG) Log.d(TAG, "Rewinding to correct" + + " window=[" + currentPlayQueueIndex + "]," + " at=[" + getTimeString((int)startPos) + "]," + " from=[" + simpleExoPlayer.getCurrentWindowIndex() + "]."); - simpleExoPlayer.seekTo(currentSourceIndex, startPos); + simpleExoPlayer.seekTo(currentPlayQueueIndex, startPos); + } + + // when starting playback on the last item, maybe auto queue + if (info != null && currentPlayQueueIndex == playQueue.size() - 1 && + PlayerHelper.isAutoQueueEnabled(context)) { + final PlayQueue autoQueue = PlayerHelper.autoQueueOf(info, playQueue.getStreams()); + if (autoQueue != null) playQueue.append(autoQueue.getStreams()); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java index 0bfdcd32a..146b511c4 100644 --- a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java @@ -129,6 +129,7 @@ public final class MainVideoPlayer extends Activity { super.onSaveInstanceState(outState); if (this.playerImpl == null) return; + playerImpl.setRecovery(); final Intent intent = NavigationHelper.getPlayerIntent( getApplicationContext(), this.getClass(), @@ -156,31 +157,27 @@ public final class MainVideoPlayer extends Activity { } @Override - protected void onStop() { - super.onStop(); - if (DEBUG) Log.d(TAG, "onStop() called"); - activityPaused = true; + protected void onPause() { + super.onPause(); + if (DEBUG) Log.d(TAG, "onPause() called"); - if (playerImpl.getPlayer() != null) { - playerImpl.wasPlaying = playerImpl.getPlayer().getPlayWhenReady(); - playerImpl.setRecovery(); - playerImpl.destroyPlayer(); + if (playerImpl.getPlayer() != null && playerImpl.isPlaying() && !activityPaused) { + playerImpl.wasPlaying = true; + playerImpl.onVideoPlayPause(); } + activityPaused = true; } @Override protected void onResume() { super.onResume(); if (DEBUG) Log.d(TAG, "onResume() called"); - if (activityPaused) { - playerImpl.initPlayer(); - playerImpl.getPlayPauseButton().setImageResource(R.drawable.ic_play_arrow_white); - - playerImpl.getPlayer().setPlayWhenReady(playerImpl.wasPlaying); - playerImpl.initPlayback(playerImpl.playQueue); - - activityPaused = false; + if (playerImpl.getPlayer() != null && playerImpl.wasPlaying() + && !playerImpl.isPlaying() && activityPaused) { + playerImpl.onVideoPlayPause(); } + activityPaused = false; + if(globalScreenOrientationLocked()) { boolean lastOrientationWasLandscape = defaultPreferences.getBoolean(getString(R.string.last_orientation_landscape_key), false); @@ -873,6 +870,13 @@ public final class MainVideoPlayer extends Activity { return true; } + @Override + public boolean onDown(MotionEvent e) { + if (DEBUG) Log.d(TAG, "onDown() called with: e = [" + e + "]"); + + return super.onDown(e); + } + private final boolean isPlayerGestureEnabled = PlayerHelper.isPlayerGestureEnabled(getApplicationContext()); private final float stepsBrightness = 15, stepBrightness = (1f / stepsBrightness), minBrightness = .01f; 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 553163d21..87b0f701f 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 @@ -4,22 +4,33 @@ import android.content.Context; import android.content.SharedPreferences; import android.preference.PreferenceManager; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; import com.google.android.exoplayer2.util.MimeTypes; import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.InfoItem; 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.StreamInfoItem; import org.schabi.newpipe.extractor.stream.SubtitlesFormat; import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.playlist.PlayQueue; +import org.schabi.newpipe.playlist.PlayQueueItem; +import org.schabi.newpipe.playlist.SinglePlayQueue; import java.text.DecimalFormat; import java.text.NumberFormat; +import java.util.ArrayList; +import java.util.Collections; import java.util.Formatter; +import java.util.HashSet; +import java.util.List; import java.util.Locale; +import java.util.Set; import javax.annotation.Nonnull; @@ -76,6 +87,7 @@ public class PlayerHelper { return displayName + (subtitles.isAutoGenerated() ? " (" + context.getString(R.string.caption_auto_generated)+ ")" : ""); } + @NonNull public static String resizeTypeOf(@NonNull final Context context, @AspectRatioFrameLayout.ResizeMode final int resizeMode) { switch (resizeMode) { @@ -86,14 +98,58 @@ public class PlayerHelper { } } + @NonNull public static String cacheKeyOf(@NonNull final StreamInfo info, @NonNull VideoStream video) { return info.getUrl() + video.getResolution() + video.getFormat().getName(); } + @NonNull public static String cacheKeyOf(@NonNull final StreamInfo info, @NonNull 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 queuing. + *

+ * This method detects and prevents cycle by naively checking if a + * candidate next video's url already exists in the existing items. + *

+ * To select the next video, {@link StreamInfo#getNextVideo()} is first + * checked. If it is nonnull and is not part of the existing items, then + * it will be used as the next video. Otherwise, an random item with + * non-repeating url will be selected from the {@link StreamInfo#getRelatedStreams()}. + * */ + @Nullable + public static PlayQueue autoQueueOf(@NonNull final StreamInfo info, + @NonNull final List existingItems) { + Set urls = new HashSet<>(existingItems.size()); + for (final PlayQueueItem item : existingItems) { + urls.add(item.getUrl()); + } + + final StreamInfoItem nextVideo = info.getNextVideo(); + if (nextVideo != null && !urls.contains(nextVideo.getUrl())) { + return new SinglePlayQueue(nextVideo); + } + + final List relatedItems = info.getRelatedStreams(); + if (relatedItems == null) return null; + + List autoQueueItems = new ArrayList<>(); + for (final InfoItem item : info.getRelatedStreams()) { + if (item instanceof StreamInfoItem && !urls.contains(item.getUrl())) { + autoQueueItems.add((StreamInfoItem) item); + } + } + Collections.shuffle(autoQueueItems); + return autoQueueItems.isEmpty() ? null : new SinglePlayQueue(autoQueueItems.get(0)); + } + + //////////////////////////////////////////////////////////////////////////// + // Settings Resolution + //////////////////////////////////////////////////////////////////////////// + public static boolean isResumeAfterAudioFocusGain(@NonNull final Context context) { return isResumeAfterAudioFocusGain(context, false); } @@ -110,6 +166,10 @@ public class PlayerHelper { return isRememberingPopupDimensions(context, true); } + public static boolean isAutoQueueEnabled(@NonNull final Context context) { + return isAutoQueueEnabled(context, false); + } + @NonNull public static SeekParameters getSeekParameters(@NonNull final Context context) { return isUsingInexactSeek(context, false) ? @@ -187,4 +247,8 @@ public class PlayerHelper { private static boolean isUsingInexactSeek(@NonNull final Context context, final boolean b) { return getPreferences(context).getBoolean(context.getString(R.string.use_inexact_seek_key), b); } + + private static boolean isAutoQueueEnabled(@NonNull final Context context, final boolean b) { + return getPreferences(context).getBoolean(context.getString(R.string.auto_queue_key), b); + } } diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index c207ed712..a897aa185 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -21,6 +21,7 @@ resume_on_audio_focus_gain popup_remember_size_pos_key use_inexact_seek_key + auto_queue_key default_resolution 360p diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6a8123bde..d875036e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -74,6 +74,8 @@ Remember last size and position of popup Use fast inexact seek Inexact seek allows the player to seek to positions faster with reduced precision + Auto-queue next stream + Automatically append a related stream when playback starts on the last stream in play queue. Player gesture controls Use gestures to control the brightness and volume of the player Search suggestions diff --git a/app/src/main/res/xml/content_settings.xml b/app/src/main/res/xml/content_settings.xml index 20099d5c0..c8c1efb12 100644 --- a/app/src/main/res/xml/content_settings.xml +++ b/app/src/main/res/xml/content_settings.xml @@ -30,6 +30,13 @@ android:key="@string/show_search_suggestions_key" android:summary="@string/show_search_suggestions_summary" android:title="@string/show_search_suggestions_title"/> + + + Date: Mon, 5 Mar 2018 19:03:49 -0800 Subject: [PATCH 16/16] -Fixed main video player losing state when killed in background. -Disabled auto queuing when repeating is enabled. -Added method to use startForegroundService instead of startService in sdk 26 and up. --- .../org/schabi/newpipe/player/BasePlayer.java | 21 ++- .../newpipe/player/MainVideoPlayer.java | 160 +++++++++++------- .../schabi/newpipe/util/NavigationHelper.java | 18 +- app/src/main/res/values/strings.xml | 2 +- 4 files changed, 125 insertions(+), 76 deletions(-) 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 73d0b9180..d5ba7bb86 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -68,7 +68,6 @@ import org.schabi.newpipe.player.playback.PlaybackListener; import org.schabi.newpipe.playlist.PlayQueue; import org.schabi.newpipe.playlist.PlayQueueAdapter; import org.schabi.newpipe.playlist.PlayQueueItem; -import org.schabi.newpipe.playlist.SinglePlayQueue; import org.schabi.newpipe.util.SerializedCache; import java.io.IOException; @@ -230,17 +229,19 @@ public abstract class BasePlayer implements final float playbackSpeed = intent.getFloatExtra(PLAYBACK_SPEED, getPlaybackSpeed()); final float playbackPitch = intent.getFloatExtra(PLAYBACK_PITCH, getPlaybackPitch()); - // Re-initialization + // Good to go... + initPlayback(queue, repeatMode, playbackSpeed, playbackPitch); + } + + protected void initPlayback(@NonNull final PlayQueue queue, + @Player.RepeatMode final int repeatMode, + final float playbackSpeed, + final float playbackPitch) { destroyPlayer(); initPlayer(); setRepeatMode(repeatMode); setPlaybackParameters(playbackSpeed, playbackPitch); - // Good to go... - initPlayback(queue); - } - - protected void initPlayback(final PlayQueue queue) { playQueue = queue; playQueue.init(); playbackManager = new MediaSourceManager(this, playQueue); @@ -840,8 +841,9 @@ public abstract class BasePlayer implements simpleExoPlayer.seekTo(currentPlayQueueIndex, startPos); } - // when starting playback on the last item, maybe auto queue + // when starting playback on the last item when not repeating, maybe auto queue if (info != null && currentPlayQueueIndex == playQueue.size() - 1 && + getRepeatMode() == Player.REPEAT_MODE_OFF && PlayerHelper.isAutoQueueEnabled(context)) { final PlayQueue autoQueue = PlayerHelper.autoQueueOf(info, playQueue.getStreams()); if (autoQueue != null) playQueue.append(autoQueue.getStreams()); @@ -1077,11 +1079,12 @@ public abstract class BasePlayer implements && simpleExoPlayer.getPlayWhenReady(); } + @Player.RepeatMode public int getRepeatMode() { return simpleExoPlayer.getRepeatMode(); } - public void setRepeatMode(final int repeatMode) { + public void setRepeatMode(@Player.RepeatMode final int repeatMode) { simpleExoPlayer.setRepeatMode(repeatMode); } diff --git a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java index 146b511c4..4f27d1fee 100644 --- a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java @@ -58,6 +58,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; import org.schabi.newpipe.player.helper.PlayerHelper; +import org.schabi.newpipe.playlist.PlayQueue; import org.schabi.newpipe.playlist.PlayQueueItem; import org.schabi.newpipe.playlist.PlayQueueItemBuilder; import org.schabi.newpipe.playlist.PlayQueueItemHolder; @@ -65,24 +66,27 @@ import org.schabi.newpipe.util.AnimationUtils; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; +import org.schabi.newpipe.util.StateSaver; import org.schabi.newpipe.util.ThemeHelper; import java.util.List; +import java.util.Queue; +import java.util.UUID; import static org.schabi.newpipe.player.BasePlayer.STATE_PLAYING; import static org.schabi.newpipe.player.VideoPlayer.DEFAULT_CONTROLS_DURATION; import static org.schabi.newpipe.player.VideoPlayer.DEFAULT_CONTROLS_HIDE_TIME; import static org.schabi.newpipe.util.AnimationUtils.animateView; +import static org.schabi.newpipe.util.StateSaver.KEY_SAVED_STATE; /** * Activity Player implementing VideoPlayer * * @author mauriciocolli */ -public final class MainVideoPlayer extends Activity { +public final class MainVideoPlayer extends Activity implements StateSaver.WriteRead { private static final String TAG = ".MainVideoPlayer"; private static final boolean DEBUG = BasePlayer.DEBUG; - private static final String PLAYER_STATE_INTENT = "player_state_intent"; private GestureDetector gestureDetector; @@ -91,6 +95,8 @@ public final class MainVideoPlayer extends Activity { private SharedPreferences defaultPreferences; + @Nullable private StateSaver.SavedState savedState; + /*////////////////////////////////////////////////////////////////////////// // Activity LifeCycle //////////////////////////////////////////////////////////////////////////*/ @@ -104,42 +110,28 @@ public final class MainVideoPlayer extends Activity { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) getWindow().setStatusBarColor(Color.BLACK); setVolumeControlStream(AudioManager.STREAM_MUSIC); - final Intent intent; - if (savedInstanceState != null && savedInstanceState.getParcelable(PLAYER_STATE_INTENT) != null) { - intent = savedInstanceState.getParcelable(PLAYER_STATE_INTENT); - } else { - intent = getIntent(); - } - - if (intent == null) { - Toast.makeText(this, R.string.general_error, Toast.LENGTH_SHORT).show(); - finish(); - return; - } - changeSystemUi(); setContentView(R.layout.activity_main_player); playerImpl = new VideoPlayerImpl(this); playerImpl.setup(findViewById(android.R.id.content)); - playerImpl.handleIntent(intent); + + if (savedInstanceState != null && savedInstanceState.get(KEY_SAVED_STATE) != null) { + return; // We have saved states, stop here to restore it + } + + final Intent intent = getIntent(); + if (intent != null) { + playerImpl.handleIntent(intent); + } else { + Toast.makeText(this, R.string.general_error, Toast.LENGTH_SHORT).show(); + finish(); + } } @Override - protected void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - if (this.playerImpl == null) return; - - playerImpl.setRecovery(); - final Intent intent = NavigationHelper.getPlayerIntent( - getApplicationContext(), - this.getClass(), - this.playerImpl.getPlayQueue(), - this.playerImpl.getRepeatMode(), - this.playerImpl.getPlaybackSpeed(), - this.playerImpl.getPlaybackPitch(), - this.playerImpl.getPlaybackQuality() - ); - outState.putParcelable(PLAYER_STATE_INTENT, intent); + protected void onRestoreInstanceState(@NonNull Bundle bundle) { + super.onRestoreInstanceState(bundle); + savedState = StateSaver.tryToRestore(bundle, this); } @Override @@ -149,31 +141,12 @@ public final class MainVideoPlayer extends Activity { playerImpl.handleIntent(intent); } - @Override - public void onBackPressed() { - if (DEBUG) Log.d(TAG, "onBackPressed() called"); - super.onBackPressed(); - if (playerImpl.isPlaying()) playerImpl.getPlayer().setPlayWhenReady(false); - } - - @Override - protected void onPause() { - super.onPause(); - if (DEBUG) Log.d(TAG, "onPause() called"); - - if (playerImpl.getPlayer() != null && playerImpl.isPlaying() && !activityPaused) { - playerImpl.wasPlaying = true; - playerImpl.onVideoPlayPause(); - } - activityPaused = true; - } - @Override protected void onResume() { super.onResume(); if (DEBUG) Log.d(TAG, "onResume() called"); - if (playerImpl.getPlayer() != null && playerImpl.wasPlaying() - && !playerImpl.isPlaying() && activityPaused) { + if (playerImpl.getPlayer() != null && activityPaused && playerImpl.wasPlaying() + && !playerImpl.isPlaying()) { playerImpl.onVideoPlayPause(); } activityPaused = false; @@ -181,15 +154,15 @@ public final class MainVideoPlayer extends Activity { if(globalScreenOrientationLocked()) { boolean lastOrientationWasLandscape = defaultPreferences.getBoolean(getString(R.string.last_orientation_landscape_key), false); - setLandScape(lastOrientationWasLandscape); + setLandscape(lastOrientationWasLandscape); } } @Override - protected void onDestroy() { - super.onDestroy(); - if (DEBUG) Log.d(TAG, "onDestroy() called"); - if (playerImpl != null) playerImpl.destroy(); + public void onBackPressed() { + if (DEBUG) Log.d(TAG, "onBackPressed() called"); + super.onBackPressed(); + if (playerImpl.isPlaying()) playerImpl.getPlayer().setPlayWhenReady(false); } @Override @@ -202,8 +175,71 @@ public final class MainVideoPlayer extends Activity { } } + @Override + protected void onPause() { + super.onPause(); + if (DEBUG) Log.d(TAG, "onPause() called"); + + if (playerImpl != null && playerImpl.getPlayer() != null && !activityPaused) { + playerImpl.wasPlaying = playerImpl.isPlaying(); + if (playerImpl.isPlaying()) playerImpl.onVideoPlayPause(); + } + activityPaused = true; + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (playerImpl == null) return; + + playerImpl.setRecovery(); + savedState = StateSaver.tryToSave(isChangingConfigurations(), savedState, + outState, this); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (DEBUG) Log.d(TAG, "onDestroy() called"); + if (playerImpl != null) playerImpl.destroy(); + } + /*////////////////////////////////////////////////////////////////////////// - // Utils + // State Saving + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public String generateSuffix() { + return "." + UUID.randomUUID().toString() + ".player"; + } + + @Override + public void writeTo(Queue objectsToSave) { + if (objectsToSave == null) return; + objectsToSave.add(playerImpl.getPlayQueue()); + objectsToSave.add(playerImpl.getRepeatMode()); + objectsToSave.add(playerImpl.getPlaybackSpeed()); + objectsToSave.add(playerImpl.getPlaybackPitch()); + objectsToSave.add(playerImpl.getPlaybackQuality()); + } + + @Override + @SuppressWarnings("unchecked") + public void readFrom(@NonNull Queue savedObjects) throws Exception { + @NonNull final PlayQueue queue = (PlayQueue) savedObjects.poll(); + final int repeatMode = (int) savedObjects.poll(); + final float playbackSpeed = (float) savedObjects.poll(); + final float playbackPitch = (float) savedObjects.poll(); + @NonNull final String playbackQuality = (String) savedObjects.poll(); + + playerImpl.setPlaybackQuality(playbackQuality); + playerImpl.initPlayback(queue, repeatMode, playbackSpeed, playbackPitch); + + StateSaver.onDestroy(savedState); + } + + /*////////////////////////////////////////////////////////////////////////// + // View //////////////////////////////////////////////////////////////////////////*/ /** @@ -256,17 +292,17 @@ public final class MainVideoPlayer extends Activity { } private void toggleOrientation() { - setLandScape(!isLandScape()); + setLandscape(!isLandscape()); defaultPreferences.edit() - .putBoolean(getString(R.string.last_orientation_landscape_key), !isLandScape()) + .putBoolean(getString(R.string.last_orientation_landscape_key), !isLandscape()) .apply(); } - private boolean isLandScape() { + private boolean isLandscape() { return getResources().getDisplayMetrics().heightPixels < getResources().getDisplayMetrics().widthPixels; } - private void setLandScape(boolean v) { + private void setLandscape(boolean v) { setRequestedOrientation(v ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE : ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT); 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 4fc854416..ee94ac81f 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -139,12 +139,12 @@ public class NavigationHelper { } Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show(); - context.startService(getPlayerIntent(context, PopupVideoPlayer.class, queue)); + startService(context, getPlayerIntent(context, PopupVideoPlayer.class, queue)); } public static void playOnBackgroundPlayer(final Context context, final PlayQueue queue) { Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT).show(); - context.startService(getPlayerIntent(context, BackgroundPlayer.class, queue)); + startService(context, getPlayerIntent(context, BackgroundPlayer.class, queue)); } public static void enqueueOnPopupPlayer(final Context context, final PlayQueue queue) { @@ -158,7 +158,8 @@ public class NavigationHelper { } Toast.makeText(context, R.string.popup_playing_append, Toast.LENGTH_SHORT).show(); - context.startService(getPlayerEnqueueIntent(context, PopupVideoPlayer.class, queue, selectOnAppend)); + startService(context, + getPlayerEnqueueIntent(context, PopupVideoPlayer.class, queue, selectOnAppend)); } public static void enqueueOnBackgroundPlayer(final Context context, final PlayQueue queue) { @@ -167,7 +168,16 @@ public class NavigationHelper { public static void enqueueOnBackgroundPlayer(final Context context, final PlayQueue queue, boolean selectOnAppend) { Toast.makeText(context, R.string.background_player_append, Toast.LENGTH_SHORT).show(); - context.startService(getPlayerEnqueueIntent(context, BackgroundPlayer.class, queue, selectOnAppend)); + startService(context, + getPlayerEnqueueIntent(context, BackgroundPlayer.class, queue, selectOnAppend)); + } + + public static void startService(@NonNull final Context context, @NonNull final Intent intent) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent); + } else { + context.startService(intent); + } } /*////////////////////////////////////////////////////////////////////////// diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d875036e4..5cde58f8d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -75,7 +75,7 @@ Use fast inexact seek Inexact seek allows the player to seek to positions faster with reduced precision Auto-queue next stream - Automatically append a related stream when playback starts on the last stream in play queue. + Automatically append a related stream when playback starts on the last stream in a non-repeating play queue. Player gesture controls Use gestures to control the brightness and volume of the player Search suggestions