From 6830890f988ea3bc4c628c355bafd917f30dbce0 Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Thu, 26 Dec 2024 11:58:27 +0100 Subject: [PATCH 1/3] PlayerUIList: transform to kotlin And simplify the code a little --- .../newpipe/player/ui/PlayerUiList.java | 90 ------------------ .../schabi/newpipe/player/ui/PlayerUiList.kt | 92 +++++++++++++++++++ 2 files changed, 92 insertions(+), 90 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt 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..46090285f7b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt @@ -0,0 +1,92 @@ +package org.schabi.newpipe.player.ui + +import androidx.core.util.Consumer +import java.util.Optional + +class PlayerUiList(vararg initialPlayerUis: PlayerUi) { + val playerUis = 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.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.add(playerUi) + } + + /** + * Destroys all matching player UIs and removes them from the list. + * @param playerUiType the class of the player UI to destroy; + * the [Class.isInstance] method will be used, so even subclasses will be + * destroyed and removed + * @param T the class type parameter + * */ + fun destroyAll(playerUiType: Class) { + for (ui in playerUis) { + if (playerUiType.isInstance(ui)) { + ui.destroyPlayer() + ui.destroy() + playerUis.remove(ui) + } + } + } + + /** + * @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 + */ + fun get(playerUiType: Class): Optional { + for (ui in playerUis) { + if (playerUiType.isInstance(ui)) { + when (val r = playerUiType.cast(ui)) { + null -> continue + else -> return Optional.of(r) + } + } + } + return Optional.empty() + } + + /** + * 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) { + for (ui in playerUis) { + consumer.accept(ui) + } + } +} From 5b92de4f764ce4006494dffed392952bf2f41dc5 Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Thu, 26 Dec 2024 12:05:10 +0100 Subject: [PATCH 2/3] PlayerUIList: rename get to getOpt and make get nullable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Kotlin, dealing with nulls works better so we don’t need optional. --- .../fragments/detail/VideoDetailFragment.java | 30 +++++++++---------- .../org/schabi/newpipe/player/Player.java | 7 +++-- .../schabi/newpipe/player/PlayerService.java | 6 ++-- .../mediasession/MediaSessionPlayerUi.java | 2 +- .../player/notification/NotificationUtil.java | 2 +- .../schabi/newpipe/player/ui/PlayerUiList.kt | 21 +++++++++---- 6 files changed, 40 insertions(+), 28 deletions(-) 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 63077e92d44..bd208bc78b9 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 @@ -243,7 +243,7 @@ public void onServiceConnected(final Player connectedPlayer, // 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; } @@ -519,7 +519,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 @@ -678,7 +678,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); } @@ -1018,7 +1018,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(); } @@ -1234,7 +1234,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 @@ -1250,7 +1250,7 @@ private void removeVideoPlayerView() { makeDefaultHeightForVideoPlaceholder(); if (player != null) { - player.UIs().get(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent); + player.UIs().getOpt(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent); } } @@ -1317,7 +1317,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)); } @@ -1848,7 +1848,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; } @@ -1877,7 +1877,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; } @@ -1977,7 +1977,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); } @@ -2054,7 +2054,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(); @@ -2319,7 +2319,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); @@ -2333,7 +2333,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); @@ -2344,7 +2344,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); } @@ -2441,7 +2441,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 ab5274996da..5ec8e634cb5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -463,14 +463,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; diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java index e7abf4320d5..393ccf75da0 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java @@ -66,7 +66,7 @@ otherwise if nothing is played or initializing the player and its components (es 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) + player.UIs().getOpt(NotificationPlayerUi.class) .ifPresent(NotificationPlayerUi::createNotificationAndStartForeground); } @@ -88,7 +88,7 @@ public int onStartCommand(final Intent intent, final int flags, final int startI do anything */ if (player != null) { - player.UIs().get(NotificationPlayerUi.class) + player.UIs().getOpt(NotificationPlayerUi.class) .ifPresent(NotificationPlayerUi::createNotificationAndStartForeground); } @@ -106,7 +106,7 @@ public int onStartCommand(final Intent intent, final int flags, final int startI if (player != null) { player.handleIntent(intent); - player.UIs().get(MediaSessionPlayerUi.class) + player.UIs().getOpt(MediaSessionPlayerUi.class) .ifPresent(ui -> ui.handleMediaButtonIntent(intent)); } 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.kt b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt index 46090285f7b..460dfeb0e0f 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt +++ b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt @@ -65,21 +65,32 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { * @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 + * @return the first player UI of the required type found in the list, or null */ - fun get(playerUiType: Class): Optional { + fun get(playerUiType: Class): T? { for (ui in playerUis) { if (playerUiType.isInstance(ui)) { when (val r = playerUiType.cast(ui)) { + // try all UIs before returning null null -> continue - else -> return Optional.of(r) + else -> return r } } } - return Optional.empty() + return 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 From cbade0b54dfc8cc116433bea3c8959f0eb3817b2 Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Thu, 26 Dec 2024 15:04:45 +0100 Subject: [PATCH 3/3] PlayerUiList: guard list actions with mutex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new implementation would throw `ConcurrentModificationExceptions` when destroying the UIs. So let’s play it safe and put the list behind a mutex. Adds a helper class `GuardedByMutex` that can be wrapped around a property to force all use-sites to acquire the lock before doing anything with the data. --- .../schabi/newpipe/player/ui/PlayerUiList.kt | 59 +++++++++++++------ .../org/schabi/newpipe/util/GuardedByMutex.kt | 47 +++++++++++++++ 2 files changed, 87 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/util/GuardedByMutex.kt 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 index 460dfeb0e0f..812f11ec46f 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt +++ b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt @@ -1,10 +1,10 @@ package org.schabi.newpipe.player.ui -import androidx.core.util.Consumer +import org.schabi.newpipe.util.GuardedByMutex import java.util.Optional class PlayerUiList(vararg initialPlayerUis: PlayerUi) { - val playerUis = mutableListOf() + var playerUis = GuardedByMutex(mutableListOf()) /** * Creates a [PlayerUiList] starting with the provided player uis. The provided player uis @@ -16,7 +16,9 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { * @param initialPlayerUis the player uis this list should start with; the order will be kept */ init { - playerUis.addAll(listOf(*initialPlayerUis)) + playerUis.runWithLockSync { + lockData.addAll(listOf(*initialPlayerUis)) + } } /** @@ -41,7 +43,9 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { } } - playerUis.add(playerUi) + playerUis.runWithLockSync { + lockData.add(playerUi) + } } /** @@ -52,12 +56,24 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { * @param T the class type parameter * */ fun destroyAll(playerUiType: Class) { - for (ui in playerUis) { - if (playerUiType.isInstance(ui)) { - ui.destroyPlayer() - ui.destroy() - playerUis.remove(ui) + 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.isInstance(ui)) { + toDestroy.add(ui) + } else { + new.add(ui) + } } + lockData = new + } + // then actually destroy the UIs + for (ui in toDestroy) { + ui.destroyPlayer() + ui.destroy() } } @@ -67,18 +83,19 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { * @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? { - for (ui in playerUis) { - if (playerUiType.isInstance(ui)) { - when (val r = playerUiType.cast(ui)) { - // try all UIs before returning null - null -> continue - else -> return r + 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 } - return null - } /** * @param playerUiType the class of the player UI to return; @@ -96,7 +113,11 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { * @param consumer the consumer to call with player UIs */ fun call(consumer: java.util.function.Consumer) { - for (ui in playerUis) { + // 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/util/GuardedByMutex.kt b/app/src/main/java/org/schabi/newpipe/util/GuardedByMutex.kt new file mode 100644 index 00000000000..1777a8cc3c8 --- /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 + } +} \ No newline at end of file