-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.
This commit is contained in:
John Zhen Mo 2018-02-25 15:10:11 -08:00
parent 19cbcd0c1d
commit 563a4137bd
11 changed files with 356 additions and 773 deletions

View File

@ -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'

View File

@ -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 {

View File

@ -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;

View File

@ -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...");

View File

@ -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<VideoStream> 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<VideoStream> 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<MediaSource> 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);
}

View File

@ -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<? super DataSource> 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<? super DataSource> 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

View File

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

View File

@ -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;
}
}

View File

@ -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<Long> 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<PlayQueueItem> 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<StreamInfo> onSuccess = info -> syncInternal(currentItem, info);
final Consumer<Throwable> onError = throwable -> {
Log.e(TAG, "Sync error:", throwable);
syncInternal(currentItem, null);
};
final Consumer<Throwable> 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<PlayQueueItem> items = new ArrayList<>(playQueue.getStreams().subList(leftBound, rightBound));
final List<PlayQueueItem> 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<ManagedMediaSource> 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<ManagedMediaSource> 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);
});
}
}

View File

@ -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<Long> 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<PlayQueueItem> 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<PlayQueueEvent> getReactor() {
return new Subscriber<PlayQueueEvent>() {
@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<StreamInfo> onSuccess = info -> syncInternal(currentItem, info);
final Consumer<Throwable> 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<PlayQueueItem> 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<ManagedMediaSource> 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<ManagedMediaSource> 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);
}
}

View File

@ -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 {