diff --git a/app/src/main/java/org/schabi/newpipe/ImageDownloader.java b/app/src/main/java/org/schabi/newpipe/ImageDownloader.java index 5ea067d00..8baabed6b 100644 --- a/app/src/main/java/org/schabi/newpipe/ImageDownloader.java +++ b/app/src/main/java/org/schabi/newpipe/ImageDownloader.java @@ -1,25 +1,40 @@ package org.schabi.newpipe; import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; import com.nostra13.universalimageloader.core.download.BaseImageDownloader; import org.schabi.newpipe.extractor.NewPipe; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; public class ImageDownloader extends BaseImageDownloader { + private static final ByteArrayInputStream DUMMY_INPUT_STREAM = + new ByteArrayInputStream(new byte[]{}); + + private final SharedPreferences preferences; + private final String downloadThumbnailKey; + public ImageDownloader(Context context) { super(context); + this.preferences = PreferenceManager.getDefaultSharedPreferences(context); + this.downloadThumbnailKey = context.getString(R.string.download_thumbnail_key); } - public ImageDownloader(Context context, int connectTimeout, int readTimeout) { - super(context, connectTimeout, readTimeout); + private boolean isDownloadingThumbnail() { + return preferences.getBoolean(downloadThumbnailKey, true); } protected InputStream getStreamFromNetwork(String imageUri, Object extra) throws IOException { - Downloader downloader = (Downloader) NewPipe.getDownloader(); - return downloader.stream(imageUri); + if (isDownloadingThumbnail()) { + final Downloader downloader = (Downloader) NewPipe.getDownloader(); + return downloader.stream(imageUri); + } else { + return DUMMY_INPUT_STREAM; + } } } 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 cee885e22..5355e19ee 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -57,6 +57,7 @@ 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.extractor.stream.StreamType; import org.schabi.newpipe.history.HistoryRecordManager; import org.schabi.newpipe.player.helper.AudioReactor; import org.schabi.newpipe.player.helper.LoadController; @@ -244,6 +245,7 @@ public abstract class BasePlayer implements playQueue = queue; playQueue.init(); + if (playbackManager != null) playbackManager.dispose(); playbackManager = new MediaSourceManager(this, playQueue); if (playQueueAdapter != null) playQueueAdapter.dispose(); @@ -272,7 +274,6 @@ public abstract class BasePlayer implements public void destroy() { if (DEBUG) Log.d(TAG, "destroy() called"); destroyPlayer(); - clearThumbnailCache(); unregisterBroadcastReceiver(); trackSelector = null; @@ -314,11 +315,6 @@ public abstract class BasePlayer implements if (DEBUG) Log.d(TAG, "Thumbnail - onLoadingCancelled() called with: " + "imageUri = [" + imageUri + "], view = [" + view + "]"); } - - protected void clearThumbnailCache() { - ImageLoader.getInstance().clearMemoryCache(); - } - /*////////////////////////////////////////////////////////////////////////// // MediaSource Building //////////////////////////////////////////////////////////////////////////*/ @@ -448,7 +444,6 @@ public abstract class BasePlayer implements public void onPlaying() { if (DEBUG) Log.d(TAG, "onPlaying() called"); if (!isProgressLoopRunning()) startProgressLoop(); - if (!isCurrentWindowValid()) seekToDefault(); } public void onBuffering() {} @@ -522,11 +517,9 @@ public abstract class BasePlayer implements ); } - private Disposable getProgressReactor() { return Observable.interval(PROGRESS_LOOP_INTERVAL, TimeUnit.MILLISECONDS) .observeOn(AndroidSchedulers.mainThread()) - .filter(ignored -> isProgressLoopRunning()) .subscribe(ignored -> triggerProgressUpdate()); } @@ -541,16 +534,19 @@ public abstract class BasePlayer implements (manifest == null ? "no manifest" : "available manifest") + ", " + "timeline size = [" + timeline.getWindowCount() + "], " + "reason = [" + reason + "]"); + if (playQueue == null) return; 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(); + // ensures MediaSourceManager#update is complete + final boolean isPlaylistStable = timeline.getWindowCount() == playQueue.size(); + // Ensure dynamic/livestream timeline changes does not cause negative position + if (isPlaylistStable && !isCurrentWindowValid()) { + simpleExoPlayer.seekTo(/*clampToMillis=*/0); } + break; } } @@ -775,6 +771,16 @@ public abstract class BasePlayer implements // Playback Listener //////////////////////////////////////////////////////////////////////////*/ + @Override + public boolean isNearPlaybackEdge(final long timeToEndMillis) { + // If live, then not near playback edge + if (simpleExoPlayer == null || simpleExoPlayer.isCurrentWindowDynamic()) return false; + + final long currentPositionMillis = simpleExoPlayer.getCurrentPosition(); + final long currentDurationMillis = simpleExoPlayer.getDuration(); + return currentDurationMillis - currentPositionMillis < timeToEndMillis; + } + @Override public void onPlaybackBlock() { if (simpleExoPlayer == null) return; @@ -796,7 +802,6 @@ public abstract class BasePlayer implements if (getCurrentState() == STATE_BLOCKED) changeState(STATE_BUFFERING); simpleExoPlayer.prepare(mediaSource); - seekToDefault(); } @Override @@ -825,16 +830,24 @@ public abstract class BasePlayer implements if (simpleExoPlayer == null) return; final int currentPlaylistIndex = simpleExoPlayer.getCurrentWindowIndex(); + final int currentPlaylistSize = simpleExoPlayer.getCurrentTimeline().getWindowCount(); // Check if on wrong window if (currentPlayQueueIndex != playQueue.getIndex()) { - Log.e(TAG, "Play Queue may be desynchronized: item " + + Log.e(TAG, "Playback - Play Queue may be desynchronized: item " + "index=[" + currentPlayQueueIndex + "], " + "queue index=[" + playQueue.getIndex() + "]"); - // on metadata changed + // Check if bad seek position + } else if ((currentPlaylistSize > 0 && currentPlayQueueIndex > currentPlaylistSize) || + currentPlaylistIndex < 0) { + Log.e(TAG, "Playback - Trying to seek to " + + "index=[" + currentPlayQueueIndex + "] with " + + "playlist length=[" + currentPlaylistSize + "]"); + + // If not playing correct stream, change window position } else if (currentPlaylistIndex != currentPlayQueueIndex || !isPlaying()) { final long startPos = info != null ? info.getStartPosition() : C.TIME_UNSET; - if (DEBUG) Log.d(TAG, "Rewinding to correct" + + if (DEBUG) Log.d(TAG, "Playback - Rewinding to correct" + " window=[" + currentPlayQueueIndex + "]," + " at=[" + getTimeString((int)startPos) + "]," + " from=[" + simpleExoPlayer.getCurrentWindowIndex() + "]."); @@ -858,6 +871,11 @@ public abstract class BasePlayer implements @Nullable @Override public MediaSource sourceOf(PlayQueueItem item, StreamInfo info) { + final StreamType streamType = info.getStreamType(); + if (!(streamType == StreamType.AUDIO_LIVE_STREAM || streamType == StreamType.LIVE_STREAM)) { + return null; + } + if (!info.getHlsUrl().isEmpty()) { return buildLiveMediaSource(info.getHlsUrl(), C.TYPE_HLS); } else if (!info.getDashMpdUrl().isEmpty()) { @@ -909,6 +927,9 @@ public abstract class BasePlayer implements if (DEBUG) Log.d(TAG, "onPrepared() called with: playWhenReady = [" + playWhenReady + "]"); if (playWhenReady) audioReactor.requestAudioFocus(); changeState(playWhenReady ? STATE_PLAYING : STATE_PAUSED); + + // On live prepared + if (simpleExoPlayer.isCurrentWindowDynamic()) seekToDefault(); } public void onVideoPlayPause() { @@ -945,14 +966,15 @@ public abstract class BasePlayer implements if (simpleExoPlayer == null || playQueue == null) return; if (DEBUG) Log.d(TAG, "onPlayPrevious() called"); - savePlaybackState(); - - /* If current playback has run for PLAY_PREV_ACTIVATION_LIMIT milliseconds, restart current track. - * Also restart the track if the current track is the first in a queue.*/ - if (simpleExoPlayer.getCurrentPosition() > PLAY_PREV_ACTIVATION_LIMIT || playQueue.getIndex() == 0) { - final long startPos = currentInfo == null ? 0 : currentInfo.getStartPosition(); - simpleExoPlayer.seekTo(startPos); + /* If current playback has run for PLAY_PREV_ACTIVATION_LIMIT milliseconds, + * restart current track. Also restart the track if the current track + * is the first in a queue.*/ + if (simpleExoPlayer.getCurrentPosition() > PLAY_PREV_ACTIVATION_LIMIT || + playQueue.getIndex() == 0) { + seekToDefault(); + playQueue.offsetIndex(0); } else { + savePlaybackState(); playQueue.offsetIndex(-1); } } @@ -962,7 +984,6 @@ public abstract class BasePlayer implements if (DEBUG) Log.d(TAG, "onPlayNext() called"); savePlaybackState(); - playQueue.offsetIndex(+1); } @@ -975,8 +996,9 @@ public abstract class BasePlayer implements if (playQueue.getIndex() == index && simpleExoPlayer.getCurrentWindowIndex() == index) { seekToDefault(); } else { - playQueue.setIndex(index); + savePlaybackState(); } + playQueue.setIndex(index); } public void seekBy(int milliSeconds) { @@ -1015,8 +1037,11 @@ public abstract class BasePlayer implements protected void reload() { if (playbackManager != null) { - playbackManager.reset(); - playbackManager.load(); + playbackManager.dispose(); + } + + if (playQueue != null) { + playbackManager = new MediaSourceManager(this, playQueue); } } 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 4f27d1fee..dd7e0c71e 100644 --- a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java @@ -62,6 +62,7 @@ import org.schabi.newpipe.playlist.PlayQueue; import org.schabi.newpipe.playlist.PlayQueueItem; import org.schabi.newpipe.playlist.PlayQueueItemBuilder; import org.schabi.newpipe.playlist.PlayQueueItemHolder; +import org.schabi.newpipe.playlist.PlayQueueItemTouchCallback; import org.schabi.newpipe.util.AnimationUtils; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; @@ -76,6 +77,8 @@ 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.Type.SLIDE_AND_ALPHA; +import static org.schabi.newpipe.util.AnimationUtils.animateRotation; import static org.schabi.newpipe.util.AnimationUtils.animateView; import static org.schabi.newpipe.util.StateSaver.KEY_SAVED_STATE; @@ -110,7 +113,7 @@ public final class MainVideoPlayer extends Activity implements StateSaver.WriteR if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) getWindow().setStatusBarColor(Color.BLACK); setVolumeControlStream(AudioManager.STREAM_MUSIC); - changeSystemUi(); + hideSystemUi(); setContentView(R.layout.activity_main_player); playerImpl = new VideoPlayerImpl(this); playerImpl.setup(findViewById(android.R.id.content)); @@ -597,28 +600,27 @@ public final class MainVideoPlayer extends Activity implements StateSaver.WriteR updatePlaybackButtons(); getControlsRoot().setVisibility(View.INVISIBLE); - queueLayout.setVisibility(View.VISIBLE); + animateView(queueLayout, SLIDE_AND_ALPHA, /*visible=*/true, + DEFAULT_CONTROLS_DURATION); itemsList.scrollToPosition(playQueue.getIndex()); } private void onQueueClosed() { - queueLayout.setVisibility(View.GONE); + animateView(queueLayout, SLIDE_AND_ALPHA, /*visible=*/false, + DEFAULT_CONTROLS_DURATION); queueVisible = false; } private void onMoreOptionsClicked() { if (DEBUG) Log.d(TAG, "onMoreOptionsClicked() called"); - if (secondaryControls.getVisibility() == View.VISIBLE) { - moreOptionsButton.setImageDrawable(getResources().getDrawable( - R.drawable.ic_expand_more_white_24dp)); - animateView(secondaryControls, false, 200); - } else { - moreOptionsButton.setImageDrawable(getResources().getDrawable( - R.drawable.ic_expand_less_white_24dp)); - animateView(secondaryControls, true, 200); - } + final boolean isMoreControlsVisible = secondaryControls.getVisibility() == View.VISIBLE; + + animateRotation(moreOptionsButton, DEFAULT_CONTROLS_DURATION, + isMoreControlsVisible ? 0 : 180); + animateView(secondaryControls, SLIDE_AND_ALPHA, !isMoreControlsVisible, + DEFAULT_CONTROLS_DURATION); showControls(DEFAULT_CONTROLS_DURATION); } @@ -696,7 +698,6 @@ public final class MainVideoPlayer extends Activity implements StateSaver.WriteR animatePlayButtons(true, 200); }); - changeSystemUi(); getRootView().setKeepScreenOn(true); } @@ -798,31 +799,11 @@ public final class MainVideoPlayer extends Activity implements StateSaver.WriteR } private ItemTouchHelper.SimpleCallback getItemTouchCallback() { - return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { + return new PlayQueueItemTouchCallback() { @Override - public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder source, RecyclerView.ViewHolder target) { - if (source.getItemViewType() != target.getItemViewType()) { - return false; - } - - final int sourceIndex = source.getLayoutPosition(); - final int targetIndex = target.getLayoutPosition(); - playQueue.move(sourceIndex, targetIndex); - return true; + public void onMove(int sourceIndex, int targetIndex) { + if (playQueue != null) playQueue.move(sourceIndex, targetIndex); } - - @Override - public boolean isLongPressDragEnabled() { - return false; - } - - @Override - public boolean isItemViewSwipeEnabled() { - return false; - } - - @Override - public void onSwiped(RecyclerView.ViewHolder viewHolder, int swipeDir) {} }; } 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 c68133094..1c3ffe911 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java @@ -34,6 +34,7 @@ import org.schabi.newpipe.player.event.PlayerEventListener; import org.schabi.newpipe.playlist.PlayQueueItem; import org.schabi.newpipe.playlist.PlayQueueItemBuilder; import org.schabi.newpipe.playlist.PlayQueueItemHolder; +import org.schabi.newpipe.playlist.PlayQueueItemTouchCallback; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.ThemeHelper; @@ -61,9 +62,6 @@ public abstract class ServicePlayerActivity extends AppCompatActivity private static final int SMOOTH_SCROLL_MAXIMUM_DISTANCE = 80; - private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 10; - private static final int MAXIMUM_INITIAL_DRAG_VELOCITY = 25; - private View rootView; private RecyclerView itemsList; @@ -398,43 +396,11 @@ public abstract class ServicePlayerActivity extends AppCompatActivity } private ItemTouchHelper.SimpleCallback getItemTouchCallback() { - return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { + return new PlayQueueItemTouchCallback() { @Override - public int interpolateOutOfBoundsScroll(RecyclerView recyclerView, int viewSize, - int viewSizeOutOfBounds, int totalSize, - long msSinceStartScroll) { - final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, - viewSizeOutOfBounds, totalSize, msSinceStartScroll); - final int clampedAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY, - Math.min(Math.abs(standardSpeed), MAXIMUM_INITIAL_DRAG_VELOCITY)); - return clampedAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); - } - - @Override - public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder source, - RecyclerView.ViewHolder target) { - if (source.getItemViewType() != target.getItemViewType()) { - return false; - } - - final int sourceIndex = source.getLayoutPosition(); - final int targetIndex = target.getLayoutPosition(); + public void onMove(int sourceIndex, int targetIndex) { if (player != null) player.getPlayQueue().move(sourceIndex, targetIndex); - return true; } - - @Override - public boolean isLongPressDragEnabled() { - return false; - } - - @Override - public boolean isItemViewSwipeEnabled() { - return false; - } - - @Override - public void onSwiped(RecyclerView.ViewHolder viewHolder, int swipeDir) {} }; } 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 ea13a28e7..50c069b40 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 @@ -21,15 +21,15 @@ 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 org.schabi.newpipe.util.ServiceHelper; -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.Observable; import io.reactivex.Single; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; @@ -42,7 +42,7 @@ import io.reactivex.subjects.PublishSubject; import static org.schabi.newpipe.playlist.PlayQueue.DEBUG; public class MediaSourceManager { - @NonNull private final static String TAG = "MediaSourceManager"; + @NonNull private final String TAG = "MediaSourceManager@" + hashCode(); /** * Determines how many streams before and after the current stream should be loaded. @@ -60,17 +60,18 @@ public class MediaSourceManager { @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. + * Determines the gap time between the playback position and the playback duration which + * the {@link #getEdgeIntervalSignal()} begins to request loading. * - * @see #loadImmediate() - * @see #isCorrectionNeeded(PlayQueueItem) + * @see #progressUpdateIntervalMillis * */ - private final long windowRefreshTimeMillis; + private final long playbackNearEndGapMillis; + /** + * Determines the interval which the {@link #getEdgeIntervalSignal()} waits for between + * each request for loading, once {@link #playbackNearEndGapMillis} has reached. + * */ + private final long progressUpdateIntervalMillis; + @NonNull private final Observable nearEndIntervalSignal; /** * Process only the last load order when receiving a stream of load orders (lessens I/O). @@ -106,23 +107,31 @@ public class MediaSourceManager { public MediaSourceManager(@NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue) { - this(listener, playQueue, - /*loadDebounceMillis=*/400L, - /*windowRefreshTimeMillis=*/TimeUnit.MILLISECONDS.convert(10, TimeUnit.MINUTES)); + this(listener, playQueue, /*loadDebounceMillis=*/400L, + /*playbackNearEndGapMillis=*/TimeUnit.MILLISECONDS.convert(30, TimeUnit.SECONDS), + /*progressUpdateIntervalMillis*/TimeUnit.MILLISECONDS.convert(2, TimeUnit.SECONDS)); } private MediaSourceManager(@NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue, final long loadDebounceMillis, - final long windowRefreshTimeMillis) { + final long playbackNearEndGapMillis, + final long progressUpdateIntervalMillis) { if (playQueue.getBroadcastReceiver() == null) { throw new IllegalArgumentException("Play Queue has not been initialized."); } + if (playbackNearEndGapMillis < progressUpdateIntervalMillis) { + throw new IllegalArgumentException("Playback end gap=[" + playbackNearEndGapMillis + + " ms] must be longer than update interval=[ " + progressUpdateIntervalMillis + + " ms] for them to be useful."); + } this.playbackListener = listener; this.playQueue = playQueue; - this.windowRefreshTimeMillis = windowRefreshTimeMillis; + this.playbackNearEndGapMillis = playbackNearEndGapMillis; + this.progressUpdateIntervalMillis = progressUpdateIntervalMillis; + this.nearEndIntervalSignal = getEdgeIntervalSignal(); this.loadDebounceMillis = loadDebounceMillis; this.debouncedSignal = PublishSubject.create(); @@ -161,28 +170,6 @@ public class MediaSourceManager { sources.releaseSource(); } - /** - * Loads the current playing stream and the streams within its windowSize bound. - * - * Unblocks the player once the item at the current index is loaded. - * */ - public void load() { - 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}. - * */ - public void reset() { - if (DEBUG) Log.d(TAG, "reset() called."); - - maybeBlock(); - populateSources(); - } /*////////////////////////////////////////////////////////////////////////// // Event Reactor //////////////////////////////////////////////////////////////////////////*/ @@ -219,11 +206,13 @@ public class MediaSourceManager { switch (event.type()) { case INIT: case ERROR: - reset(); - break; + maybeBlock(); case APPEND: populateSources(); break; + case SELECT: + maybeRenewCurrentIndex(); + break; case REMOVE: final RemoveEvent removeEvent = (RemoveEvent) event; remove(removeEvent.getRemoveIndex()); @@ -238,7 +227,6 @@ public class MediaSourceManager { final ReorderEvent reorderEvent = (ReorderEvent) event; move(reorderEvent.getFromSelectedIndex(), reorderEvent.getToSelectedIndex()); break; - case SELECT: case RECOVERY: default: break; @@ -347,8 +335,13 @@ public class MediaSourceManager { // MediaSource Loading //////////////////////////////////////////////////////////////////////////*/ + private Observable getEdgeIntervalSignal() { + return Observable.interval(progressUpdateIntervalMillis, TimeUnit.MILLISECONDS) + .filter(ignored -> playbackListener.isNearPlaybackEdge(playbackNearEndGapMillis)); + } + private Disposable getDebouncedLoader() { - return debouncedSignal + return debouncedSignal.mergeWith(nearEndIntervalSignal) .debounce(loadDebounceMillis, TimeUnit.MILLISECONDS) .observeOn(AndroidSchedulers.mainThread()) .subscribe(timestamp -> loadImmediate()); @@ -359,13 +352,14 @@ public class MediaSourceManager { } private void loadImmediate() { + if (DEBUG) Log.d(TAG, "MediaSource - loadImmediate() called"); // The current item has higher priority final int currentIndex = playQueue.getIndex(); final PlayQueueItem currentItem = playQueue.getItem(currentIndex); if (currentItem == null) return; // Evict the items being loaded to free up memory - if (!loadingItems.contains(currentItem) && loaderReactor.size() > MAXIMUM_LOADER_SIZE) { + if (loaderReactor.size() > MAXIMUM_LOADER_SIZE) { loaderReactor.clear(); loadingItems.clear(); } @@ -377,7 +371,7 @@ public class MediaSourceManager { 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<>( + final Set items = new HashSet<>( playQueue.getStreams().subList(leftBound,rightBound)); // Do a round robin @@ -385,6 +379,7 @@ public class MediaSourceManager { if (excess >= 0) { items.addAll(playQueue.getStreams().subList(0, Math.min(playQueue.size(), excess))); } + items.remove(currentItem); for (final PlayQueueItem item : items) { maybeLoadItem(item); @@ -405,9 +400,9 @@ public class MediaSourceManager { /* No exception handling since getLoadedMediaSource guarantees nonnull return */ .subscribe(mediaSource -> onMediaSourceReceived(item, mediaSource)); loaderReactor.add(loader); + } else { + maybeSynchronizePlayer(); } - - maybeSynchronizePlayer(); } private Single getLoadedMediaSource(@NonNull final PlayQueueItem stream) { @@ -423,7 +418,8 @@ public class MediaSourceManager { return new FailedMediaSource(stream, exception); } - final long expiration = System.currentTimeMillis() + windowRefreshTimeMillis; + final long expiration = System.currentTimeMillis() + + ServiceHelper.getCacheExpirationMillis(streamInfo.getServiceId()); return new LoadedMediaSource(source, stream, expiration); }).onErrorReturn(throwable -> new FailedMediaSource(stream, throwable)); } @@ -467,6 +463,24 @@ public class MediaSourceManager { } } + /** + * Checks if the current playing index contains an expired {@link ManagedMediaSource}. + * If so, the expired source is replaced by a {@link PlaceholderMediaSource} and + * {@link #loadImmediate()} is called to reload the current item. + * */ + private void maybeRenewCurrentIndex() { + final int currentIndex = playQueue.getIndex(); + if (sources.getSize() <= currentIndex) return; + + final ManagedMediaSource currentSource = + (ManagedMediaSource) sources.getMediaSource(currentIndex); + final PlayQueueItem currentItem = playQueue.getItem(); + if (!currentSource.canReplace(currentItem)) return; + + if (DEBUG) Log.d(TAG, "MediaSource - Reloading currently playing, " + + "index=[" + currentIndex + "], item=[" + currentItem.getTitle() + "]"); + update(currentIndex, new PlaceholderMediaSource(), this::loadImmediate); + } /*////////////////////////////////////////////////////////////////////////// // MediaSource Playlist Helpers //////////////////////////////////////////////////////////////////////////*/ @@ -476,6 +490,7 @@ public class MediaSourceManager { this.sources.releaseSource(); this.sources = new DynamicConcatenatingMediaSource(false, + // Shuffling is done on PlayQueue, thus no need to use ExoPlayer's shuffle order new ShuffleOrder.UnshuffledShuffleOrder(0)); } 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 b37a269e2..34c7702bc 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 @@ -11,6 +11,16 @@ import org.schabi.newpipe.playlist.PlayQueueItem; import java.util.List; public interface PlaybackListener { + + /** + * Called to check if the currently playing stream is close to the end of its playback. + * Implementation should return true when the current playback position is within + * timeToEndMillis or less until its playback completes or transitions. + * + * May be called at any time. + * */ + boolean isNearPlaybackEdge(final long timeToEndMillis); + /** * Called when the stream at the current queue index is not ready yet. * Signals to the listener to block the player from playing anything and notify the source diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItemTouchCallback.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItemTouchCallback.java new file mode 100644 index 000000000..405dba11e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItemTouchCallback.java @@ -0,0 +1,52 @@ +package org.schabi.newpipe.playlist; + +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.helper.ItemTouchHelper; + +public abstract class PlayQueueItemTouchCallback extends ItemTouchHelper.SimpleCallback { + private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 10; + private static final int MAXIMUM_INITIAL_DRAG_VELOCITY = 25; + + public PlayQueueItemTouchCallback() { + super(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0); + } + + public abstract void onMove(final int sourceIndex, final int targetIndex); + + @Override + public int interpolateOutOfBoundsScroll(RecyclerView recyclerView, int viewSize, + int viewSizeOutOfBounds, int totalSize, + long msSinceStartScroll) { + final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, + viewSizeOutOfBounds, totalSize, msSinceStartScroll); + final int clampedAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY, + Math.min(Math.abs(standardSpeed), MAXIMUM_INITIAL_DRAG_VELOCITY)); + return clampedAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); + } + + @Override + public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder source, + RecyclerView.ViewHolder target) { + if (source.getItemViewType() != target.getItemViewType()) { + return false; + } + + final int sourceIndex = source.getLayoutPosition(); + final int targetIndex = target.getLayoutPosition(); + onMove(sourceIndex, targetIndex); + return true; + } + + @Override + public boolean isLongPressDragEnabled() { + return false; + } + + @Override + public boolean isItemViewSwipeEnabled() { + return false; + } + + @Override + public void onSwiped(RecyclerView.ViewHolder viewHolder, int swipeDir) {} +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java index e0836e06c..53e8d6fc4 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java @@ -1,12 +1,35 @@ package org.schabi.newpipe.settings; import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v7.preference.Preference; +import android.widget.Toast; import org.schabi.newpipe.R; +import org.schabi.newpipe.util.InfoCache; public class HistorySettingsFragment extends BasePreferenceFragment { + private String cacheWipeKey; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + cacheWipeKey = getString(R.string.metadata_cache_wipe_key); + } + @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { addPreferencesFromResource(R.xml.history_settings); } + + @Override + public boolean onPreferenceTreeClick(Preference preference) { + if (preference.getKey().equals(cacheWipeKey)) { + InfoCache.getInstance().clearCache(); + Toast.makeText(preference.getContext(), R.string.metadata_cache_wipe_complete_notice, + Toast.LENGTH_SHORT).show(); + } + + return super.onPreferenceTreeClick(preference); + } } 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 47c45e82a..ecc66bb40 100644 --- a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java +++ b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java @@ -43,7 +43,6 @@ public final class InfoCache { * Trim the cache to this size */ private static final int TRIM_CACHE_TO = 30; - private static final int DEFAULT_TIMEOUT_HOURS = 4; private static final LruCache lruCache = new LruCache<>(MAX_ITEMS_ON_CACHE); @@ -66,13 +65,7 @@ public final class InfoCache { public void putInfo(int serviceId, @NonNull String url, @NonNull Info info) { if (DEBUG) Log.d(TAG, "putInfo() 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); - } - + final long expirationMillis = ServiceHelper.getCacheExpirationMillis(info.getServiceId()); synchronized (lruCache) { final CacheData data = new CacheData(info, expirationMillis); lruCache.put(keyOf(serviceId, url), data); diff --git a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java index 7d71750eb..9d71ae83a 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java @@ -12,6 +12,10 @@ import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import java.util.concurrent.TimeUnit; + +import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; + public class ServiceHelper { private static final StreamingService DEFAULT_FALLBACK_SERVICE = ServiceList.YouTube; @@ -98,4 +102,12 @@ public class ServiceHelper { PreferenceManager.getDefaultSharedPreferences(context).edit(). putString(context.getString(R.string.current_service_key), serviceName).apply(); } + + public static long getCacheExpirationMillis(final int serviceId) { + if (serviceId == SoundCloud.getServiceId()) { + return TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES); + } else { + return TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS); + } + } } 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 c3480c547..11765f901 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 @@ -301,9 +301,13 @@ android:id="@+id/live_sync" android:layout_width="wrap_content" android:layout_height="match_parent" + android:paddingLeft="4dp" + android:paddingRight="4dp" android:gravity="center" - android:text="@string/live_sync" + android:text="@string/duration_live" + android:textAllCaps="true" android:textColor="?attr/colorAccent" + android:maxLength="4" android:background="?attr/selectableItemBackground" android:visibility="gone"/> diff --git a/app/src/main/res/layout/activity_main_player.xml b/app/src/main/res/layout/activity_main_player.xml index e7d337c17..8f608de3a 100644 --- a/app/src/main/res/layout/activity_main_player.xml +++ b/app/src/main/res/layout/activity_main_player.xml @@ -308,7 +308,7 @@ android:id="@+id/toggleOrientation" android:layout_width="30dp" android:layout_height="30dp" - android:layout_marginLeft="2dp" + android:layout_marginLeft="4dp" android:layout_marginRight="2dp" android:layout_alignParentRight="true" android:layout_centerVertical="true" @@ -325,8 +325,8 @@ android:id="@+id/switchPopup" android:layout_width="30dp" android:layout_height="30dp" - android:layout_marginLeft="2dp" - android:layout_marginRight="2dp" + android:layout_marginLeft="4dp" + android:layout_marginRight="4dp" android:layout_toLeftOf="@id/toggleOrientation" android:layout_centerVertical="true" android:clickable="true" @@ -341,8 +341,8 @@ android:id="@+id/switchBackground" android:layout_width="30dp" android:layout_height="30dp" - android:layout_marginLeft="2dp" - android:layout_marginRight="2dp" + android:layout_marginLeft="4dp" + android:layout_marginRight="4dp" android:layout_toLeftOf="@id/switchPopup" android:layout_centerVertical="true" android:clickable="true" @@ -403,9 +403,13 @@ android:id="@+id/playbackLiveSync" android:layout_width="wrap_content" android:layout_height="match_parent" + android:paddingLeft="4dp" + android:paddingRight="4dp" android:gravity="center" - android:text="@string/live_sync" + android:text="@string/duration_live" + android:textAllCaps="true" android:textColor="@android:color/white" + android:maxLength="4" android:visibility="gone" android:background="?attr/selectableItemBackground" tools:ignore="HardcodedText,RtlHardcoded,RtlSymmetry" /> 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 639a8037c..7f649e382 100644 --- a/app/src/main/res/layout/activity_player_queue_control.xml +++ b/app/src/main/res/layout/activity_player_queue_control.xml @@ -151,9 +151,13 @@ android:id="@+id/live_sync" android:layout_width="wrap_content" android:layout_height="match_parent" + android:paddingLeft="4dp" + android:paddingRight="4dp" android:gravity="center" - android:text="@string/live_sync" + android:text="@string/duration_live" + android:textAllCaps="true" android:textColor="?attr/colorAccent" + android:maxLength="4" android:background="?attr/selectableItemBackground" android:visibility="gone"/> diff --git a/app/src/main/res/layout/player_popup.xml b/app/src/main/res/layout/player_popup.xml index 9bbd72fec..0c3ea77df 100644 --- a/app/src/main/res/layout/player_popup.xml +++ b/app/src/main/res/layout/player_popup.xml @@ -195,9 +195,13 @@ android:id="@+id/playbackLiveSync" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:paddingLeft="4dp" + android:paddingRight="4dp" android:gravity="center_vertical" - android:text="@string/live_sync" + android:text="@string/duration_live" + android:textAllCaps="true" android:textColor="@android:color/white" + android:maxLength="4" android:visibility="gone" android:background="?attr/selectableItemBackground" tools:ignore="HardcodedText,RtlHardcoded,RtlSymmetry" /> diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index a897aa185..68d75737a 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -160,6 +160,10 @@ import_data export_data + download_thumbnail_key + + cache_wipe_key + file_rename file_replacement_character diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c97f12809..e1a353807 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -74,6 +74,11 @@ Remember last size and position of popup Use fast inexact seek Inexact seek allows the player to seek to positions faster with reduced precision + Load thumbnails + Disable to stop all non-cached thumbnail from loading and save on data and memory usage + Wipe cached metadata + Remove all cached webpage data + Metadata cache wiped 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 @@ -89,7 +94,7 @@ Download Next video Show next and similar videos - Show Hold to Append Tip + Show hold to append tip Show tip when background or popup button is pressed on video details page URL not supported Default content country @@ -98,7 +103,7 @@ Player Behavior Video & Audio - History + History & Cache Popup Appearance Other @@ -418,18 +423,16 @@ ZOOM Auto-generated - Caption Font Size - Smaller Font - Normal Font - Larger Font - - SYNC + Caption font size + Smaller font + Normal font + Larger font Enable LeakCanary Memory leak monitoring may cause app to become unresponsive when heap dumping - Report Out-of-Lifecycle Errors + Report Out-of-lifecycle errors Force reporting of undeliverable Rx exceptions occurring outside of fragment or activity lifecycle after dispose diff --git a/app/src/main/res/xml/content_settings.xml b/app/src/main/res/xml/content_settings.xml index c8c1efb12..2ce8bf9e6 100644 --- a/app/src/main/res/xml/content_settings.xml +++ b/app/src/main/res/xml/content_settings.xml @@ -37,6 +37,12 @@ android:summary="@string/auto_queue_summary" android:title="@string/auto_queue_title"/> + + + +