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); + } +}