diff --git a/app/build.gradle b/app/build.gradle index e20535ee4..a0b4da510 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -42,7 +42,7 @@ android { ext { supportLibVersion = '27.1.0' - exoPlayerLibVersion = '2.7.1' + exoPlayerLibVersion = '2.7.3' roomDbLibVersion = '1.0.0' leakCanaryLibVersion = '1.5.4' okHttpLibVersion = '1.5.0' @@ -73,6 +73,7 @@ dependencies { implementation 'de.hdodenhof:circleimageview:2.2.0' implementation 'com.github.nirhart:ParallaxScroll:dd53d1f9d1' implementation 'com.nononsenseapps:filepicker:4.2.1' + implementation "com.google.android.exoplayer:exoplayer:$exoPlayerLibVersion" implementation "com.google.android.exoplayer:extension-mediasession:$exoPlayerLibVersion" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7447c81ed..1e55270be 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,7 +42,11 @@ + android:exported="false"> + + + + triggerProgressUpdate()); } @@ -553,8 +555,8 @@ public abstract class BasePlayer implements // Ensure dynamic/livestream timeline changes does not cause negative position if (isPlaylistStable && !isCurrentWindowValid() && !isSynchronizing) { if (DEBUG) Log.d(TAG, "Playback - negative time position reached, " + - "clamping position to 0ms."); - seekTo(/*clampToTime=*/0); + "clamping to default position."); + seekToDefault(); } break; } @@ -640,12 +642,12 @@ public abstract class BasePlayer implements seekTo(recoveryPositionMillis); playQueue.unsetRecovery(currentSourceIndex); - } else if (isSynchronizing && simpleExoPlayer.isCurrentWindowDynamic()) { + } else if (isSynchronizing && isLive()) { if (DEBUG) Log.d(TAG, "Playback - Synchronizing livestream to default time"); // Is still synchronizing? seekToDefault(); - } else if (isSynchronizing && presetStartPositionMillis != 0L) { + } else if (isSynchronizing && presetStartPositionMillis > 0L) { if (DEBUG) Log.d(TAG, "Playback - Seeking to preset start " + "position=[" + presetStartPositionMillis + "]"); // Has another start position? @@ -700,41 +702,23 @@ public abstract class BasePlayer implements } } - /** - * 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(); - } + setRecovery(); final Throwable cause = error.getCause(); if (cause instanceof BehindLiveWindowException) { reload(); } else if (cause instanceof UnknownHostException) { playQueue.error(/*isNetworkProblem=*/true); + } else if (isCurrentWindowValid()) { + playQueue.error(/*isTransitioningToBadStream=*/true); + } else if (cause instanceof FailedMediaSource.MediaSourceResolutionException) { + playQueue.error(/*recoverableWithNoAvailableStream=*/false); + } else if (cause instanceof FailedMediaSource.StreamInfoLoadException) { + playQueue.error(/*recoverableIfLoadFailsWhenNetworkIsFine=*/false); } else { - playQueue.error(isCurrentWindowValid()); + playQueue.error(/*noIdeaWhatHappenedAndLetUserChooseWhatToDo=*/true); } } @@ -787,9 +771,10 @@ public abstract class BasePlayer implements //////////////////////////////////////////////////////////////////////////*/ @Override - public boolean isNearPlaybackEdge(final long timeToEndMillis) { + public boolean isApproachingPlaybackEdge(final long timeToEndMillis) { // If live, then not near playback edge - if (simpleExoPlayer == null || simpleExoPlayer.isCurrentWindowDynamic()) return false; + // If not playing, then not approaching playback edge + if (simpleExoPlayer == null || isLive() || !isPlaying()) return false; final long currentPositionMillis = simpleExoPlayer.getCurrentPosition(); final long currentDurationMillis = simpleExoPlayer.getDuration(); @@ -985,22 +970,22 @@ public abstract class BasePlayer implements public void onFastRewind() { if (DEBUG) Log.d(TAG, "onFastRewind() called"); - seekBy(-FAST_FORWARD_REWIND_AMOUNT); + seekBy(-FAST_FORWARD_REWIND_AMOUNT_MILLIS); } public void onFastForward() { if (DEBUG) Log.d(TAG, "onFastForward() called"); - seekBy(FAST_FORWARD_REWIND_AMOUNT); + seekBy(FAST_FORWARD_REWIND_AMOUNT_MILLIS); } public void onPlayPrevious() { if (simpleExoPlayer == null || playQueue == null) return; if (DEBUG) Log.d(TAG, "onPlayPrevious() called"); - /* If current playback has run for PLAY_PREV_ACTIVATION_LIMIT milliseconds, + /* If current playback has run for PLAY_PREV_ACTIVATION_LIMIT_MILLIS 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 || + if (simpleExoPlayer.getCurrentPosition() > PLAY_PREV_ACTIVATION_LIMIT_MILLIS || playQueue.getIndex() == 0) { seekToDefault(); playQueue.offsetIndex(0); @@ -1050,7 +1035,9 @@ public abstract class BasePlayer implements } public void seekToDefault() { - if (simpleExoPlayer != null) simpleExoPlayer.seekToDefaultPosition(); + if (simpleExoPlayer != null) { + simpleExoPlayer.seekToDefaultPosition(); + } } /*////////////////////////////////////////////////////////////////////////// @@ -1091,9 +1078,9 @@ public abstract class BasePlayer implements private void savePlaybackState() { if (simpleExoPlayer == null || currentInfo == null) return; - if (simpleExoPlayer.getCurrentPosition() > RECOVERY_SKIP_THRESHOLD && + if (simpleExoPlayer.getCurrentPosition() > RECOVERY_SKIP_THRESHOLD_MILLIS && simpleExoPlayer.getCurrentPosition() < - simpleExoPlayer.getDuration() - RECOVERY_SKIP_THRESHOLD) { + simpleExoPlayer.getDuration() - RECOVERY_SKIP_THRESHOLD_MILLIS) { savePlaybackState(currentInfo, simpleExoPlayer.getCurrentPosition()); } } @@ -1127,9 +1114,7 @@ public abstract class BasePlayer implements /** Checks if the current playback is a livestream AND is playing at or beyond the live edge */ public boolean isLiveEdge() { - if (simpleExoPlayer == null) return false; - final boolean isLive = simpleExoPlayer.isCurrentWindowDynamic(); - if (!isLive) return false; + if (simpleExoPlayer == null || !isLive()) return false; final Timeline currentTimeline = simpleExoPlayer.getCurrentTimeline(); final int currentWindowIndex = simpleExoPlayer.getCurrentWindowIndex(); @@ -1143,6 +1128,16 @@ public abstract class BasePlayer implements return timelineWindow.getDefaultPositionMs() <= simpleExoPlayer.getCurrentPosition(); } + public boolean isLive() { + if (simpleExoPlayer == null) return false; + try { + return simpleExoPlayer.isCurrentWindowDynamic(); + } catch (@NonNull IndexOutOfBoundsException ignored) { + // Why would this even happen =( + return false; + } + } + public boolean isPlaying() { final int state = simpleExoPlayer.getPlaybackState(); return (state == Player.STATE_READY || state == Player.STATE_BUFFERING) @@ -1170,10 +1165,6 @@ public abstract class BasePlayer implements setPlaybackParameters(speed, getPlaybackPitch()); } - public void setPlaybackPitch(float pitch) { - setPlaybackParameters(getPlaybackSpeed(), pitch); - } - public PlaybackParameters getPlaybackParameters() { final PlaybackParameters defaultParameters = new PlaybackParameters(1f, 1f); if (simpleExoPlayer == null) return defaultParameters; 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 dbc34b11a..19621593c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java @@ -30,8 +30,10 @@ import android.os.Build; import android.os.Bundle; import android.preference.PreferenceManager; import android.provider.Settings; +import android.support.annotation.ColorInt; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.v4.app.ActivityCompat; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.helper.ItemTouchHelper; @@ -59,7 +61,6 @@ import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; import org.schabi.newpipe.player.helper.PlaybackParameterDialog; 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; @@ -95,12 +96,12 @@ public final class MainVideoPlayer extends AppCompatActivity private GestureDetector gestureDetector; - private boolean activityPaused; private VideoPlayerImpl playerImpl; private SharedPreferences defaultPreferences; - @Nullable private StateSaver.SavedState savedState; + @Nullable private PlayerState playerState; + private boolean isInMultiWindow; /*////////////////////////////////////////////////////////////////////////// // Activity LifeCycle @@ -135,8 +136,9 @@ public final class MainVideoPlayer extends AppCompatActivity @Override protected void onRestoreInstanceState(@NonNull Bundle bundle) { + if (DEBUG) Log.d(TAG, "onRestoreInstanceState() called"); super.onRestoreInstanceState(bundle); - savedState = StateSaver.tryToRestore(bundle, this); + StateSaver.tryToRestore(bundle, this); } @Override @@ -148,26 +150,28 @@ public final class MainVideoPlayer extends AppCompatActivity @Override protected void onResume() { - super.onResume(); if (DEBUG) Log.d(TAG, "onResume() called"); - if (playerImpl.getPlayer() != null && activityPaused && playerImpl.wasPlaying() - && !playerImpl.isPlaying()) { - playerImpl.onPlay(); - } - activityPaused = false; + super.onResume(); - if(globalScreenOrientationLocked()) { - boolean lastOrientationWasLandscape - = defaultPreferences.getBoolean(getString(R.string.last_orientation_landscape_key), 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"); - super.onBackPressed(); - if (playerImpl.isPlaying()) playerImpl.getPlayer().setPlayWhenReady(false); + // Upon going in or out of multiwindow mode, isInMultiWindow will always be false, + // since the first onResume needs to restore the player. + // Subsequent onResume calls while multiwindow mode remains the same and the player is + // prepared should be ignored. + if (isInMultiWindow) return; + isInMultiWindow = isInMultiWindow(); + + if (playerState != null) { + playerImpl.setPlaybackQuality(playerState.getPlaybackQuality()); + playerImpl.initPlayback(playerState.getPlayQueue(), playerState.getRepeatMode(), + playerState.getPlaybackSpeed(), playerState.getPlaybackPitch(), + playerState.wasPlaying()); + } } @Override @@ -180,33 +184,24 @@ public final class MainVideoPlayer extends AppCompatActivity } } - @Override - protected void onPause() { - super.onPause(); - if (DEBUG) Log.d(TAG, "onPause() called"); - - if (playerImpl != null && playerImpl.getPlayer() != null && !activityPaused) { - playerImpl.wasPlaying = playerImpl.isPlaying(); - playerImpl.onPause(); - } - activityPaused = true; - } - @Override protected void onSaveInstanceState(Bundle outState) { + if (DEBUG) Log.d(TAG, "onSaveInstanceState() called"); super.onSaveInstanceState(outState); if (playerImpl == null) return; playerImpl.setRecovery(); - savedState = StateSaver.tryToSave(isChangingConfigurations(), savedState, - outState, this); + playerState = new PlayerState(playerImpl.getPlayQueue(), playerImpl.getRepeatMode(), + playerImpl.getPlaybackSpeed(), playerImpl.getPlaybackPitch(), + playerImpl.getPlaybackQuality(), playerImpl.isPlaying()); + StateSaver.tryToSave(isChangingConfigurations(), null, outState, this); } @Override - protected void onDestroy() { - super.onDestroy(); - if (DEBUG) Log.d(TAG, "onDestroy() called"); - if (playerImpl != null) playerImpl.destroy(); + protected void onStop() { + if (DEBUG) Log.d(TAG, "onStop() called"); + super.onStop(); + playerImpl.destroy(); } /*////////////////////////////////////////////////////////////////////////// @@ -221,48 +216,19 @@ public final class MainVideoPlayer extends AppCompatActivity @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()); + objectsToSave.add(playerState); } @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); + public void readFrom(@NonNull Queue savedObjects) { + playerState = (PlayerState) savedObjects.poll(); } /*////////////////////////////////////////////////////////////////////////// // 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; @@ -275,6 +241,14 @@ public final class MainVideoPlayer extends AppCompatActivity } else { visibility = View.STATUS_BAR_VISIBLE; } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + @ColorInt final int systenUiColor = + ActivityCompat.getColor(getApplicationContext(), R.color.video_overlay_color); + getWindow().setStatusBarColor(systenUiColor); + getWindow().setNavigationBarColor(systenUiColor); + } + getWindow().getDecorView().setSystemUiVisibility(visibility); getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); } @@ -342,6 +316,10 @@ public final class MainVideoPlayer extends AppCompatActivity } } + private boolean isInMultiWindow() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode(); + } + //////////////////////////////////////////////////////////////////////////// // Playback Parameters Listener //////////////////////////////////////////////////////////////////////////// @@ -411,15 +389,6 @@ public final class MainVideoPlayer extends AppCompatActivity 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); @@ -727,7 +696,7 @@ public final class MainVideoPlayer extends AppCompatActivity animatePlayButtons(true, 200); }); - changeSystemUi(); + showSystemUi(); getRootView().setKeepScreenOn(false); } @@ -900,7 +869,7 @@ public final class MainVideoPlayer extends AppCompatActivity playerImpl.hideControls(150, 0); } else { playerImpl.showControlsThenHide(); - changeSystemUi(); + showSystemUi(); } return true; } diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerState.java b/app/src/main/java/org/schabi/newpipe/player/PlayerState.java new file mode 100644 index 000000000..6f38ce835 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerState.java @@ -0,0 +1,88 @@ +package org.schabi.newpipe.player; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.Log; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +import org.schabi.newpipe.playlist.PlayQueue; + +import java.io.Serializable; + +public class PlayerState implements Serializable { + private final static String TAG = "PlayerState"; + + @NonNull private final PlayQueue playQueue; + private final int repeatMode; + private final float playbackSpeed; + private final float playbackPitch; + @Nullable private final String playbackQuality; + private final boolean wasPlaying; + + PlayerState(@NonNull final PlayQueue playQueue, final int repeatMode, + final float playbackSpeed, final float playbackPitch, final boolean wasPlaying) { + this(playQueue, repeatMode, playbackSpeed, playbackPitch, null, wasPlaying); + } + + PlayerState(@NonNull final PlayQueue playQueue, final int repeatMode, + final float playbackSpeed, final float playbackPitch, + @Nullable final String playbackQuality, final boolean wasPlaying) { + this.playQueue = playQueue; + this.repeatMode = repeatMode; + this.playbackSpeed = playbackSpeed; + this.playbackPitch = playbackPitch; + this.playbackQuality = playbackQuality; + this.wasPlaying = wasPlaying; + } + + /*////////////////////////////////////////////////////////////////////////// + // Serdes + //////////////////////////////////////////////////////////////////////////*/ + + @Nullable + public static PlayerState fromJson(@NonNull final String json) { + try { + return new Gson().fromJson(json, PlayerState.class); + } catch (JsonSyntaxException error) { + Log.e(TAG, "Failed to deserialize PlayerState from json=[" + json + "]", error); + return null; + } + } + + @NonNull + public String toJson() { + return new Gson().toJson(this); + } + + /*////////////////////////////////////////////////////////////////////////// + // Getters + //////////////////////////////////////////////////////////////////////////*/ + + @NonNull + public PlayQueue getPlayQueue() { + return playQueue; + } + + public int getRepeatMode() { + return repeatMode; + } + + public float getPlaybackSpeed() { + return playbackSpeed; + } + + public float getPlaybackPitch() { + return playbackPitch; + } + + @Nullable + public String getPlaybackQuality() { + return playbackQuality; + } + + public boolean wasPlaying() { + return wasPlaying; + } +} 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 239c9c8d3..ccaa6f225 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java @@ -32,6 +32,7 @@ import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; import org.schabi.newpipe.fragments.local.dialog.PlaylistAppendDialog; import org.schabi.newpipe.player.event.PlayerEventListener; import org.schabi.newpipe.player.helper.PlaybackParameterDialog; +import org.schabi.newpipe.playlist.PlayQueueAdapter; import org.schabi.newpipe.playlist.PlayQueueItem; import org.schabi.newpipe.playlist.PlayQueueItemBuilder; import org.schabi.newpipe.playlist.PlayQueueItemHolder; @@ -40,6 +41,9 @@ import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.ThemeHelper; +import java.util.Collections; +import java.util.List; + import static org.schabi.newpipe.player.helper.PlayerHelper.formatPitch; import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; @@ -151,7 +155,7 @@ public abstract class ServicePlayerActivity extends AppCompatActivity finish(); return true; case R.id.action_append_playlist: - appendToPlaylist(); + appendAllToPlaylist(); return true; case R.id.action_settings: NavigationHelper.openSettings(this); @@ -187,13 +191,6 @@ public abstract class ServicePlayerActivity extends AppCompatActivity ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); } - private void appendToPlaylist() { - if (this.player != null && this.player.getPlayQueue() != null) { - PlaylistAppendDialog.fromPlayQueueItems(this.player.getPlayQueue().getStreams()) - .show(getSupportFragmentManager(), getTag()); - } - } - //////////////////////////////////////////////////////////////////////////// // Service Connection //////////////////////////////////////////////////////////////////////////// @@ -319,7 +316,8 @@ public abstract class ServicePlayerActivity extends AppCompatActivity private void buildItemPopupMenu(final PlayQueueItem item, final View view) { final PopupMenu menu = new PopupMenu(this, view); - final MenuItem remove = menu.getMenu().add(RECYCLER_ITEM_POPUP_MENU_GROUP_ID, 0, Menu.NONE, R.string.play_queue_remove); + final MenuItem remove = menu.getMenu().add(RECYCLER_ITEM_POPUP_MENU_GROUP_ID, /*pos=*/0, + Menu.NONE, R.string.play_queue_remove); remove.setOnMenuItemClickListener(menuItem -> { if (player == null) return false; @@ -328,12 +326,20 @@ public abstract class ServicePlayerActivity extends AppCompatActivity return true; }); - final MenuItem detail = menu.getMenu().add(RECYCLER_ITEM_POPUP_MENU_GROUP_ID, 1, Menu.NONE, R.string.play_queue_stream_detail); + final MenuItem detail = menu.getMenu().add(RECYCLER_ITEM_POPUP_MENU_GROUP_ID, /*pos=*/1, + Menu.NONE, R.string.play_queue_stream_detail); detail.setOnMenuItemClickListener(menuItem -> { onOpenDetail(item.getServiceId(), item.getUrl(), item.getTitle()); return true; }); + final MenuItem append = menu.getMenu().add(RECYCLER_ITEM_POPUP_MENU_GROUP_ID, /*pos=*/2, + Menu.NONE, R.string.append_playlist); + append.setOnMenuItemClickListener(menuItem -> { + openPlaylistAppendDialog(Collections.singletonList(item)); + return true; + }); + menu.show(); } @@ -488,6 +494,21 @@ public abstract class ServicePlayerActivity extends AppCompatActivity seeking = false; } + //////////////////////////////////////////////////////////////////////////// + // Playlist append + //////////////////////////////////////////////////////////////////////////// + + private void appendAllToPlaylist() { + if (player != null && player.getPlayQueue() != null) { + openPlaylistAppendDialog(player.getPlayQueue().getStreams()); + } + } + + private void openPlaylistAppendDialog(final List playlist) { + PlaylistAppendDialog.fromPlayQueueItems(playlist) + .show(getSupportFragmentManager(), getTag()); + } + //////////////////////////////////////////////////////////////////////////// // Binding Service Listener //////////////////////////////////////////////////////////////////////////// @@ -497,6 +518,7 @@ public abstract class ServicePlayerActivity extends AppCompatActivity onStateChanged(state); onPlayModeChanged(repeatMode, shuffled); onPlaybackParameterChanged(parameters); + onMaybePlaybackAdapterChanged(); } @Override @@ -609,4 +631,12 @@ public abstract class ServicePlayerActivity extends AppCompatActivity playbackPitchButton.setText(formatPitch(parameters.pitch)); } } + + private void onMaybePlaybackAdapterChanged() { + if (itemsList == null || player == null) return; + final PlayQueueAdapter maybeNewAdapter = player.getPlayQueueAdapter(); + if (maybeNewAdapter != null && itemsList.getAdapter() != maybeNewAdapter) { + itemsList.setAdapter(maybeNewAdapter); + } + } } 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 b019ea91e..0e0dca983 100644 --- a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java @@ -228,8 +228,8 @@ public abstract class VideoPlayer extends BasePlayer } @Override - public void initPlayer() { - super.initPlayer(); + public void initPlayer(final boolean playOnReady) { + super.initPlayer(playOnReady); // Setup video view simpleExoPlayer.setVideoSurfaceView(surfaceView); diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java b/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java index 8405e45fd..2611705a8 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java @@ -1,8 +1,12 @@ package org.schabi.newpipe.player.helper; import android.content.Context; +import android.content.Intent; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.media.session.MediaButtonReceiver; import android.support.v4.media.session.MediaSessionCompat; +import android.view.KeyEvent; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; @@ -15,8 +19,8 @@ import org.schabi.newpipe.player.mediasession.PlayQueuePlaybackController; public class MediaSessionManager { private static final String TAG = "MediaSessionManager"; - private final MediaSessionCompat mediaSession; - private final MediaSessionConnector sessionConnector; + @NonNull private final MediaSessionCompat mediaSession; + @NonNull private final MediaSessionConnector sessionConnector; public MediaSessionManager(@NonNull final Context context, @NonNull final Player player, @@ -28,11 +32,9 @@ public class MediaSessionManager { this.sessionConnector.setPlayer(player, new DummyPlaybackPreparer()); } - public MediaSessionCompat getMediaSession() { - return mediaSession; - } - - public MediaSessionConnector getSessionConnector() { - return sessionConnector; + @Nullable + @SuppressWarnings("UnusedReturnValue") + public KeyEvent handleMediaButtonIntent(final Intent intent) { + return MediaButtonReceiver.handleIntent(mediaSession, intent); } } 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 63ac7e8a1..84c7a619b 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 @@ -7,7 +7,11 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; import com.google.android.exoplayer2.SeekParameters; +import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; +import com.google.android.exoplayer2.upstream.BandwidthMeter; +import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.MimeTypes; import org.schabi.newpipe.R; @@ -203,6 +207,16 @@ public class PlayerHelper { return 60000; } + public static TrackSelection.Factory getQualitySelector(@NonNull final Context context, + @NonNull final BandwidthMeter meter) { + return new AdaptiveTrackSelection.Factory(meter, + AdaptiveTrackSelection.DEFAULT_MAX_INITIAL_BITRATE, + /*bufferDurationRequiredForQualityIncrease=*/1000, + AdaptiveTrackSelection.DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, + AdaptiveTrackSelection.DEFAULT_BANDWIDTH_FRACTION); + } + public static boolean isUsingDSP(@NonNull final Context context) { return true; } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java index 5f029cc50..878d7c711 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java @@ -14,13 +14,35 @@ import java.io.IOException; public class FailedMediaSource implements ManagedMediaSource { private final String TAG = "FailedMediaSource@" + Integer.toHexString(hashCode()); + public static class FailedMediaSourceException extends Exception { + FailedMediaSourceException(String message) { + super(message); + } + + FailedMediaSourceException(Throwable cause) { + super(cause); + } + } + + public static final class MediaSourceResolutionException extends FailedMediaSourceException { + public MediaSourceResolutionException(String message) { + super(message); + } + } + + public static final class StreamInfoLoadException extends FailedMediaSourceException { + public StreamInfoLoadException(Throwable cause) { + super(cause); + } + } + private final PlayQueueItem playQueueItem; - private final Throwable error; + private final FailedMediaSourceException error; private final long retryTimestamp; public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem, - @NonNull final Throwable error, + @NonNull final FailedMediaSourceException error, final long retryTimestamp) { this.playQueueItem = playQueueItem; this.error = error; @@ -32,7 +54,7 @@ public class FailedMediaSource implements ManagedMediaSource { * The error will always be propagated to ExoPlayer. * */ public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem, - @NonNull final Throwable error) { + @NonNull final FailedMediaSourceException error) { this.playQueueItem = playQueueItem; this.error = error; this.retryTimestamp = Long.MAX_VALUE; @@ -42,7 +64,7 @@ public class FailedMediaSource implements ManagedMediaSource { return playQueueItem; } - public Throwable getError() { + public FailedMediaSourceException getError() { return error; } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java new file mode 100644 index 000000000..310f1062b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java @@ -0,0 +1,135 @@ +package org.schabi.newpipe.player.mediasource; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.google.android.exoplayer2.source.DynamicConcatenatingMediaSource; +import com.google.android.exoplayer2.source.ShuffleOrder; + +public class ManagedMediaSourcePlaylist { + @NonNull private final DynamicConcatenatingMediaSource internalSource; + + public ManagedMediaSourcePlaylist() { + internalSource = new DynamicConcatenatingMediaSource(/*isPlaylistAtomic=*/false, + new ShuffleOrder.UnshuffledShuffleOrder(0)); + } + + /*////////////////////////////////////////////////////////////////////////// + // MediaSource Delegations + //////////////////////////////////////////////////////////////////////////*/ + + public int size() { + return internalSource.getSize(); + } + + /** + * Returns the {@link ManagedMediaSource} at the given index of the playlist. + * If the index is invalid, then null is returned. + * */ + @Nullable + public ManagedMediaSource get(final int index) { + return (index < 0 || index >= size()) ? + null : (ManagedMediaSource) internalSource.getMediaSource(index); + } + + public void dispose() { + internalSource.releaseSource(); + } + + @NonNull + public DynamicConcatenatingMediaSource getParentMediaSource() { + return internalSource; + } + + /*////////////////////////////////////////////////////////////////////////// + // Playlist Manipulation + //////////////////////////////////////////////////////////////////////////*/ + + /** + * Expands the {@link DynamicConcatenatingMediaSource} by appending it with a + * {@link PlaceholderMediaSource}. + * + * @see #append(ManagedMediaSource) + * */ + public synchronized void expand() { + append(new PlaceholderMediaSource()); + } + + /** + * Appends a {@link ManagedMediaSource} to the end of {@link DynamicConcatenatingMediaSource}. + * @see DynamicConcatenatingMediaSource#addMediaSource + * */ + public synchronized void append(@NonNull final ManagedMediaSource source) { + internalSource.addMediaSource(source); + } + + /** + * Removes a {@link ManagedMediaSource} from {@link DynamicConcatenatingMediaSource} + * at the given index. If this index is out of bound, then the removal is ignored. + * @see DynamicConcatenatingMediaSource#removeMediaSource(int) + * */ + public synchronized void remove(final int index) { + if (index < 0 || index > internalSource.getSize()) return; + + internalSource.removeMediaSource(index); + } + + /** + * Moves a {@link ManagedMediaSource} in {@link DynamicConcatenatingMediaSource} + * from the given source index to the target index. If either index is out of bound, + * then the call is ignored. + * @see DynamicConcatenatingMediaSource#moveMediaSource(int, int) + * */ + public synchronized void move(final int source, final int target) { + if (source < 0 || target < 0) return; + if (source >= internalSource.getSize() || target >= internalSource.getSize()) return; + + internalSource.moveMediaSource(source, target); + } + + /** + * Invalidates the {@link ManagedMediaSource} at the given index by replacing it + * with a {@link PlaceholderMediaSource}. + * @see #update(int, ManagedMediaSource, Runnable) + * */ + public synchronized void invalidate(final int index, + @Nullable final Runnable finalizingAction) { + if (get(index) instanceof PlaceholderMediaSource) return; + update(index, new PlaceholderMediaSource(), finalizingAction); + } + + /** + * Updates the {@link ManagedMediaSource} in {@link DynamicConcatenatingMediaSource} + * at the given index with a given {@link ManagedMediaSource}. + * @see #update(int, ManagedMediaSource, Runnable) + * */ + public synchronized void update(final int index, @NonNull final ManagedMediaSource source) { + update(index, source, /*doNothing=*/null); + } + + /** + * Updates the {@link ManagedMediaSource} in {@link DynamicConcatenatingMediaSource} + * at the given index with a given {@link ManagedMediaSource}. If the index is out of bound, + * then the replacement is ignored. + * @see DynamicConcatenatingMediaSource#addMediaSource + * @see DynamicConcatenatingMediaSource#removeMediaSource(int, Runnable) + * */ + public synchronized void update(final int index, @NonNull final ManagedMediaSource source, + @Nullable final Runnable finalizingAction) { + if (index < 0 || index >= internalSource.getSize()) return; + + // Add and remove are sequential on the same thread, therefore here, the exoplayer + // message queue must receive and process add before remove, effectively treating them + // as atomic. + + // Since the finalizing action occurs strictly after the timeline has completed + // all its changes on the playback thread, thus, it is possible, in the meantime, + // other calls that modifies the playlist media source occur in between. This makes + // it unsafe to call remove as the finalizing action of add. + internalSource.addMediaSource(index + 1, source); + + // Because of the above race condition, it is thus only safe to synchronize the player + // in the finalizing action AFTER the removal is complete and the timeline has changed. + internalSource.removeMediaSource(index, finalizingAction); + } +} 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 477358113..b4236d3c5 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 @@ -2,11 +2,11 @@ package org.schabi.newpipe.player.playback; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.v4.util.ArraySet; 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; @@ -14,6 +14,7 @@ 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.ManagedMediaSourcePlaylist; import org.schabi.newpipe.player.mediasource.PlaceholderMediaSource; import org.schabi.newpipe.playlist.PlayQueue; import org.schabi.newpipe.playlist.PlayQueueItem; @@ -23,8 +24,10 @@ 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.Collection; 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; @@ -37,8 +40,11 @@ import io.reactivex.disposables.Disposable; import io.reactivex.disposables.SerialDisposable; import io.reactivex.functions.Consumer; import io.reactivex.internal.subscriptions.EmptySubscription; +import io.reactivex.schedulers.Schedulers; import io.reactivex.subjects.PublishSubject; +import static org.schabi.newpipe.player.mediasource.FailedMediaSource.MediaSourceResolutionException; +import static org.schabi.newpipe.player.mediasource.FailedMediaSource.StreamInfoLoadException; import static org.schabi.newpipe.playlist.PlayQueue.DEBUG; public class MediaSourceManager { @@ -52,7 +58,6 @@ public class MediaSourceManager { * 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; @@ -103,7 +108,7 @@ public class MediaSourceManager { @NonNull private final AtomicBoolean isBlocked; - @NonNull private DynamicConcatenatingMediaSource sources; + @NonNull private ManagedMediaSourcePlaylist playlist; public MediaSourceManager(@NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue) { @@ -143,9 +148,9 @@ public class MediaSourceManager { this.isBlocked = new AtomicBoolean(false); - this.sources = new DynamicConcatenatingMediaSource(); + this.playlist = new ManagedMediaSourcePlaylist(); - this.loadingItems = Collections.synchronizedSet(new HashSet<>()); + this.loadingItems = Collections.synchronizedSet(new ArraySet<>()); playQueue.getBroadcastReceiver() .observeOn(AndroidSchedulers.mainThread()) @@ -167,7 +172,7 @@ public class MediaSourceManager { playQueueReactor.cancel(); loaderReactor.dispose(); syncReactor.dispose(); - sources.releaseSource(); + playlist.dispose(); } /*////////////////////////////////////////////////////////////////////////// @@ -215,17 +220,18 @@ public class MediaSourceManager { break; case REMOVE: final RemoveEvent removeEvent = (RemoveEvent) event; - remove(removeEvent.getRemoveIndex()); + playlist.remove(removeEvent.getRemoveIndex()); break; case MOVE: final MoveEvent moveEvent = (MoveEvent) event; - move(moveEvent.getFromIndex(), moveEvent.getToIndex()); + playlist.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()); + playlist.move(reorderEvent.getFromSelectedIndex(), + reorderEvent.getToSelectedIndex()); break; case RECOVERY: default: @@ -266,10 +272,11 @@ public class MediaSourceManager { } private boolean isPlaybackReady() { - if (sources.getSize() != playQueue.size()) return false; + if (playlist.size() != playQueue.size()) return false; + + final ManagedMediaSource mediaSource = playlist.get(playQueue.getIndex()); + if (mediaSource == null) return false; - final ManagedMediaSource mediaSource = - (ManagedMediaSource) sources.getMediaSource(playQueue.getIndex()); final PlayQueueItem playQueueItem = playQueue.getItem(); return mediaSource.isStreamEqual(playQueueItem); } @@ -288,9 +295,9 @@ public class MediaSourceManager { private void maybeUnblock() { if (DEBUG) Log.d(TAG, "maybeUnblock() called."); - if (isPlayQueueReady() && isPlaybackReady() && isBlocked.get()) { + if (isBlocked.get()) { isBlocked.set(false); - playbackListener.onPlaybackUnblock(sources); + playbackListener.onPlaybackUnblock(playlist.getParentMediaSource()); } } @@ -299,10 +306,10 @@ public class MediaSourceManager { //////////////////////////////////////////////////////////////////////////*/ private void maybeSync() { - if (DEBUG) Log.d(TAG, "onPlaybackSynchronize() called."); + if (DEBUG) Log.d(TAG, "maybeSync() called."); final PlayQueueItem currentItem = playQueue.getItem(); - if (isBlocked.get() || !isPlaybackReady() || currentItem == null) return; + if (isBlocked.get() || currentItem == null) return; final Consumer onSuccess = info -> syncInternal(currentItem, info); final Consumer onError = throwable -> syncInternal(currentItem, null); @@ -321,9 +328,11 @@ public class MediaSourceManager { } } - private void maybeSynchronizePlayer() { - maybeUnblock(); - maybeSync(); + private synchronized void maybeSynchronizePlayer() { + if (isPlayQueueReady() && isPlaybackReady()) { + maybeUnblock(); + maybeSync(); + } } /*////////////////////////////////////////////////////////////////////////// @@ -332,12 +341,14 @@ public class MediaSourceManager { private Observable getEdgeIntervalSignal() { return Observable.interval(progressUpdateIntervalMillis, TimeUnit.MILLISECONDS) - .filter(ignored -> playbackListener.isNearPlaybackEdge(playbackNearEndGapMillis)); + .filter(ignored -> + playbackListener.isApproachingPlaybackEdge(playbackNearEndGapMillis)); } private Disposable getDebouncedLoader() { return debouncedSignal.mergeWith(nearEndIntervalSignal) .debounce(loadDebounceMillis, TimeUnit.MILLISECONDS) + .subscribeOn(Schedulers.single()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(timestamp -> loadImmediate()); } @@ -348,42 +359,21 @@ 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; + final ItemsToLoad itemsToLoad = getItemsToLoad(playQueue, WINDOW_SIZE); + if (itemsToLoad == null) return; - // Evict the items being loaded to free up memory - if (loaderReactor.size() > MAXIMUM_LOADER_SIZE) { - loaderReactor.clear(); - loadingItems.clear(); - } - maybeLoadItem(currentItem); + // Evict the previous items being loaded to free up memory, before start loading new ones + maybeClearLoaders(); - // The rest are just for seamless playback - // 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 Set items = new HashSet<>( - 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))); - } - items.remove(currentItem); - - for (final PlayQueueItem item : items) { + maybeLoadItem(itemsToLoad.center); + for (final PlayQueueItem item : itemsToLoad.neighbors) { maybeLoadItem(item); } } private void maybeLoadItem(@NonNull final PlayQueueItem item) { if (DEBUG) Log.d(TAG, "maybeLoadItem() called."); - if (playQueue.indexOf(item) >= sources.getSize()) return; + if (playQueue.indexOf(item) >= playlist.size()) return; if (!loadingItems.contains(item) && isCorrectionNeeded(item)) { if (DEBUG) Log.d(TAG, "MediaSource - Loading=[" + item.getTitle() + @@ -402,19 +392,19 @@ public class MediaSourceManager { 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.getAudioStreams().size() + - ", video count: " + streamInfo.getVideoOnlyStreams().size() + - streamInfo.getVideoStreams().size()); - return new FailedMediaSource(stream, exception); + final String message = "Unable to resolve source from stream info." + + " URL: " + stream.getUrl() + + ", audio count: " + streamInfo.getAudioStreams().size() + + ", video count: " + streamInfo.getVideoOnlyStreams().size() + + streamInfo.getVideoStreams().size(); + return new FailedMediaSource(stream, new MediaSourceResolutionException(message)); } final long expiration = System.currentTimeMillis() + ServiceHelper.getCacheExpirationMillis(streamInfo.getServiceId()); return new LoadedMediaSource(source, stream, expiration); - }).onErrorReturn(throwable -> new FailedMediaSource(stream, throwable)); + }).onErrorReturn(throwable -> new FailedMediaSource(stream, + new StreamInfoLoadException(throwable))); } private void onMediaSourceReceived(@NonNull final PlayQueueItem item, @@ -426,10 +416,10 @@ public class MediaSourceManager { 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 (isCorrectionNeeded(item)) { if (DEBUG) Log.d(TAG, "MediaSource - Updating index=[" + itemIndex + "] with " + "title=[" + item.getTitle() + "] at url=[" + item.getUrl() + "]"); - update(itemIndex, mediaSource, this::maybeSynchronizePlayer); + playlist.update(itemIndex, mediaSource, this::maybeSynchronizePlayer); } } @@ -445,10 +435,8 @@ public class MediaSourceManager { * */ 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); - return mediaSource.shouldBeReplacedWith(item, + final ManagedMediaSource mediaSource = playlist.get(index); + return mediaSource != null && mediaSource.shouldBeReplacedWith(item, /*mightBeInProgress=*/index != playQueue.getIndex()); } @@ -465,10 +453,9 @@ public class MediaSourceManager { * */ private void maybeRenewCurrentIndex() { final int currentIndex = playQueue.getIndex(); - if (sources.getSize() <= currentIndex) return; + final ManagedMediaSource currentSource = playlist.get(currentIndex); + if (currentSource == null) return; - final ManagedMediaSource currentSource = - (ManagedMediaSource) sources.getMediaSource(currentIndex); final PlayQueueItem currentItem = playQueue.getItem(); if (!currentSource.shouldBeReplacedWith(currentItem, /*canInterruptOnRenew=*/true)) { maybeSynchronizePlayer(); @@ -477,7 +464,16 @@ public class MediaSourceManager { if (DEBUG) Log.d(TAG, "MediaSource - Reloading currently playing, " + "index=[" + currentIndex + "], item=[" + currentItem.getTitle() + "]"); - update(currentIndex, new PlaceholderMediaSource(), this::loadImmediate); + playlist.invalidate(currentIndex, this::loadImmediate); + } + + private void maybeClearLoaders() { + if (DEBUG) Log.d(TAG, "MediaSource - maybeClearLoaders() called."); + if (!loadingItems.contains(playQueue.getItem()) && + loaderReactor.size() > MAXIMUM_LOADER_SIZE) { + loaderReactor.clear(); + loadingItems.clear(); + } } /*////////////////////////////////////////////////////////////////////////// // MediaSource Playlist Helpers @@ -486,72 +482,55 @@ public class MediaSourceManager { private void resetSources() { if (DEBUG) Log.d(TAG, "resetSources() called."); - 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)); + playlist.dispose(); + playlist = new ManagedMediaSourcePlaylist(); } 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()); + while (playlist.size() < playQueue.size()) { + playlist.expand(); } } /*////////////////////////////////////////////////////////////////////////// - // MediaSource Playlist Manipulation + // Manager Helpers //////////////////////////////////////////////////////////////////////////*/ + @Nullable + private static ItemsToLoad getItemsToLoad(@NonNull final PlayQueue playQueue, + final int windowSize) { + // The current item has higher priority + final int currentIndex = playQueue.getIndex(); + final PlayQueueItem currentItem = playQueue.getItem(currentIndex); + if (currentItem == null) return null; - /** - * 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 synchronized void emplace(final int index, @NonNull final MediaSource source) { - if (index < sources.getSize()) return; + // The rest are just for seamless playback + // 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 - windowSize); + final int rightLimit = currentIndex + windowSize + 1; + final int rightBound = Math.min(playQueue.size(), rightLimit); + final Set neighbors = new ArraySet<>( + playQueue.getStreams().subList(leftBound,rightBound)); - sources.addMediaSource(index, source); + // Do a round robin + final int excess = rightLimit - playQueue.size(); + if (excess >= 0) { + neighbors.addAll(playQueue.getStreams().subList(0, Math.min(playQueue.size(), excess))); + } + neighbors.remove(currentItem); + + return new ItemsToLoad(currentItem, neighbors); } - /** - * Removes a {@link MediaSource} from {@link DynamicConcatenatingMediaSource} - * at the given index. If this index is out of bound, then the removal is ignored. - * */ - private synchronized void remove(final int index) { - if (index < 0 || index > sources.getSize()) return; + private static class ItemsToLoad { + @NonNull final private PlayQueueItem center; + @NonNull final private Collection neighbors; - sources.removeMediaSource(index); - } - - /** - * Moves a {@link MediaSource} in {@link DynamicConcatenatingMediaSource} - * from the given source index to the target index. If either index is out of bound, - * then the call is ignored. - * */ - private 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)); + ItemsToLoad(@NonNull final PlayQueueItem center, + @NonNull final Collection neighbors) { + this.center = center; + this.neighbors = neighbors; + } } } 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 34c7702bc..daf58d5dd 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 @@ -13,13 +13,13 @@ 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. + * Called to check if the currently playing stream is approaching the end of its playback. + * Implementation should return true when the current playback position is progressing within + * timeToEndMillis or less to its playback during. * * May be called at any time. * */ - boolean isNearPlaybackEdge(final long timeToEndMillis); + boolean isApproachingPlaybackEdge(final long timeToEndMillis); /** * Called when the stream at the current queue index is not ready yet. diff --git a/app/src/main/res/drawable/player_controls_bg.xml b/app/src/main/res/drawable/player_controls_bg.xml index 7e1981347..f250e3558 100644 --- a/app/src/main/res/drawable/player_controls_bg.xml +++ b/app/src/main/res/drawable/player_controls_bg.xml @@ -3,5 +3,5 @@ + android:startColor="@color/video_overlay_color"/> \ No newline at end of file diff --git a/app/src/main/res/drawable/player_top_controls_bg.xml b/app/src/main/res/drawable/player_top_controls_bg.xml index f1e8b98fc..ba62ce863 100644 --- a/app/src/main/res/drawable/player_top_controls_bg.xml +++ b/app/src/main/res/drawable/player_top_controls_bg.xml @@ -3,5 +3,5 @@ + android:startColor="@color/video_overlay_color"/> \ No newline at end of file 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 11765f901..72f673ffc 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 @@ -304,7 +304,7 @@ android:paddingLeft="4dp" android:paddingRight="4dp" android:gravity="center" - android:text="@string/duration_live" + android:text="@string/duration_live_button" android:textAllCaps="true" android:textColor="?attr/colorAccent" android:maxLength="4" diff --git a/app/src/main/res/layout/activity_main_player.xml b/app/src/main/res/layout/activity_main_player.xml index c581c3203..616f93536 100644 --- a/app/src/main/res/layout/activity_main_player.xml +++ b/app/src/main/res/layout/activity_main_player.xml @@ -129,7 +129,7 @@ android:id="@+id/playbackControlRoot" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="#64000000" + android:background="@color/video_overlay_color" android:visibility="gone" tools:visibility="visible"> @@ -406,7 +406,7 @@ android:paddingLeft="4dp" android:paddingRight="4dp" android:gravity="center" - android:text="@string/duration_live" + android:text="@string/duration_live_button" android:textAllCaps="true" android:textColor="@android:color/white" android:maxLength="4" 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 7f649e382..e81a19553 100644 --- a/app/src/main/res/layout/activity_player_queue_control.xml +++ b/app/src/main/res/layout/activity_player_queue_control.xml @@ -154,7 +154,7 @@ android:paddingLeft="4dp" android:paddingRight="4dp" android:gravity="center" - android:text="@string/duration_live" + android:text="@string/duration_live_button" android:textAllCaps="true" android:textColor="?attr/colorAccent" android:maxLength="4" diff --git a/app/src/main/res/layout/player_popup.xml b/app/src/main/res/layout/player_popup.xml index 0c3ea77df..5e8ed664e 100644 --- a/app/src/main/res/layout/player_popup.xml +++ b/app/src/main/res/layout/player_popup.xml @@ -198,7 +198,7 @@ android:paddingLeft="4dp" android:paddingRight="4dp" android:gravity="center_vertical" - android:text="@string/duration_live" + android:text="@string/duration_live_button" android:textAllCaps="true" android:textColor="@android:color/white" android:maxLength="4" diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 6b43999a0..df2c73ec8 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -40,7 +40,7 @@ #e6000000 #EEFFFFFF #ffffff - #66000000 + #64000000 #323232 #ffffff diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f22f42e95..33ce46412 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -119,6 +119,7 @@ Show age restricted content Age Restricted Video. Allowing such material is possible from Settings. live + LIVE Downloads Downloads Error report