diff --git a/README.md b/README.md index 030963a89..52c1159dd 100644 --- a/README.md +++ b/README.md @@ -59,18 +59,17 @@ NewPipe does not use any Google framework libraries, or the YouTube API. It only * Search/Watch Playlists * Watch as queues Playlists * Queuing videos +* Local playlists +* Subtitles +* Multi-service support (eg. SoundCloud in NewPipe Beta) ### Coming Features -* Multiservice support (eg. SoundCloud) -* Bookmarks -* Subtitles support -* livestream support +* Livestream support +* Cast to UPnP and Cast +* Show comments * ... and many more -### Multiservice support -Although NewPipe only supports YouTube at the moment, it's designed to support many more streaming services. The plan is, that NewPipe will get such support by the version 2.0. - ## Contribution Whether you have ideas, translations, design changes, code cleaning, or real heavy code changes, help is always welcome. The more is done the better it gets! diff --git a/app/build.gradle b/app/build.gradle index c5887faed..630e6ba4d 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.TeamNewPipe:NewPipeExtractor:b1130629bb' testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-core:1.10.19' @@ -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/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 94a2f8ec0..8e8179b9a 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 @@ -16,6 +16,7 @@ import android.support.v4.content.ContextCompat; import android.support.v4.view.animation.FastOutSlowInInterpolator; import android.support.v7.app.ActionBar; import android.support.v7.app.AlertDialog; +import android.support.v7.widget.Toolbar; import android.text.Html; import android.text.Spanned; import android.text.TextUtils; @@ -56,6 +57,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; @@ -138,6 +140,7 @@ public class VideoDetailFragment //////////////////////////////////////////////////////////////////////////*/ private Menu menu; + private Toolbar toolbar; private Spinner spinnerToolbar; private ParallaxScrollView parallaxScrollRootView; @@ -321,7 +324,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); @@ -459,7 +462,8 @@ public class VideoDetailFragment protected void initViews(View rootView, Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); - spinnerToolbar = activity.findViewById(R.id.toolbar).findViewById(R.id.toolbar_spinner); + toolbar = activity.findViewById(R.id.toolbar); + spinnerToolbar = toolbar.findViewById(R.id.toolbar_spinner); parallaxScrollRootView = rootView.findViewById(R.id.detail_main_content); @@ -1192,11 +1196,21 @@ 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); + toolbar.setTitle(R.string.live); + 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 +1230,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/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/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/BackgroundPlayer.java b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java index fd47a7167..a43f434ff 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; @@ -46,6 +47,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; @@ -291,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); } @@ -378,29 +380,34 @@ 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); - - resetNotification(); - updateNotification(-1); - updateMetadata(); + protected void onMetadataChanged(@NonNull final PlayQueueItem item, + @Nullable final StreamInfo info, + final int newPlayQueueIndex, + final boolean hasPlayQueueItemChanged) { + if (shouldUpdateOnProgress || hasPlayQueueItemChanged) { + resetNotification(); + updateNotification(-1); + updateMetadata(); + } } @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; 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 - public void shutdown() { - super.shutdown(); + public void onPlaybackShutdown() { + super.onPlaybackShutdown(); onClose(); } @@ -429,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 222f0fad8..d5ba7bb86 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -43,37 +43,35 @@ 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.BehindLiveWindowException; import com.google.android.exoplayer2.source.MediaSource; 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.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.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; 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.helper.PlayerHelper; +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; import org.schabi.newpipe.playlist.PlayQueueItem; +import org.schabi.newpipe.util.SerializedCache; -import java.io.Serializable; +import java.io.IOException; +import java.net.UnknownHostException; import java.util.concurrent.TimeUnit; import io.reactivex.Observable; @@ -93,17 +91,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 @@ -113,7 +112,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"; @@ -124,8 +123,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; @@ -141,23 +142,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 DefaultTrackSelector trackSelector; - protected DataSource.Factory cacheDataSourceFactory; - protected DefaultExtractorsFactory extractorsFactory; - 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() { @@ -169,6 +167,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() { @@ -179,51 +179,46 @@ 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(); + final String userAgent = Downloader.USER_AGENT; final DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(); - final AdaptiveTrackSelection.Factory trackSelectionFactory = new AdaptiveTrackSelection.Factory(bandwidthMeter); + dataSource = new PlayerDataSource(context, userAgent, bandwidthMeter); + + final AdaptiveTrackSelection.Factory trackSelectionFactory = + new AdaptiveTrackSelection.Factory(bandwidthMeter); + trackSelector = new CustomTrackSelector(trackSelectionFactory); + final LoadControl loadControl = new LoadController(context); final RenderersFactory renderFactory = new DefaultRenderersFactory(context); - - trackSelector = new DefaultTrackSelector(trackSelectionFactory); - extractorsFactory = new DefaultExtractorsFactory(); - cacheDataSourceFactory = new CacheFactory(context); - simpleExoPlayer = ExoPlayerFactory.newSimpleInstance(renderFactory, trackSelector, loadControl); audioReactor = new AudioReactor(context, simpleExoPlayer); simpleExoPlayer.addListener(this); simpleExoPlayer.setPlayWhenReady(true); + simpleExoPlayer.setSeekParameters(PlayerHelper.getSeekParameters(context)); } 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; // 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) { 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); } @@ -234,17 +229,19 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen 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); @@ -253,24 +250,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) { @@ -298,34 +277,99 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen trackSelector = null; simpleExoPlayer = null; - recordManager = null; } - public MediaSource buildMediaSource(String url, String overrideExtension) { + /*////////////////////////////////////////////////////////////////////////// + // 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(); + } + + /*////////////////////////////////////////////////////////////////////////// + // 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); - MediaSource mediaSource; + if (dataSource == null) return null; + + final Uri uri = Uri.parse(sourceUrl); switch (type) { case C.TYPE_SS: - mediaSource = new SsMediaSource(uri, cacheDataSourceFactory, new DefaultSsChunkSource.Factory(cacheDataSourceFactory), null, null); - break; + return dataSource.getLiveSsMediaSourceFactory().createMediaSource(uri); case C.TYPE_DASH: - mediaSource = new DashMediaSource(uri, cacheDataSourceFactory, new DefaultDashChunkSource.Factory(cacheDataSourceFactory), null, null); - break; + return dataSource.getLiveDashMediaSourceFactory().createMediaSource(uri); case C.TYPE_HLS: - mediaSource = new HlsMediaSource(uri, cacheDataSourceFactory, null, null); - break; - case C.TYPE_OTHER: - mediaSource = new ExtractorMediaSource(uri, cacheDataSourceFactory, extractorsFactory, null, null); - break; - 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); - } } - return mediaSource; } /*////////////////////////////////////////////////////////////////////////// @@ -345,15 +389,16 @@ 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; } } 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); } } @@ -403,17 +448,16 @@ 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() { - } + 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"); @@ -450,21 +494,134 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen public void onShuffleClicked() { if (DEBUG) Log.d(TAG, "onShuffleClicked() called"); - if (playQueue == null) return; + if (simpleExoPlayer == null) return; + simpleExoPlayer.setShuffleModeEnabled(!simpleExoPlayer.getShuffleModeEnabled()); + } - setRecovery(); - if (playQueue.isShuffled()) { - playQueue.unshuffle(); - } else { - playQueue.shuffle(); - } + /*////////////////////////////////////////////////////////////////////////// + // 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 recover() { + @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 + "]"); + + 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 onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + if (DEBUG) Log.d(TAG, "ExoPlayer - onTracksChanged(), " + + "track group size = " + trackGroups.length); + } + + @Override + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + if (DEBUG) Log.d(TAG, "ExoPlayer - playbackParameters(), " + + "speed: " + playbackParameters.speed + ", " + + "pitch: " + playbackParameters.pitch); + } + + @Override + 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(); + } + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + if (DEBUG) Log.d(TAG, "ExoPlayer - onPlayerStateChanged() called with: " + + "playWhenReady = [" + playWhenReady + "], " + + "playbackState = [" + playbackState + "]"); + + if (getCurrentState() == STATE_PAUSED_SEEK) { + if (DEBUG) Log.d(TAG, "ExoPlayer - onPlayerStateChanged() is currently blocked"); + return; + } + + switch (playbackState) { + case Player.STATE_IDLE: // 1 + isPrepared = false; + break; + case Player.STATE_BUFFERING: // 2 + if (isPrepared) { + changeState(STATE_BUFFERING); + } + break; + case Player.STATE_READY: //3 + maybeRecover(); + if (!isPrepared) { + isPrepared = true; + onPrepared(playWhenReady); + break; + } + if (currentState == STATE_PAUSED_SEEK) break; + changeState(playWhenReady ? STATE_PLAYING : STATE_PAUSED); + break; + 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()) { + changeState(STATE_COMPLETED); + isPrepared = false; + } + break; + } + } + + private void maybeRecover() { final int currentSourceIndex = playQueue.getIndex(); final PlayQueueItem currentSourceItem = playQueue.getItem(); @@ -488,90 +645,11 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen } } - @Override - public void onTimelineChanged(Timeline timeline, Object manifest) { - if (DEBUG) Log.d(TAG, "onTimelineChanged(), timeline size = " + timeline.getWindowCount()); - - if (playbackManager != null) { - playbackManager.load(); - } - } - - @Override - public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { - if (DEBUG) Log.d(TAG, "onTracksChanged(), track group size = " + trackGroups.length); - } - - @Override - public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { - if (DEBUG) Log.d(TAG, "playbackParameters(), speed: " + playbackParameters.speed + ", pitch: " + playbackParameters.pitch); - } - - @Override - public void onLoadingChanged(boolean isLoading) { - if (DEBUG) Log.d(TAG, "onLoadingChanged() called with: isLoading = [" + isLoading + "]"); - - 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 (getCurrentState() == STATE_PAUSED_SEEK) { - if (DEBUG) Log.d(TAG, "onPlayerStateChanged() is currently blocked"); - return; - } - - switch (playbackState) { - case Player.STATE_IDLE: // 1 - isPrepared = false; - break; - case Player.STATE_BUFFERING: // 2 - if (isPrepared) { - changeState(STATE_BUFFERING); - } - break; - case Player.STATE_READY: //3 - recover(); - if (!isPrepared) { - isPrepared = true; - onPrepared(playWhenReady); - break; - } - if (currentState == STATE_PAUSED_SEEK) break; - changeState(playWhenReady ? STATE_PLAYING : STATE_PAUSED); - break; - 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()) { - changeState(STATE_COMPLETED); - isPrepared = false; - } - break; - } - } - /** * 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 @@ -580,11 +658,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; @@ -594,11 +674,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: @@ -608,14 +684,53 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen break; default: showUnrecoverableError(error); - shutdown(); + onPlaybackShutdown(); break; } } + /** + * 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(); @@ -627,39 +742,43 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen } else { playQueue.offsetIndex(+1); } - break; case DISCONTINUITY_REASON_SEEK: case DISCONTINUITY_REASON_SEEK_ADJUSTMENT: case DISCONTINUITY_REASON_INTERNAL: - default: break; } - playbackManager.load(); } @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 + "]"); + if (playQueue == null) return; + if (shuffleModeEnabled) { + playQueue.shuffle(); + } else { + playQueue.unshuffle(); + } } @Override public void onSeekProcessed() { - if (DEBUG) Log.d(TAG, "onSeekProcessed() called"); + if (DEBUG) Log.d(TAG, "ExoPlayer - onSeekProcessed() called"); } /*////////////////////////////////////////////////////////////////////////// // Playback Listener //////////////////////////////////////////////////////////////////////////*/ @Override - public void block() { + public void onPlaybackBlock() { if (simpleExoPlayer == null) return; - if (DEBUG) Log.d(TAG, "Blocking..."); + if (DEBUG) Log.d(TAG, "Playback - onPlaybackBlock() called"); currentItem = null; currentInfo = null; @@ -670,44 +789,86 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen } @Override - public void unblock(final MediaSource mediaSource) { + public void onPlaybackUnblock(final MediaSource mediaSource) { if (simpleExoPlayer == null) return; - if (DEBUG) Log.d(TAG, "Unblocking..."); + if (DEBUG) Log.d(TAG, "Playback - onPlaybackUnblock() called"); if (getCurrentState() == STATE_BLOCKED) changeState(STATE_BUFFERING); simpleExoPlayer.prepare(mediaSource); - simpleExoPlayer.seekToDefaultPosition(); + seekToDefault(); } @Override - public void sync(@NonNull final PlayQueueItem item, - @Nullable final StreamInfo info) { - if (currentItem == item && currentInfo == info) return; - currentItem = item; - currentInfo = info; + 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 (DEBUG) Log.d(TAG, "Syncing..."); - if (simpleExoPlayer == null) return; - - // Check if on wrong window - final int currentSourceIndex = playQueue.indexOf(item); - 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()) { - final long startPos = info != null ? info.start_position : 0; - if (DEBUG) Log.d(TAG, "Rewinding to correct window: " + currentSourceIndex + - " at: " + getTimeString((int)startPos)); - simpleExoPlayer.seekTo(currentSourceIndex, startPos); + final boolean hasPlayQueueItemChanged = currentItem != item; + final boolean hasStreamInfoChanged = currentInfo != info; + if (!hasPlayQueueItemChanged && !hasStreamInfoChanged) { + return; // Nothing to synchronize } - registerView(); - initThumbnail(info == null ? item.getThumbnailUrl() : info.thumbnail_url); + 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 currentPlayQueueIndex = playQueue.indexOf(item); + onMetadataChanged(item, info, currentPlayQueueIndex, hasPlayQueueItemChanged); + + if (simpleExoPlayer == null) return; + 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 (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(currentPlayQueueIndex, startPos); + } + + // 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()); + } + } + + 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) { + if (!info.getHlsUrl().isEmpty()) { + return buildLiveMediaSource(info.getHlsUrl(), C.TYPE_HLS); + } else if (!info.getDashMpdUrl().isEmpty()) { + return buildLiveMediaSource(info.getDashMpdUrl(), C.TYPE_DASH); + } + + return null; } @Override - public void shutdown() { + public void onPlaybackShutdown() { if (DEBUG) Log.d(TAG, "Shutting down..."); destroy(); } @@ -750,8 +911,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"); @@ -763,7 +922,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen if (getCurrentState() == STATE_COMPLETED) { if (playQueue.getIndex() == 0) { - simpleExoPlayer.seekToDefaultPosition(); + seekToDefault(); } else { playQueue.setIndex(0); } @@ -808,11 +967,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); } @@ -820,8 +981,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); @@ -832,12 +996,16 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen && simpleExoPlayer.getCurrentPosition() >= 0; } + public void seekToDefault() { + if (simpleExoPlayer != null) simpleExoPlayer.seekToDefaultPosition(); + } + /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ 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 */}, @@ -852,30 +1020,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() @@ -928,14 +1074,17 @@ 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(); } + @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 d48994d0f..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,21 +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; @@ -88,6 +95,8 @@ public final class MainVideoPlayer extends Activity { private SharedPreferences defaultPreferences; + @Nullable private StateSaver.SavedState savedState; + /*////////////////////////////////////////////////////////////////////////// // Activity LifeCycle //////////////////////////////////////////////////////////////////////////*/ @@ -101,41 +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; - } - - showSystemUi(); + 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; - - 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 @@ -145,6 +141,23 @@ public final class MainVideoPlayer extends Activity { playerImpl.handleIntent(intent); } + @Override + protected void onResume() { + super.onResume(); + if (DEBUG) Log.d(TAG, "onResume() called"); + if (playerImpl.getPlayer() != null && activityPaused && playerImpl.wasPlaying() + && !playerImpl.isPlaying()) { + playerImpl.onVideoPlayPause(); + } + activityPaused = false; + + if(globalScreenOrientationLocked()) { + boolean lastOrientationWasLandscape + = defaultPreferences.getBoolean(getString(R.string.last_orientation_landscape_key), false); + setLandscape(lastOrientationWasLandscape); + } + } + @Override public void onBackPressed() { if (DEBUG) Log.d(TAG, "onBackPressed() called"); @@ -152,46 +165,6 @@ public final class MainVideoPlayer extends Activity { if (playerImpl.isPlaying()) playerImpl.getPlayer().setPlayWhenReady(false); } - @Override - protected void onStop() { - super.onStop(); - if (DEBUG) Log.d(TAG, "onStop() called"); - activityPaused = true; - - if (playerImpl.getPlayer() != null) { - playerImpl.wasPlaying = playerImpl.getPlayer().getPlayWhenReady(); - playerImpl.setRecovery(); - playerImpl.destroyPlayer(); - } - } - - @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(globalScreenOrientationLocked()) { - boolean lastOrientationWasLandscape - = defaultPreferences.getBoolean(getString(R.string.last_orientation_landscape_key), false); - setLandScape(lastOrientationWasLandscape); - } - } - - @Override - protected void onDestroy() { - super.onDestroy(); - if (DEBUG) Log.d(TAG, "onDestroy() called"); - if (playerImpl != null) playerImpl.destroy(); - } - @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); @@ -202,49 +175,134 @@ 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 + //////////////////////////////////////////////////////////////////////////*/ + + /** + * 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() { - 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); @@ -307,6 +365,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 +393,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); @@ -391,31 +463,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 //////////////////////////////////////////////////////////////////////////*/ @@ -508,9 +581,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); } }); } @@ -546,7 +619,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() { @@ -558,15 +631,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 @@ -624,7 +695,8 @@ public final class MainVideoPlayer extends Activity { playPauseButton.setImageResource(R.drawable.ic_pause_white); animatePlayButtons(true, 200); }); - showSystemUi(); + + changeSystemUi(); getRootView().setKeepScreenOn(true); } @@ -636,7 +708,7 @@ public final class MainVideoPlayer extends Activity { animatePlayButtons(true, 200); }); - showSystemUi(); + changeSystemUi(); getRootView().setKeepScreenOn(false); } @@ -650,10 +722,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); @@ -683,8 +754,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 ); } @@ -697,11 +769,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); @@ -830,14 +897,22 @@ 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; } + @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; @@ -916,11 +991,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 f4e7a0d6a..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; @@ -419,13 +422,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); } @@ -533,7 +538,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()); } } @@ -572,16 +578,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(); } @@ -646,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 @@ -778,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/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 40b7df2dc..aa90b7b88 100644 --- a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java @@ -50,21 +50,21 @@ 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.SingleSampleMediaSource; import com.google.android.exoplayer2.source.TrackGroup; 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; 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; @@ -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, @@ -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; @@ -131,6 +132,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; @@ -159,7 +161,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) { @@ -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); @@ -305,8 +309,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() - .withPreferredTextLanguage(captionLanguage)); + trackSelector.setPreferredTextLanguage(captionLanguage); trackSelector.setRendererDisabled(textRendererIndex, false); } return true; @@ -322,27 +325,53 @@ 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); - 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()); - } + playbackEndTime.setVisibility(View.GONE); + playbackLiveSync.setVisibility(View.GONE); - buildQualityMenu(); - qualityTextView.setVisibility(View.VISIBLE); - surfaceView.setVisibility(View.VISIBLE); - } else { - surfaceView.setVisibility(View.GONE); + final StreamType streamType = info == null ? StreamType.NONE : info.getStreamType(); + + 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; + + 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); + default: + playbackEndTime.setVisibility(View.VISIBLE); + break; } buildPlaybackSpeedMenu(); @@ -352,6 +381,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 @@ -368,6 +400,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); } @@ -380,6 +413,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); } @@ -395,8 +429,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 = dataSource.getSampleMediaSourceFactory() + .createMediaSource(Uri.parse(subtitle.getURL()), textFormat, TIME_UNSET); mediaSources.add(textSource); } @@ -417,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 @@ -442,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); } @@ -529,26 +563,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); } @@ -595,9 +618,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() { @@ -633,6 +656,8 @@ public abstract class VideoPlayer extends BasePlayer onResizeClicked(); } else if (v.getId() == captionTextView.getId()) { onCaptionClicked(); + } else if (v.getId() == playbackLiveSync.getId()) { + seekToDefault(); } } @@ -683,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) { @@ -699,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() { @@ -739,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 @@ -795,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) { @@ -837,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) { @@ -854,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/java/org/schabi/newpipe/player/helper/CacheFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java index dce74ffb5..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 @@ -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; @@ -18,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; @@ -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/helper/LoadController.java b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java index be7b8efde..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,12 +11,14 @@ 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; public class LoadController implements LoadControl { public static final String TAG = "LoadController"; + private final long initialPlaybackBufferUs; private final LoadControl internalLoadControl; /*////////////////////////////////////////////////////////////////////////// @@ -24,19 +26,25 @@ 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)); } - public 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); } /*////////////////////////////////////////////////////////////////////////// @@ -49,7 +57,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 +78,27 @@ 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) { + 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/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java new file mode 100644 index 000000000..133121269 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -0,0 +1,78 @@ +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 EXTRACTOR_MINIMUM_RETRY = Integer.MAX_VALUE; + 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) + .setMinLoadableRetryCount(EXTRACTOR_MINIMUM_RETRY); + } + + public ExtractorMediaSource.Factory getExtractorMediaSourceFactory(@NonNull final String key) { + return getExtractorMediaSourceFactory().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..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,18 +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; @@ -69,10 +84,10 @@ 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)+ ")" : ""); } + @NonNull public static String resizeTypeOf(@NonNull final Context context, @AspectRatioFrameLayout.ResizeMode final int resizeMode) { switch (resizeMode) { @@ -83,6 +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); } @@ -99,6 +166,16 @@ 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) ? + SeekParameters.CLOSEST_SYNC : SeekParameters.EXACT; + } + public static long getPreferredCacheSize(@NonNull final Context context) { return 64 * 1024 * 1024L; } @@ -107,16 +184,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) { @@ -155,4 +243,12 @@ 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); + } + + 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/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..d07baf2a7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java @@ -0,0 +1,78 @@ +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; +import com.google.android.exoplayer2.upstream.Allocator; + +import org.schabi.newpipe.playlist.PlayQueueItem; + +import java.io.IOException; + +public class FailedMediaSource implements ManagedMediaSource { + private final String TAG = "FailedMediaSource@" + Integer.toHexString(hashCode()); + + 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; + } + + /** + * 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; + this.error = error; + this.retryTimestamp = Long.MAX_VALUE; + } + + public PlayQueueItem getStream() { + return playQueueItem; + } + + public Throwable getError() { + return error; + } + + private boolean canRetry() { + return System.currentTimeMillis() >= retryTimestamp; + } + + @Override + public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { + Log.e(TAG, "Loading failed source: ", error); + } + + @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(@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 new file mode 100644 index 000000000..f523667f9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java @@ -0,0 +1,65 @@ +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 LoadedMediaSource implements ManagedMediaSource { + + private final MediaSource source; + private final PlayQueueItem stream; + private 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); + } + + @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(@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 new file mode 100644 index 000000000..3bb7ca429 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java @@ -0,0 +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(@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 new file mode 100644 index 000000000..0d3436a01 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java @@ -0,0 +1,25 @@ +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.upstream.Allocator; + +import org.schabi.newpipe.playlist.PlayQueueItem; + +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(@NonNull final PlayQueueItem newIdentity) { + return true; + } +} 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 54eb4078a..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 @@ -1,96 +1,148 @@ package org.schabi.newpipe.player.playback; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; 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; 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 org.schabi.newpipe.playlist.events.ReorderEvent; 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 java.util.concurrent.atomic.AtomicBoolean; +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.internal.subscriptions.EmptySubscription; import io.reactivex.subjects.PublishSubject; +import static org.schabi.newpipe.playlist.PlayQueue.DEBUG; + 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; - private final PlaybackListener playbackListener; - private final PlayQueue playQueue; + @NonNull private final static String TAG = "MediaSourceManager"; - // 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 + /** + * 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, Runnable) + * */ + private final static int WINDOW_SIZE = 1; + + @NonNull private final PlaybackListener playbackListener; + @NonNull private final PlayQueue playQueue; + + /** + * 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. + * + * @see #loadDebounced() + * */ private final long loadDebounceMillis; - private final PublishSubject debouncedLoadSignal; - private final Disposable debouncedLoader; + @NonNull private final Disposable debouncedLoader; + @NonNull private final PublishSubject debouncedSignal; - private final DeferredMediaSource.Callback sourceBuilder; + @NonNull private Subscription playQueueReactor; - private DynamicConcatenatingMediaSource sources; + /** + * 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 final Set loadingItems; + @NonNull private final SerialDisposable syncReactor; - private Subscription playQueueReactor; - private SerialDisposable syncReactor; + @NonNull private final AtomicBoolean isBlocked; - private PlayQueueItem syncedItem; - - private boolean isBlocked; + @NonNull private DynamicConcatenatingMediaSource sources; public MediaSourceManager(@NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue) { - this(listener, playQueue, 1, 400L); + this(listener, playQueue, + /*loadDebounceMillis=*/400L, + /*windowRefreshTimeMillis=*/TimeUnit.MILLISECONDS.convert(10, TimeUnit.MINUTES)); } private MediaSourceManager(@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"); + final long loadDebounceMillis, + final long windowRefreshTimeMillis) { + if (playQueue.getBroadcastReceiver() == null) { + throw new IllegalArgumentException("Play Queue has not been initialized."); } this.playbackListener = listener; this.playQueue = playQueue; - this.windowSize = windowSize; - this.loadDebounceMillis = loadDebounceMillis; - this.syncReactor = new SerialDisposable(); - this.debouncedLoadSignal = PublishSubject.create(); + this.windowRefreshTimeMillis = windowRefreshTimeMillis; + + this.loadDebounceMillis = loadDebounceMillis; + this.debouncedSignal = PublishSubject.create(); this.debouncedLoader = getDebouncedLoader(); - this.sourceBuilder = getSourceBuilder(); + this.playQueueReactor = EmptySubscription.INSTANCE; + this.loaderReactor = new CompositeDisposable(); + this.syncReactor = new SerialDisposable(); + + this.isBlocked = new AtomicBoolean(false); this.sources = new DynamicConcatenatingMediaSource(); + this.loadingItems = Collections.synchronizedSet(new HashSet<>()); + playQueue.getBroadcastReceiver() .observeOn(AndroidSchedulers.mainThread()) .subscribe(getReactor()); } - /*////////////////////////////////////////////////////////////////////////// - // DeferredMediaSource listener - //////////////////////////////////////////////////////////////////////////*/ - - private DeferredMediaSource.Callback getSourceBuilder() { - return playbackListener::sourceOf; - } - /*////////////////////////////////////////////////////////////////////////// // Exposed Methods //////////////////////////////////////////////////////////////////////////*/ @@ -98,16 +150,15 @@ 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 (playQueueReactor != null) playQueueReactor.cancel(); - if (syncReactor != null) syncReactor.dispose(); - if (sources != null) sources.releaseSource(); + if (DEBUG) Log.d(TAG, "dispose() called."); - playQueueReactor = null; - syncReactor = null; - syncedItem = null; - sources = null; + debouncedSignal.onComplete(); + debouncedLoader.dispose(); + + playQueueReactor.cancel(); + loaderReactor.dispose(); + syncReactor.dispose(); + sources.releaseSource(); } /** @@ -116,18 +167,20 @@ 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(); } /** * 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(); + if (DEBUG) Log.d(TAG, "reset() called."); - syncedItem = null; + maybeBlock(); populateSources(); } /*////////////////////////////////////////////////////////////////////////// @@ -138,14 +191,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 @@ -158,14 +211,13 @@ public class MediaSourceManager { private void onPlayQueueChanged(final PlayQueueEvent event) { if (playQueue.isEmpty() && playQueue.isComplete()) { - playbackListener.shutdown(); + playbackListener.onPlaybackShutdown(); return; } // Event specific action switch (event.type()) { case INIT: - case REORDER: case ERROR: reset(); break; @@ -180,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: @@ -191,11 +249,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: @@ -204,69 +262,100 @@ public class MediaSourceManager { } if (!isPlayQueueReady()) { - tryBlock(); + maybeBlock(); playQueue.fetch(); } - if (playQueueReactor != null) playQueueReactor.request(1); + playQueueReactor.request(1); } /*////////////////////////////////////////////////////////////////////////// - // Internal Helpers + // Playback Locking //////////////////////////////////////////////////////////////////////////*/ private boolean isPlayQueueReady() { - return playQueue.isComplete() || playQueue.size() - playQueue.getIndex() > windowSize; + final boolean isWindowLoaded = playQueue.size() - playQueue.getIndex() > WINDOW_SIZE; + return playQueue.isComplete() || isWindowLoaded; } - private boolean tryBlock() { - if (!isBlocked) { - playbackListener.block(); - resetSources(); - isBlocked = true; - return true; + private boolean isPlaybackReady() { + if (sources.getSize() != playQueue.size()) return false; + + final MediaSource mediaSource = sources.getMediaSource(playQueue.getIndex()); + final PlayQueueItem playQueueItem = playQueue.getItem(); + + if (mediaSource instanceof LoadedMediaSource) { + return playQueueItem == ((LoadedMediaSource) mediaSource).getStream(); + } else if (mediaSource instanceof FailedMediaSource) { + return playQueueItem == ((FailedMediaSource) mediaSource).getStream(); } return false; } - private boolean tryUnblock() { - if (isPlayQueueReady() && isBlocked && sources != null) { - isBlocked = false; - playbackListener.unblock(sources); - return true; - } - return false; + private void maybeBlock() { + if (DEBUG) Log.d(TAG, "maybeBlock() called."); + + if (isBlocked.get()) return; + + playbackListener.onPlaybackBlock(); + resetSources(); + + isBlocked.set(true); } - private void sync() { + private void maybeUnblock() { + if (DEBUG) Log.d(TAG, "maybeUnblock() called."); + + if (isPlayQueueReady() && isPlaybackReady() && isBlocked.get()) { + isBlocked.set(false); + playbackListener.onPlaybackUnblock(sources); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Metadata Synchronization + //////////////////////////////////////////////////////////////////////////*/ + + private void maybeSync() { + if (DEBUG) Log.d(TAG, "onPlaybackSynchronize() called."); + final PlayQueueItem currentItem = playQueue.getItem(); - if (currentItem == null) return; + if (isBlocked.get() || 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; - 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) { + playbackListener.onPlaybackSynchronize(item, info); } } - 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); - } + private void maybeSynchronizePlayer() { + maybeUnblock(); + maybeSync(); + } + + /*////////////////////////////////////////////////////////////////////////// + // MediaSource Loading + //////////////////////////////////////////////////////////////////////////*/ + + private Disposable getDebouncedLoader() { + 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() { @@ -274,87 +363,182 @@ public class MediaSourceManager { final int currentIndex = playQueue.getIndex(); final PlayQueueItem currentItem = playQueue.getItem(currentIndex); if (currentItem == null) return; - loadItem(currentItem); + + // 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 - final int leftBound = Math.max(0, currentIndex - windowSize); - final int rightLimit = currentIndex + windowSize + 1; + // 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)); + 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; - - 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(); - - tryUnblock(); - if (!isBlocked) 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 DeferredMediaSource(item, sourceBuilder)); + for (final PlayQueueItem item : items) { + maybeLoadItem(item); } } - private Disposable getDebouncedLoader() { - return debouncedLoadSignal - .debounce(loadDebounceMillis, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(timestamp -> loadImmediate()); + private void maybeLoadItem(@NonNull final PlayQueueItem item) { + if (DEBUG) Log.d(TAG, "maybeLoadItem() called."); + 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() + "]"); + + loadingItems.add(item); + final Disposable loader = getLoadedMediaSource(item) + .observeOn(AndroidSchedulers.mainThread()) + /* No exception handling since getLoadedMediaSource guarantees nonnull return */ + .subscribe(mediaSource -> onMediaSourceReceived(item, mediaSource)); + loaderReactor.add(loader); + } + + maybeSynchronizePlayer(); } + + private Single getLoadedMediaSource(@NonNull final PlayQueueItem stream) { + return stream.getStream().map(streamInfo -> { + final MediaSource source = playbackListener.sourceOf(stream, streamInfo); + if (source == null) { + 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, exception); + } + + 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() + "]"); + + 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 index=[" + itemIndex + "] with " + + "title=[" + item.getTitle() + "] at url=[" + item.getUrl() + "]"); + update(itemIndex, mediaSource, this::maybeSynchronizePlayer); + } + } + + /** + * 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) { + final int index = playQueue.indexOf(item); + if (index == -1 || index >= sources.getSize()) return false; + + final ManagedMediaSource mediaSource = (ManagedMediaSource) sources.getMediaSource(index); + + if (index == playQueue.getIndex() && mediaSource instanceof LoadedMediaSource) { + return item != ((LoadedMediaSource) mediaSource).getStream(); + } else { + return mediaSource.canReplace(item); + } + } + /*////////////////////////////////////////////////////////////////////////// - // Media Source List Manipulation + // MediaSource Playlist Helpers + //////////////////////////////////////////////////////////////////////////*/ + + private void resetSources() { + if (DEBUG) Log.d(TAG, "resetSources() called."); + + this.sources.releaseSource(); + this.sources = new DynamicConcatenatingMediaSource(false, + new ShuffleOrder.UnshuffledShuffleOrder(0)); + } + + private void populateSources() { + if (DEBUG) Log.d(TAG, "populateSources() called."); + if (sources.getSize() >= playQueue.size()) return; + + for (int index = sources.getSize() - 1; index < playQueue.size(); index++) { + emplace(index, new PlaceholderMediaSource()); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // 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) { - if (sources == null) return; - if (queueIndex < 0 || queueIndex < sources.getSize()) return; + private synchronized void emplace(final int index, @NonNull final MediaSource source) { + if (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) { - if (sources == null) return; - if (queueIndex < 0 || queueIndex > sources.getSize()) return; + private synchronized void remove(final int index) { + if (index < 0 || index > sources.getSize()) return; - sources.removeMediaSource(queueIndex); + sources.removeMediaSource(index); } - private void move(final int source, final int target) { - if (sources == null) return; + /** + * 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 synchronized void move(final int source, final int target) { 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. + *

+ * Not recommended to use on indices LESS THAN the currently playing index, since + * 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, + @Nullable final Runnable finalizingAction) { + if (index < 0 || index >= sources.getSize()) return; + + sources.addMediaSource(index + 1, source, () -> + 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/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 3daa58bb7..19e6dc63d 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; @@ -44,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; @@ -83,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; } @@ -131,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); @@ -170,7 +172,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; } @@ -211,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)); } @@ -223,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()) { @@ -349,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); @@ -358,7 +361,7 @@ public abstract class PlayQueue implements Serializable { } queueIndex.set(0); - broadcast(new ReorderEvent()); + broadcast(new ReorderEvent(originIndex, queueIndex.get())); } /** @@ -371,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(); @@ -384,14 +388,14 @@ public abstract class PlayQueue implements Serializable { queueIndex.set(0); } - broadcast(new ReorderEvent()); + broadcast(new ReorderEvent(originIndex, queueIndex.get())); } /*////////////////////////////////////////////////////////////////////////// // 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/playlist/PlayQueueAdapter.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java index cd833c1ab..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,7 +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) { @@ -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 { 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; } 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..ee94ac81f 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,45 @@ 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); + @NonNull + 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) { + @NonNull + 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, + @NonNull + 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, + @NonNull + 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) @@ -131,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) { @@ -150,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) { @@ -159,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/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; + } + } +} 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..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"> @@ -397,6 +398,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/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index fc31ee02c..a897aa185 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -20,6 +20,8 @@ player_gesture_controls 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 495842092..e64f15206 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -72,6 +72,10 @@ 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 + Auto-queue next stream + 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 @@ -109,6 +113,7 @@ Show age restricted content Age Restricted Video. Allowing such material is possible from Settings. live + LIVE Downloads Downloads Error report @@ -413,6 +418,8 @@ Normal Font Larger Font + SYNC + Enable LeakCanary Memory leak monitoring may cause app to become unresponsive when heap dumping 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"/> + + + +