diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 976d5bdbeb9..3d6ca44c68d 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -190,21 +190,21 @@ public final class VideoDetailFragment }; @State - protected int serviceId = Constants.NO_SERVICE_ID; + int serviceId = Constants.NO_SERVICE_ID; @State @NonNull - protected String title = ""; + String title = ""; @State @Nullable - protected String url = null; + String url = null; @Nullable - protected PlayQueue playQueue = null; + private PlayQueue playQueue = null; @State int bottomSheetState = BottomSheetBehavior.STATE_EXPANDED; @State int lastStableBottomSheetState = BottomSheetBehavior.STATE_EXPANDED; @State - protected boolean autoPlayEnabled = true; + boolean autoPlayEnabled = true; @Nullable private StreamInfo currentInfo = null; @@ -244,7 +244,7 @@ public void onServiceConnected(final PlayerService connectedPlayerService, // It will do nothing if the player is not in fullscreen mode hideSystemUiIfNeeded(); - final Optional playerUi = player.UIs().get(MainPlayerUi.class); + final Optional playerUi = player.UIs().getOpt(MainPlayerUi.class); if (!player.videoPlayerSelected() && !playAfterConnect) { return; } @@ -429,18 +429,15 @@ public void onDestroyView() { @Override public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { super.onActivityResult(requestCode, resultCode, data); - switch (requestCode) { - case ReCaptchaActivity.RECAPTCHA_REQUEST: - if (resultCode == Activity.RESULT_OK) { - NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), - serviceId, url, title, null, false); - } else { - Log.e(TAG, "ReCaptcha failed"); - } - break; - default: - Log.e(TAG, "Request code from activity not supported [" + requestCode + "]"); - break; + if (requestCode == ReCaptchaActivity.RECAPTCHA_REQUEST) { + if (resultCode == Activity.RESULT_OK) { + NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), + serviceId, url, title, null, false); + } else { + Log.e(TAG, "ReCaptcha failed"); + } + } else { + Log.e(TAG, "Request code from activity not supported [" + requestCode + "]"); } } @@ -520,7 +517,7 @@ private void setOnClickListeners() { binding.overlayPlayPauseButton.setOnClickListener(v -> { if (playerIsNotStopped()) { player.playPause(); - player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); + player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); showSystemUi(); } else { autoPlayEnabled = true; // forcefully start playing @@ -679,7 +676,7 @@ protected void initListeners() { @Override public boolean onKeyDown(final int keyCode) { return isPlayerAvailable() - && player.UIs().get(VideoPlayerUi.class) + && player.UIs().getOpt(VideoPlayerUi.class) .map(playerUi -> playerUi.onKeyDown(keyCode)).orElse(false); } @@ -806,25 +803,17 @@ private void prepareAndHandleInfo(final StreamInfo info, final boolean scrollToT } - protected void prepareAndLoadInfo() { + private void prepareAndLoadInfo() { scrollToTop(); startLoading(false); } @Override public void startLoading(final boolean forceLoad) { - super.startLoading(forceLoad); - - initTabs(); - currentInfo = null; - if (currentWorker != null) { - currentWorker.dispose(); - } - - runWorker(forceLoad, stack.isEmpty()); + startLoading(forceLoad, null); } - private void startLoading(final boolean forceLoad, final boolean addToBackStack) { + private void startLoading(final boolean forceLoad, final @Nullable Boolean addToBackStack) { super.startLoading(forceLoad); initTabs(); @@ -833,7 +822,7 @@ private void startLoading(final boolean forceLoad, final boolean addToBackStack) currentWorker.dispose(); } - runWorker(forceLoad, addToBackStack); + runWorker(forceLoad, addToBackStack != null ? addToBackStack : stack.isEmpty()); } private void runWorker(final boolean forceLoad, final boolean addToBackStack) { @@ -1019,7 +1008,7 @@ private void toggleFullscreenIfInFullscreenMode() { // If a user watched video inside fullscreen mode and than chose another player // return to non-fullscreen mode if (isPlayerAvailable()) { - player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> { + player.UIs().getOpt(MainPlayerUi.class).ifPresent(playerUi -> { if (playerUi.isFullscreen()) { playerUi.toggleFullscreen(); } @@ -1129,7 +1118,7 @@ private void openNormalBackgroundPlayer(final boolean append) { } private void openMainPlayer() { - if (!isPlayerServiceAvailable()) { + if (noPlayerServiceAvailable()) { playerHolder.startService(autoPlayEnabled, this, this); return; } @@ -1154,7 +1143,7 @@ private void openMainPlayer() { */ private void hideMainPlayerOnLoadingNewStream() { final var root = getRoot(); - if (!isPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) { + if (noPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) { return; } @@ -1235,7 +1224,7 @@ private void tryAddVideoPlayerView() { // setup the surface view height, so that it fits the video correctly setHeightThumbnail(); - player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> { + player.UIs().getOpt(MainPlayerUi.class).ifPresent(playerUi -> { // sometimes binding would be null here, even though getView() != null above u.u if (binding != null) { // prevent from re-adding a view multiple times @@ -1251,7 +1240,7 @@ private void removeVideoPlayerView() { makeDefaultHeightForVideoPlaceholder(); if (player != null) { - player.UIs().get(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent); + player.UIs().getOpt(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent); } } @@ -1318,7 +1307,7 @@ private void setHeightThumbnail(final int newHeight, final DisplayMetrics metric binding.detailThumbnailImageView.setMinimumHeight(newHeight); if (isPlayerAvailable()) { final int maxHeight = (int) (metrics.heightPixels * MAX_PLAYER_HEIGHT); - player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> + player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui -> ui.getBinding().surfaceView.setHeights(newHeight, ui.isFullscreen() ? newHeight : maxHeight)); } @@ -1328,23 +1317,23 @@ private void showContent() { binding.detailContentRootHiding.setVisibility(View.VISIBLE); } - protected void setInitialData(final int newServiceId, - @Nullable final String newUrl, - @NonNull final String newTitle, - @Nullable final PlayQueue newPlayQueue) { + private void setInitialData(final int newServiceId, + @Nullable final String newUrl, + @NonNull final String newTitle, + @Nullable final PlayQueue newPlayQueue) { this.serviceId = newServiceId; this.url = newUrl; this.title = newTitle; this.playQueue = newPlayQueue; } - private void setErrorImage(final int imageResource) { + private void setErrorImage() { if (binding == null || activity == null) { return; } binding.detailThumbnailImageView.setImageDrawable( - AppCompatResources.getDrawable(requireContext(), imageResource)); + AppCompatResources.getDrawable(requireContext(), R.drawable.not_available_monkey)); animate(binding.detailThumbnailImageView, false, 0, AnimationType.ALPHA, 0, () -> animate(binding.detailThumbnailImageView, true, 500)); } @@ -1352,7 +1341,7 @@ private void setErrorImage(final int imageResource) { @Override public void handleError() { super.handleError(); - setErrorImage(R.drawable.not_available_monkey); + setErrorImage(); if (binding.relatedItemsLayout != null) { // hide related streams for tablets binding.relatedItemsLayout.setVisibility(View.INVISIBLE); @@ -1769,16 +1758,14 @@ public void onPlaybackUpdate(final int state, final PlaybackParameters parameters) { setOverlayPlayPauseImage(player != null && player.isPlaying()); - switch (state) { - case Player.STATE_PLAYING: - if (binding.positionView.getAlpha() != 1.0f - && player.getPlayQueue() != null - && player.getPlayQueue().getItem() != null - && player.getPlayQueue().getItem().getUrl().equals(url)) { - animate(binding.positionView, true, 100); - animate(binding.detailPositionView, true, 100); - } - break; + if (state == Player.STATE_PLAYING) { + if (binding.positionView.getAlpha() != 1.0f + && player.getPlayQueue() != null + && player.getPlayQueue().getItem() != null + && player.getPlayQueue().getItem().getUrl().equals(url)) { + animate(binding.positionView, true, 100); + animate(binding.detailPositionView, true, 100); + } } } @@ -1851,7 +1838,7 @@ public void onServiceStopped() { public void onFullscreenStateChanged(final boolean fullscreen) { setupBrightness(); if (!isPlayerAndPlayerServiceAvailable() - || player.UIs().get(MainPlayerUi.class).isEmpty() + || player.UIs().getOpt(MainPlayerUi.class).isEmpty() || getRoot().map(View::getParent).isEmpty()) { return; } @@ -1880,7 +1867,7 @@ public void onScreenRotationButtonClicked() { final boolean isLandscape = DeviceUtils.isLandscape(requireContext()); if (DeviceUtils.isTablet(activity) && (!globalScreenOrientationLocked(activity) || isLandscape)) { - player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen); + player.UIs().getOpt(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen); return; } @@ -1980,7 +1967,7 @@ public void hideSystemUiIfNeeded() { } private boolean isFullscreen() { - return isPlayerAvailable() && player.UIs().get(VideoPlayerUi.class) + return isPlayerAvailable() && player.UIs().getOpt(VideoPlayerUi.class) .map(VideoPlayerUi::isFullscreen).orElse(false); } @@ -2057,7 +2044,7 @@ private void checkLandscape() { setAutoPlay(true); } - player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::checkLandscape); + player.UIs().getOpt(MainPlayerUi.class).ifPresent(MainPlayerUi::checkLandscape); // Let's give a user time to look at video information page if video is not playing if (globalScreenOrientationLocked(activity) && !player.isPlaying()) { player.play(); @@ -2322,7 +2309,7 @@ && isPlayerAvailable() && player.isPlaying() && !isFullscreen() && !DeviceUtils.isTablet(activity)) { - player.UIs().get(MainPlayerUi.class) + player.UIs().getOpt(MainPlayerUi.class) .ifPresent(MainPlayerUi::toggleFullscreen); } setOverlayLook(binding.appBarLayout, behavior, 1); @@ -2336,7 +2323,7 @@ && isPlayerAvailable() // Re-enable clicks setOverlayElementsClickable(true); if (isPlayerAvailable()) { - player.UIs().get(MainPlayerUi.class) + player.UIs().getOpt(MainPlayerUi.class) .ifPresent(MainPlayerUi::closeItemsList); } setOverlayLook(binding.appBarLayout, behavior, 0); @@ -2347,7 +2334,7 @@ && isPlayerAvailable() showSystemUi(); } if (isPlayerAvailable()) { - player.UIs().get(MainPlayerUi.class).ifPresent(ui -> { + player.UIs().getOpt(MainPlayerUi.class).ifPresent(ui -> { if (ui.isControlsVisible()) { ui.hideControls(0, 0); } @@ -2434,8 +2421,8 @@ boolean isPlayerAvailable() { return player != null; } - boolean isPlayerServiceAvailable() { - return playerService != null; + boolean noPlayerServiceAvailable() { + return playerService == null; } boolean isPlayerAndPlayerServiceAvailable() { @@ -2444,7 +2431,7 @@ boolean isPlayerAndPlayerServiceAvailable() { public Optional getRoot() { return Optional.ofNullable(player) - .flatMap(player1 -> player1.UIs().get(VideoPlayerUi.class)) + .flatMap(player1 -> player1.UIs().getOpt(VideoPlayerUi.class)) .map(playerUi -> playerUi.getBinding().getRoot()); } diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index c6319c9e812..97d8d01debe 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -462,14 +462,15 @@ public void handleIntent(@NonNull final Intent intent) { } private void initUIsForCurrentPlayerType() { - if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN) - || (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) { + if ((UIs.getOpt(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN) + || (UIs.getOpt(PopupPlayerUi.class).isPresent() + && playerType == PlayerType.POPUP)) { // correct UI already in place return; } // try to reuse binding if possible - final PlayerBinding binding = UIs.get(VideoPlayerUi.class).map(VideoPlayerUi::getBinding) + final PlayerBinding binding = UIs.getOpt(VideoPlayerUi.class).map(VideoPlayerUi::getBinding) .orElseGet(() -> { if (playerType == PlayerType.AUDIO) { return null; @@ -480,15 +481,15 @@ private void initUIsForCurrentPlayerType() { switch (playerType) { case MAIN: - UIs.destroyAll(PopupPlayerUi.class); + UIs.destroyAllOfType(PopupPlayerUi.class); UIs.addAndPrepare(new MainPlayerUi(this, binding)); break; case POPUP: - UIs.destroyAll(MainPlayerUi.class); + UIs.destroyAllOfType(MainPlayerUi.class); UIs.addAndPrepare(new PopupPlayerUi(this, binding)); break; case AUDIO: - UIs.destroyAll(VideoPlayerUi.class); + UIs.destroyAllOfType(VideoPlayerUi.class); break; } } @@ -579,7 +580,12 @@ private void destroyPlayer() { } } - public void destroy() { + + /** Shut down this player. + * Saves the stream progress, sets recovery. + * Then destroys the player in all UIs and destroys the UIs as well. + */ + public void saveAndShutdown() { if (DEBUG) { Log.d(TAG, "destroy() called"); } @@ -594,7 +600,7 @@ public void destroy() { databaseUpdateDisposable.clear(); progressUpdateDisposable.set(null); - UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object + UIs.destroyAllOfType(null); } public void setRecovery() { @@ -1970,6 +1976,9 @@ public void setFragmentListener(final PlayerServiceEventListener listener) { triggerProgressUpdate(); } + /** Remove the listener, if it was set. + * @param listener listener to remove + * */ public void removeFragmentListener(final PlayerServiceEventListener listener) { if (fragmentListener == listener) { fragmentListener = null; @@ -1984,6 +1993,9 @@ void setActivityListener(final PlayerEventListener listener) { triggerProgressUpdate(); } + /** Remove the listener, if it was set. + * @param listener listener to remove + * */ void removeActivityListener(final PlayerEventListener listener) { if (activityListener == listener) { activityListener = null; diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java deleted file mode 100644 index 0e8ff795e6a..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright 2017 Mauricio Colli - * Part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.schabi.newpipe.player; - -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.os.Binder; -import android.os.IBinder; -import android.util.Log; - -import androidx.annotation.Nullable; - -import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; -import org.schabi.newpipe.player.notification.NotificationPlayerUi; -import org.schabi.newpipe.util.ThemeHelper; - -import java.lang.ref.WeakReference; - - -/** - * One background service for our player. Even though the player has multiple UIs - * (e.g. the audio-only UI, the main UI, the pulldown-menu UI), - * this allows us to keep playing even when switching between the different UIs. - */ -public final class PlayerService extends Service { - private static final String TAG = PlayerService.class.getSimpleName(); - private static final boolean DEBUG = Player.DEBUG; - - private Player player; - - private final IBinder mBinder = new PlayerService.LocalBinder(this); - - public Player getPlayer() { - return player; - } - - /*////////////////////////////////////////////////////////////////////////// - // Service's LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate() { - if (DEBUG) { - Log.d(TAG, "onCreate() called"); - } - assureCorrectAppLanguage(this); - ThemeHelper.setTheme(this); - - player = new Player(this); - /* - Create the player notification and start immediately the service in foreground, - otherwise if nothing is played or initializing the player and its components (especially - loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the - service would never be put in the foreground while we said to the system we would do so - */ - player.UIs().get(NotificationPlayerUi.class) - .ifPresent(NotificationPlayerUi::createNotificationAndStartForeground); - } - - @Override - public int onStartCommand(final Intent intent, final int flags, final int startId) { - if (DEBUG) { - Log.d(TAG, "onStartCommand() called with: intent = [" + intent - + "], flags = [" + flags + "], startId = [" + startId + "]"); - } - - /* - Be sure that the player notification is set and the service is started in foreground, - otherwise, the app may crash on Android 8+ as the service would never be put in the - foreground while we said to the system we would do so - The service is always requested to be started in foreground, so always creating a - notification if there is no one already and starting the service in foreground should - not create any issues - If the service is already started in foreground, requesting it to be started shouldn't - do anything - */ - if (player != null) { - player.UIs().get(NotificationPlayerUi.class) - .ifPresent(NotificationPlayerUi::createNotificationAndStartForeground); - } - - if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction()) - && (player == null || player.getPlayQueue() == null)) { - /* - No need to process media button's actions if the player is not working, otherwise - the player service would strangely start with nothing to play - Stop the service in this case, which will be removed from the foreground and its - notification cancelled in its destruction - */ - stopSelf(); - return START_NOT_STICKY; - } - - if (player != null) { - player.handleIntent(intent); - player.UIs().get(MediaSessionPlayerUi.class) - .ifPresent(ui -> ui.handleMediaButtonIntent(intent)); - } - - return START_NOT_STICKY; - } - - public void stopForImmediateReusing() { - if (DEBUG) { - Log.d(TAG, "stopForImmediateReusing() called"); - } - - if (player != null && !player.exoPlayerIsNull()) { - // Releases wifi & cpu, disables keepScreenOn, etc. - // We can't just pause the player here because it will make transition - // from one stream to a new stream not smooth - player.smoothStopForImmediateReusing(); - } - } - - @Override - public void onTaskRemoved(final Intent rootIntent) { - super.onTaskRemoved(rootIntent); - if (player != null && !player.videoPlayerSelected()) { - return; - } - onDestroy(); - // Unload from memory completely - Runtime.getRuntime().halt(0); - } - - @Override - public void onDestroy() { - if (DEBUG) { - Log.d(TAG, "destroy() called"); - } - cleanup(); - } - - private void cleanup() { - if (player != null) { - player.destroy(); - player = null; - } - } - - public void stopService() { - cleanup(); - stopSelf(); - } - - @Override - protected void attachBaseContext(final Context base) { - super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base)); - } - - @Override - public IBinder onBind(final Intent intent) { - return mBinder; - } - - /** - * Allows us this {@link org.schabi.newpipe.player.PlayerService} over the Service boundary - * back to our {@link org.schabi.newpipe.player.helper.PlayerHolder}. - */ - public static class LocalBinder extends Binder { - private final WeakReference playerService; - - LocalBinder(final PlayerService playerService) { - this.playerService = new WeakReference<>(playerService); - } - - /** - * Get the PlayerService object itself. - * @return this - * */ - public @Nullable PlayerService getService() { - return playerService.get(); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt new file mode 100644 index 00000000000..1f4687ad078 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2017 Mauricio Colli + * Part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.schabi.newpipe.player + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Binder +import android.os.IBinder +import android.util.Log +import org.schabi.newpipe.player.PlayerService.LocalBinder +import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi +import org.schabi.newpipe.player.notification.NotificationPlayerUi +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.ThemeHelper +import java.lang.ref.WeakReference + +/** + * One background service for our player. Even though the player has multiple UIs + * (e.g. the audio-only UI, the main UI, the pulldown-menu UI), + * this allows us to keep playing even when switching between the different UIs. + */ +class PlayerService : Service() { + lateinit var player: Player + private set + + private val mBinder: IBinder = LocalBinder(this) + + /*////////////////////////////////////////////////////////////////////////// + // Service's LifeCycle + ////////////////////////////////////////////////////////////////////////// */ + override fun onCreate() { + if (DEBUG) { + Log.d(TAG, "onCreate() called") + } + Localization.assureCorrectAppLanguage(this) + ThemeHelper.setTheme(this) + + player = Player(this) + /* + Create the player notification and start immediately the service in foreground, + otherwise if nothing is played or initializing the player and its components (especially + loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the + service would never be put in the foreground while we said to the system we would do so + */ + player.UIs().get(NotificationPlayerUi::class.java) + ?.createNotificationAndStartForeground() + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + if (DEBUG) { + Log.d( + TAG, + ( + "onStartCommand() called with: intent = [" + intent + + "], flags = [" + flags + "], startId = [" + startId + "]" + ) + ) + } + + /* + Be sure that the player notification is set and the service is started in foreground, + otherwise, the app may crash on Android 8+ as the service would never be put in the + foreground while we said to the system we would do so + The service is always requested to be started in foreground, so always creating a + notification if there is no one already and starting the service in foreground should + not create any issues + If the service is already started in foreground, requesting it to be started shouldn't + do anything + */ + player.UIs().get(NotificationPlayerUi::class.java) + ?.createNotificationAndStartForeground() + + if (Intent.ACTION_MEDIA_BUTTON == intent.action && + (player.playQueue == null) + ) { + /* + No need to process media button's actions if the player is not working, otherwise + the player service would strangely start with nothing to play + Stop the service in this case, which will be removed from the foreground and its + notification cancelled in its destruction + */ + stopSelf() + return START_NOT_STICKY + } + + player.handleIntent(intent) + player.UIs().get(MediaSessionPlayerUi::class.java) + ?.handleMediaButtonIntent(intent) + + return START_NOT_STICKY + } + + fun stopForImmediateReusing() { + if (DEBUG) { + Log.d(TAG, "stopForImmediateReusing() called") + } + + if (!player.exoPlayerIsNull()) { + // Releases wifi & cpu, disables keepScreenOn, etc. + // We can't just pause the player here because it will make transition + // from one stream to a new stream not smooth + player.smoothStopForImmediateReusing() + } + } + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + if (!player.videoPlayerSelected()) { + return + } + onDestroy() + // Unload from memory completely + Runtime.getRuntime().halt(0) + } + + override fun onDestroy() { + if (DEBUG) { + Log.d(TAG, "destroy() called") + } + player.saveAndShutdown() + } + + fun stopService() { + player.saveAndShutdown() + stopSelf() + } + + override fun attachBaseContext(base: Context?) { + super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base)) + } + + override fun onBind(intent: Intent?): IBinder { + return mBinder + } + + /** + * Allows us this [PlayerService] over the Service boundary + * back to our [org.schabi.newpipe.player.helper.PlayerHolder]. + */ + class LocalBinder internal constructor(playerService: PlayerService?) : Binder() { + private val playerService: WeakReference = + WeakReference(playerService) + + /** + * Get the PlayerService object itself. + * @return this + */ + fun getService(): PlayerService? { + return playerService.get() + } + } + + companion object { + private val TAG: String = PlayerService::class.java.getSimpleName() + private val DEBUG = Player.DEBUG + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerHolderLifecycleEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerHolderLifecycleEventListener.java index e5eaa09c734..e8e8f730773 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerHolderLifecycleEventListener.java +++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerHolderLifecycleEventListener.java @@ -2,9 +2,18 @@ import org.schabi.newpipe.player.PlayerService; -/** Gets signalled if our PlayerHolder (dis)connects from the PlayerService. */ +/** Gets signalled if our PlayerHolder (dis)connects from the PlayerService. + * This is currently only implemented by the + * {@link org.schabi.newpipe.fragments.detail.VideoDetailFragment}. */ public interface PlayerHolderLifecycleEventListener { + + /** Our {@link org.schabi.newpipe.player.helper.PlayerHolder} connected to its service. + * @param playerService The service the holder connected to + * @param playAfterConnect */ void onServiceConnected(PlayerService playerService, boolean playAfterConnect); + + /** Our {@link org.schabi.newpipe.player.helper.PlayerHolder} was unbound and thus + * disconnected from the {@link org.schabi.newpipe.player.PlayerService}. */ void onServiceDisconnected(); } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java index 707a44ea5a4..26ef6d213bf 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java @@ -7,7 +7,6 @@ import android.os.IBinder; import android.util.Log; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; @@ -111,18 +110,6 @@ public void unsetListeners() { holderListener = null; } - public void setListener(@NonNull final PlayerServiceEventListener newListener, - @NonNull final PlayerHolderLifecycleEventListener newHolderListener) { - listener = newListener; - holderListener = newHolderListener; - - // Force reload data from service - if (player != null) { - holderListener.onServiceConnected(playerService, false); - player.setFragmentListener(internalListener); - } - } - /** * Helper to handle context in common place as using the same * context to bind/unbind a service is crucial. @@ -138,7 +125,8 @@ private Context getCommonContext() { * Connect to (and if needed start) the {@link PlayerService} * and bind {@link PlayerServiceConnection} to it. * If the service is already started, only set the listener. - * @param playAfterConnect If the service is started, start playing immediately + * @param playAfterConnect If this holder’s service was already started, + * start playing immediately * @param newListener set this listener * @param newHolderListener set this listener * */ @@ -147,7 +135,14 @@ public void startService(final boolean playAfterConnect, final PlayerHolderLifecycleEventListener newHolderListener ) { final Context context = getCommonContext(); - setListener(newListener, newHolderListener); + listener = newListener; + holderListener = newHolderListener; + + // Force reload data from service + if (player != null) { + holderListener.onServiceConnected(playerService, false); + player.setFragmentListener(internalListener); + } if (bound) { return; } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java index c673e688c47..c3b427e2326 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java @@ -145,7 +145,7 @@ private ForwardingPlayer getForwardingPlayer() { public void play() { player.play(); // hide the player controls even if the play command came from the media session - player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); + player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java index 30420b0c7da..5658693f24d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java +++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java @@ -102,7 +102,7 @@ private synchronized NotificationCompat.Builder createNotification() { mediaStyle.setShowActionsInCompactView(compactSlots); } player.UIs() - .get(MediaSessionPlayerUi.class) + .getOpt(MediaSessionPlayerUi.class) .flatMap(MediaSessionPlayerUi::getSessionToken) .ifPresent(mediaStyle::setMediaSession); diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java deleted file mode 100644 index 24fec3b8afc..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java +++ /dev/null @@ -1,90 +0,0 @@ -package org.schabi.newpipe.player.ui; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.function.Consumer; - -public final class PlayerUiList { - final List playerUis = new ArrayList<>(); - - /** - * Creates a {@link PlayerUiList} starting with the provided player uis. The provided player uis - * will not be prepared like those passed to {@link #addAndPrepare(PlayerUi)}, because when - * the {@link PlayerUiList} constructor is called, the player is still not running and it - * wouldn't make sense to initialize uis then. Instead the player will initialize them by doing - * proper calls to {@link #call(Consumer)}. - * - * @param initialPlayerUis the player uis this list should start with; the order will be kept - */ - public PlayerUiList(final PlayerUi... initialPlayerUis) { - playerUis.addAll(List.of(initialPlayerUis)); - } - - /** - * Adds the provided player ui to the list and calls on it the initialization functions that - * apply based on the current player state. The preparation step needs to be done since when UIs - * are removed and re-added, the player will not call e.g. initPlayer again since the exoplayer - * is already initialized, but we need to notify the newly built UI that the player is ready - * nonetheless. - * @param playerUi the player ui to prepare and add to the list; its {@link - * PlayerUi#getPlayer()} will be used to query information about the player - * state - */ - public void addAndPrepare(final PlayerUi playerUi) { - if (playerUi.getPlayer().getFragmentListener().isPresent()) { - // make sure UIs know whether a service is connected or not - playerUi.onFragmentListenerSet(); - } - - if (!playerUi.getPlayer().exoPlayerIsNull()) { - playerUi.initPlayer(); - if (playerUi.getPlayer().getPlayQueue() != null) { - playerUi.initPlayback(); - } - } - - playerUis.add(playerUi); - } - - /** - * Destroys all matching player UIs and removes them from the list. - * @param playerUiType the class of the player UI to destroy; the {@link - * Class#isInstance(Object)} method will be used, so even subclasses will be - * destroyed and removed - * @param the class type parameter - */ - public void destroyAll(final Class playerUiType) { - playerUis.stream() - .filter(playerUiType::isInstance) - .forEach(playerUi -> { - playerUi.destroyPlayer(); - playerUi.destroy(); - }); - playerUis.removeIf(playerUiType::isInstance); - } - - /** - * @param playerUiType the class of the player UI to return; the {@link - * Class#isInstance(Object)} method will be used, so even subclasses could - * be returned - * @param the class type parameter - * @return the first player UI of the required type found in the list, or an empty {@link - * Optional} otherwise - */ - public Optional get(final Class playerUiType) { - return playerUis.stream() - .filter(playerUiType::isInstance) - .map(playerUiType::cast) - .findFirst(); - } - - /** - * Calls the provided consumer on all player UIs in the list, in order of addition. - * @param consumer the consumer to call with player UIs - */ - public void call(final Consumer consumer) { - //noinspection SimplifyStreamApiCallChains - playerUis.stream().forEachOrdered(consumer); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt new file mode 100644 index 00000000000..280e34ae79b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt @@ -0,0 +1,124 @@ +package org.schabi.newpipe.player.ui + +import org.schabi.newpipe.util.GuardedByMutex +import java.util.Optional + +class PlayerUiList(vararg initialPlayerUis: PlayerUi) { + var playerUis = GuardedByMutex(mutableListOf()) + + /** + * Creates a [PlayerUiList] starting with the provided player uis. The provided player uis + * will not be prepared like those passed to [.addAndPrepare], because when + * the [PlayerUiList] constructor is called, the player is still not running and it + * wouldn't make sense to initialize uis then. Instead the player will initialize them by doing + * proper calls to [.call]. + * + * @param initialPlayerUis the player uis this list should start with; the order will be kept + */ + init { + playerUis.runWithLockSync { + lockData.addAll(listOf(*initialPlayerUis)) + } + } + + /** + * Adds the provided player ui to the list and calls on it the initialization functions that + * apply based on the current player state. The preparation step needs to be done since when UIs + * are removed and re-added, the player will not call e.g. initPlayer again since the exoplayer + * is already initialized, but we need to notify the newly built UI that the player is ready + * nonetheless. + * @param playerUi the player ui to prepare and add to the list; its [PlayerUi.getPlayer] + * will be used to query information about the player state + */ + fun addAndPrepare(playerUi: PlayerUi) { + if (playerUi.getPlayer().fragmentListener.isPresent) { + // make sure UIs know whether a service is connected or not + playerUi.onFragmentListenerSet() + } + + if (!playerUi.getPlayer().exoPlayerIsNull()) { + playerUi.initPlayer() + if (playerUi.getPlayer().playQueue != null) { + playerUi.initPlayback() + } + } + + playerUis.runWithLockSync { + lockData.add(playerUi) + } + } + + /** + * Destroys all matching player UIs and removes them from the list. + * @param playerUiType the class of the player UI to destroy, everything if `null`. + * The [Class.isInstance] method will be used, so even subclasses will be + * destroyed and removed + * @param T the class type parameter + * */ + fun destroyAllOfType(playerUiType: Class? = null) { + val toDestroy = mutableListOf() + + // short blocking removal from class to prevent interfering from other threads + playerUis.runWithLockSync { + val new = mutableListOf() + for (ui in lockData) { + if (playerUiType == null || playerUiType.isInstance(ui)) { + toDestroy.add(ui) + } else { + new.add(ui) + } + } + lockData = new + } + // then actually destroy the UIs + for (ui in toDestroy) { + ui.destroyPlayer() + ui.destroy() + } + } + + /** + * @param playerUiType the class of the player UI to return; + * the [Class.isInstance] method will be used, so even subclasses could be returned + * @param T the class type parameter + * @return the first player UI of the required type found in the list, or null + */ + fun get(playerUiType: Class): T? = + playerUis.runWithLockSync { + for (ui in lockData) { + if (playerUiType.isInstance(ui)) { + when (val r = playerUiType.cast(ui)) { + // try all UIs before returning null + null -> continue + else -> return@runWithLockSync r + } + } + } + return@runWithLockSync null + } + + /** + * @param playerUiType the class of the player UI to return; + * the [Class.isInstance] method will be used, so even subclasses could be returned + * @param T the class type parameter + * @return the first player UI of the required type found in the list, or an empty + * [ ] otherwise + */ + @Deprecated("use get", ReplaceWith("get(playerUiType)")) + fun getOpt(playerUiType: Class): Optional = + Optional.ofNullable(get(playerUiType)) + + /** + * Calls the provided consumer on all player UIs in the list, in order of addition. + * @param consumer the consumer to call with player UIs + */ + fun call(consumer: java.util.function.Consumer) { + // copy the list out of the mutex before calling the consumer which might block + val new = playerUis.runWithLockSync { + lockData.toMutableList() + } + for (ui in new) { + consumer.accept(ui) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java index ec9d6783b48..bde34560445 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java @@ -16,6 +16,7 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.nextResizeModeAndSaveToPrefs; import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences; +import android.annotation.SuppressLint; import android.content.Intent; import android.content.res.Resources; import android.graphics.Bitmap; @@ -761,7 +762,7 @@ public boolean isFullscreen() { } /** - * Update the play/pause button ({@link R.id.playPauseButton}) to reflect the action + * Update the play/pause button (`R.id.playPauseButton`) to reflect the action * that will be performed when the button is clicked.. * @param action the action that is performed when the play/pause button is clicked */ @@ -947,6 +948,7 @@ public void onShuffleClicked() { player.toggleShuffleModeEnabled(); } + @SuppressLint("PrivateResource") @Override public void onRepeatModeChanged(@RepeatMode final int repeatMode) { super.onRepeatModeChanged(repeatMode); diff --git a/app/src/main/java/org/schabi/newpipe/util/GuardedByMutex.kt b/app/src/main/java/org/schabi/newpipe/util/GuardedByMutex.kt new file mode 100644 index 00000000000..b3bd077f813 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/GuardedByMutex.kt @@ -0,0 +1,47 @@ +package org.schabi.newpipe.util + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** Guard the given data so that it can only be accessed by locking the mutex first. + * + * Inspired by [this blog post](https://jonnyzzz.com/blog/2017/03/01/guarded-by-lock/) + * */ +class GuardedByMutex( + private var data: T, + private val lock: Mutex = Mutex(locked = false), +) { + + /** Lock the mutex and access the data, blocking the current thread. + * @param action to run with locked mutex + * */ + fun runWithLockSync( + action: MutexData.() -> Y + ) = + runBlocking { + lock.withLock { + MutexData(data, { d -> data = d }).action() + } + } + + /** Lock the mutex and access the data, suspending the coroutine. + * @param action to run with locked mutex + * */ + suspend fun runWithLock(action: MutexData.() -> Y) = + lock.withLock { + MutexData(data, { d -> data = d }).action() + } +} + +/** The data inside a [GuardedByMutex], which can be accessed via [lockData]. + * [lockData] is a `var`, so you can `set` it as well. + * */ +class MutexData(data: T, val setFun: (T) -> Unit) { + /** The data inside this [GuardedByMutex] */ + var lockData: T = data + set(t) { + setFun(t) + field = t + } +}