From 462f0ad5c05f5a35104142181ac6e2a30c84d8d2 Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Wed, 14 May 2025 21:38:42 +0200 Subject: [PATCH 01/18] PlayerUIList: inline init block --- .../schabi/newpipe/player/ui/PlayerUiList.kt | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) 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 190da81e6..ec0c85c93 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 @@ -3,10 +3,21 @@ package org.schabi.newpipe.player.ui import org.schabi.newpipe.util.GuardedByMutex import java.util.Optional +/** + * 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 + */ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { - private val playerUis = GuardedByMutex(mutableListOf()) + private val playerUis = GuardedByMutex(mutableListOf(*initialPlayerUis)) /** + * Adds the provided player ui to the list and calls on it the initialization functions that + /** * 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 @@ -14,16 +25,7 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { * 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 + */* 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. From c9be4066f26d7b0c6bc606879d69c54f1bb1251e Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Wed, 14 May 2025 21:38:59 +0200 Subject: [PATCH 02/18] PlayerService: inline init block & make non-optional --- app/src/main/java/org/schabi/newpipe/player/PlayerService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt index c335611b0..ad000a1cf 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -295,7 +295,7 @@ class PlayerService : MediaBrowserServiceCompat() { } class LocalBinder internal constructor(playerService: PlayerService) : Binder() { - private val playerService = WeakReference(playerService) + private val playerService = WeakReference(playerService) val service: PlayerService? get() = playerService.get() From 86063fda6a6be0318fce7f9a33c954074b874c78 Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Tue, 13 May 2025 17:13:27 +0200 Subject: [PATCH 03/18] PlayQueueActivity: inline getServiceConnection() and bind() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are only used once, and it’s easier to see what’s going on if they are just inlined in `onCreate`. --- .../newpipe/player/PlayQueueActivity.java | 92 +++++++++---------- 1 file changed, 42 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java index 9d680da4d..c5c8b20f6 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java @@ -97,8 +97,48 @@ public final class PlayQueueActivity extends AppCompatActivity getSupportActionBar().setTitle(R.string.title_activity_play_queue); } - serviceConnection = getServiceConnection(); - bind(); + serviceConnection = new ServiceConnection() { + @Override + public void onServiceDisconnected(final ComponentName name) { + Log.d(TAG, "Player service is disconnected"); + } + + @Override + public void onServiceConnected(final ComponentName name, final IBinder binder) { + Log.d(TAG, "Player service is connected"); + + if (binder instanceof PlayerService.LocalBinder) { + @Nullable final PlayerService s = + ((PlayerService.LocalBinder) binder).getService(); + if (s == null) { + throw new IllegalArgumentException( + "PlayerService.LocalBinder.getService() must never be" + + "null after the service connects"); + } + player = s.getPlayer(); + } + + if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) { + unbind(); + } else { + onQueueUpdate(player.getPlayQueue()); + buildComponents(); + if (player != null) { + player.setActivityListener(PlayQueueActivity.this); + } + } + } + }; + + // Note: this code should not really exist, and PlayerHolder should be used instead, but + // it will be rewritten when NewPlayer will replace the current player. + final Intent bindIntent = new Intent(this, PlayerService.class); + bindIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION); + final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE); + if (!success) { + unbindService(serviceConnection); + } + serviceBound = success; } @Override @@ -180,19 +220,6 @@ public final class PlayQueueActivity extends AppCompatActivity //////////////////////////////////////////////////////////////////////////// // Service Connection - //////////////////////////////////////////////////////////////////////////// - - private void bind() { - // Note: this code should not really exist, and PlayerHolder should be used instead, but - // it will be rewritten when NewPlayer will replace the current player. - final Intent bindIntent = new Intent(this, PlayerService.class); - bindIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION); - final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE); - if (!success) { - unbindService(serviceConnection); - } - serviceBound = success; - } private void unbind() { if (serviceBound) { @@ -212,41 +239,6 @@ public final class PlayQueueActivity extends AppCompatActivity } } - private ServiceConnection getServiceConnection() { - return new ServiceConnection() { - @Override - public void onServiceDisconnected(final ComponentName name) { - Log.d(TAG, "Player service is disconnected"); - } - - @Override - public void onServiceConnected(final ComponentName name, final IBinder binder) { - Log.d(TAG, "Player service is connected"); - - if (binder instanceof PlayerService.LocalBinder) { - @Nullable final PlayerService s = - ((PlayerService.LocalBinder) binder).getService(); - if (s == null) { - throw new IllegalArgumentException( - "PlayerService.LocalBinder.getService() must never be" - + "null after the service connects"); - } - player = s.getPlayer(); - } - - if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) { - unbind(); - } else { - onQueueUpdate(player.getPlayQueue()); - buildComponents(); - if (player != null) { - player.setActivityListener(PlayQueueActivity.this); - } - } - } - }; - } - //////////////////////////////////////////////////////////////////////////// // Component Building //////////////////////////////////////////////////////////////////////////// From 9b227730702179c7574da67b847e5f8c38e4d390 Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Wed, 1 Jan 2025 15:18:37 +0100 Subject: [PATCH 04/18] PlayerHolder: convert to kotlin (mechanical) --- .../java/org/schabi/newpipe/MainActivity.java | 6 +- .../org/schabi/newpipe/RouterActivity.java | 2 +- .../fragments/detail/VideoDetailFragment.java | 2 +- .../info_list/dialog/InfoItemDialog.java | 2 +- .../newpipe/player/helper/PlayerHolder.java | 385 ------------------ .../newpipe/player/helper/PlayerHolder.kt | 384 +++++++++++++++++ .../ui/components/items/stream/StreamMenu.kt | 2 +- .../schabi/newpipe/util/NavigationHelper.java | 8 +- 8 files changed, 395 insertions(+), 396 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index bf23d3d70..157511c9f 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -849,7 +849,7 @@ public class MainActivity extends AppCompatActivity { return; } - if (PlayerHolder.getInstance().isPlayerOpen()) { + if (PlayerHolder.Companion.getInstance().isPlayerOpen()) { // if the player is already open, no need for a broadcast receiver openMiniPlayerIfMissing(); } else { @@ -859,7 +859,7 @@ public class MainActivity extends AppCompatActivity { public void onReceive(final Context context, final Intent intent) { if (Objects.equals(intent.getAction(), VideoDetailFragment.ACTION_PLAYER_STARTED) - && PlayerHolder.getInstance().isPlayerOpen()) { + && PlayerHolder.Companion.getInstance().isPlayerOpen()) { openMiniPlayerIfMissing(); // At this point the player is added 100%, we can unregister. Other actions // are useless since the fragment will not be removed after that. @@ -874,7 +874,7 @@ public class MainActivity extends AppCompatActivity { // If the PlayerHolder is not bound yet, but the service is running, try to bind to it. // Once the connection is established, the ACTION_PLAYER_STARTED will be sent. - PlayerHolder.getInstance().tryBindIfNeeded(this); + PlayerHolder.Companion.getInstance().tryBindIfNeeded(this); } } diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index 197c965ba..27ae603c7 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -701,7 +701,7 @@ public class RouterActivity extends AppCompatActivity { } // ...the player is not running or in normal Video-mode/type - final PlayerType playerType = PlayerHolder.getInstance().getType(); + final PlayerType playerType = PlayerHolder.Companion.getInstance().getType(); return playerType == null || playerType == PlayerType.MAIN; } 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 5e0373122..ce1a50ad1 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 @@ -228,7 +228,7 @@ public final class VideoDetailFragment @Nullable private PlayerService playerService; private Player player; - private final PlayerHolder playerHolder = PlayerHolder.getInstance(); + private final PlayerHolder playerHolder = PlayerHolder.Companion.getInstance(); /*////////////////////////////////////////////////////////////////////////// // Service management diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java index dcf01e190..55d49b145 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java @@ -252,7 +252,7 @@ public final class InfoItemDialog { * @return the current {@link Builder} instance */ public Builder addEnqueueEntriesIfNeeded() { - final PlayerHolder holder = PlayerHolder.getInstance(); + final PlayerHolder holder = PlayerHolder.Companion.getInstance(); if (holder.isPlayQueueReady()) { addEntry(StreamDialogDefaultEntry.ENQUEUE); 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 deleted file mode 100644 index ba8a5e0ff..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java +++ /dev/null @@ -1,385 +0,0 @@ -package org.schabi.newpipe.player.helper; - -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.os.IBinder; -import android.util.Log; - -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; - -import com.google.android.exoplayer2.PlaybackException; -import com.google.android.exoplayer2.PlaybackParameters; - -import org.schabi.newpipe.App; -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.PlayerService; -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.player.PlayerType; -import org.schabi.newpipe.player.event.PlayerServiceEventListener; -import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.util.NavigationHelper; - -import java.util.Optional; -import java.util.function.Consumer; - -public final class PlayerHolder { - - private PlayerHolder() { - } - - private static PlayerHolder instance; - public static synchronized PlayerHolder getInstance() { - if (PlayerHolder.instance == null) { - PlayerHolder.instance = new PlayerHolder(); - } - return PlayerHolder.instance; - } - - private static final boolean DEBUG = MainActivity.DEBUG; - private static final String TAG = PlayerHolder.class.getSimpleName(); - - @Nullable private PlayerServiceExtendedEventListener listener; - - private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection(); - private boolean bound; - @Nullable private PlayerService playerService; - - private Optional getPlayer() { - return Optional.ofNullable(playerService) - .flatMap(s -> Optional.ofNullable(s.getPlayer())); - } - - private Optional getPlayQueue() { - // player play queue might be null e.g. while player is starting - return getPlayer().flatMap(p -> Optional.ofNullable(p.getPlayQueue())); - } - - /** - * Returns the current {@link PlayerType} of the {@link PlayerService} service, - * otherwise `null` if no service is running. - * - * @return Current PlayerType - */ - @Nullable - public PlayerType getType() { - return getPlayer().map(Player::getPlayerType).orElse(null); - } - - public boolean isPlaying() { - return getPlayer().map(Player::isPlaying).orElse(false); - } - - public boolean isPlayerOpen() { - return getPlayer().isPresent(); - } - - /** - * Use this method to only allow the user to manipulate the play queue (e.g. by enqueueing via - * the stream long press menu) when there actually is a play queue to manipulate. - * @return true only if the player is open and its play queue is ready (i.e. it is not null) - */ - public boolean isPlayQueueReady() { - return getPlayQueue().isPresent(); - } - - public boolean isBound() { - return bound; - } - - public int getQueueSize() { - return getPlayQueue().map(PlayQueue::size).orElse(0); - } - - public int getQueuePosition() { - return getPlayQueue().map(PlayQueue::getIndex).orElse(0); - } - - public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) { - listener = newListener; - - if (listener == null) { - return; - } - - // Force reload data from service - if (playerService != null) { - listener.onServiceConnected(playerService); - startPlayerListener(); - // ^ will call listener.onPlayerConnected() down the line if there is an active player - } - } - - // helper to handle context in common place as using the same - // context to bind/unbind a service is crucial - private Context getCommonContext() { - return App.getInstance(); - } - - /** - * 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 this holder’s service was already started, - * start playing immediately - * @param newListener set this listener - * */ - public void startService(final boolean playAfterConnect, - final PlayerServiceExtendedEventListener newListener) { - if (DEBUG) { - Log.d(TAG, "startService() called with playAfterConnect=" + playAfterConnect); - } - final Context context = getCommonContext(); - setListener(newListener); - if (bound) { - return; - } - // startService() can be called concurrently and it will give a random crashes - // and NullPointerExceptions inside the service because the service will be - // bound twice. Prevent it with unbinding first - unbind(context); - final Intent intent = new Intent(context, PlayerService.class); - intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true); - ContextCompat.startForegroundService(context, intent); - serviceConnection.doPlayAfterConnect(playAfterConnect); - bind(context); - } - - public void stopService() { - if (DEBUG) { - Log.d(TAG, "stopService() called"); - } - if (playerService != null) { - playerService.destroyPlayerAndStopService(); - } - final Context context = getCommonContext(); - unbind(context); - // destroyPlayerAndStopService() already runs the next line of code, but run it again just - // to make sure to stop the service even if playerService is null by any chance. - context.stopService(new Intent(context, PlayerService.class)); - } - - class PlayerServiceConnection implements ServiceConnection { - - private boolean playAfterConnect = false; - - /** - * @param playAfterConnection Sets the value of `playAfterConnect` to pass to the {@link - * PlayerServiceExtendedEventListener#onPlayerConnected(Player, boolean)} the next time it - * is called. The value of `playAfterConnect` will be reset to false after that. - */ - public void doPlayAfterConnect(final boolean playAfterConnection) { - this.playAfterConnect = playAfterConnection; - } - - @Override - public void onServiceDisconnected(final ComponentName compName) { - if (DEBUG) { - Log.d(TAG, "Player service is disconnected"); - } - - final Context context = getCommonContext(); - unbind(context); - } - - @Override - public void onServiceConnected(final ComponentName compName, final IBinder service) { - if (DEBUG) { - Log.d(TAG, "Player service is connected"); - } - final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service; - - @Nullable final PlayerService s = localBinder.getService(); - if (s == null) { - throw new IllegalArgumentException( - "PlayerService.LocalBinder.getService() must never be" - + "null after the service connects"); - } - playerService = s; - if (listener != null) { - listener.onServiceConnected(s); - getPlayer().ifPresent(p -> listener.onPlayerConnected(p, playAfterConnect)); - } - startPlayerListener(); - // ^ will call listener.onPlayerConnected() down the line if there is an active player - - // notify the main activity that binding the service has completed, so that it can - // open the bottom mini-player - NavigationHelper.sendPlayerStartedEvent(s); - } - } - - private void bind(final Context context) { - if (DEBUG) { - Log.d(TAG, "bind() called"); - } - // BIND_AUTO_CREATE starts the service if it's not already running - bound = bind(context, Context.BIND_AUTO_CREATE); - if (!bound) { - context.unbindService(serviceConnection); - } - } - - public void tryBindIfNeeded(final Context context) { - if (!bound) { - // flags=0 means the service will not be started if it does not already exist. In this - // case the return value is not useful, as a value of "true" does not really indicate - // that the service is going to be bound. - bind(context, 0); - } - } - - private boolean bind(final Context context, final int flags) { - final Intent serviceIntent = new Intent(context, PlayerService.class); - serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION); - return context.bindService(serviceIntent, serviceConnection, flags); - } - - private void unbind(final Context context) { - if (DEBUG) { - Log.d(TAG, "unbind() called"); - } - - if (bound) { - context.unbindService(serviceConnection); - bound = false; - stopPlayerListener(); - playerService = null; - if (listener != null) { - listener.onPlayerDisconnected(); - listener.onServiceDisconnected(); - } - } - } - - private void startPlayerListener() { - if (playerService != null) { - // setting the player listener will take care of calling relevant callbacks if the - // player in the service is (not) already active, also see playerStateListener below - playerService.setPlayerListener(playerStateListener); - } - getPlayer().ifPresent(p -> p.setFragmentListener(internalListener)); - } - - private void stopPlayerListener() { - if (playerService != null) { - playerService.setPlayerListener(null); - } - getPlayer().ifPresent(p -> p.removeFragmentListener(internalListener)); - } - - /** - * This listener will be held by the players created by {@link PlayerService}. - */ - private final PlayerServiceEventListener internalListener = - new PlayerServiceEventListener() { - @Override - public void onViewCreated() { - if (listener != null) { - listener.onViewCreated(); - } - } - - @Override - public void onFullscreenStateChanged(final boolean fullscreen) { - if (listener != null) { - listener.onFullscreenStateChanged(fullscreen); - } - } - - @Override - public void onScreenRotationButtonClicked() { - if (listener != null) { - listener.onScreenRotationButtonClicked(); - } - } - - @Override - public void onMoreOptionsLongClicked() { - if (listener != null) { - listener.onMoreOptionsLongClicked(); - } - } - - @Override - public void onPlayerError(final PlaybackException error, - final boolean isCatchableException) { - if (listener != null) { - listener.onPlayerError(error, isCatchableException); - } - } - - @Override - public void hideSystemUiIfNeeded() { - if (listener != null) { - listener.hideSystemUiIfNeeded(); - } - } - - @Override - public void onQueueUpdate(final PlayQueue queue) { - if (listener != null) { - listener.onQueueUpdate(queue); - } - } - - @Override - public void onPlaybackUpdate(final int state, - final int repeatMode, - final boolean shuffled, - final PlaybackParameters parameters) { - if (listener != null) { - listener.onPlaybackUpdate(state, repeatMode, shuffled, parameters); - } - } - - @Override - public void onProgressUpdate(final int currentProgress, - final int duration, - final int bufferPercent) { - if (listener != null) { - listener.onProgressUpdate(currentProgress, duration, bufferPercent); - } - } - - @Override - public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) { - if (listener != null) { - listener.onMetadataUpdate(info, queue); - } - } - - @Override - public void onServiceStopped() { - if (listener != null) { - listener.onServiceStopped(); - } - unbind(getCommonContext()); - } - }; - - /** - * This listener will be held by bound {@link PlayerService}s to notify of the player starting - * or stopping. This is necessary since the service outlives the player e.g. to answer Android - * Auto media browser queries. - */ - private final Consumer playerStateListener = (@Nullable final Player player) -> { - if (listener != null) { - if (player == null) { - // player.fragmentListener=null is already done by player.stopActivityBinding(), - // which is called by player.destroy(), which is in turn called by PlayerService - // before setting its player to null - listener.onPlayerDisconnected(); - } else { - listener.onPlayerConnected(player, serviceConnection.playAfterConnect); - // reset the value of playAfterConnect: if it was true before, it is now "consumed" - serviceConnection.playAfterConnect = false; - player.setFragmentListener(internalListener); - } - } - }; -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt new file mode 100644 index 000000000..8cef16bec --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt @@ -0,0 +1,384 @@ +package org.schabi.newpipe.player.helper + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.util.Log +import androidx.core.content.ContextCompat +import com.google.android.exoplayer2.PlaybackException +import com.google.android.exoplayer2.PlaybackParameters +import org.schabi.newpipe.App +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.player.Player +import org.schabi.newpipe.player.PlayerService +import org.schabi.newpipe.player.PlayerService.LocalBinder +import org.schabi.newpipe.player.PlayerType +import org.schabi.newpipe.player.event.PlayerServiceEventListener +import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener +import org.schabi.newpipe.player.playqueue.PlayQueue +import org.schabi.newpipe.util.NavigationHelper +import java.util.Optional +import java.util.function.Consumer +import java.util.function.Function + +class PlayerHolder private constructor() { + private var listener: PlayerServiceExtendedEventListener? = null + + private val serviceConnection = PlayerServiceConnection() + var isBound: Boolean = false + private set + private var playerService: PlayerService? = null + + private val player: Optional + get() = Optional.ofNullable(playerService) + .flatMap( + Function { s: PlayerService? -> + Optional.ofNullable( + s!!.player + ) + } + ) + + private val playQueue: Optional + get() = // player play queue might be null e.g. while player is starting + this.player.flatMap( + Function { p: Player? -> + Optional.ofNullable( + p!!.getPlayQueue() + ) + } + ) + + val type: PlayerType? + /** + * Returns the current [PlayerType] of the [PlayerService] service, + * otherwise `null` if no service is running. + * + * @return Current PlayerType + */ + get() = this.player.map(Function { obj: Player? -> obj!!.getPlayerType() }) + .orElse(null) + + val isPlaying: Boolean + get() = this.player.map(Function { obj: Player? -> obj!!.isPlaying() }) + .orElse(false) + + val isPlayerOpen: Boolean + get() = this.player.isPresent() + + val isPlayQueueReady: Boolean + /** + * Use this method to only allow the user to manipulate the play queue (e.g. by enqueueing via + * the stream long press menu) when there actually is a play queue to manipulate. + * @return true only if the player is open and its play queue is ready (i.e. it is not null) + */ + get() = this.playQueue.isPresent() + + val queueSize: Int + get() = this.playQueue.map(Function { obj: PlayQueue? -> obj!!.size() }).orElse(0) + + val queuePosition: Int + get() = this.playQueue.map(Function { obj: PlayQueue? -> obj!!.getIndex() }).orElse(0) + + fun setListener(newListener: PlayerServiceExtendedEventListener?) { + listener = newListener + + if (listener == null) { + return + } + + // Force reload data from service + if (playerService != null) { + listener!!.onServiceConnected(playerService!!) + startPlayerListener() + // ^ will call listener.onPlayerConnected() down the line if there is an active player + } + } + + private val commonContext: Context + // helper to handle context in common place as using the same + get() = App.instance + + /** + * Connect to (and if needed start) the [PlayerService] + * and bind [PlayerServiceConnection] to it. + * If the service is already started, only set the listener. + * @param playAfterConnect If this holder’s service was already started, + * start playing immediately + * @param newListener set this listener + */ + fun startService( + playAfterConnect: Boolean, + newListener: PlayerServiceExtendedEventListener? + ) { + if (DEBUG) { + Log.d(TAG, "startService() called with playAfterConnect=" + playAfterConnect) + } + val context = this.commonContext + setListener(newListener) + if (this.isBound) { + return + } + // startService() can be called concurrently and it will give a random crashes + // and NullPointerExceptions inside the service because the service will be + // bound twice. Prevent it with unbinding first + unbind(context) + val intent = Intent(context, PlayerService::class.java) + intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true) + ContextCompat.startForegroundService(context, intent) + serviceConnection.doPlayAfterConnect(playAfterConnect) + bind(context) + } + + fun stopService() { + if (DEBUG) { + Log.d(TAG, "stopService() called") + } + if (playerService != null) { + playerService!!.destroyPlayerAndStopService() + } + val context = this.commonContext + unbind(context) + // destroyPlayerAndStopService() already runs the next line of code, but run it again just + // to make sure to stop the service even if playerService is null by any chance. + context.stopService(Intent(context, PlayerService::class.java)) + } + + internal inner class PlayerServiceConnection : ServiceConnection { + internal var playAfterConnect = false + + /** + * @param playAfterConnection Sets the value of [playAfterConnect] to pass to the + * [PlayerServiceExtendedEventListener.onPlayerConnected] the next time it + * is called. The value of [playAfterConnect] will be reset to false after that. + */ + fun doPlayAfterConnect(playAfterConnection: Boolean) { + this.playAfterConnect = playAfterConnection + } + + override fun onServiceDisconnected(compName: ComponentName?) { + if (DEBUG) { + Log.d(TAG, "Player service is disconnected") + } + + val context: Context = this@PlayerHolder.commonContext + unbind(context) + } + + override fun onServiceConnected(compName: ComponentName?, service: IBinder?) { + if (DEBUG) { + Log.d(TAG, "Player service is connected") + } + val localBinder = service as LocalBinder + + val s = localBinder.service + requireNotNull(s) { + ( + "PlayerService.LocalBinder.getService() must never be" + + "null after the service connects" + ) + } + playerService = s + if (listener != null) { + listener!!.onServiceConnected(s) + this@PlayerHolder.player.ifPresent( + Consumer { p: Player? -> + listener!!.onPlayerConnected( + p!!, + playAfterConnect + ) + } + ) + } + startPlayerListener() + + // ^ will call listener.onPlayerConnected() down the line if there is an active player + + // notify the main activity that binding the service has completed, so that it can + // open the bottom mini-player + NavigationHelper.sendPlayerStartedEvent(s) + } + } + + private fun bind(context: Context) { + if (DEBUG) { + Log.d(TAG, "bind() called") + } + // BIND_AUTO_CREATE starts the service if it's not already running + this.isBound = bind(context, Context.BIND_AUTO_CREATE) + if (!this.isBound) { + context.unbindService(serviceConnection) + } + } + + fun tryBindIfNeeded(context: Context) { + if (!this.isBound) { + // flags=0 means the service will not be started if it does not already exist. In this + // case the return value is not useful, as a value of "true" does not really indicate + // that the service is going to be bound. + bind(context, 0) + } + } + + private fun bind(context: Context, flags: Int): Boolean { + val serviceIntent = Intent(context, PlayerService::class.java) + serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION) + return context.bindService(serviceIntent, serviceConnection, flags) + } + + private fun unbind(context: Context) { + if (DEBUG) { + Log.d(TAG, "unbind() called") + } + + if (this.isBound) { + context.unbindService(serviceConnection) + this.isBound = false + stopPlayerListener() + playerService = null + if (listener != null) { + listener!!.onPlayerDisconnected() + listener!!.onServiceDisconnected() + } + } + } + + private fun startPlayerListener() { + if (playerService != null) { + // setting the player listener will take care of calling relevant callbacks if the + // player in the service is (not) already active, also see playerStateListener below + playerService!!.setPlayerListener(playerStateListener) + } + this.player.ifPresent(Consumer { p: Player? -> p!!.setFragmentListener(internalListener) }) + } + + private fun stopPlayerListener() { + if (playerService != null) { + playerService!!.setPlayerListener(null) + } + this.player.ifPresent(Consumer { p: Player? -> p!!.removeFragmentListener(internalListener) }) + } + + /** + * This listener will be held by the players created by [PlayerService]. + */ + private val internalListener: PlayerServiceEventListener = object : PlayerServiceEventListener { + override fun onViewCreated() { + if (listener != null) { + listener!!.onViewCreated() + } + } + + override fun onFullscreenStateChanged(fullscreen: Boolean) { + if (listener != null) { + listener!!.onFullscreenStateChanged(fullscreen) + } + } + + override fun onScreenRotationButtonClicked() { + if (listener != null) { + listener!!.onScreenRotationButtonClicked() + } + } + + override fun onMoreOptionsLongClicked() { + if (listener != null) { + listener!!.onMoreOptionsLongClicked() + } + } + + override fun onPlayerError( + error: PlaybackException?, + isCatchableException: Boolean + ) { + if (listener != null) { + listener!!.onPlayerError(error, isCatchableException) + } + } + + override fun hideSystemUiIfNeeded() { + if (listener != null) { + listener!!.hideSystemUiIfNeeded() + } + } + + override fun onQueueUpdate(queue: PlayQueue?) { + if (listener != null) { + listener!!.onQueueUpdate(queue) + } + } + + override fun onPlaybackUpdate( + state: Int, + repeatMode: Int, + shuffled: Boolean, + parameters: PlaybackParameters? + ) { + if (listener != null) { + listener!!.onPlaybackUpdate(state, repeatMode, shuffled, parameters) + } + } + + override fun onProgressUpdate( + currentProgress: Int, + duration: Int, + bufferPercent: Int + ) { + if (listener != null) { + listener!!.onProgressUpdate(currentProgress, duration, bufferPercent) + } + } + + override fun onMetadataUpdate(info: StreamInfo?, queue: PlayQueue?) { + if (listener != null) { + listener!!.onMetadataUpdate(info, queue) + } + } + + override fun onServiceStopped() { + if (listener != null) { + listener!!.onServiceStopped() + } + unbind(this@PlayerHolder.commonContext) + } + } + + /** + * This listener will be held by bound [PlayerService]s to notify of the player starting + * or stopping. This is necessary since the service outlives the player e.g. to answer Android + * Auto media browser queries. + */ + private val playerStateListener = Consumer { player: Player? -> + if (listener != null) { + if (player == null) { + // player.fragmentListener=null is already done by player.stopActivityBinding(), + // which is called by player.destroy(), which is in turn called by PlayerService + // before setting its player to null + listener!!.onPlayerDisconnected() + } else { + listener!!.onPlayerConnected(player, serviceConnection.playAfterConnect) + // reset the value of playAfterConnect: if it was true before, it is now "consumed" + serviceConnection.playAfterConnect = false; + player.setFragmentListener(internalListener) + } + } + } + + companion object { + private var instance: PlayerHolder? = null + + @Synchronized + fun getInstance(): PlayerHolder { + if (instance == null) { + instance = PlayerHolder() + } + return instance!! + } + + private val DEBUG = MainActivity.DEBUG + private val TAG: String = PlayerHolder::class.java.getSimpleName() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt index 935bda85f..26d385518 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt @@ -28,7 +28,7 @@ fun StreamMenu( ) { val context = LocalContext.current val streamViewModel = viewModel() - val playerHolder = PlayerHolder.getInstance() + val playerHolder = PlayerHolder.Companion.getInstance() DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { if (playerHolder.isPlayQueueReady) { diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index aba27c259..9d8d3c3b2 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -200,7 +200,7 @@ public final class NavigationHelper { } public static void enqueueOnPlayer(final Context context, final PlayQueue queue) { - PlayerType playerType = PlayerHolder.getInstance().getType(); + PlayerType playerType = PlayerHolder.Companion.getInstance().getType(); if (playerType == null) { Log.e(TAG, "Enqueueing but no player is open; defaulting to background player"); playerType = PlayerType.AUDIO; @@ -211,7 +211,7 @@ public final class NavigationHelper { /* ENQUEUE NEXT */ public static void enqueueNextOnPlayer(final Context context, final PlayQueue queue) { - PlayerType playerType = PlayerHolder.getInstance().getType(); + PlayerType playerType = PlayerHolder.Companion.getInstance().getType(); if (playerType == null) { Log.e(TAG, "Enqueueing next but no player is open; defaulting to background player"); playerType = PlayerType.AUDIO; @@ -421,13 +421,13 @@ public final class NavigationHelper { final boolean switchingPlayers) { final boolean autoPlay; - @Nullable final PlayerType playerType = PlayerHolder.getInstance().getType(); + @Nullable final PlayerType playerType = PlayerHolder.Companion.getInstance().getType(); if (playerType == null) { // no player open autoPlay = PlayerHelper.isAutoplayAllowedByUser(context); } else if (switchingPlayers) { // switching player to main player - autoPlay = PlayerHolder.getInstance().isPlaying(); // keep play/pause state + autoPlay = PlayerHolder.Companion.getInstance().isPlaying(); // keep play/pause state } else if (playerType == PlayerType.MAIN) { // opening new stream while already playing in main player autoPlay = PlayerHelper.isAutoplayAllowedByUser(context); From fc7daa96e99cb0fdcc6b1be345aff327ccae58d3 Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Tue, 13 May 2025 17:41:51 +0200 Subject: [PATCH 05/18] PlayerHolder: kotlinify getters --- .../newpipe/player/helper/PlayerHolder.kt | 55 ++++++------------- 1 file changed, 18 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt index 8cef16bec..22fff7d5d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt @@ -32,25 +32,12 @@ class PlayerHolder private constructor() { private set private var playerService: PlayerService? = null - private val player: Optional - get() = Optional.ofNullable(playerService) - .flatMap( - Function { s: PlayerService? -> - Optional.ofNullable( - s!!.player - ) - } - ) + private val player: Player? + get() = playerService?.player - private val playQueue: Optional + private val playQueue: PlayQueue? get() = // player play queue might be null e.g. while player is starting - this.player.flatMap( - Function { p: Player? -> - Optional.ofNullable( - p!!.getPlayQueue() - ) - } - ) + this.player?.playQueue val type: PlayerType? /** @@ -59,15 +46,13 @@ class PlayerHolder private constructor() { * * @return Current PlayerType */ - get() = this.player.map(Function { obj: Player? -> obj!!.getPlayerType() }) - .orElse(null) + get() = this.player?.playerType val isPlaying: Boolean - get() = this.player.map(Function { obj: Player? -> obj!!.isPlaying() }) - .orElse(false) + get() = this.player?.isPlaying == true val isPlayerOpen: Boolean - get() = this.player.isPresent() + get() = this.player != null val isPlayQueueReady: Boolean /** @@ -75,13 +60,13 @@ class PlayerHolder private constructor() { * the stream long press menu) when there actually is a play queue to manipulate. * @return true only if the player is open and its play queue is ready (i.e. it is not null) */ - get() = this.playQueue.isPresent() + get() = this.playQueue != null val queueSize: Int - get() = this.playQueue.map(Function { obj: PlayQueue? -> obj!!.size() }).orElse(0) + get() = this.playQueue?.size() ?: 0 val queuePosition: Int - get() = this.playQueue.map(Function { obj: PlayQueue? -> obj!!.getIndex() }).orElse(0) + get() = this.playQueue?.index ?: 0 fun setListener(newListener: PlayerServiceExtendedEventListener?) { listener = newListener @@ -182,16 +167,12 @@ class PlayerHolder private constructor() { ) } playerService = s - if (listener != null) { - listener!!.onServiceConnected(s) - this@PlayerHolder.player.ifPresent( - Consumer { p: Player? -> - listener!!.onPlayerConnected( - p!!, - playAfterConnect - ) - } - ) + val l = listener + if (l != null) { + l.onServiceConnected(s) + player?.let { + l.onPlayerConnected(it, playAfterConnect) + } } startPlayerListener() @@ -252,14 +233,14 @@ class PlayerHolder private constructor() { // player in the service is (not) already active, also see playerStateListener below playerService!!.setPlayerListener(playerStateListener) } - this.player.ifPresent(Consumer { p: Player? -> p!!.setFragmentListener(internalListener) }) + this.player?.setFragmentListener(internalListener) } private fun stopPlayerListener() { if (playerService != null) { playerService!!.setPlayerListener(null) } - this.player.ifPresent(Consumer { p: Player? -> p!!.removeFragmentListener(internalListener) }) + this.player?.removeFragmentListener(internalListener) } /** From 4fd3ddf3921d0c894b32a6860d44c6530767fb13 Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Tue, 13 May 2025 17:45:29 +0200 Subject: [PATCH 06/18] PlayerHolder: kotlinify setListener --- .../schabi/newpipe/player/helper/PlayerHolder.kt | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt index 22fff7d5d..6e4c80cd5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt @@ -71,15 +71,13 @@ class PlayerHolder private constructor() { fun setListener(newListener: PlayerServiceExtendedEventListener?) { listener = newListener - if (listener == null) { - return - } - // Force reload data from service - if (playerService != null) { - listener!!.onServiceConnected(playerService!!) - startPlayerListener() - // ^ will call listener.onPlayerConnected() down the line if there is an active player + newListener?.let { listener -> + playerService?.let { + listener.onServiceConnected(it) + startPlayerListener() + // ^ will call listener.onPlayerConnected() down the line if there is an active player + } } } From 86b27cf77ddcf17083075456be56ff94e924e1fd Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Tue, 13 May 2025 17:53:43 +0200 Subject: [PATCH 07/18] PlayerHolder: kotlinify optional calls --- .../newpipe/player/helper/PlayerHolder.kt | 75 ++++++------------- 1 file changed, 22 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt index 6e4c80cd5..b3196aeb5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt @@ -20,9 +20,7 @@ import org.schabi.newpipe.player.event.PlayerServiceEventListener import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener import org.schabi.newpipe.player.playqueue.PlayQueue import org.schabi.newpipe.util.NavigationHelper -import java.util.Optional import java.util.function.Consumer -import java.util.function.Function class PlayerHolder private constructor() { private var listener: PlayerServiceExtendedEventListener? = null @@ -120,9 +118,7 @@ class PlayerHolder private constructor() { if (DEBUG) { Log.d(TAG, "stopService() called") } - if (playerService != null) { - playerService!!.destroyPlayerAndStopService() - } + playerService?.destroyPlayerAndStopService() val context = this.commonContext unbind(context) // destroyPlayerAndStopService() already runs the next line of code, but run it again just @@ -218,26 +214,20 @@ class PlayerHolder private constructor() { this.isBound = false stopPlayerListener() playerService = null - if (listener != null) { - listener!!.onPlayerDisconnected() - listener!!.onServiceDisconnected() - } + listener?.onPlayerDisconnected() + listener?.onServiceDisconnected() } } private fun startPlayerListener() { - if (playerService != null) { - // setting the player listener will take care of calling relevant callbacks if the - // player in the service is (not) already active, also see playerStateListener below - playerService!!.setPlayerListener(playerStateListener) - } + // setting the player listener will take care of calling relevant callbacks if the + // player in the service is (not) already active, also see playerStateListener below + playerService?.setPlayerListener(playerStateListener) this.player?.setFragmentListener(internalListener) } private fun stopPlayerListener() { - if (playerService != null) { - playerService!!.setPlayerListener(null) - } + playerService?.setPlayerListener(null) this.player?.removeFragmentListener(internalListener) } @@ -246,48 +236,34 @@ class PlayerHolder private constructor() { */ private val internalListener: PlayerServiceEventListener = object : PlayerServiceEventListener { override fun onViewCreated() { - if (listener != null) { - listener!!.onViewCreated() - } + listener?.onViewCreated() } override fun onFullscreenStateChanged(fullscreen: Boolean) { - if (listener != null) { - listener!!.onFullscreenStateChanged(fullscreen) - } + listener?.onFullscreenStateChanged(fullscreen) } override fun onScreenRotationButtonClicked() { - if (listener != null) { - listener!!.onScreenRotationButtonClicked() - } + listener?.onScreenRotationButtonClicked() } override fun onMoreOptionsLongClicked() { - if (listener != null) { - listener!!.onMoreOptionsLongClicked() - } + listener?.onMoreOptionsLongClicked() } override fun onPlayerError( error: PlaybackException?, isCatchableException: Boolean ) { - if (listener != null) { - listener!!.onPlayerError(error, isCatchableException) - } + listener?.onPlayerError(error, isCatchableException) } override fun hideSystemUiIfNeeded() { - if (listener != null) { - listener!!.hideSystemUiIfNeeded() - } + listener?.hideSystemUiIfNeeded() } override fun onQueueUpdate(queue: PlayQueue?) { - if (listener != null) { - listener!!.onQueueUpdate(queue) - } + listener?.onQueueUpdate(queue) } override fun onPlaybackUpdate( @@ -296,9 +272,7 @@ class PlayerHolder private constructor() { shuffled: Boolean, parameters: PlaybackParameters? ) { - if (listener != null) { - listener!!.onPlaybackUpdate(state, repeatMode, shuffled, parameters) - } + listener?.onPlaybackUpdate(state, repeatMode, shuffled, parameters) } override fun onProgressUpdate( @@ -306,21 +280,15 @@ class PlayerHolder private constructor() { duration: Int, bufferPercent: Int ) { - if (listener != null) { - listener!!.onProgressUpdate(currentProgress, duration, bufferPercent) - } + listener?.onProgressUpdate(currentProgress, duration, bufferPercent) } override fun onMetadataUpdate(info: StreamInfo?, queue: PlayQueue?) { - if (listener != null) { - listener!!.onMetadataUpdate(info, queue) - } + listener?.onMetadataUpdate(info, queue) } override fun onServiceStopped() { - if (listener != null) { - listener!!.onServiceStopped() - } + listener?.onServiceStopped() unbind(this@PlayerHolder.commonContext) } } @@ -331,14 +299,15 @@ class PlayerHolder private constructor() { * Auto media browser queries. */ private val playerStateListener = Consumer { player: Player? -> - if (listener != null) { + val l = listener + if (l != null) { if (player == null) { // player.fragmentListener=null is already done by player.stopActivityBinding(), // which is called by player.destroy(), which is in turn called by PlayerService // before setting its player to null - listener!!.onPlayerDisconnected() + l.onPlayerDisconnected() } else { - listener!!.onPlayerConnected(player, serviceConnection.playAfterConnect) + l.onPlayerConnected(player, serviceConnection.playAfterConnect) // reset the value of playAfterConnect: if it was true before, it is now "consumed" serviceConnection.playAfterConnect = false; player.setFragmentListener(internalListener) From bf72fd1fa5138f56875256498c14f7f651348bab Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Mon, 27 Jan 2025 15:22:56 +0100 Subject: [PATCH 08/18] VideoDetailFragment: convert to kotlin (mechanical, failing) Just the conversion, errors still there for easier rebasing later. --- .../fragments/detail/VideoDetailFragment.java | 2453 -------------- .../fragments/detail/VideoDetailFragment.kt | 2808 +++++++++++++++++ 2 files changed, 2808 insertions(+), 2453 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt 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 deleted file mode 100644 index ce1a50ad1..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ /dev/null @@ -1,2453 +0,0 @@ -package org.schabi.newpipe.fragments.detail; - -import static android.text.TextUtils.isEmpty; -import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.COMMENTS; -import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; -import static org.schabi.newpipe.ktx.ViewUtils.animate; -import static org.schabi.newpipe.ktx.ViewUtils.animateRotation; -import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked; -import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired; -import static org.schabi.newpipe.util.DependentPreferenceHelper.getResumePlaybackEnabled; -import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; -import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; -import static org.schabi.newpipe.util.NavigationHelper.openPlayQueue; - -import android.animation.ValueAnimator; -import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.content.pm.ActivityInfo; -import android.database.ContentObserver; -import android.graphics.Color; -import android.graphics.Rect; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.provider.Settings; -import android.util.DisplayMetrics; -import android.util.Log; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewTreeObserver; -import android.view.WindowManager; -import android.view.animation.DecelerateInterpolator; -import android.widget.FrameLayout; -import android.widget.RelativeLayout; -import android.widget.Toast; - -import androidx.annotation.AttrRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.appcompat.widget.Toolbar; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.core.content.ContextCompat; -import androidx.fragment.app.Fragment; -import androidx.preference.PreferenceManager; - -import com.evernote.android.state.State; -import com.google.android.exoplayer2.PlaybackException; -import com.google.android.exoplayer2.PlaybackParameters; -import com.google.android.material.appbar.AppBarLayout; -import com.google.android.material.bottomsheet.BottomSheetBehavior; -import com.google.android.material.tabs.TabLayout; - -import org.schabi.newpipe.App; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.databinding.FragmentVideoDetailBinding; -import org.schabi.newpipe.download.DownloadDialog; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.ReCaptchaActivity; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.Image; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.Stream; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.fragments.BackPressable; -import org.schabi.newpipe.fragments.BaseStateFragment; -import org.schabi.newpipe.fragments.EmptyFragment; -import org.schabi.newpipe.fragments.MainFragment; -import org.schabi.newpipe.fragments.list.comments.CommentsFragment; -import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment; -import org.schabi.newpipe.ktx.AnimationType; -import org.schabi.newpipe.local.dialog.PlaylistDialog; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.local.playlist.LocalPlaylistFragment; -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.player.PlayerService; -import org.schabi.newpipe.player.PlayerType; -import org.schabi.newpipe.player.event.OnKeyDownListener; -import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener; -import org.schabi.newpipe.player.helper.PlayerHelper; -import org.schabi.newpipe.player.helper.PlayerHolder; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; -import org.schabi.newpipe.player.ui.MainPlayerUi; -import org.schabi.newpipe.player.ui.VideoPlayerUi; -import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.InfoCache; -import org.schabi.newpipe.util.ListHelper; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.PermissionHelper; -import org.schabi.newpipe.util.PlayButtonHelper; -import org.schabi.newpipe.util.StreamTypeUtil; -import org.schabi.newpipe.util.ThemeHelper; -import org.schabi.newpipe.util.external_communication.KoreUtils; -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.image.CoilHelper; - -import java.util.ArrayList; -import java.util.Iterator; -import java.util.LinkedList; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; - -import coil3.util.CoilUtils; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public final class VideoDetailFragment - extends BaseStateFragment - implements BackPressable, - PlayerServiceExtendedEventListener, - OnKeyDownListener { - public static final String KEY_SWITCHING_PLAYERS = "switching_players"; - - private static final float MAX_OVERLAY_ALPHA = 0.9f; - private static final float MAX_PLAYER_HEIGHT = 0.7f; - - public static final String ACTION_SHOW_MAIN_PLAYER = - App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER"; - public static final String ACTION_HIDE_MAIN_PLAYER = - App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER"; - public static final String ACTION_PLAYER_STARTED = - App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_PLAYER_STARTED"; - public static final String ACTION_VIDEO_FRAGMENT_RESUMED = - App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED"; - public static final String ACTION_VIDEO_FRAGMENT_STOPPED = - App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED"; - - private static final String COMMENTS_TAB_TAG = "COMMENTS"; - private static final String RELATED_TAB_TAG = "NEXT VIDEO"; - private static final String DESCRIPTION_TAB_TAG = "DESCRIPTION TAB"; - private static final String EMPTY_TAB_TAG = "EMPTY TAB"; - - // tabs - private boolean showComments; - private boolean showRelatedItems; - private boolean showDescription; - private String selectedTabTag; - @AttrRes - @NonNull - final List tabIcons = new ArrayList<>(); - @StringRes - @NonNull - final List tabContentDescriptions = new ArrayList<>(); - private boolean tabSettingsChanged = false; - private int lastAppBarVerticalOffset = Integer.MAX_VALUE; // prevents useless updates - - private final SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener = - (sharedPreferences, key) -> { - if (getString(R.string.show_comments_key).equals(key)) { - showComments = sharedPreferences.getBoolean(key, true); - tabSettingsChanged = true; - } else if (getString(R.string.show_next_video_key).equals(key)) { - showRelatedItems = sharedPreferences.getBoolean(key, true); - tabSettingsChanged = true; - } else if (getString(R.string.show_description_key).equals(key)) { - showDescription = sharedPreferences.getBoolean(key, true); - tabSettingsChanged = true; - } - }; - - @State - int serviceId = Constants.NO_SERVICE_ID; - @State - @NonNull - String title = ""; - @State - @Nullable - String url = null; - @Nullable - private PlayQueue playQueue = null; - @State - int bottomSheetState = BottomSheetBehavior.STATE_EXPANDED; - @State - int lastStableBottomSheetState = BottomSheetBehavior.STATE_EXPANDED; - @State - boolean autoPlayEnabled = true; - - @Nullable - private StreamInfo currentInfo = null; - private Disposable currentWorker; - @NonNull - private final CompositeDisposable disposables = new CompositeDisposable(); - @Nullable - private Disposable positionSubscriber = null; - - private BottomSheetBehavior bottomSheetBehavior; - private BottomSheetBehavior.BottomSheetCallback bottomSheetCallback; - private BroadcastReceiver broadcastReceiver; - - /*////////////////////////////////////////////////////////////////////////// - // Views - //////////////////////////////////////////////////////////////////////////*/ - - private FragmentVideoDetailBinding binding; - - private TabAdapter pageAdapter; - - private ContentObserver settingsContentObserver; - @Nullable - private PlayerService playerService; - private Player player; - private final PlayerHolder playerHolder = PlayerHolder.Companion.getInstance(); - - /*////////////////////////////////////////////////////////////////////////// - // Service management - //////////////////////////////////////////////////////////////////////////*/ - @Override - public void onServiceConnected(@NonNull final PlayerService connectedPlayerService) { - playerService = connectedPlayerService; - } - - @Override - public void onPlayerConnected(@NonNull final Player connectedPlayer, - final boolean playAfterConnect) { - player = connectedPlayer; - - // It will do nothing if the player is not in fullscreen mode - hideSystemUiIfNeeded(); - - final Optional playerUi = player.UIs().getOpt(MainPlayerUi.class); - if (!player.videoPlayerSelected() && !playAfterConnect) { - return; - } - - if (DeviceUtils.isLandscape(requireContext())) { - // If the video is playing but orientation changed - // let's make the video in fullscreen again - checkLandscape(); - } else if (playerUi.map(ui -> ui.isFullscreen() && !ui.isVerticalVideo()).orElse(false) - // Tablet UI has orientation-independent fullscreen - && !DeviceUtils.isTablet(activity)) { - // Device is in portrait orientation after rotation but UI is in fullscreen. - // Return back to non-fullscreen state - playerUi.ifPresent(MainPlayerUi::toggleFullscreen); - } - - if (playAfterConnect - || (currentInfo != null - && isAutoplayEnabled() - && playerUi.isEmpty())) { - autoPlayEnabled = true; // forcefully start playing - openVideoPlayerAutoFullscreen(); - } - updateOverlayPlayQueueButtonVisibility(); - } - - @Override - public void onPlayerDisconnected() { - player = null; - // the binding could be null at this point, if the app is finishing - if (binding != null) { - restoreDefaultBrightness(); - } - } - - @Override - public void onServiceDisconnected() { - playerService = null; - } - - - /*////////////////////////////////////////////////////////////////////////*/ - - public static VideoDetailFragment getInstance(final int serviceId, - @Nullable final String url, - @NonNull final String name, - @Nullable final PlayQueue queue) { - final VideoDetailFragment instance = new VideoDetailFragment(); - instance.setInitialData(serviceId, url, name, queue); - return instance; - } - - public static VideoDetailFragment getInstanceInCollapsedState() { - final VideoDetailFragment instance = new VideoDetailFragment(); - instance.updateBottomSheetState(BottomSheetBehavior.STATE_COLLAPSED); - return instance; - } - - - /*////////////////////////////////////////////////////////////////////////// - // Fragment's Lifecycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); - showComments = prefs.getBoolean(getString(R.string.show_comments_key), true); - showRelatedItems = prefs.getBoolean(getString(R.string.show_next_video_key), true); - showDescription = prefs.getBoolean(getString(R.string.show_description_key), true); - selectedTabTag = prefs.getString( - getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG); - prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener); - - setupBroadcastReceiver(); - - settingsContentObserver = new ContentObserver(new Handler()) { - @Override - public void onChange(final boolean selfChange) { - if (activity != null && !globalScreenOrientationLocked(activity)) { - activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); - } - } - }; - activity.getContentResolver().registerContentObserver( - Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, - settingsContentObserver); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - binding = FragmentVideoDetailBinding.inflate(inflater, container, false); - return binding.getRoot(); - } - - @Override - public void onPause() { - super.onPause(); - if (currentWorker != null) { - currentWorker.dispose(); - } - restoreDefaultBrightness(); - PreferenceManager.getDefaultSharedPreferences(requireContext()) - .edit() - .putString(getString(R.string.stream_info_selected_tab_key), - pageAdapter.getItemTitle(binding.viewPager.getCurrentItem())) - .apply(); - } - - @Override - public void onResume() { - super.onResume(); - if (DEBUG) { - Log.d(TAG, "onResume() called"); - } - - activity.sendBroadcast(new Intent(ACTION_VIDEO_FRAGMENT_RESUMED)); - - updateOverlayPlayQueueButtonVisibility(); - - setupBrightness(); - - if (tabSettingsChanged) { - tabSettingsChanged = false; - initTabs(); - if (currentInfo != null) { - updateTabs(currentInfo); - } - } - - // Check if it was loading when the fragment was stopped/paused - if (wasLoading.getAndSet(false) && !wasCleared()) { - startLoading(false); - } - } - - @Override - public void onStop() { - super.onStop(); - - if (!activity.isChangingConfigurations()) { - activity.sendBroadcast(new Intent(ACTION_VIDEO_FRAGMENT_STOPPED)); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - - // Stop the service when user leaves the app with double back press - // if video player is selected. Otherwise unbind - if (activity.isFinishing() && isPlayerAvailable() && player.videoPlayerSelected()) { - playerHolder.stopService(); - } else { - playerHolder.setListener(null); - } - - PreferenceManager.getDefaultSharedPreferences(activity) - .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener); - activity.unregisterReceiver(broadcastReceiver); - activity.getContentResolver().unregisterContentObserver(settingsContentObserver); - - if (positionSubscriber != null) { - positionSubscriber.dispose(); - } - if (currentWorker != null) { - currentWorker.dispose(); - } - disposables.clear(); - positionSubscriber = null; - currentWorker = null; - bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback); - - if (activity.isFinishing()) { - playQueue = null; - currentInfo = null; - stack = new LinkedList<>(); - } - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - @Override - public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { - super.onActivityResult(requestCode, resultCode, data); - 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 + "]"); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // OnClick - //////////////////////////////////////////////////////////////////////////*/ - - private void setOnClickListeners() { - binding.detailTitleRootLayout.setOnClickListener(v -> toggleTitleAndSecondaryControls()); - binding.detailUploaderRootLayout.setOnClickListener(makeOnClickListener(info -> { - if (isEmpty(info.getSubChannelUrl())) { - if (!isEmpty(info.getUploaderUrl())) { - openChannel(info.getUploaderUrl(), info.getUploaderName()); - } - - if (DEBUG) { - Log.i(TAG, "Can't open sub-channel because we got no channel URL"); - } - } else { - openChannel(info.getSubChannelUrl(), info.getSubChannelName()); - } - })); - binding.detailThumbnailRootLayout.setOnClickListener(v -> { - autoPlayEnabled = true; // forcefully start playing - // FIXME Workaround #7427 - if (isPlayerAvailable()) { - player.setRecovery(); - } - openVideoPlayerAutoFullscreen(); - }); - - binding.detailControlsBackground.setOnClickListener(v -> openBackgroundPlayer(false)); - binding.detailControlsPopup.setOnClickListener(v -> openPopupPlayer(false)); - binding.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(info -> { - if (getFM() != null && currentInfo != null) { - final Fragment fragment = getParentFragmentManager(). - findFragmentById(R.id.fragment_holder); - - // commit previous pending changes to database - if (fragment instanceof LocalPlaylistFragment) { - ((LocalPlaylistFragment) fragment).saveImmediate(); - } else if (fragment instanceof MainFragment) { - ((MainFragment) fragment).commitPlaylistTabs(); - } - - disposables.add(PlaylistDialog.createCorrespondingDialog(requireContext(), - List.of(new StreamEntity(info)), - dialog -> dialog.show(getParentFragmentManager(), TAG))); - } - })); - binding.detailControlsDownload.setOnClickListener(v -> { - if (PermissionHelper.checkStoragePermissions(activity, - PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { - openDownloadDialog(); - } - }); - binding.detailControlsShare.setOnClickListener(makeOnClickListener(info -> - ShareUtils.shareText(requireContext(), info.getName(), info.getUrl(), - info.getThumbnails()))); - binding.detailControlsOpenInBrowser.setOnClickListener(makeOnClickListener(info -> - ShareUtils.openUrlInBrowser(requireContext(), info.getUrl()))); - binding.detailControlsPlayWithKodi.setOnClickListener(makeOnClickListener(info -> - KoreUtils.playWithKore(requireContext(), Uri.parse(info.getUrl())))); - if (DEBUG) { - binding.detailControlsCrashThePlayer.setOnClickListener(v -> - VideoDetailPlayerCrasher.onCrashThePlayer(requireContext(), player)); - } - - final View.OnClickListener overlayListener = v -> bottomSheetBehavior - .setState(BottomSheetBehavior.STATE_EXPANDED); - binding.overlayThumbnail.setOnClickListener(overlayListener); - binding.overlayMetadataLayout.setOnClickListener(overlayListener); - binding.overlayButtonsLayout.setOnClickListener(overlayListener); - binding.overlayCloseButton.setOnClickListener(v -> bottomSheetBehavior - .setState(BottomSheetBehavior.STATE_HIDDEN)); - binding.overlayPlayQueueButton.setOnClickListener(v -> openPlayQueue(requireContext())); - binding.overlayPlayPauseButton.setOnClickListener(v -> { - if (playerIsNotStopped()) { - player.playPause(); - player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); - showSystemUi(); - } else { - autoPlayEnabled = true; // forcefully start playing - openVideoPlayer(false); - } - - setOverlayPlayPauseImage(isPlayerAvailable() && player.isPlaying()); - }); - } - - private View.OnClickListener makeOnClickListener(final Consumer consumer) { - return v -> { - if (!isLoading.get() && currentInfo != null) { - consumer.accept(currentInfo); - } - }; - } - - private void setOnLongClickListeners() { - binding.detailTitleRootLayout.setOnLongClickListener(makeOnLongClickListener(info -> - ShareUtils.copyToClipboard(requireContext(), - binding.detailVideoTitleView.getText().toString()))); - binding.detailUploaderRootLayout.setOnLongClickListener(makeOnLongClickListener(info -> { - if (isEmpty(info.getSubChannelUrl())) { - Log.w(TAG, "Can't open parent channel because we got no parent channel URL"); - } else { - openChannel(info.getUploaderUrl(), info.getUploaderName()); - } - })); - - binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info -> - openBackgroundPlayer(true) - )); - binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info -> - openPopupPlayer(true) - )); - binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info -> - NavigationHelper.openDownloads(activity))); - - final View.OnLongClickListener overlayListener = makeOnLongClickListener(info -> - openChannel(info.getUploaderUrl(), info.getUploaderName())); - binding.overlayThumbnail.setOnLongClickListener(overlayListener); - binding.overlayMetadataLayout.setOnLongClickListener(overlayListener); - } - - private View.OnLongClickListener makeOnLongClickListener(final Consumer consumer) { - return v -> { - if (isLoading.get() || currentInfo == null) { - return false; - } - consumer.accept(currentInfo); - return true; - }; - } - - private void openChannel(final String subChannelUrl, final String subChannelName) { - try { - NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(), - subChannelUrl, subChannelName); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); - } - } - - private void toggleTitleAndSecondaryControls() { - if (binding.detailSecondaryControlPanel.getVisibility() == View.GONE) { - binding.detailVideoTitleView.setMaxLines(10); - animateRotation(binding.detailToggleSecondaryControlsView, - VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 180); - binding.detailSecondaryControlPanel.setVisibility(View.VISIBLE); - } else { - binding.detailVideoTitleView.setMaxLines(1); - animateRotation(binding.detailToggleSecondaryControlsView, - VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 0); - binding.detailSecondaryControlPanel.setVisibility(View.GONE); - } - // view pager height has changed, update the tab layout - updateTabLayoutVisibility(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Init - //////////////////////////////////////////////////////////////////////////*/ - - @Override // called from onViewCreated in {@link BaseFragment#onViewCreated} - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - - pageAdapter = new TabAdapter(getChildFragmentManager()); - binding.viewPager.setAdapter(pageAdapter); - binding.tabLayout.setupWithViewPager(binding.viewPager); - - binding.detailThumbnailRootLayout.requestFocus(); - - binding.detailControlsPlayWithKodi.setVisibility( - KoreUtils.shouldShowPlayWithKodi(requireContext(), serviceId) - ? View.VISIBLE - : View.GONE - ); - binding.detailControlsCrashThePlayer.setVisibility( - DEBUG && PreferenceManager.getDefaultSharedPreferences(getContext()) - .getBoolean(getString(R.string.show_crash_the_player_key), false) - ? View.VISIBLE - : View.GONE - ); - accommodateForTvAndDesktopMode(); - } - - @Override - @SuppressLint("ClickableViewAccessibility") - protected void initListeners() { - super.initListeners(); - - setOnClickListeners(); - setOnLongClickListeners(); - - final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> { - if (motionEvent.getAction() == MotionEvent.ACTION_DOWN - && PlayButtonHelper.shouldShowHoldToAppendTip(activity)) { - - animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () -> - animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000)); - } - return false; - }; - binding.detailControlsBackground.setOnTouchListener(controlsTouchListener); - binding.detailControlsPopup.setOnTouchListener(controlsTouchListener); - - binding.appBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> { - // prevent useless updates to tab layout visibility if nothing changed - if (verticalOffset != lastAppBarVerticalOffset) { - lastAppBarVerticalOffset = verticalOffset; - // the view was scrolled - updateTabLayoutVisibility(); - } - }); - - setupBottomPlayer(); - if (!playerHolder.isBound()) { - setHeightThumbnail(); - } else { - playerHolder.startService(false, this); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // OwnStack - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Stack that contains the "navigation history".
- * The peek is the current video. - */ - private static LinkedList stack = new LinkedList<>(); - - @Override - public boolean onKeyDown(final int keyCode) { - return isPlayerAvailable() - && player.UIs().getOpt(VideoPlayerUi.class) - .map(playerUi -> playerUi.onKeyDown(keyCode)).orElse(false); - } - - @Override - public boolean onBackPressed() { - if (DEBUG) { - Log.d(TAG, "onBackPressed() called"); - } - - // If we are in fullscreen mode just exit from it via first back press - if (isFullscreen()) { - if (!DeviceUtils.isTablet(activity)) { - player.pause(); - } - restoreDefaultOrientation(); - setAutoPlay(false); - return true; - } - - // If we have something in history of played items we replay it here - if (isPlayerAvailable() - && player.getPlayQueue() != null - && player.videoPlayerSelected() - && player.getPlayQueue().previous()) { - return true; // no code here, as previous() was used in the if - } - - // That means that we are on the start of the stack, - if (stack.size() <= 1) { - restoreDefaultOrientation(); - return false; // let MainActivity handle the onBack (e.g. to minimize the mini player) - } - - // Remove top - stack.pop(); - // Get stack item from the new top - setupFromHistoryItem(Objects.requireNonNull(stack.peek())); - - return true; - } - - private void setupFromHistoryItem(final StackItem item) { - setAutoPlay(false); - hideMainPlayerOnLoadingNewStream(); - - setInitialData(item.getServiceId(), item.getUrl(), - item.getTitle() == null ? "" : item.getTitle(), item.getPlayQueue()); - startLoading(false); - - // Maybe an item was deleted in background activity - if (item.getPlayQueue().getItem() == null) { - return; - } - - final PlayQueueItem playQueueItem = item.getPlayQueue().getItem(); - // Update title, url, uploader from the last item in the stack (it's current now) - final boolean isPlayerStopped = !isPlayerAvailable() || player.isStopped(); - if (playQueueItem != null && isPlayerStopped) { - updateOverlayData(playQueueItem.getTitle(), - playQueueItem.getUploader(), playQueueItem.getThumbnails()); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Info loading and handling - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected void doInitialLoadLogic() { - if (wasCleared()) { - return; - } - - if (currentInfo == null) { - prepareAndLoadInfo(); - } else { - prepareAndHandleInfoIfNeededAfterDelay(currentInfo, false, 50); - } - } - - public void selectAndLoadVideo(final int newServiceId, - @Nullable final String newUrl, - @NonNull final String newTitle, - @Nullable final PlayQueue newQueue) { - if (isPlayerAvailable() && newQueue != null && playQueue != null - && playQueue.getItem() != null && !playQueue.getItem().getUrl().equals(newUrl)) { - // Preloading can be disabled since playback is surely being replaced. - player.disablePreloadingOfCurrentTrack(); - } - - setInitialData(newServiceId, newUrl, newTitle, newQueue); - startLoading(false, true); - } - - private void prepareAndHandleInfoIfNeededAfterDelay(final StreamInfo info, - final boolean scrollToTop, - final long delay) { - new Handler(Looper.getMainLooper()).postDelayed(() -> { - if (activity == null) { - return; - } - // Data can already be drawn, don't spend time twice - if (info.getName().equals(binding.detailVideoTitleView.getText().toString())) { - return; - } - prepareAndHandleInfo(info, scrollToTop); - }, delay); - } - - private void prepareAndHandleInfo(final StreamInfo info, final boolean scrollToTop) { - if (DEBUG) { - Log.d(TAG, "prepareAndHandleInfo() called with: " - + "info = [" + info + "], scrollToTop = [" + scrollToTop + "]"); - } - - showLoading(); - initTabs(); - - if (scrollToTop) { - scrollToTop(); - } - handleResult(info); - showContent(); - - } - - private void prepareAndLoadInfo() { - scrollToTop(); - startLoading(false); - } - - @Override - public void startLoading(final boolean forceLoad) { - startLoading(forceLoad, null); - } - - private void startLoading(final boolean forceLoad, final @Nullable Boolean addToBackStack) { - super.startLoading(forceLoad); - - initTabs(); - currentInfo = null; - if (currentWorker != null) { - currentWorker.dispose(); - } - - runWorker(forceLoad, addToBackStack != null ? addToBackStack : stack.isEmpty()); - } - - private void runWorker(final boolean forceLoad, final boolean addToBackStack) { - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); - currentWorker = ExtractorHelper.getStreamInfo(serviceId, url, forceLoad) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - isLoading.set(false); - hideMainPlayerOnLoadingNewStream(); - if (result.getAgeLimit() != NO_AGE_LIMIT && !prefs.getBoolean( - getString(R.string.show_age_restricted_content), false)) { - hideAgeRestrictedContent(); - } else { - handleResult(result); - showContent(); - if (addToBackStack) { - if (playQueue == null) { - playQueue = new SinglePlayQueue(result); - } - if (stack.isEmpty() || !stack.peek().getPlayQueue().equals(playQueue)) { - stack.push(new StackItem(serviceId, url, title, playQueue)); - } - } - - if (isAutoplayEnabled()) { - openVideoPlayerAutoFullscreen(); - } - } - }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, - url == null ? "no url" : url, serviceId))); - } - - /*////////////////////////////////////////////////////////////////////////// - // Tabs - //////////////////////////////////////////////////////////////////////////*/ - - private void initTabs() { - if (pageAdapter.getCount() != 0) { - selectedTabTag = pageAdapter.getItemTitle(binding.viewPager.getCurrentItem()); - } - pageAdapter.clearAllItems(); - tabIcons.clear(); - tabContentDescriptions.clear(); - - if (shouldShowComments()) { - pageAdapter.addFragment(CommentsFragment.getInstance(serviceId, url), COMMENTS_TAB_TAG); - tabIcons.add(R.drawable.ic_comment); - tabContentDescriptions.add(R.string.comments_tab_description); - } - - if (showRelatedItems && binding.relatedItemsLayout == null) { - // temp empty fragment. will be updated in handleResult - pageAdapter.addFragment(EmptyFragment.newInstance(false), RELATED_TAB_TAG); - tabIcons.add(R.drawable.ic_art_track); - tabContentDescriptions.add(R.string.related_items_tab_description); - } - - if (showDescription) { - // temp empty fragment. will be updated in handleResult - pageAdapter.addFragment(EmptyFragment.newInstance(false), DESCRIPTION_TAB_TAG); - tabIcons.add(R.drawable.ic_description); - tabContentDescriptions.add(R.string.description_tab_description); - } - - if (pageAdapter.getCount() == 0) { - pageAdapter.addFragment(EmptyFragment.newInstance(true), EMPTY_TAB_TAG); - } - pageAdapter.notifyDataSetUpdate(); - - if (pageAdapter.getCount() >= 2) { - final int position = pageAdapter.getItemPositionByTitle(selectedTabTag); - if (position != -1) { - binding.viewPager.setCurrentItem(position); - } - updateTabIconsAndContentDescriptions(); - } - // the page adapter now contains tabs: show the tab layout - updateTabLayoutVisibility(); - } - - /** - * To be called whenever {@link #pageAdapter} is modified, since that triggers a refresh in - * {@link FragmentVideoDetailBinding#tabLayout} resetting all tab's icons and content - * descriptions. This reads icons from {@link #tabIcons} and content descriptions from - * {@link #tabContentDescriptions}, which are all set in {@link #initTabs()}. - */ - private void updateTabIconsAndContentDescriptions() { - for (int i = 0; i < tabIcons.size(); ++i) { - final TabLayout.Tab tab = binding.tabLayout.getTabAt(i); - if (tab != null) { - tab.setIcon(tabIcons.get(i)); - tab.setContentDescription(tabContentDescriptions.get(i)); - } - } - } - - private void updateTabs(@NonNull final StreamInfo info) { - if (showRelatedItems) { - if (binding.relatedItemsLayout == null) { // phone - pageAdapter.updateItem(RELATED_TAB_TAG, RelatedItemsFragment.getInstance(info)); - } else { // tablet + TV - getChildFragmentManager().beginTransaction() - .replace(R.id.relatedItemsLayout, RelatedItemsFragment.getInstance(info)) - .commitAllowingStateLoss(); - binding.relatedItemsLayout.setVisibility(isFullscreen() ? View.GONE : View.VISIBLE); - } - } - - if (showDescription) { - pageAdapter.updateItem(DESCRIPTION_TAB_TAG, new DescriptionFragment(info)); - } - - binding.viewPager.setVisibility(View.VISIBLE); - // make sure the tab layout is visible - updateTabLayoutVisibility(); - pageAdapter.notifyDataSetUpdate(); - updateTabIconsAndContentDescriptions(); - } - - private boolean shouldShowComments() { - try { - return showComments && NewPipe.getService(serviceId) - .getServiceInfo() - .getMediaCapabilities() - .contains(COMMENTS); - } catch (final ExtractionException e) { - return false; - } - } - - public void updateTabLayoutVisibility() { - - if (binding == null) { - //If binding is null we do not need to and should not do anything with its object(s) - return; - } - - if (pageAdapter.getCount() < 2 || binding.viewPager.getVisibility() != View.VISIBLE) { - // hide tab layout if there is only one tab or if the view pager is also hidden - binding.tabLayout.setVisibility(View.GONE); - } else { - // call `post()` to be sure `viewPager.getHitRect()` - // is up to date and not being currently recomputed - binding.tabLayout.post(() -> { - final var activity = getActivity(); - if (activity != null) { - final Rect pagerHitRect = new Rect(); - binding.viewPager.getHitRect(pagerHitRect); - - final int height = DeviceUtils.getWindowHeight(activity.getWindowManager()); - final int viewPagerVisibleHeight = height - pagerHitRect.top; - // see TabLayout.DEFAULT_HEIGHT, which is equal to 48dp - final float tabLayoutHeight = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, 48, getResources().getDisplayMetrics()); - - if (viewPagerVisibleHeight > tabLayoutHeight * 2) { - // no translation at all when viewPagerVisibleHeight > tabLayout.height * 3 - binding.tabLayout.setTranslationY( - Math.max(0, tabLayoutHeight * 3 - viewPagerVisibleHeight)); - binding.tabLayout.setVisibility(View.VISIBLE); - } else { - // view pager is not visible enough - binding.tabLayout.setVisibility(View.GONE); - } - } - }); - } - } - - public void scrollToTop() { - binding.appBarLayout.setExpanded(true, true); - // notify tab layout of scrolling - updateTabLayoutVisibility(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Play Utils - //////////////////////////////////////////////////////////////////////////*/ - - 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().getOpt(MainPlayerUi.class).ifPresent(playerUi -> { - if (playerUi.isFullscreen()) { - playerUi.toggleFullscreen(); - } - }); - } - } - - private void openBackgroundPlayer(final boolean append) { - final boolean useExternalAudioPlayer = PreferenceManager - .getDefaultSharedPreferences(activity) - .getBoolean(activity.getString(R.string.use_external_audio_player_key), false); - - toggleFullscreenIfInFullscreenMode(); - - if (isPlayerAvailable()) { - // FIXME Workaround #7427 - player.setRecovery(); - } - - if (useExternalAudioPlayer) { - showExternalAudioPlaybackDialog(); - } else { - openNormalBackgroundPlayer(append); - } - } - - private void openPopupPlayer(final boolean append) { - if (!PermissionHelper.isPopupEnabledElseAsk(activity)) { - return; - } - - // See UI changes while remote playQueue changes - if (!isPlayerAvailable()) { - playerHolder.startService(false, this); - } else { - // FIXME Workaround #7427 - player.setRecovery(); - } - - toggleFullscreenIfInFullscreenMode(); - - final PlayQueue queue = setupPlayQueueForIntent(append); - if (append) { //resumePlayback: false - NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.POPUP); - } else { - replaceQueueIfUserConfirms(() -> NavigationHelper - .playOnPopupPlayer(activity, queue, true)); - } - } - - /** - * Opens the video player, in fullscreen if needed. In order to open fullscreen, the activity - * is toggled to landscape orientation (which will then cause fullscreen mode). - * - * @param directlyFullscreenIfApplicable whether to open fullscreen if we are not already - * in landscape and screen orientation is locked - */ - public void openVideoPlayer(final boolean directlyFullscreenIfApplicable) { - if (directlyFullscreenIfApplicable - && !DeviceUtils.isLandscape(requireContext()) - && PlayerHelper.globalScreenOrientationLocked(requireContext())) { - // Make sure the bottom sheet turns out expanded. When this code kicks in the bottom - // sheet could not have fully expanded yet, and thus be in the STATE_SETTLING state. - // When the activity is rotated, and its state is saved and then restored, the bottom - // sheet would forget what it was doing, since even if STATE_SETTLING is restored, it - // doesn't tell which state it was settling to, and thus the bottom sheet settles to - // STATE_COLLAPSED. This can be solved by manually setting the state that will be - // restored (i.e. bottomSheetState) to STATE_EXPANDED. - updateBottomSheetState(BottomSheetBehavior.STATE_EXPANDED); - // toggle landscape in order to open directly in fullscreen - onScreenRotationButtonClicked(); - } - - if (PreferenceManager.getDefaultSharedPreferences(activity) - .getBoolean(this.getString(R.string.use_external_video_player_key), false)) { - showExternalVideoPlaybackDialog(); - } else { - replaceQueueIfUserConfirms(this::openMainPlayer); - } - } - - /** - * If the option to start directly fullscreen is enabled, calls - * {@link #openVideoPlayer(boolean)} with {@code directlyFullscreenIfApplicable = true}, so that - * if the user is not already in landscape and he has screen orientation locked the activity - * rotates and fullscreen starts. Otherwise, if the option to start directly fullscreen is - * disabled, calls {@link #openVideoPlayer(boolean)} with {@code directlyFullscreenIfApplicable - * = false}, hence preventing it from going directly fullscreen. - */ - public void openVideoPlayerAutoFullscreen() { - openVideoPlayer(PlayerHelper.isStartMainPlayerFullscreenEnabled(requireContext())); - } - - private void openNormalBackgroundPlayer(final boolean append) { - // See UI changes while remote playQueue changes - if (!isPlayerAvailable()) { - playerHolder.startService(false, this); - } - - final PlayQueue queue = setupPlayQueueForIntent(append); - if (append) { - NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.AUDIO); - } else { - replaceQueueIfUserConfirms(() -> NavigationHelper - .playOnBackgroundPlayer(activity, queue, true)); - } - } - - private void openMainPlayer() { - if (noPlayerServiceAvailable()) { - playerHolder.startService(autoPlayEnabled, this); - return; - } - if (currentInfo == null) { - return; - } - - final PlayQueue queue = setupPlayQueueForIntent(false); - tryAddVideoPlayerView(); - - final Intent playerIntent = NavigationHelper.getPlayerIntent(requireContext(), - PlayerService.class, queue, true, autoPlayEnabled); - ContextCompat.startForegroundService(activity, playerIntent); - } - - /** - * When the video detail fragment is already showing details for a video and the user opens a - * new one, the video detail fragment changes all of its old data to the new stream, so if there - * is a video player currently open it should be hidden. This method does exactly that. If - * autoplay is enabled, the underlying player is not stopped completely, since it is going to - * be reused in a few milliseconds and the flickering would be annoying. - */ - private void hideMainPlayerOnLoadingNewStream() { - final var root = getRoot(); - if (noPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) { - return; - } - - removeVideoPlayerView(); - if (isAutoplayEnabled()) { - playerService.stopForImmediateReusing(); - root.ifPresent(view -> view.setVisibility(View.GONE)); - } else { - playerHolder.stopService(); - } - } - - private PlayQueue setupPlayQueueForIntent(final boolean append) { - if (append) { - return new SinglePlayQueue(currentInfo); - } - - PlayQueue queue = playQueue; - // Size can be 0 because queue removes bad stream automatically when error occurs - if (queue == null || queue.isEmpty()) { - queue = new SinglePlayQueue(currentInfo); - } - - return queue; - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - public void setAutoPlay(final boolean autoPlay) { - this.autoPlayEnabled = autoPlay; - } - - private void startOnExternalPlayer(@NonNull final Context context, - @NonNull final StreamInfo info, - @NonNull final Stream selectedStream) { - NavigationHelper.playOnExternalPlayer(context, currentInfo.getName(), - currentInfo.getSubChannelName(), selectedStream); - - final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext()); - disposables.add(recordManager.onViewed(info).onErrorComplete() - .subscribe( - ignored -> { /* successful */ }, - error -> Log.e(TAG, "Register view failure: ", error) - )); - } - - private boolean isExternalPlayerEnabled() { - return PreferenceManager.getDefaultSharedPreferences(requireContext()) - .getBoolean(getString(R.string.use_external_video_player_key), false); - } - - // This method overrides default behaviour when setAutoPlay() is called. - // Don't auto play if the user selected an external player or disabled it in settings - private boolean isAutoplayEnabled() { - return autoPlayEnabled - && !isExternalPlayerEnabled() - && (!isPlayerAvailable() || player.videoPlayerSelected()) - && bottomSheetState != BottomSheetBehavior.STATE_HIDDEN - && PlayerHelper.isAutoplayAllowedByUser(requireContext()); - } - - private void tryAddVideoPlayerView() { - if (isPlayerAvailable() && getView() != null) { - // Setup the surface view height, so that it fits the video correctly; this is done also - // here, and not only in the Handler, to avoid a choppy fullscreen rotation animation. - setHeightThumbnail(); - } - - // do all the null checks in the posted lambda, too, since the player, the binding and the - // view could be set or unset before the lambda gets executed on the next main thread cycle - new Handler(Looper.getMainLooper()).post(() -> { - if (!isPlayerAvailable() || getView() == null) { - return; - } - - // setup the surface view height, so that it fits the video correctly - setHeightThumbnail(); - - 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 - playerUi.removeViewFromParent(); - binding.playerPlaceholder.addView(playerUi.getBinding().getRoot()); - playerUi.setupVideoSurfaceIfNeeded(); - } - }); - }); - } - - private void removeVideoPlayerView() { - makeDefaultHeightForVideoPlaceholder(); - - if (player != null) { - player.UIs().getOpt(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent); - } - } - - private void makeDefaultHeightForVideoPlaceholder() { - if (getView() == null) { - return; - } - - binding.playerPlaceholder.getLayoutParams().height = FrameLayout.LayoutParams.MATCH_PARENT; - binding.playerPlaceholder.requestLayout(); - } - - private final ViewTreeObserver.OnPreDrawListener preDrawListener = - new ViewTreeObserver.OnPreDrawListener() { - @Override - public boolean onPreDraw() { - final DisplayMetrics metrics = getResources().getDisplayMetrics(); - - if (getView() != null) { - final int height = (DeviceUtils.isInMultiWindow(activity) - ? requireView() - : activity.getWindow().getDecorView()).getHeight(); - setHeightThumbnail(height, metrics); - getView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener); - } - return false; - } - }; - - /** - * Method which controls the size of thumbnail and the size of main player inside - * a layout with thumbnail. It decides what height the player should have in both - * screen orientations. It knows about multiWindow feature - * and about videos with aspectRatio ZOOM (the height for them will be a bit higher, - * {@link #MAX_PLAYER_HEIGHT}) - */ - private void setHeightThumbnail() { - final DisplayMetrics metrics = getResources().getDisplayMetrics(); - final boolean isPortrait = metrics.heightPixels > metrics.widthPixels; - requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener); - - if (isFullscreen()) { - final int height = (DeviceUtils.isInMultiWindow(activity) - ? requireView() - : activity.getWindow().getDecorView()).getHeight(); - // Height is zero when the view is not yet displayed like after orientation change - if (height != 0) { - setHeightThumbnail(height, metrics); - } else { - requireView().getViewTreeObserver().addOnPreDrawListener(preDrawListener); - } - } else { - final int height = (int) (isPortrait - ? metrics.widthPixels / (16.0f / 9.0f) - : metrics.heightPixels / 2.0f); - setHeightThumbnail(height, metrics); - } - } - - private void setHeightThumbnail(final int newHeight, final DisplayMetrics metrics) { - binding.detailThumbnailImageView.setLayoutParams( - new FrameLayout.LayoutParams( - RelativeLayout.LayoutParams.MATCH_PARENT, newHeight)); - binding.detailThumbnailImageView.setMinimumHeight(newHeight); - if (isPlayerAvailable()) { - final int maxHeight = (int) (metrics.heightPixels * MAX_PLAYER_HEIGHT); - player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui -> - ui.getBinding().surfaceView.setHeights(newHeight, - ui.isFullscreen() ? newHeight : maxHeight)); - } - } - - private void showContent() { - binding.detailContentRootHiding.setVisibility(View.VISIBLE); - } - - 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() { - if (binding == null || activity == null) { - return; - } - - binding.detailThumbnailImageView.setImageDrawable( - AppCompatResources.getDrawable(requireContext(), R.drawable.not_available_monkey)); - animate(binding.detailThumbnailImageView, false, 0, AnimationType.ALPHA, - 0, () -> animate(binding.detailThumbnailImageView, true, 500)); - } - - @Override - public void handleError() { - super.handleError(); - setErrorImage(); - - if (binding.relatedItemsLayout != null) { // hide related streams for tablets - binding.relatedItemsLayout.setVisibility(View.INVISIBLE); - } - - // hide comments / related streams / description tabs - binding.viewPager.setVisibility(View.GONE); - binding.tabLayout.setVisibility(View.GONE); - } - - private void hideAgeRestrictedContent() { - showTextError(getString(R.string.restricted_video, - getString(R.string.show_age_restricted_content_title))); - } - - private void setupBroadcastReceiver() { - broadcastReceiver = new BroadcastReceiver() { - @Override - public void onReceive(final Context context, final Intent intent) { - switch (intent.getAction()) { - case ACTION_SHOW_MAIN_PLAYER: - bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); - break; - case ACTION_HIDE_MAIN_PLAYER: - bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - break; - case ACTION_PLAYER_STARTED: - // If the state is not hidden we don't need to show the mini player - if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN) { - bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); - } - // Rebound to the service if it was closed via notification or mini player - if (!playerHolder.isBound()) { - playerHolder.startService( - false, VideoDetailFragment.this); - } - break; - } - } - }; - final IntentFilter intentFilter = new IntentFilter(); - intentFilter.addAction(ACTION_SHOW_MAIN_PLAYER); - intentFilter.addAction(ACTION_HIDE_MAIN_PLAYER); - intentFilter.addAction(ACTION_PLAYER_STARTED); - activity.registerReceiver(broadcastReceiver, intentFilter); - } - - - /*////////////////////////////////////////////////////////////////////////// - // Orientation listener - //////////////////////////////////////////////////////////////////////////*/ - - private void restoreDefaultOrientation() { - if (isPlayerAvailable() && player.videoPlayerSelected()) { - toggleFullscreenIfInFullscreenMode(); - } - - // This will show systemUI and pause the player. - // User can tap on Play button and video will be in fullscreen mode again - // Note for tablet: trying to avoid orientation changes since it's not easy - // to physically rotate the tablet every time - if (activity != null && !DeviceUtils.isTablet(activity)) { - activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void showLoading() { - - super.showLoading(); - - //if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required - if (!ExtractorHelper.isCached(serviceId, url, InfoCache.Type.STREAM)) { - binding.detailContentRootHiding.setVisibility(View.INVISIBLE); - } - - animate(binding.detailThumbnailPlayButton, false, 50); - animate(binding.detailDurationView, false, 100); - binding.detailPositionView.setVisibility(View.GONE); - binding.positionView.setVisibility(View.GONE); - - binding.detailVideoTitleView.setText(title); - binding.detailVideoTitleView.setMaxLines(1); - animate(binding.detailVideoTitleView, true, 0); - - binding.detailToggleSecondaryControlsView.setVisibility(View.GONE); - binding.detailTitleRootLayout.setClickable(false); - binding.detailSecondaryControlPanel.setVisibility(View.GONE); - - if (binding.relatedItemsLayout != null) { - if (showRelatedItems) { - binding.relatedItemsLayout.setVisibility( - isFullscreen() ? View.GONE : View.INVISIBLE); - } else { - binding.relatedItemsLayout.setVisibility(View.GONE); - } - } - - CoilUtils.dispose(binding.detailThumbnailImageView); - CoilUtils.dispose(binding.detailSubChannelThumbnailView); - CoilUtils.dispose(binding.overlayThumbnail); - CoilUtils.dispose(binding.detailUploaderThumbnailView); - - binding.detailThumbnailImageView.setImageBitmap(null); - binding.detailSubChannelThumbnailView.setImageBitmap(null); - } - - @Override - public void handleResult(@NonNull final StreamInfo info) { - super.handleResult(info); - - currentInfo = info; - setInitialData(info.getServiceId(), info.getOriginalUrl(), info.getName(), playQueue); - - updateTabs(info); - - animate(binding.detailThumbnailPlayButton, true, 200); - binding.detailVideoTitleView.setText(title); - - binding.detailSubChannelThumbnailView.setVisibility(View.GONE); - - if (!isEmpty(info.getSubChannelName())) { - displayBothUploaderAndSubChannel(info); - } else { - displayUploaderAsSubChannel(info); - } - - if (info.getViewCount() >= 0) { - if (info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { - binding.detailViewCountView.setText(Localization.listeningCount(activity, - info.getViewCount())); - } else if (info.getStreamType().equals(StreamType.LIVE_STREAM)) { - binding.detailViewCountView.setText(Localization - .localizeWatchingCount(activity, info.getViewCount())); - } else { - binding.detailViewCountView.setText(Localization - .localizeViewCount(activity, info.getViewCount())); - } - binding.detailViewCountView.setVisibility(View.VISIBLE); - } else { - binding.detailViewCountView.setVisibility(View.GONE); - } - - if (info.getDislikeCount() == -1 && info.getLikeCount() == -1) { - binding.detailThumbsDownImgView.setVisibility(View.VISIBLE); - binding.detailThumbsUpImgView.setVisibility(View.VISIBLE); - binding.detailThumbsUpCountView.setVisibility(View.GONE); - binding.detailThumbsDownCountView.setVisibility(View.GONE); - - binding.detailThumbsDisabledView.setVisibility(View.VISIBLE); - } else { - if (info.getDislikeCount() >= 0) { - binding.detailThumbsDownCountView.setText(Localization - .shortCount(activity, info.getDislikeCount())); - binding.detailThumbsDownCountView.setVisibility(View.VISIBLE); - binding.detailThumbsDownImgView.setVisibility(View.VISIBLE); - } else { - binding.detailThumbsDownCountView.setVisibility(View.GONE); - binding.detailThumbsDownImgView.setVisibility(View.GONE); - } - - if (info.getLikeCount() >= 0) { - binding.detailThumbsUpCountView.setText(Localization.shortCount(activity, - info.getLikeCount())); - binding.detailThumbsUpCountView.setVisibility(View.VISIBLE); - binding.detailThumbsUpImgView.setVisibility(View.VISIBLE); - } else { - binding.detailThumbsUpCountView.setVisibility(View.GONE); - binding.detailThumbsUpImgView.setVisibility(View.GONE); - } - binding.detailThumbsDisabledView.setVisibility(View.GONE); - } - - if (info.getDuration() > 0) { - binding.detailDurationView.setText(Localization.getDurationString(info.getDuration())); - binding.detailDurationView.setBackgroundColor( - ContextCompat.getColor(activity, R.color.duration_background_color)); - animate(binding.detailDurationView, true, 100); - } else if (info.getStreamType() == StreamType.LIVE_STREAM) { - binding.detailDurationView.setText(R.string.duration_live); - binding.detailDurationView.setBackgroundColor( - ContextCompat.getColor(activity, R.color.live_duration_background_color)); - animate(binding.detailDurationView, true, 100); - } else { - binding.detailDurationView.setVisibility(View.GONE); - } - - binding.detailTitleRootLayout.setClickable(true); - binding.detailToggleSecondaryControlsView.setRotation(0); - binding.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE); - binding.detailSecondaryControlPanel.setVisibility(View.GONE); - - checkUpdateProgressInfo(info); - CoilHelper.INSTANCE.loadDetailsThumbnail(binding.detailThumbnailImageView, - info.getThumbnails()); - showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, - binding.detailMetaInfoSeparator, disposables); - - if (!isPlayerAvailable() || player.isStopped()) { - updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()); - } - - if (!info.getErrors().isEmpty()) { - // Bandcamp fan pages are not yet supported and thus a ContentNotAvailableException is - // thrown. This is not an error and thus should not be shown to the user. - for (final Throwable throwable : info.getErrors()) { - if (throwable instanceof ContentNotSupportedException - && "Fan pages are not supported".equals(throwable.getMessage())) { - info.getErrors().remove(throwable); - } - } - - if (!info.getErrors().isEmpty()) { - showSnackBarError(new ErrorInfo(info.getErrors(), - UserAction.REQUESTED_STREAM, info.getUrl(), info)); - } - } - - binding.detailControlsDownload.setVisibility( - StreamTypeUtil.isLiveStream(info.getStreamType()) ? View.GONE : View.VISIBLE); - binding.detailControlsBackground.setVisibility( - info.getAudioStreams().isEmpty() && info.getVideoStreams().isEmpty() - ? View.GONE : View.VISIBLE); - - final boolean noVideoStreams = - info.getVideoStreams().isEmpty() && info.getVideoOnlyStreams().isEmpty(); - binding.detailControlsPopup.setVisibility(noVideoStreams ? View.GONE : View.VISIBLE); - binding.detailThumbnailPlayButton.setImageResource( - noVideoStreams ? R.drawable.ic_headset_shadow : R.drawable.ic_play_arrow_shadow); - } - - private void displayUploaderAsSubChannel(final StreamInfo info) { - binding.detailSubChannelTextView.setText(info.getUploaderName()); - binding.detailSubChannelTextView.setVisibility(View.VISIBLE); - binding.detailSubChannelTextView.setSelected(true); - - if (info.getUploaderSubscriberCount() > -1) { - binding.detailUploaderTextView.setText( - Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount())); - binding.detailUploaderTextView.setVisibility(View.VISIBLE); - } else { - binding.detailUploaderTextView.setVisibility(View.GONE); - } - - CoilHelper.INSTANCE.loadAvatar(binding.detailSubChannelThumbnailView, - info.getUploaderAvatars()); - binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); - binding.detailUploaderThumbnailView.setVisibility(View.GONE); - } - - private void displayBothUploaderAndSubChannel(final StreamInfo info) { - binding.detailSubChannelTextView.setText(info.getSubChannelName()); - binding.detailSubChannelTextView.setVisibility(View.VISIBLE); - binding.detailSubChannelTextView.setSelected(true); - - final StringBuilder subText = new StringBuilder(); - if (!isEmpty(info.getUploaderName())) { - subText.append( - String.format(getString(R.string.video_detail_by), info.getUploaderName())); - } - if (info.getUploaderSubscriberCount() > -1) { - if (subText.length() > 0) { - subText.append(Localization.DOT_SEPARATOR); - } - subText.append( - Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount())); - } - - if (subText.length() > 0) { - binding.detailUploaderTextView.setText(subText); - binding.detailUploaderTextView.setVisibility(View.VISIBLE); - binding.detailUploaderTextView.setSelected(true); - } else { - binding.detailUploaderTextView.setVisibility(View.GONE); - } - - CoilHelper.INSTANCE.loadAvatar(binding.detailSubChannelThumbnailView, - info.getSubChannelAvatars()); - binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); - CoilHelper.INSTANCE.loadAvatar(binding.detailUploaderThumbnailView, - info.getUploaderAvatars()); - binding.detailUploaderThumbnailView.setVisibility(View.VISIBLE); - } - - public void openDownloadDialog() { - if (currentInfo == null) { - return; - } - - try { - final DownloadDialog downloadDialog = new DownloadDialog(activity, currentInfo); - downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog"); - } catch (final Exception e) { - ErrorUtil.showSnackbar(activity, new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG, - "Showing download dialog", currentInfo)); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Stream Results - //////////////////////////////////////////////////////////////////////////*/ - - private void checkUpdateProgressInfo(@NonNull final StreamInfo info) { - if (positionSubscriber != null) { - positionSubscriber.dispose(); - } - if (!getResumePlaybackEnabled(activity)) { - binding.positionView.setVisibility(View.GONE); - binding.detailPositionView.setVisibility(View.GONE); - return; - } - final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext()); - positionSubscriber = recordManager.loadStreamState(info) - .subscribeOn(Schedulers.io()) - .onErrorComplete() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(state -> { - updatePlaybackProgress( - state.getProgressMillis(), info.getDuration() * 1000); - }, e -> { - // impossible since the onErrorComplete() - }, () -> { - binding.positionView.setVisibility(View.GONE); - binding.detailPositionView.setVisibility(View.GONE); - }); - } - - private void updatePlaybackProgress(final long progress, final long duration) { - if (!getResumePlaybackEnabled(activity)) { - return; - } - final int progressSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(progress); - final int durationSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(duration); - // If the old and the new progress values have a big difference then use animation. - // Otherwise don't because it affects CPU - final int progressDifference = Math.abs(binding.positionView.getProgress() - - progressSeconds); - binding.positionView.setMax(durationSeconds); - if (progressDifference > 2) { - binding.positionView.setProgressAnimated(progressSeconds); - } else { - binding.positionView.setProgress(progressSeconds); - } - final String position = Localization.getDurationString(progressSeconds); - if (position != binding.detailPositionView.getText()) { - binding.detailPositionView.setText(position); - } - if (binding.positionView.getVisibility() != View.VISIBLE) { - animate(binding.positionView, true, 100); - animate(binding.detailPositionView, true, 100); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Player event listener - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onViewCreated() { - tryAddVideoPlayerView(); - } - - @Override - public void onQueueUpdate(final PlayQueue queue) { - playQueue = queue; - if (DEBUG) { - Log.d(TAG, "onQueueUpdate() called with: serviceId = [" - + serviceId + "], url = [" + url + "], name = [" - + title + "], playQueue = [" + playQueue + "]"); - } - - // Register broadcast receiver to listen to playQueue changes - // and hide the overlayPlayQueueButton when the playQueue is empty / destroyed. - if (playQueue != null && playQueue.getBroadcastReceiver() != null) { - playQueue.getBroadcastReceiver().subscribe( - event -> updateOverlayPlayQueueButtonVisibility() - ); - } - - // This should be the only place where we push data to stack. - // It will allow to have live instance of PlayQueue with actual information about - // deleted/added items inside Channel/Playlist queue and makes possible to have - // a history of played items - @Nullable final StackItem stackPeek = stack.peek(); - if (stackPeek != null && !stackPeek.getPlayQueue().equals(queue)) { - @Nullable final PlayQueueItem playQueueItem = queue.getItem(); - if (playQueueItem != null) { - stack.push(new StackItem(playQueueItem.getServiceId(), playQueueItem.getUrl(), - playQueueItem.getTitle(), queue)); - return; - } // else continue below - } - - @Nullable final StackItem stackWithQueue = findQueueInStack(queue); - if (stackWithQueue != null) { - // On every MainPlayer service's destroy() playQueue gets disposed and - // no longer able to track progress. That's why we update our cached disposed - // queue with the new one that is active and have the same history. - // Without that the cached playQueue will have an old recovery position - stackWithQueue.setPlayQueue(queue); - } - } - - @Override - public void onPlaybackUpdate(final int state, - final int repeatMode, - final boolean shuffled, - final PlaybackParameters parameters) { - setOverlayPlayPauseImage(player != null && player.isPlaying()); - - 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); - } - } - } - - @Override - public void onProgressUpdate(final int currentProgress, - final int duration, - final int bufferPercent) { - // Progress updates every second even if media is paused. It's useless until playing - if (!player.isPlaying() || playQueue == null) { - return; - } - - if (player.getPlayQueue().getItem().getUrl().equals(url)) { - updatePlaybackProgress(currentProgress, duration); - } - } - - @Override - public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) { - final StackItem item = findQueueInStack(queue); - if (item != null) { - // When PlayQueue can have multiple streams (PlaylistPlayQueue or ChannelPlayQueue) - // every new played stream gives new title and url. - // StackItem contains information about first played stream. Let's update it here - item.setTitle(info.getName()); - item.setUrl(info.getUrl()); - } - // They are not equal when user watches something in popup while browsing in fragment and - // then changes screen orientation. In that case the fragment will set itself as - // a service listener and will receive initial call to onMetadataUpdate() - if (!queue.equals(playQueue)) { - return; - } - - updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()); - if (currentInfo != null && info.getUrl().equals(currentInfo.getUrl())) { - return; - } - - currentInfo = info; - setInitialData(info.getServiceId(), info.getUrl(), info.getName(), queue); - setAutoPlay(false); - // Delay execution just because it freezes the main thread, and while playing - // next/previous video you see visual glitches - // (when non-vertical video goes after vertical video) - prepareAndHandleInfoIfNeededAfterDelay(info, true, 200); - } - - @Override - public void onPlayerError(final PlaybackException error, final boolean isCatchableException) { - if (!isCatchableException) { - // Properly exit from fullscreen - toggleFullscreenIfInFullscreenMode(); - hideMainPlayerOnLoadingNewStream(); - } - } - - @Override - public void onServiceStopped() { - // the binding could be null at this point, if the app is finishing - if (binding != null) { - setOverlayPlayPauseImage(false); - if (currentInfo != null) { - updateOverlayData(currentInfo.getName(), - currentInfo.getUploaderName(), - currentInfo.getThumbnails()); - } - updateOverlayPlayQueueButtonVisibility(); - } - } - - @Override - public void onFullscreenStateChanged(final boolean fullscreen) { - setupBrightness(); - if (!isPlayerAndPlayerServiceAvailable() - || player.UIs().getOpt(MainPlayerUi.class).isEmpty() - || getRoot().map(View::getParent).isEmpty()) { - return; - } - - if (fullscreen) { - hideSystemUiIfNeeded(); - binding.overlayPlayPauseButton.requestFocus(); - } else { - showSystemUi(); - } - - if (binding.relatedItemsLayout != null) { - binding.relatedItemsLayout.setVisibility(fullscreen ? View.GONE : View.VISIBLE); - } - scrollToTop(); - - tryAddVideoPlayerView(); - } - - @Override - public void onScreenRotationButtonClicked() { - // In tablet user experience will be better if screen will not be rotated - // from landscape to portrait every time. - // Just turn on fullscreen mode in landscape orientation - // or portrait & unlocked global orientation - final boolean isLandscape = DeviceUtils.isLandscape(requireContext()); - if (DeviceUtils.isTablet(activity) - && (!globalScreenOrientationLocked(activity) || isLandscape)) { - player.UIs().getOpt(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen); - return; - } - - final int newOrientation = isLandscape - ? ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - : ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE; - - activity.setRequestedOrientation(newOrientation); - } - - /* - * Will scroll down to description view after long click on moreOptionsButton - * */ - @Override - public void onMoreOptionsLongClicked() { - final CoordinatorLayout.LayoutParams params = - (CoordinatorLayout.LayoutParams) binding.appBarLayout.getLayoutParams(); - final AppBarLayout.Behavior behavior = (AppBarLayout.Behavior) params.getBehavior(); - final ValueAnimator valueAnimator = ValueAnimator - .ofInt(0, -binding.playerPlaceholder.getHeight()); - valueAnimator.setInterpolator(new DecelerateInterpolator()); - valueAnimator.addUpdateListener(animation -> { - behavior.setTopAndBottomOffset((int) animation.getAnimatedValue()); - binding.appBarLayout.requestLayout(); - }); - valueAnimator.setInterpolator(new DecelerateInterpolator()); - valueAnimator.setDuration(500); - valueAnimator.start(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Player related utils - //////////////////////////////////////////////////////////////////////////*/ - - private void showSystemUi() { - if (DEBUG) { - Log.d(TAG, "showSystemUi() called"); - } - - if (activity == null) { - return; - } - - // Prevent jumping of the player on devices with cutout - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - activity.getWindow().getAttributes().layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT; - } - activity.getWindow().getDecorView().setSystemUiVisibility(0); - activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - activity.getWindow().setStatusBarColor(ThemeHelper.resolveColorFromAttr( - requireContext(), android.R.attr.colorPrimary)); - } - - private void hideSystemUi() { - if (DEBUG) { - Log.d(TAG, "hideSystemUi() called"); - } - - if (activity == null) { - return; - } - - // Prevent jumping of the player on devices with cutout - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - activity.getWindow().getAttributes().layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; - } - int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; - - // In multiWindow mode status bar is not transparent for devices with cutout - // if I include this flag. So without it is better in this case - final boolean isInMultiWindow = DeviceUtils.isInMultiWindow(activity); - if (!isInMultiWindow) { - visibility |= View.SYSTEM_UI_FLAG_FULLSCREEN; - } - activity.getWindow().getDecorView().setSystemUiVisibility(visibility); - - if (isInMultiWindow || isFullscreen()) { - activity.getWindow().setStatusBarColor(Color.TRANSPARENT); - activity.getWindow().setNavigationBarColor(Color.TRANSPARENT); - } - activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - } - - // Listener implementation - @Override - public void hideSystemUiIfNeeded() { - if (isFullscreen() - && bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { - hideSystemUi(); - } - } - - private boolean isFullscreen() { - return isPlayerAvailable() && player.UIs().getOpt(VideoPlayerUi.class) - .map(VideoPlayerUi::isFullscreen).orElse(false); - } - - private boolean playerIsNotStopped() { - return isPlayerAvailable() && !player.isStopped(); - } - - private void restoreDefaultBrightness() { - final WindowManager.LayoutParams lp = activity.getWindow().getAttributes(); - if (lp.screenBrightness == -1) { - return; - } - - // Restore the old brightness when fragment.onPause() called or - // when a player is in portrait - lp.screenBrightness = -1; - activity.getWindow().setAttributes(lp); - } - - private void setupBrightness() { - if (activity == null) { - return; - } - - final WindowManager.LayoutParams lp = activity.getWindow().getAttributes(); - if (!isFullscreen() || bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) { - // Apply system brightness when the player is not in fullscreen - restoreDefaultBrightness(); - } else { - // Do not restore if user has disabled brightness gesture - if (!PlayerHelper.getActionForRightGestureSide(activity) - .equals(getString(R.string.brightness_control_key)) - && !PlayerHelper.getActionForLeftGestureSide(activity) - .equals(getString(R.string.brightness_control_key))) { - return; - } - // Restore already saved brightness level - final float brightnessLevel = PlayerHelper.getScreenBrightness(activity); - if (brightnessLevel == lp.screenBrightness) { - return; - } - lp.screenBrightness = brightnessLevel; - activity.getWindow().setAttributes(lp); - } - } - - /** - * Make changes to the UI to accommodate for better usability on bigger screens such as TVs - * or in Android's desktop mode (DeX etc). - */ - private void accommodateForTvAndDesktopMode() { - if (DeviceUtils.isTv(getContext())) { - // remove ripple effects from detail controls - final int transparent = ContextCompat.getColor(requireContext(), - R.color.transparent_background_color); - binding.detailControlsPlaylistAppend.setBackgroundColor(transparent); - binding.detailControlsBackground.setBackgroundColor(transparent); - binding.detailControlsPopup.setBackgroundColor(transparent); - binding.detailControlsDownload.setBackgroundColor(transparent); - binding.detailControlsShare.setBackgroundColor(transparent); - binding.detailControlsOpenInBrowser.setBackgroundColor(transparent); - binding.detailControlsPlayWithKodi.setBackgroundColor(transparent); - } - if (DeviceUtils.isDesktopMode(getContext())) { - // Remove the "hover" overlay (since it is visible on all mouse events and interferes - // with the video content being played) - binding.detailThumbnailRootLayout.setForeground(null); - } - } - - private void checkLandscape() { - if ((!player.isPlaying() && player.getPlayQueue() != playQueue) - || player.getPlayQueue() == null) { - setAutoPlay(true); - } - - 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(); - } - } - - /* - * Means that the player fragment was swiped away via BottomSheetLayout - * and is empty but ready for any new actions. See cleanUp() - * */ - private boolean wasCleared() { - return url == null; - } - - @Nullable - private StackItem findQueueInStack(final PlayQueue queue) { - StackItem item = null; - final Iterator iterator = stack.descendingIterator(); - while (iterator.hasNext()) { - final StackItem next = iterator.next(); - if (next.getPlayQueue().equals(queue)) { - item = next; - break; - } - } - return item; - } - - private void replaceQueueIfUserConfirms(final Runnable onAllow) { - @Nullable final PlayQueue activeQueue = isPlayerAvailable() ? player.getPlayQueue() : null; - - // Player will have STATE_IDLE when a user pressed back button - if (isClearingQueueConfirmationRequired(activity) - && playerIsNotStopped() - && !Objects.equals(activeQueue, playQueue)) { - showClearingQueueConfirmation(onAllow); - } else { - onAllow.run(); - } - } - - private void showClearingQueueConfirmation(final Runnable onAllow) { - new AlertDialog.Builder(activity) - .setTitle(R.string.clear_queue_confirmation_description) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.ok, (dialog, which) -> { - onAllow.run(); - dialog.dismiss(); - }) - .show(); - } - - private void showExternalVideoPlaybackDialog() { - if (currentInfo == null) { - return; - } - - final AlertDialog.Builder builder = new AlertDialog.Builder(activity); - builder.setTitle(R.string.select_quality_external_players); - builder.setNeutralButton(R.string.open_in_browser, (dialog, i) -> - ShareUtils.openUrlInBrowser(requireActivity(), url)); - - final List videoStreamsForExternalPlayers = - ListHelper.getSortedStreamVideosList( - activity, - getUrlAndNonTorrentStreams(currentInfo.getVideoStreams()), - getUrlAndNonTorrentStreams(currentInfo.getVideoOnlyStreams()), - false, - false - ); - - if (videoStreamsForExternalPlayers.isEmpty()) { - builder.setMessage(R.string.no_video_streams_available_for_external_players); - builder.setPositiveButton(R.string.ok, null); - - } else { - final int selectedVideoStreamIndexForExternalPlayers = - ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers); - final CharSequence[] resolutions = videoStreamsForExternalPlayers.stream() - .map(VideoStream::getResolution).toArray(CharSequence[]::new); - - builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndexForExternalPlayers, - null); - builder.setNegativeButton(R.string.cancel, null); - builder.setPositiveButton(R.string.ok, (dialog, i) -> { - final int index = ((AlertDialog) dialog).getListView().getCheckedItemPosition(); - // We don't have to manage the index validity because if there is no stream - // available for external players, this code will be not executed and if there is - // no stream which matches the default resolution, 0 is returned by - // ListHelper.getDefaultResolutionIndex. - // The index cannot be outside the bounds of the list as its always between 0 and - // the list size - 1, . - startOnExternalPlayer(activity, currentInfo, - videoStreamsForExternalPlayers.get(index)); - }); - } - builder.show(); - } - - private void showExternalAudioPlaybackDialog() { - if (currentInfo == null) { - return; - } - - final List audioStreams = getUrlAndNonTorrentStreams( - currentInfo.getAudioStreams()); - final List audioTracks = - ListHelper.getFilteredAudioStreams(activity, audioStreams); - - if (audioTracks.isEmpty()) { - Toast.makeText(activity, R.string.no_audio_streams_available_for_external_players, - Toast.LENGTH_SHORT).show(); - } else if (audioTracks.size() == 1) { - startOnExternalPlayer(activity, currentInfo, audioTracks.get(0)); - } else { - final int selectedAudioStream = - ListHelper.getDefaultAudioFormat(activity, audioTracks); - final CharSequence[] trackNames = audioTracks.stream() - .map(audioStream -> Localization.audioTrackName(activity, audioStream)) - .toArray(CharSequence[]::new); - - new AlertDialog.Builder(activity) - .setTitle(R.string.select_audio_track_external_players) - .setNeutralButton(R.string.open_in_browser, (dialog, i) -> - ShareUtils.openUrlInBrowser(requireActivity(), url)) - .setSingleChoiceItems(trackNames, selectedAudioStream, null) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.ok, (dialog, i) -> { - final int index = ((AlertDialog) dialog).getListView() - .getCheckedItemPosition(); - startOnExternalPlayer(activity, currentInfo, audioTracks.get(index)); - }) - .show(); - } - } - - /* - * Remove unneeded information while waiting for a next task - * */ - private void cleanUp() { - // New beginning - stack.clear(); - if (currentWorker != null) { - currentWorker.dispose(); - } - playerHolder.stopService(); - setInitialData(0, null, "", null); - currentInfo = null; - updateOverlayData(null, null, List.of()); - } - - /*////////////////////////////////////////////////////////////////////////// - // Bottom mini player - //////////////////////////////////////////////////////////////////////////*/ - - /** - * That's for Android TV support. Move focus from main fragment to the player or back - * based on what is currently selected - * - * @param toMain if true than the main fragment will be focused or the player otherwise - */ - private void moveFocusToMainFragment(final boolean toMain) { - setupBrightness(); - final ViewGroup mainFragment = requireActivity().findViewById(R.id.fragment_holder); - // Hamburger button steels a focus even under bottomSheet - final Toolbar toolbar = requireActivity().findViewById(R.id.toolbar); - final int afterDescendants = ViewGroup.FOCUS_AFTER_DESCENDANTS; - final int blockDescendants = ViewGroup.FOCUS_BLOCK_DESCENDANTS; - if (toMain) { - mainFragment.setDescendantFocusability(afterDescendants); - toolbar.setDescendantFocusability(afterDescendants); - ((ViewGroup) requireView()).setDescendantFocusability(blockDescendants); - // Only focus the mainFragment if the mainFragment (e.g. search-results) - // or the toolbar (e.g. Textfield for search) don't have focus. - // This was done to fix problems with the keyboard input, see also #7490 - if (!mainFragment.hasFocus() && !toolbar.hasFocus()) { - mainFragment.requestFocus(); - } - } else { - mainFragment.setDescendantFocusability(blockDescendants); - toolbar.setDescendantFocusability(blockDescendants); - ((ViewGroup) requireView()).setDescendantFocusability(afterDescendants); - // Only focus the player if it not already has focus - if (!binding.getRoot().hasFocus()) { - binding.detailThumbnailRootLayout.requestFocus(); - } - } - } - - /** - * When the mini player exists the view underneath it is not touchable. - * Bottom padding should be equal to the mini player's height in this case - * - * @param showMore whether main fragment should be expanded or not - */ - private void manageSpaceAtTheBottom(final boolean showMore) { - final int peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height); - final ViewGroup holder = requireActivity().findViewById(R.id.fragment_holder); - final int newBottomPadding; - if (showMore) { - newBottomPadding = 0; - } else { - newBottomPadding = peekHeight; - } - if (holder.getPaddingBottom() == newBottomPadding) { - return; - } - holder.setPadding(holder.getPaddingLeft(), - holder.getPaddingTop(), - holder.getPaddingRight(), - newBottomPadding); - } - - private void setupBottomPlayer() { - final CoordinatorLayout.LayoutParams params = - (CoordinatorLayout.LayoutParams) binding.appBarLayout.getLayoutParams(); - final AppBarLayout.Behavior behavior = (AppBarLayout.Behavior) params.getBehavior(); - - final FrameLayout bottomSheetLayout = activity.findViewById(R.id.fragment_player_holder); - bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout); - bottomSheetBehavior.setState(lastStableBottomSheetState); - updateBottomSheetState(lastStableBottomSheetState); - - final int peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height); - if (bottomSheetState != BottomSheetBehavior.STATE_HIDDEN) { - manageSpaceAtTheBottom(false); - bottomSheetBehavior.setPeekHeight(peekHeight); - if (bottomSheetState == BottomSheetBehavior.STATE_COLLAPSED) { - binding.overlayLayout.setAlpha(MAX_OVERLAY_ALPHA); - } else if (bottomSheetState == BottomSheetBehavior.STATE_EXPANDED) { - binding.overlayLayout.setAlpha(0); - setOverlayElementsClickable(false); - } - } - - bottomSheetCallback = new BottomSheetBehavior.BottomSheetCallback() { - @Override - public void onStateChanged(@NonNull final View bottomSheet, final int newState) { - updateBottomSheetState(newState); - - switch (newState) { - case BottomSheetBehavior.STATE_HIDDEN: - moveFocusToMainFragment(true); - manageSpaceAtTheBottom(true); - - bottomSheetBehavior.setPeekHeight(0); - cleanUp(); - break; - case BottomSheetBehavior.STATE_EXPANDED: - moveFocusToMainFragment(false); - manageSpaceAtTheBottom(false); - - bottomSheetBehavior.setPeekHeight(peekHeight); - // Disable click because overlay buttons located on top of buttons - // from the player - setOverlayElementsClickable(false); - hideSystemUiIfNeeded(); - // Conditions when the player should be expanded to fullscreen - if (DeviceUtils.isLandscape(requireContext()) - && isPlayerAvailable() - && player.isPlaying() - && !isFullscreen() - && !DeviceUtils.isTablet(activity)) { - player.UIs().getOpt(MainPlayerUi.class) - .ifPresent(MainPlayerUi::toggleFullscreen); - } - setOverlayLook(binding.appBarLayout, behavior, 1); - break; - case BottomSheetBehavior.STATE_COLLAPSED: - moveFocusToMainFragment(true); - manageSpaceAtTheBottom(false); - - bottomSheetBehavior.setPeekHeight(peekHeight); - - // Re-enable clicks - setOverlayElementsClickable(true); - if (isPlayerAvailable()) { - player.UIs().getOpt(MainPlayerUi.class) - .ifPresent(MainPlayerUi::closeItemsList); - } - setOverlayLook(binding.appBarLayout, behavior, 0); - break; - case BottomSheetBehavior.STATE_DRAGGING: - case BottomSheetBehavior.STATE_SETTLING: - if (isFullscreen()) { - showSystemUi(); - } - if (isPlayerAvailable()) { - player.UIs().getOpt(MainPlayerUi.class).ifPresent(ui -> { - if (ui.isControlsVisible()) { - ui.hideControls(0, 0); - } - }); - } - break; - case BottomSheetBehavior.STATE_HALF_EXPANDED: - break; - } - } - - @Override - public void onSlide(@NonNull final View bottomSheet, final float slideOffset) { - setOverlayLook(binding.appBarLayout, behavior, slideOffset); - } - }; - - bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback); - - // User opened a new page and the player will hide itself - activity.getSupportFragmentManager().addOnBackStackChangedListener(() -> { - if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { - bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); - } - }); - } - - private void updateOverlayPlayQueueButtonVisibility() { - final boolean isPlayQueueEmpty = - player == null // no player => no play queue :) - || player.getPlayQueue() == null - || player.getPlayQueue().isEmpty(); - if (binding != null) { - // binding is null when rotating the device... - binding.overlayPlayQueueButton.setVisibility( - isPlayQueueEmpty ? View.GONE : View.VISIBLE); - } - } - - private void updateOverlayData(@Nullable final String overlayTitle, - @Nullable final String uploader, - @NonNull final List thumbnails) { - binding.overlayTitleTextView.setText(isEmpty(overlayTitle) ? "" : overlayTitle); - binding.overlayChannelTextView.setText(isEmpty(uploader) ? "" : uploader); - binding.overlayThumbnail.setImageDrawable(null); - CoilHelper.INSTANCE.loadDetailsThumbnail(binding.overlayThumbnail, thumbnails); - } - - private void setOverlayPlayPauseImage(final boolean playerIsPlaying) { - final int drawable = playerIsPlaying - ? R.drawable.ic_pause - : R.drawable.ic_play_arrow; - binding.overlayPlayPauseButton.setImageResource(drawable); - } - - private void setOverlayLook(final AppBarLayout appBar, - final AppBarLayout.Behavior behavior, - final float slideOffset) { - // SlideOffset < 0 when mini player is about to close via swipe. - // Stop animation in this case - if (behavior == null || slideOffset < 0) { - return; - } - binding.overlayLayout.setAlpha(Math.min(MAX_OVERLAY_ALPHA, 1 - slideOffset)); - // These numbers are not special. They just do a cool transition - behavior.setTopAndBottomOffset( - (int) (-binding.detailThumbnailImageView.getHeight() * 2 * (1 - slideOffset) / 3)); - appBar.requestLayout(); - } - - private void setOverlayElementsClickable(final boolean enable) { - binding.overlayThumbnail.setClickable(enable); - binding.overlayThumbnail.setLongClickable(enable); - binding.overlayMetadataLayout.setClickable(enable); - binding.overlayMetadataLayout.setLongClickable(enable); - binding.overlayButtonsLayout.setClickable(enable); - binding.overlayPlayQueueButton.setClickable(enable); - binding.overlayPlayPauseButton.setClickable(enable); - binding.overlayCloseButton.setClickable(enable); - } - - // helpers to check the state of player and playerService - boolean isPlayerAvailable() { - return player != null; - } - - boolean noPlayerServiceAvailable() { - return playerService == null; - } - - boolean isPlayerAndPlayerServiceAvailable() { - return player != null && playerService != null; - } - - public Optional getRoot() { - return Optional.ofNullable(player) - .flatMap(player1 -> player1.UIs().getOpt(VideoPlayerUi.class)) - .map(playerUi -> playerUi.getBinding().getRoot()); - } - - private void updateBottomSheetState(final int newState) { - bottomSheetState = newState; - if (newState != BottomSheetBehavior.STATE_DRAGGING - && newState != BottomSheetBehavior.STATE_SETTLING) { - lastStableBottomSheetState = newState; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt new file mode 100644 index 000000000..ad9d21481 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -0,0 +1,2808 @@ +package org.schabi.newpipe.fragments.detail + +import android.animation.ValueAnimator +import android.animation.ValueAnimator.AnimatorUpdateListener +import android.annotation.SuppressLint +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.IntentFilter +import android.content.SharedPreferences +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +import android.content.pm.ActivityInfo +import android.database.ContentObserver +import android.graphics.Color +import android.graphics.Rect +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.text.TextUtils +import android.util.DisplayMetrics +import android.util.Log +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.View.OnLongClickListener +import android.view.View.OnTouchListener +import android.view.ViewGroup +import android.view.ViewParent +import android.view.ViewTreeObserver +import android.view.WindowManager +import android.view.animation.DecelerateInterpolator +import android.widget.FrameLayout +import android.widget.RelativeLayout +import android.widget.Toast +import androidx.annotation.AttrRes +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.content.res.AppCompatResources +import androidx.appcompat.widget.Toolbar +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentManager +import androidx.preference.PreferenceManager +import coil3.util.CoilUtils.dispose +import com.evernote.android.state.State +import com.google.android.exoplayer2.PlaybackException +import com.google.android.exoplayer2.PlaybackParameters +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.functions.Action +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.App +import org.schabi.newpipe.R +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.stream.model.StreamStateEntity +import org.schabi.newpipe.databinding.FragmentVideoDetailBinding +import org.schabi.newpipe.download.DownloadDialog +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar +import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar +import org.schabi.newpipe.error.ReCaptchaActivity +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.Image +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability +import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException +import org.schabi.newpipe.extractor.exceptions.ExtractionException +import org.schabi.newpipe.extractor.stream.AudioStream +import org.schabi.newpipe.extractor.stream.Stream +import org.schabi.newpipe.extractor.stream.StreamExtractor +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.extractor.stream.VideoStream +import org.schabi.newpipe.fragments.BackPressable +import org.schabi.newpipe.fragments.BaseStateFragment +import org.schabi.newpipe.fragments.EmptyFragment +import org.schabi.newpipe.fragments.MainFragment +import org.schabi.newpipe.fragments.list.comments.CommentsFragment.Companion.getInstance +import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment.Companion.getInstance +import org.schabi.newpipe.ktx.AnimationType +import org.schabi.newpipe.ktx.animate +import org.schabi.newpipe.ktx.animateRotation +import org.schabi.newpipe.local.dialog.PlaylistDialog +import org.schabi.newpipe.local.history.HistoryRecordManager +import org.schabi.newpipe.local.playlist.LocalPlaylistFragment +import org.schabi.newpipe.player.Player +import org.schabi.newpipe.player.PlayerService +import org.schabi.newpipe.player.PlayerType +import org.schabi.newpipe.player.event.OnKeyDownListener +import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener +import org.schabi.newpipe.player.helper.PlayerHelper +import org.schabi.newpipe.player.helper.PlayerHolder.Companion.getInstance +import org.schabi.newpipe.player.playqueue.PlayQueue +import org.schabi.newpipe.player.playqueue.SinglePlayQueue +import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent +import org.schabi.newpipe.player.ui.MainPlayerUi +import org.schabi.newpipe.player.ui.VideoPlayerUi +import org.schabi.newpipe.util.DependentPreferenceHelper +import org.schabi.newpipe.util.DeviceUtils +import org.schabi.newpipe.util.ExtractorHelper +import org.schabi.newpipe.util.InfoCache +import org.schabi.newpipe.util.ListHelper +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.PermissionHelper +import org.schabi.newpipe.util.PlayButtonHelper +import org.schabi.newpipe.util.StreamTypeUtil +import org.schabi.newpipe.util.ThemeHelper +import org.schabi.newpipe.util.external_communication.KoreUtils +import org.schabi.newpipe.util.external_communication.ShareUtils +import org.schabi.newpipe.util.image.CoilHelper +import org.schabi.newpipe.util.image.CoilHelper.loadAvatar +import org.schabi.newpipe.util.image.CoilHelper.loadDetailsThumbnail +import java.util.LinkedList +import java.util.List +import java.util.Objects +import java.util.Optional +import java.util.concurrent.TimeUnit +import java.util.function.Consumer +import java.util.function.Function +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +class VideoDetailFragment : + + BaseStateFragment(), + BackPressable, + PlayerServiceExtendedEventListener, + OnKeyDownListener { + // tabs + private var showComments = false + private var showRelatedItems = false + private var showDescription = false + private var selectedTabTag: String? = null + + @AttrRes + val tabIcons: MutableList = ArrayList() + + @StringRes + val tabContentDescriptions: MutableList = ArrayList() + private var tabSettingsChanged = false + private var lastAppBarVerticalOffset = Int.Companion.MAX_VALUE // prevents useless updates + + private val preferenceChangeListener = + OnSharedPreferenceChangeListener { sharedPreferences: SharedPreferences?, key: String? -> + if (getString(R.string.show_comments_key) == key) { + showComments = sharedPreferences!!.getBoolean(key, true) + tabSettingsChanged = true + } else if (getString(R.string.show_next_video_key) == key) { + showRelatedItems = sharedPreferences!!.getBoolean(key, true) + tabSettingsChanged = true + } else if (getString(R.string.show_description_key) == key) { + showDescription = sharedPreferences!!.getBoolean(key, true) + tabSettingsChanged = true + } + } + + @JvmField + @State + var serviceId: Int = NO_SERVICE_ID + + @JvmField + @State + var title: String = "" + + @JvmField + @State + var url: String? = null + private var playQueue: PlayQueue? = null + + @JvmField + @State + var bottomSheetState: Int = BottomSheetBehavior.STATE_EXPANDED + + @JvmField + @State + var lastStableBottomSheetState: Int = BottomSheetBehavior.STATE_EXPANDED + + @JvmField + @State + var autoPlayEnabled: Boolean = true + + private var currentInfo: StreamInfo? = null + private var currentWorker: Disposable? = null + private val disposables = CompositeDisposable() + private var positionSubscriber: Disposable? = null + + private var bottomSheetBehavior: BottomSheetBehavior? = null + private var bottomSheetCallback: BottomSheetCallback? = null + private var broadcastReceiver: BroadcastReceiver? = null + + /*////////////////////////////////////////////////////////////////////////// + // Views + ////////////////////////////////////////////////////////////////////////// */ + private var binding: FragmentVideoDetailBinding? = null + + private var pageAdapter: TabAdapter? = null + + private var settingsContentObserver: ContentObserver? = null + private var playerService: PlayerService? = null + private var player: Player? = null + private val playerHolder = getInstance() + + /*////////////////////////////////////////////////////////////////////////// + // Service management + ////////////////////////////////////////////////////////////////////////// */ + override fun onServiceConnected(connectedPlayerService: PlayerService) { + playerService = connectedPlayerService + } + + override fun onPlayerConnected( + connectedPlayer: Player, + playAfterConnect: Boolean + ) { + player = connectedPlayer + + // It will do nothing if the player is not in fullscreen mode + hideSystemUiIfNeeded() + + val playerUi: Optional = + player!!.UIs().getOpt(MainPlayerUi::class.java) + if (!player!!.videoPlayerSelected() && !playAfterConnect) { + return + } + + if (DeviceUtils.isLandscape(requireContext())) { + // If the video is playing but orientation changed + // let's make the video in fullscreen again + checkLandscape() + } else if (playerUi.map(Function { ui: MainPlayerUi? -> ui!!.isFullscreen() && !ui.isVerticalVideo() }) + .orElse(false) && // Tablet UI has orientation-independent fullscreen + !DeviceUtils.isTablet(activity) + ) { + // Device is in portrait orientation after rotation but UI is in fullscreen. + // Return back to non-fullscreen state + playerUi.ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.toggleFullscreen() }) + } + + if (playAfterConnect || + ( + currentInfo != null && this.isAutoplayEnabled && + playerUi.isEmpty() + ) + ) { + autoPlayEnabled = true // forcefully start playing + openVideoPlayerAutoFullscreen() + } + updateOverlayPlayQueueButtonVisibility() + } + + override fun onPlayerDisconnected() { + player = null + // the binding could be null at this point, if the app is finishing + if (binding != null) { + restoreDefaultBrightness() + } + } + + override fun onServiceDisconnected() { + playerService = null + } + + /*////////////////////////////////////////////////////////////////////////// + // Fragment's Lifecycle + ////////////////////////////////////////////////////////////////////////// */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val prefs = PreferenceManager.getDefaultSharedPreferences(activity) + showComments = prefs.getBoolean(getString(R.string.show_comments_key), true) + showRelatedItems = prefs.getBoolean(getString(R.string.show_next_video_key), true) + showDescription = prefs.getBoolean(getString(R.string.show_description_key), true) + selectedTabTag = prefs.getString( + getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG + ) + prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + + setupBroadcastReceiver() + + settingsContentObserver = object : ContentObserver(Handler()) { + override fun onChange(selfChange: Boolean) { + if (activity != null && !PlayerHelper.globalScreenOrientationLocked(activity)) { + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) + } + } + } + activity.getContentResolver().registerContentObserver( + Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, + settingsContentObserver!! + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentVideoDetailBinding.inflate(inflater, container, false) + return binding!!.getRoot() + } + + override fun onPause() { + super.onPause() + if (currentWorker != null) { + currentWorker!!.dispose() + } + restoreDefaultBrightness() + PreferenceManager.getDefaultSharedPreferences(requireContext()) + .edit() + .putString( + getString(R.string.stream_info_selected_tab_key), + pageAdapter!!.getItemTitle(binding!!.viewPager.getCurrentItem()) + ) + .apply() + } + + override fun onResume() { + super.onResume() + if (DEBUG) { + Log.d(TAG, "onResume() called") + } + + activity.sendBroadcast(Intent(ACTION_VIDEO_FRAGMENT_RESUMED)) + + updateOverlayPlayQueueButtonVisibility() + + setupBrightness() + + if (tabSettingsChanged) { + tabSettingsChanged = false + initTabs() + if (currentInfo != null) { + updateTabs(currentInfo!!) + } + } + + // Check if it was loading when the fragment was stopped/paused + if (wasLoading.getAndSet(false) && !wasCleared()) { + startLoading(false) + } + } + + override fun onStop() { + super.onStop() + + if (!activity.isChangingConfigurations()) { + activity.sendBroadcast(Intent(ACTION_VIDEO_FRAGMENT_STOPPED)) + } + } + + override fun onDestroy() { + super.onDestroy() + + // Stop the service when user leaves the app with double back press + // if video player is selected. Otherwise unbind + if (activity.isFinishing() && this.isPlayerAvailable && player!!.videoPlayerSelected()) { + playerHolder.stopService() + } else { + playerHolder.setListener(null) + } + + PreferenceManager.getDefaultSharedPreferences(activity) + .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + activity.unregisterReceiver(broadcastReceiver) + activity.getContentResolver().unregisterContentObserver(settingsContentObserver!!) + + if (positionSubscriber != null) { + positionSubscriber!!.dispose() + } + if (currentWorker != null) { + currentWorker!!.dispose() + } + disposables.clear() + positionSubscriber = null + currentWorker = null + bottomSheetBehavior!!.removeBottomSheetCallback(bottomSheetCallback!!) + + if (activity.isFinishing()) { + playQueue = null + currentInfo = null + stack = LinkedList() + } + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + 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 + "]") + } + } + + /*////////////////////////////////////////////////////////////////////////// + // OnClick + ////////////////////////////////////////////////////////////////////////// */ + private fun setOnClickListeners() { + binding!!.detailTitleRootLayout.setOnClickListener(View.OnClickListener { v: View? -> toggleTitleAndSecondaryControls() }) + binding!!.detailUploaderRootLayout.setOnClickListener( + makeOnClickListener( + Consumer { info: StreamInfo? -> + if (TextUtils.isEmpty( + info!!.getSubChannelUrl() + ) + ) { + if (!TextUtils.isEmpty(info.getUploaderUrl())) { + openChannel(info.getUploaderUrl(), info.getUploaderName()) + } + + if (DEBUG) { + Log.i(TAG, "Can't open sub-channel because we got no channel URL") + } + } else { + openChannel(info.getSubChannelUrl(), info.getSubChannelName()) + } + } + ) + ) + binding!!.detailThumbnailRootLayout.setOnClickListener( + View.OnClickListener { v: View? -> + autoPlayEnabled = true // forcefully start playing + // FIXME Workaround #7427 + if (this.isPlayerAvailable) { + player!!.setRecovery() + } + openVideoPlayerAutoFullscreen() + } + ) + + binding!!.detailControlsBackground.setOnClickListener( + View.OnClickListener { v: View? -> + openBackgroundPlayer( + false + ) + } + ) + binding!!.detailControlsPopup.setOnClickListener( + View.OnClickListener { v: View? -> + openPopupPlayer( + false + ) + } + ) + binding!!.detailControlsPlaylistAppend.setOnClickListener( + makeOnClickListener( + Consumer { info: StreamInfo? -> + if (getFM() != null && currentInfo != null) { + val fragment = getParentFragmentManager().findFragmentById + (R.id.fragment_holder) + + // commit previous pending changes to database + if (fragment is LocalPlaylistFragment) { + fragment.saveImmediate() + } else if (fragment is MainFragment) { + fragment.commitPlaylistTabs() + } + + disposables.add( + PlaylistDialog.createCorrespondingDialog( + requireContext(), + List.of(StreamEntity(info!!)), + Consumer { dialog: PlaylistDialog? -> + dialog!!.show( + getParentFragmentManager(), + TAG + ) + } + ) + ) + } + } + ) + ) + binding!!.detailControlsDownload.setOnClickListener( + View.OnClickListener { v: View? -> + if (PermissionHelper.checkStoragePermissions( + activity, + PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE + ) + ) { + openDownloadDialog() + } + } + ) + binding!!.detailControlsShare.setOnClickListener( + makeOnClickListener( + Consumer { info: StreamInfo? -> + ShareUtils.shareText( + requireContext(), info!!.getName(), info.getUrl(), + info.getThumbnails() + ) + } + ) + ) + binding!!.detailControlsOpenInBrowser.setOnClickListener( + makeOnClickListener( + Consumer { info: StreamInfo? -> + ShareUtils.openUrlInBrowser( + requireContext(), + info!!.getUrl() + ) + } + ) + ) + binding!!.detailControlsPlayWithKodi.setOnClickListener( + makeOnClickListener( + Consumer { info: StreamInfo? -> + KoreUtils.playWithKore( + requireContext(), + Uri.parse( + info!!.getUrl() + ) + ) + } + ) + ) + if (DEBUG) { + binding!!.detailControlsCrashThePlayer.setOnClickListener( + View.OnClickListener { v: View? -> + VideoDetailPlayerCrasher.onCrashThePlayer( + requireContext(), + player + ) + } + ) + } + + val overlayListener = View.OnClickListener { v: View? -> + bottomSheetBehavior!! + .setState(BottomSheetBehavior.STATE_EXPANDED) + } + binding!!.overlayThumbnail.setOnClickListener(overlayListener) + binding!!.overlayMetadataLayout.setOnClickListener(overlayListener) + binding!!.overlayButtonsLayout.setOnClickListener(overlayListener) + binding!!.overlayCloseButton.setOnClickListener( + View.OnClickListener { v: View? -> + bottomSheetBehavior!! + .setState(BottomSheetBehavior.STATE_HIDDEN) + } + ) + binding!!.overlayPlayQueueButton.setOnClickListener( + View.OnClickListener { v: View? -> + NavigationHelper.openPlayQueue( + requireContext() + ) + } + ) + binding!!.overlayPlayPauseButton.setOnClickListener( + View.OnClickListener { v: View? -> + if (playerIsNotStopped()) { + player!!.playPause() + player!!.UIs().getOpt(VideoPlayerUi::class.java) + .ifPresent(Consumer { ui: VideoPlayerUi? -> ui!!.hideControls(0, 0) }) + showSystemUi() + } else { + autoPlayEnabled = true // forcefully start playing + openVideoPlayer(false) + } + setOverlayPlayPauseImage(this.isPlayerAvailable && player!!.isPlaying()) + } + ) + } + + private fun makeOnClickListener(consumer: Consumer): View.OnClickListener { + return View.OnClickListener { v: View? -> + if (!isLoading.get() && currentInfo != null) { + consumer.accept(currentInfo) + } + } + } + + private fun setOnLongClickListeners() { + binding!!.detailTitleRootLayout.setOnLongClickListener( + makeOnLongClickListener( + Consumer { info: StreamInfo? -> + ShareUtils.copyToClipboard( + requireContext(), + binding!!.detailVideoTitleView.getText().toString() + ) + } + ) + ) + binding!!.detailUploaderRootLayout.setOnLongClickListener( + makeOnLongClickListener( + Consumer { info: StreamInfo? -> + if (TextUtils.isEmpty( + info!!.getSubChannelUrl() + ) + ) { + Log.w(TAG, "Can't open parent channel because we got no parent channel URL") + } else { + openChannel(info.getUploaderUrl(), info.getUploaderName()) + } + } + ) + ) + + binding!!.detailControlsBackground.setOnLongClickListener( + makeOnLongClickListener( + Consumer { info: StreamInfo? -> + openBackgroundPlayer( + true + ) + } + ) + ) + binding!!.detailControlsPopup.setOnLongClickListener( + makeOnLongClickListener( + Consumer { info: StreamInfo? -> + openPopupPlayer( + true + ) + } + ) + ) + binding!!.detailControlsDownload.setOnLongClickListener( + makeOnLongClickListener( + Consumer { info: StreamInfo? -> + NavigationHelper.openDownloads( + activity + ) + } + ) + ) + + val overlayListener = makeOnLongClickListener( + Consumer { info: StreamInfo? -> + openChannel( + info!!.getUploaderUrl(), info.getUploaderName() + ) + } + ) + binding!!.overlayThumbnail.setOnLongClickListener(overlayListener) + binding!!.overlayMetadataLayout.setOnLongClickListener(overlayListener) + } + + private fun makeOnLongClickListener(consumer: Consumer): OnLongClickListener { + return OnLongClickListener { v: View? -> + if (isLoading.get() || currentInfo == null) { + return@OnLongClickListener false + } + consumer.accept(currentInfo) + true + } + } + + private fun openChannel(subChannelUrl: String?, subChannelName: String) { + try { + NavigationHelper.openChannelFragment( + getFM(), currentInfo!!.getServiceId(), + subChannelUrl, subChannelName + ) + } catch (e: Exception) { + showUiErrorSnackbar(this, "Opening channel fragment", e) + } + } + + private fun toggleTitleAndSecondaryControls() { + if (binding!!.detailSecondaryControlPanel.getVisibility() == View.GONE) { + binding!!.detailVideoTitleView.setMaxLines(10) + binding!!.detailToggleSecondaryControlsView + .animateRotation(VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 180) + binding!!.detailSecondaryControlPanel.setVisibility(View.VISIBLE) + } else { + binding!!.detailVideoTitleView.setMaxLines(1) + binding!!.detailToggleSecondaryControlsView + .animateRotation(VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 0) + binding!!.detailSecondaryControlPanel.setVisibility(View.GONE) + } + // view pager height has changed, update the tab layout + updateTabLayoutVisibility() + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + ////////////////////////////////////////////////////////////////////////// */ + // called from onViewCreated in {@link BaseFragment#onViewCreated} + override fun initViews(rootView: View?, savedInstanceState: Bundle?) { + super.initViews(rootView, savedInstanceState) + + pageAdapter = TabAdapter(getChildFragmentManager()) + binding!!.viewPager.setAdapter(pageAdapter) + binding!!.tabLayout.setupWithViewPager(binding!!.viewPager) + + binding!!.detailThumbnailRootLayout.requestFocus() + + binding!!.detailControlsPlayWithKodi.setVisibility( + if (KoreUtils.shouldShowPlayWithKodi(requireContext(), serviceId)) + View.VISIBLE + else + View.GONE + ) + binding!!.detailControlsCrashThePlayer.setVisibility( + if (DEBUG && PreferenceManager.getDefaultSharedPreferences(getContext()!!) + .getBoolean(getString(R.string.show_crash_the_player_key), false) + ) + View.VISIBLE + else + View.GONE + ) + accommodateForTvAndDesktopMode() + } + + @SuppressLint("ClickableViewAccessibility") + override fun initListeners() { + super.initListeners() + + setOnClickListeners() + setOnLongClickListeners() + + val controlsTouchListener = OnTouchListener { view: View?, motionEvent: MotionEvent? -> + if (motionEvent!!.getAction() == MotionEvent.ACTION_DOWN && + PlayButtonHelper.shouldShowHoldToAppendTip(activity) + ) { + binding!!.touchAppendDetail.animate( + true, + 250, + AnimationType.ALPHA, + 0, + Runnable { + binding!!.touchAppendDetail.animate( + false, + 1500, + AnimationType.ALPHA, + 1000 + ) + } + ) + } + false + } + binding!!.detailControlsBackground.setOnTouchListener(controlsTouchListener) + binding!!.detailControlsPopup.setOnTouchListener(controlsTouchListener) + + binding!!.appBarLayout.addOnOffsetChangedListener( + OnOffsetChangedListener { layout: AppBarLayout?, verticalOffset: Int -> + // prevent useless updates to tab layout visibility if nothing changed + if (verticalOffset != lastAppBarVerticalOffset) { + lastAppBarVerticalOffset = verticalOffset + // the view was scrolled + updateTabLayoutVisibility() + } + } + ) + + setupBottomPlayer() + if (!playerHolder.isBound) { + setHeightThumbnail() + } else { + playerHolder.startService(false, this) + } + } + + override fun onKeyDown(keyCode: Int): Boolean { + return this.isPlayerAvailable && + player!!.UIs().getOpt(VideoPlayerUi::class.java) + .map(Function { playerUi: VideoPlayerUi? -> playerUi!!.onKeyDown(keyCode) }) + .orElse(false) + } + + override fun onBackPressed(): Boolean { + if (DEBUG) { + Log.d(TAG, "onBackPressed() called") + } + + // If we are in fullscreen mode just exit from it via first back press + if (this.isFullscreen) { + if (!DeviceUtils.isTablet(activity)) { + player!!.pause() + } + restoreDefaultOrientation() + setAutoPlay(false) + return true + } + + // If we have something in history of played items we replay it here + if (this.isPlayerAvailable && + player!!.getPlayQueue() != null && player!!.videoPlayerSelected() && + player!!.getPlayQueue()!!.previous() + ) { + return true // no code here, as previous() was used in the if + } + + // That means that we are on the start of the stack, + if (stack.size <= 1) { + restoreDefaultOrientation() + return false // let MainActivity handle the onBack (e.g. to minimize the mini player) + } + + // Remove top + stack.pop() + // Get stack item from the new top + setupFromHistoryItem(Objects.requireNonNull(stack.peek())) + + return true + } + + private fun setupFromHistoryItem(item: StackItem) { + setAutoPlay(false) + hideMainPlayerOnLoadingNewStream() + + setInitialData( + item.getServiceId(), item.getUrl(), + if (item.getTitle() == null) "" else item.getTitle(), item.getPlayQueue() + ) + startLoading(false) + + // Maybe an item was deleted in background activity + if (item.getPlayQueue().getItem() == null) { + return + } + + val playQueueItem = item.getPlayQueue().getItem() + // Update title, url, uploader from the last item in the stack (it's current now) + val isPlayerStopped = !this.isPlayerAvailable || player!!.isStopped() + if (playQueueItem != null && isPlayerStopped) { + updateOverlayData( + playQueueItem.getTitle(), + playQueueItem.getUploader(), playQueueItem.getThumbnails() + ) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Info loading and handling + ////////////////////////////////////////////////////////////////////////// */ + override fun doInitialLoadLogic() { + if (wasCleared()) { + return + } + + if (currentInfo == null) { + prepareAndLoadInfo() + } else { + prepareAndHandleInfoIfNeededAfterDelay(currentInfo!!, false, 50) + } + } + + fun selectAndLoadVideo( + newServiceId: Int, + newUrl: String?, + newTitle: String, + newQueue: PlayQueue? + ) { + if (this.isPlayerAvailable && newQueue != null && playQueue != null && playQueue!!.getItem() != null && ( + playQueue!!.getItem()!! + .getUrl() != newUrl + ) + ) { + // Preloading can be disabled since playback is surely being replaced. + player!!.disablePreloadingOfCurrentTrack() + } + + setInitialData(newServiceId, newUrl, newTitle, newQueue) + startLoading(false, true) + } + + private fun prepareAndHandleInfoIfNeededAfterDelay( + info: StreamInfo, + scrollToTop: Boolean, + delay: Long + ) { + Handler(Looper.getMainLooper()).postDelayed( + Runnable { + if (activity == null) { + return@postDelayed + } + // Data can already be drawn, don't spend time twice + if (info.getName() == binding!!.detailVideoTitleView.getText().toString()) { + return@postDelayed + } + prepareAndHandleInfo(info, scrollToTop) + }, + delay + ) + } + + private fun prepareAndHandleInfo(info: StreamInfo, scrollToTop: Boolean) { + if (DEBUG) { + Log.d( + TAG, + ( + "prepareAndHandleInfo() called with: " + + "info = [" + info + "], scrollToTop = [" + scrollToTop + "]" + ) + ) + } + + showLoading() + initTabs() + + if (scrollToTop) { + scrollToTop() + } + handleResult(info) + showContent() + } + + private fun prepareAndLoadInfo() { + scrollToTop() + startLoading(false) + } + + public override fun startLoading(forceLoad: Boolean) { + startLoading(forceLoad, null) + } + + private fun startLoading(forceLoad: Boolean, addToBackStack: Boolean?) { + super.startLoading(forceLoad) + + initTabs() + currentInfo = null + if (currentWorker != null) { + currentWorker!!.dispose() + } + + runWorker(forceLoad, if (addToBackStack != null) addToBackStack else stack.isEmpty()) + } + + private fun runWorker(forceLoad: Boolean, addToBackStack: Boolean) { + val prefs = PreferenceManager.getDefaultSharedPreferences(activity) + currentWorker = ExtractorHelper.getStreamInfo(serviceId, url, forceLoad) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + io.reactivex.rxjava3.functions.Consumer { result: StreamInfo? -> + isLoading.set(false) + hideMainPlayerOnLoadingNewStream() + if (result!!.getAgeLimit() != StreamExtractor.NO_AGE_LIMIT && !prefs.getBoolean( + getString(R.string.show_age_restricted_content), false + ) + ) { + hideAgeRestrictedContent() + } else { + handleResult(result) + showContent() + if (addToBackStack) { + if (playQueue == null) { + playQueue = SinglePlayQueue(result) + } + if (stack.isEmpty() || stack.peek()!!.getPlayQueue() != playQueue) { + stack.push(StackItem(serviceId, url, title, playQueue)) + } + } + + if (this.isAutoplayEnabled) { + openVideoPlayerAutoFullscreen() + } + } + }, + io.reactivex.rxjava3.functions.Consumer { throwable: Throwable? -> + showError( + ErrorInfo( + throwable!!, UserAction.REQUESTED_STREAM, + (if (url == null) "no url" else url)!!, serviceId + ) + ) + } + ) + } + + /*////////////////////////////////////////////////////////////////////////// + // Tabs + ////////////////////////////////////////////////////////////////////////// */ + private fun initTabs() { + if (pageAdapter!!.getCount() != 0) { + selectedTabTag = pageAdapter!!.getItemTitle(binding!!.viewPager.getCurrentItem()) + } + pageAdapter!!.clearAllItems() + tabIcons.clear() + tabContentDescriptions.clear() + + if (shouldShowComments()) { + pageAdapter!!.addFragment(getInstance(serviceId, url), COMMENTS_TAB_TAG) + tabIcons.add(R.drawable.ic_comment) + tabContentDescriptions.add(R.string.comments_tab_description) + } + + if (showRelatedItems && binding!!.relatedItemsLayout == null) { + // temp empty fragment. will be updated in handleResult + pageAdapter!!.addFragment(EmptyFragment.newInstance(false), RELATED_TAB_TAG) + tabIcons.add(R.drawable.ic_art_track) + tabContentDescriptions.add(R.string.related_items_tab_description) + } + + if (showDescription) { + // temp empty fragment. will be updated in handleResult + pageAdapter!!.addFragment(EmptyFragment.newInstance(false), DESCRIPTION_TAB_TAG) + tabIcons.add(R.drawable.ic_description) + tabContentDescriptions.add(R.string.description_tab_description) + } + + if (pageAdapter!!.getCount() == 0) { + pageAdapter!!.addFragment(EmptyFragment.newInstance(true), EMPTY_TAB_TAG) + } + pageAdapter!!.notifyDataSetUpdate() + + if (pageAdapter!!.getCount() >= 2) { + val position = pageAdapter!!.getItemPositionByTitle(selectedTabTag) + if (position != -1) { + binding!!.viewPager.setCurrentItem(position) + } + updateTabIconsAndContentDescriptions() + } + // the page adapter now contains tabs: show the tab layout + updateTabLayoutVisibility() + } + + /** + * To be called whenever [.pageAdapter] is modified, since that triggers a refresh in + * [FragmentVideoDetailBinding.tabLayout] resetting all tab's icons and content + * descriptions. This reads icons from [.tabIcons] and content descriptions from + * [.tabContentDescriptions], which are all set in [.initTabs]. + */ + private fun updateTabIconsAndContentDescriptions() { + for (i in tabIcons.indices) { + val tab = binding!!.tabLayout.getTabAt(i) + if (tab != null) { + tab.setIcon(tabIcons.get(i)!!) + tab.setContentDescription(tabContentDescriptions.get(i)!!) + } + } + } + + private fun updateTabs(info: StreamInfo) { + if (showRelatedItems) { + if (binding!!.relatedItemsLayout == null) { // phone + pageAdapter!!.updateItem(RELATED_TAB_TAG, getInstance(info)) + } else { // tablet + TV + getChildFragmentManager().beginTransaction() + .replace(R.id.relatedItemsLayout, getInstance(info)) + .commitAllowingStateLoss() + binding!!.relatedItemsLayout!!.setVisibility(if (this.isFullscreen) View.GONE else View.VISIBLE) + } + } + + if (showDescription) { + pageAdapter!!.updateItem(DESCRIPTION_TAB_TAG, DescriptionFragment(info)) + } + + binding!!.viewPager.setVisibility(View.VISIBLE) + // make sure the tab layout is visible + updateTabLayoutVisibility() + pageAdapter!!.notifyDataSetUpdate() + updateTabIconsAndContentDescriptions() + } + + private fun shouldShowComments(): Boolean { + try { + return showComments && NewPipe.getService(serviceId) + .getServiceInfo() + .getMediaCapabilities() + .contains(MediaCapability.COMMENTS) + } catch (e: ExtractionException) { + return false + } + } + + fun updateTabLayoutVisibility() { + if (binding == null) { + // If binding is null we do not need to and should not do anything with its object(s) + return + } + + if (pageAdapter!!.getCount() < 2 || binding!!.viewPager.getVisibility() != View.VISIBLE) { + // hide tab layout if there is only one tab or if the view pager is also hidden + binding!!.tabLayout.setVisibility(View.GONE) + } else { + // call `post()` to be sure `viewPager.getHitRect()` + // is up to date and not being currently recomputed + binding!!.tabLayout.post( + Runnable { + val activity = getActivity() + if (activity != null) { + val pagerHitRect = Rect() + binding!!.viewPager.getHitRect(pagerHitRect) + + val height = DeviceUtils.getWindowHeight(activity.getWindowManager()) + val viewPagerVisibleHeight = height - pagerHitRect.top + // see TabLayout.DEFAULT_HEIGHT, which is equal to 48dp + val tabLayoutHeight = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 48f, getResources().getDisplayMetrics() + ) + + if (viewPagerVisibleHeight > tabLayoutHeight * 2) { + // no translation at all when viewPagerVisibleHeight > tabLayout.height * 3 + binding!!.tabLayout.setTranslationY( + max( + 0.0, + (tabLayoutHeight * 3 - viewPagerVisibleHeight).toDouble() + ).toFloat() + ) + binding!!.tabLayout.setVisibility(View.VISIBLE) + } else { + // view pager is not visible enough + binding!!.tabLayout.setVisibility(View.GONE) + } + } + } + ) + } + } + + fun scrollToTop() { + binding!!.appBarLayout.setExpanded(true, true) + // notify tab layout of scrolling + updateTabLayoutVisibility() + } + + /*////////////////////////////////////////////////////////////////////////// + // Play Utils + ////////////////////////////////////////////////////////////////////////// */ + private fun toggleFullscreenIfInFullscreenMode() { + // If a user watched video inside fullscreen mode and than chose another player + // return to non-fullscreen mode + if (this.isPlayerAvailable) { + player!!.UIs().getOpt(MainPlayerUi::class.java) + .ifPresent( + Consumer { playerUi: MainPlayerUi? -> + if (playerUi!!.isFullscreen()) { + playerUi.toggleFullscreen() + } + } + ) + } + } + + private fun openBackgroundPlayer(append: Boolean) { + val useExternalAudioPlayer = PreferenceManager + .getDefaultSharedPreferences(activity) + .getBoolean(activity.getString(R.string.use_external_audio_player_key), false) + + toggleFullscreenIfInFullscreenMode() + + if (this.isPlayerAvailable) { + // FIXME Workaround #7427 + player!!.setRecovery() + } + + if (useExternalAudioPlayer) { + showExternalAudioPlaybackDialog() + } else { + openNormalBackgroundPlayer(append) + } + } + + private fun openPopupPlayer(append: Boolean) { + if (!PermissionHelper.isPopupEnabledElseAsk(activity)) { + return + } + + // See UI changes while remote playQueue changes + if (!this.isPlayerAvailable) { + playerHolder.startService(false, this) + } else { + // FIXME Workaround #7427 + player!!.setRecovery() + } + + toggleFullscreenIfInFullscreenMode() + + val queue = setupPlayQueueForIntent(append) + if (append) { // resumePlayback: false + NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.POPUP) + } else { + replaceQueueIfUserConfirms( + Runnable { + NavigationHelper + .playOnPopupPlayer(activity, queue, true) + } + ) + } + } + + /** + * Opens the video player, in fullscreen if needed. In order to open fullscreen, the activity + * is toggled to landscape orientation (which will then cause fullscreen mode). + * + * @param directlyFullscreenIfApplicable whether to open fullscreen if we are not already + * in landscape and screen orientation is locked + */ + fun openVideoPlayer(directlyFullscreenIfApplicable: Boolean) { + if (directlyFullscreenIfApplicable && + !DeviceUtils.isLandscape(requireContext()) && PlayerHelper.globalScreenOrientationLocked( + requireContext() + ) + ) { + // Make sure the bottom sheet turns out expanded. When this code kicks in the bottom + // sheet could not have fully expanded yet, and thus be in the STATE_SETTLING state. + // When the activity is rotated, and its state is saved and then restored, the bottom + // sheet would forget what it was doing, since even if STATE_SETTLING is restored, it + // doesn't tell which state it was settling to, and thus the bottom sheet settles to + // STATE_COLLAPSED. This can be solved by manually setting the state that will be + // restored (i.e. bottomSheetState) to STATE_EXPANDED. + updateBottomSheetState(BottomSheetBehavior.STATE_EXPANDED) + // toggle landscape in order to open directly in fullscreen + onScreenRotationButtonClicked() + } + + if (PreferenceManager.getDefaultSharedPreferences(activity) + .getBoolean(this.getString(R.string.use_external_video_player_key), false) + ) { + showExternalVideoPlaybackDialog() + } else { + replaceQueueIfUserConfirms(Runnable { this.openMainPlayer() }) + } + } + + /** + * If the option to start directly fullscreen is enabled, calls + * [.openVideoPlayer] with `directlyFullscreenIfApplicable = true`, so that + * if the user is not already in landscape and he has screen orientation locked the activity + * rotates and fullscreen starts. Otherwise, if the option to start directly fullscreen is + * disabled, calls [.openVideoPlayer] with `directlyFullscreenIfApplicable + * = false`, hence preventing it from going directly fullscreen. + */ + fun openVideoPlayerAutoFullscreen() { + openVideoPlayer(PlayerHelper.isStartMainPlayerFullscreenEnabled(requireContext())) + } + + private fun openNormalBackgroundPlayer(append: Boolean) { + // See UI changes while remote playQueue changes + if (!this.isPlayerAvailable) { + playerHolder.startService(false, this) + } + + val queue = setupPlayQueueForIntent(append) + if (append) { + NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.AUDIO) + } else { + replaceQueueIfUserConfirms( + Runnable { + NavigationHelper + .playOnBackgroundPlayer(activity, queue, true) + } + ) + } + } + + private fun openMainPlayer() { + if (noPlayerServiceAvailable()) { + playerHolder.startService(autoPlayEnabled, this) + return + } + if (currentInfo == null) { + return + } + + val queue = setupPlayQueueForIntent(false) + tryAddVideoPlayerView() + + val playerIntent = NavigationHelper.getPlayerIntent( + requireContext(), + PlayerService::class.java, queue, true, autoPlayEnabled + ) + ContextCompat.startForegroundService(activity, playerIntent) + } + + /** + * When the video detail fragment is already showing details for a video and the user opens a + * new one, the video detail fragment changes all of its old data to the new stream, so if there + * is a video player currently open it should be hidden. This method does exactly that. If + * autoplay is enabled, the underlying player is not stopped completely, since it is going to + * be reused in a few milliseconds and the flickering would be annoying. + */ + private fun hideMainPlayerOnLoadingNewStream() { + val root = this.root + if (noPlayerServiceAvailable() || root.isEmpty() || !player!!.videoPlayerSelected()) { + return + } + + removeVideoPlayerView() + if (this.isAutoplayEnabled) { + playerService!!.stopForImmediateReusing() + root.ifPresent(Consumer { view: View? -> view!!.setVisibility(View.GONE) }) + } else { + playerHolder.stopService() + } + } + + private fun setupPlayQueueForIntent(append: Boolean): PlayQueue { + if (append) { + return SinglePlayQueue(currentInfo) + } + + var queue = playQueue + // Size can be 0 because queue removes bad stream automatically when error occurs + if (queue == null || queue.isEmpty()) { + queue = SinglePlayQueue(currentInfo) + } + + return queue + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + ////////////////////////////////////////////////////////////////////////// */ + fun setAutoPlay(autoPlay: Boolean) { + this.autoPlayEnabled = autoPlay + } + + private fun startOnExternalPlayer( + context: Context, + info: StreamInfo, + selectedStream: Stream + ) { + NavigationHelper.playOnExternalPlayer( + context, currentInfo!!.getName(), + currentInfo!!.getSubChannelName(), selectedStream + ) + + val recordManager = HistoryRecordManager(requireContext()) + disposables.add( + recordManager.onViewed(info).onErrorComplete() + .subscribe( + io.reactivex.rxjava3.functions.Consumer { ignored: Long? -> }, + io.reactivex.rxjava3.functions.Consumer { error: Throwable? -> + Log.e( + TAG, + "Register view failure: ", + error + ) + } + ) + ) + } + + private val isExternalPlayerEnabled: Boolean + get() = PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getBoolean(getString(R.string.use_external_video_player_key), false) + + private val isAutoplayEnabled: Boolean + // This method overrides default behaviour when setAutoPlay() is called. + get() = autoPlayEnabled && + !this.isExternalPlayerEnabled && (!this.isPlayerAvailable || player!!.videoPlayerSelected()) && + bottomSheetState != BottomSheetBehavior.STATE_HIDDEN && PlayerHelper.isAutoplayAllowedByUser( + requireContext() + ) + + private fun tryAddVideoPlayerView() { + if (this.isPlayerAvailable && getView() != null) { + // Setup the surface view height, so that it fits the video correctly; this is done also + // here, and not only in the Handler, to avoid a choppy fullscreen rotation animation. + setHeightThumbnail() + } + + // do all the null checks in the posted lambda, too, since the player, the binding and the + // view could be set or unset before the lambda gets executed on the next main thread cycle + Handler(Looper.getMainLooper()).post( + Runnable { + if (!this.isPlayerAvailable || getView() == null) { + return@post + } + // setup the surface view height, so that it fits the video correctly + setHeightThumbnail() + player!!.UIs().getOpt(MainPlayerUi::class.java) + .ifPresent( + Consumer { playerUi: MainPlayerUi? -> + // sometimes binding would be null here, even though getView() != null above u.u + if (binding != null) { + // prevent from re-adding a view multiple times + playerUi!!.removeViewFromParent() + binding!!.playerPlaceholder.addView(playerUi.getBinding().getRoot()) + playerUi.setupVideoSurfaceIfNeeded() + } + } + ) + } + ) + } + + private fun removeVideoPlayerView() { + makeDefaultHeightForVideoPlaceholder() + + if (player != null) { + player!!.UIs().getOpt(VideoPlayerUi::class.java) + .ifPresent(Consumer { obj: VideoPlayerUi? -> obj!!.removeViewFromParent() }) + } + } + + private fun makeDefaultHeightForVideoPlaceholder() { + if (getView() == null) { + return + } + + binding!!.playerPlaceholder.getLayoutParams().height = FrameLayout.LayoutParams.MATCH_PARENT + binding!!.playerPlaceholder.requestLayout() + } + + private val preDrawListener: ViewTreeObserver.OnPreDrawListener = + object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + val metrics = getResources().getDisplayMetrics() + + if (getView() != null) { + val height = ( + if (DeviceUtils.isInMultiWindow(activity)) + requireView() + else + activity.getWindow().getDecorView() + ).getHeight() + setHeightThumbnail(height, metrics) + getView()!!.getViewTreeObserver().removeOnPreDrawListener(preDrawListener) + } + return false + } + } + + /** + * Method which controls the size of thumbnail and the size of main player inside + * a layout with thumbnail. It decides what height the player should have in both + * screen orientations. It knows about multiWindow feature + * and about videos with aspectRatio ZOOM (the height for them will be a bit higher, + * [.MAX_PLAYER_HEIGHT]) + */ + private fun setHeightThumbnail() { + val metrics = getResources().getDisplayMetrics() + val isPortrait = metrics.heightPixels > metrics.widthPixels + requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener) + + if (this.isFullscreen) { + val height = ( + if (DeviceUtils.isInMultiWindow(activity)) + requireView() + else + activity.getWindow().getDecorView() + ).getHeight() + // Height is zero when the view is not yet displayed like after orientation change + if (height != 0) { + setHeightThumbnail(height, metrics) + } else { + requireView().getViewTreeObserver().addOnPreDrawListener(preDrawListener) + } + } else { + val height = ( + if (isPortrait) + metrics.widthPixels / (16.0f / 9.0f) + else + metrics.heightPixels / 2.0f + ).toInt() + setHeightThumbnail(height, metrics) + } + } + + private fun setHeightThumbnail(newHeight: Int, metrics: DisplayMetrics) { + binding!!.detailThumbnailImageView.setLayoutParams( + FrameLayout.LayoutParams( + RelativeLayout.LayoutParams.MATCH_PARENT, newHeight + ) + ) + binding!!.detailThumbnailImageView.setMinimumHeight(newHeight) + if (this.isPlayerAvailable) { + val maxHeight = (metrics.heightPixels * MAX_PLAYER_HEIGHT).toInt() + player!!.UIs().getOpt(VideoPlayerUi::class.java) + .ifPresent( + Consumer { ui: VideoPlayerUi? -> + ui!!.getBinding().surfaceView.setHeights( + newHeight, + if (ui.isFullscreen()) newHeight else maxHeight + ) + } + ) + } + } + + private fun showContent() { + binding!!.detailContentRootHiding.setVisibility(View.VISIBLE) + } + + private fun setInitialData( + newServiceId: Int, + newUrl: String?, + newTitle: String, + newPlayQueue: PlayQueue? + ) { + this.serviceId = newServiceId + this.url = newUrl + this.title = newTitle + this.playQueue = newPlayQueue + } + + private fun setErrorImage() { + if (binding == null || activity == null) { + return + } + + binding!!.detailThumbnailImageView.setImageDrawable( + AppCompatResources.getDrawable(requireContext(), R.drawable.not_available_monkey) + ) + binding!!.detailThumbnailImageView.animate( + false, 0, AnimationType.ALPHA, + 0, Runnable { binding!!.detailThumbnailImageView.animate(true, 500) } + ) + } + + override fun handleError() { + super.handleError() + setErrorImage() + + if (binding!!.relatedItemsLayout != null) { // hide related streams for tablets + binding!!.relatedItemsLayout!!.setVisibility(View.INVISIBLE) + } + + // hide comments / related streams / description tabs + binding!!.viewPager.setVisibility(View.GONE) + binding!!.tabLayout.setVisibility(View.GONE) + } + + private fun hideAgeRestrictedContent() { + showTextError( + getString( + R.string.restricted_video, + getString(R.string.show_age_restricted_content_title) + ) + ) + } + + private fun setupBroadcastReceiver() { + broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent) { + when (intent.getAction()) { + ACTION_SHOW_MAIN_PLAYER -> bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_EXPANDED) + ACTION_HIDE_MAIN_PLAYER -> bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_HIDDEN) + ACTION_PLAYER_STARTED -> { + // If the state is not hidden we don't need to show the mini player + if (bottomSheetBehavior!!.getState() == BottomSheetBehavior.STATE_HIDDEN) { + bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_COLLAPSED) + } + // Rebound to the service if it was closed via notification or mini player + if (!playerHolder.isBound) { + playerHolder.startService( + false, this@VideoDetailFragment + ) + } + } + } + } + } + val intentFilter = IntentFilter() + intentFilter.addAction(ACTION_SHOW_MAIN_PLAYER) + intentFilter.addAction(ACTION_HIDE_MAIN_PLAYER) + intentFilter.addAction(ACTION_PLAYER_STARTED) + activity.registerReceiver(broadcastReceiver, intentFilter) + } + + /*////////////////////////////////////////////////////////////////////////// + // Orientation listener + ////////////////////////////////////////////////////////////////////////// */ + private fun restoreDefaultOrientation() { + if (this.isPlayerAvailable && player!!.videoPlayerSelected()) { + toggleFullscreenIfInFullscreenMode() + } + + // This will show systemUI and pause the player. + // User can tap on Play button and video will be in fullscreen mode again + // Note for tablet: trying to avoid orientation changes since it's not easy + // to physically rotate the tablet every time + if (activity != null && !DeviceUtils.isTablet(activity)) { + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + ////////////////////////////////////////////////////////////////////////// */ + override fun showLoading() { + super.showLoading() + + // if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required + if (!ExtractorHelper.isCached(serviceId, url!!, InfoCache.Type.STREAM)) { + binding!!.detailContentRootHiding.setVisibility(View.INVISIBLE) + } + + binding!!.detailThumbnailPlayButton.animate(false, 50) + binding!!.detailDurationView.animate(false, 100) + binding!!.detailPositionView.setVisibility(View.GONE) + binding!!.positionView.setVisibility(View.GONE) + + binding!!.detailVideoTitleView.setText(title) + binding!!.detailVideoTitleView.setMaxLines(1) + binding!!.detailVideoTitleView.animate(true, 0) + + binding!!.detailToggleSecondaryControlsView.setVisibility(View.GONE) + binding!!.detailTitleRootLayout.setClickable(false) + binding!!.detailSecondaryControlPanel.setVisibility(View.GONE) + + if (binding!!.relatedItemsLayout != null) { + if (showRelatedItems) { + binding!!.relatedItemsLayout!!.setVisibility( + if (this.isFullscreen) View.GONE else View.INVISIBLE + ) + } else { + binding!!.relatedItemsLayout!!.setVisibility(View.GONE) + } + } + + dispose(binding!!.detailThumbnailImageView) + dispose(binding!!.detailSubChannelThumbnailView) + dispose(binding!!.overlayThumbnail) + dispose(binding!!.detailUploaderThumbnailView) + + binding!!.detailThumbnailImageView.setImageBitmap(null) + binding!!.detailSubChannelThumbnailView.setImageBitmap(null) + } + + override fun handleResult(info: StreamInfo) { + super.handleResult(info) + + currentInfo = info + setInitialData(info.getServiceId(), info.getOriginalUrl(), info.getName(), playQueue) + + updateTabs(info) + + binding!!.detailThumbnailPlayButton.animate(true, 200) + binding!!.detailVideoTitleView.setText(title) + + binding!!.detailSubChannelThumbnailView.setVisibility(View.GONE) + + if (!TextUtils.isEmpty(info.getSubChannelName())) { + displayBothUploaderAndSubChannel(info) + } else { + displayUploaderAsSubChannel(info) + } + + if (info.getViewCount() >= 0) { + if (info.getStreamType() == StreamType.AUDIO_LIVE_STREAM) { + binding!!.detailViewCountView.setText( + Localization.listeningCount( + activity, + info.getViewCount() + ) + ) + } else if (info.getStreamType() == StreamType.LIVE_STREAM) { + binding!!.detailViewCountView.setText( + Localization + .localizeWatchingCount(activity, info.getViewCount()) + ) + } else { + binding!!.detailViewCountView.setText( + Localization + .localizeViewCount(activity, info.getViewCount()) + ) + } + binding!!.detailViewCountView.setVisibility(View.VISIBLE) + } else { + binding!!.detailViewCountView.setVisibility(View.GONE) + } + + if (info.getDislikeCount() == -1L && info.getLikeCount() == -1L) { + binding!!.detailThumbsDownImgView.setVisibility(View.VISIBLE) + binding!!.detailThumbsUpImgView.setVisibility(View.VISIBLE) + binding!!.detailThumbsUpCountView.setVisibility(View.GONE) + binding!!.detailThumbsDownCountView.setVisibility(View.GONE) + + binding!!.detailThumbsDisabledView.setVisibility(View.VISIBLE) + } else { + if (info.getDislikeCount() >= 0) { + binding!!.detailThumbsDownCountView.setText( + Localization + .shortCount(activity, info.getDislikeCount()) + ) + binding!!.detailThumbsDownCountView.setVisibility(View.VISIBLE) + binding!!.detailThumbsDownImgView.setVisibility(View.VISIBLE) + } else { + binding!!.detailThumbsDownCountView.setVisibility(View.GONE) + binding!!.detailThumbsDownImgView.setVisibility(View.GONE) + } + + if (info.getLikeCount() >= 0) { + binding!!.detailThumbsUpCountView.setText( + Localization.shortCount( + activity, + info.getLikeCount() + ) + ) + binding!!.detailThumbsUpCountView.setVisibility(View.VISIBLE) + binding!!.detailThumbsUpImgView.setVisibility(View.VISIBLE) + } else { + binding!!.detailThumbsUpCountView.setVisibility(View.GONE) + binding!!.detailThumbsUpImgView.setVisibility(View.GONE) + } + binding!!.detailThumbsDisabledView.setVisibility(View.GONE) + } + + if (info.getDuration() > 0) { + binding!!.detailDurationView.setText(Localization.getDurationString(info.getDuration())) + binding!!.detailDurationView.setBackgroundColor( + ContextCompat.getColor(activity, R.color.duration_background_color) + ) + binding!!.detailDurationView.animate(true, 100) + } else if (info.getStreamType() == StreamType.LIVE_STREAM) { + binding!!.detailDurationView.setText(R.string.duration_live) + binding!!.detailDurationView.setBackgroundColor( + ContextCompat.getColor(activity, R.color.live_duration_background_color) + ) + binding!!.detailDurationView.animate(true, 100) + } else { + binding!!.detailDurationView.setVisibility(View.GONE) + } + + binding!!.detailTitleRootLayout.setClickable(true) + binding!!.detailToggleSecondaryControlsView.setRotation(0f) + binding!!.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE) + binding!!.detailSecondaryControlPanel.setVisibility(View.GONE) + + checkUpdateProgressInfo(info) + loadDetailsThumbnail( + binding!!.detailThumbnailImageView, + info.getThumbnails() + ) + ExtractorHelper.showMetaInfoInTextView( + info.getMetaInfo(), binding!!.detailMetaInfoTextView, + binding!!.detailMetaInfoSeparator, disposables + ) + + if (!this.isPlayerAvailable || player!!.isStopped()) { + updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()) + } + + if (!info.getErrors().isEmpty()) { + // Bandcamp fan pages are not yet supported and thus a ContentNotAvailableException is + // thrown. This is not an error and thus should not be shown to the user. + for (throwable in info.getErrors()) { + if (throwable is ContentNotSupportedException && + "Fan pages are not supported" == throwable.message + ) { + info.getErrors().remove(throwable) + } + } + + if (!info.getErrors().isEmpty()) { + showSnackBarError( + ErrorInfo( + info.getErrors(), + UserAction.REQUESTED_STREAM, info.getUrl(), info + ) + ) + } + } + + binding!!.detailControlsDownload.setVisibility( + if (StreamTypeUtil.isLiveStream(info.getStreamType())) View.GONE else View.VISIBLE + ) + binding!!.detailControlsBackground.setVisibility( + if (info.getAudioStreams().isEmpty() && info.getVideoStreams().isEmpty()) + View.GONE + else + View.VISIBLE + ) + + val noVideoStreams = + info.getVideoStreams().isEmpty() && info.getVideoOnlyStreams().isEmpty() + binding!!.detailControlsPopup.setVisibility(if (noVideoStreams) View.GONE else View.VISIBLE) + binding!!.detailThumbnailPlayButton.setImageResource( + if (noVideoStreams) R.drawable.ic_headset_shadow else R.drawable.ic_play_arrow_shadow + ) + } + + private fun displayUploaderAsSubChannel(info: StreamInfo) { + binding!!.detailSubChannelTextView.setText(info.getUploaderName()) + binding!!.detailSubChannelTextView.setVisibility(View.VISIBLE) + binding!!.detailSubChannelTextView.setSelected(true) + + if (info.getUploaderSubscriberCount() > -1) { + binding!!.detailUploaderTextView.setText( + Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount()) + ) + binding!!.detailUploaderTextView.setVisibility(View.VISIBLE) + } else { + binding!!.detailUploaderTextView.setVisibility(View.GONE) + } + + loadAvatar( + binding!!.detailSubChannelThumbnailView, + info.getUploaderAvatars() + ) + binding!!.detailSubChannelThumbnailView.setVisibility(View.VISIBLE) + binding!!.detailUploaderThumbnailView.setVisibility(View.GONE) + } + + private fun displayBothUploaderAndSubChannel(info: StreamInfo) { + binding!!.detailSubChannelTextView.setText(info.getSubChannelName()) + binding!!.detailSubChannelTextView.setVisibility(View.VISIBLE) + binding!!.detailSubChannelTextView.setSelected(true) + + val subText = StringBuilder() + if (!TextUtils.isEmpty(info.getUploaderName())) { + subText.append( + String.format(getString(R.string.video_detail_by), info.getUploaderName()) + ) + } + if (info.getUploaderSubscriberCount() > -1) { + if (subText.length > 0) { + subText.append(Localization.DOT_SEPARATOR) + } + subText.append( + Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount()) + ) + } + + if (subText.length > 0) { + binding!!.detailUploaderTextView.setText(subText) + binding!!.detailUploaderTextView.setVisibility(View.VISIBLE) + binding!!.detailUploaderTextView.setSelected(true) + } else { + binding!!.detailUploaderTextView.setVisibility(View.GONE) + } + + loadAvatar( + binding!!.detailSubChannelThumbnailView, + info.getSubChannelAvatars() + ) + binding!!.detailSubChannelThumbnailView.setVisibility(View.VISIBLE) + loadAvatar( + binding!!.detailUploaderThumbnailView, + info.getUploaderAvatars() + ) + binding!!.detailUploaderThumbnailView.setVisibility(View.VISIBLE) + } + + fun openDownloadDialog() { + if (currentInfo == null) { + return + } + + try { + val downloadDialog = DownloadDialog(activity, currentInfo!!) + downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog") + } catch (e: Exception) { + showSnackbar( + activity, + ErrorInfo( + e, UserAction.DOWNLOAD_OPEN_DIALOG, + "Showing download dialog", currentInfo + ) + ) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Stream Results + ////////////////////////////////////////////////////////////////////////// */ + private fun checkUpdateProgressInfo(info: StreamInfo) { + if (positionSubscriber != null) { + positionSubscriber!!.dispose() + } + if (!DependentPreferenceHelper.getResumePlaybackEnabled(activity)) { + binding!!.positionView.setVisibility(View.GONE) + binding!!.detailPositionView.setVisibility(View.GONE) + return + } + val recordManager = HistoryRecordManager(requireContext()) + positionSubscriber = recordManager.loadStreamState(info) + .subscribeOn(Schedulers.io()) + .onErrorComplete() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + io.reactivex.rxjava3.functions.Consumer { state: StreamStateEntity? -> + updatePlaybackProgress( + state!!.getProgressMillis(), info.getDuration() * 1000 + ) + }, + io.reactivex.rxjava3.functions.Consumer { e: Throwable? -> }, + Action { + binding!!.positionView.setVisibility(View.GONE) + binding!!.detailPositionView.setVisibility(View.GONE) + } + ) + } + + private fun updatePlaybackProgress(progress: Long, duration: Long) { + if (!DependentPreferenceHelper.getResumePlaybackEnabled(activity)) { + return + } + val progressSeconds = TimeUnit.MILLISECONDS.toSeconds(progress).toInt() + val durationSeconds = TimeUnit.MILLISECONDS.toSeconds(duration).toInt() + // If the old and the new progress values have a big difference then use animation. + // Otherwise don't because it affects CPU + val progressDifference = abs( + ( + binding!!.positionView.getProgress() - + progressSeconds + ).toDouble() + ).toInt() + binding!!.positionView.setMax(durationSeconds) + if (progressDifference > 2) { + binding!!.positionView.setProgressAnimated(progressSeconds) + } else { + binding!!.positionView.setProgress(progressSeconds) + } + val position = Localization.getDurationString(progressSeconds.toLong()) + if (position !== binding!!.detailPositionView.getText()) { + binding!!.detailPositionView.setText(position) + } + if (binding!!.positionView.getVisibility() != View.VISIBLE) { + binding!!.positionView.animate(true, 100) + binding!!.detailPositionView.animate(true, 100) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Player event listener + ////////////////////////////////////////////////////////////////////////// */ + override fun onViewCreated() { + tryAddVideoPlayerView() + } + + override fun onQueueUpdate(queue: PlayQueue) { + playQueue = queue + if (DEBUG) { + Log.d( + TAG, + ( + "onQueueUpdate() called with: serviceId = [" + + serviceId + "], url = [" + url + "], name = [" + + title + "], playQueue = [" + playQueue + "]" + ) + ) + } + + // Register broadcast receiver to listen to playQueue changes + // and hide the overlayPlayQueueButton when the playQueue is empty / destroyed. + if (playQueue != null && playQueue!!.getBroadcastReceiver() != null) { + playQueue!!.getBroadcastReceiver()!!.subscribe( + io.reactivex.rxjava3.functions.Consumer { event: PlayQueueEvent? -> updateOverlayPlayQueueButtonVisibility() } + ) + } + + // This should be the only place where we push data to stack. + // It will allow to have live instance of PlayQueue with actual information about + // deleted/added items inside Channel/Playlist queue and makes possible to have + // a history of played items + val stackPeek: StackItem? = stack.peek() + if (stackPeek != null && stackPeek.getPlayQueue() != queue) { + val playQueueItem = queue.getItem() + if (playQueueItem != null) { + stack.push( + StackItem( + playQueueItem.getServiceId(), playQueueItem.getUrl(), + playQueueItem.getTitle(), queue + ) + ) + return + } // else continue below + } + + val stackWithQueue = findQueueInStack(queue) + if (stackWithQueue != null) { + // On every MainPlayer service's destroy() playQueue gets disposed and + // no longer able to track progress. That's why we update our cached disposed + // queue with the new one that is active and have the same history. + // Without that the cached playQueue will have an old recovery position + stackWithQueue.setPlayQueue(queue) + } + } + + override fun onPlaybackUpdate( + state: Int, + repeatMode: Int, + shuffled: Boolean, + parameters: PlaybackParameters? + ) { + setOverlayPlayPauseImage(player != null && player!!.isPlaying()) + + if (state == Player.STATE_PLAYING) { + if (binding!!.positionView.getAlpha() != 1.0f && player!!.getPlayQueue() != null && player!!.getPlayQueue()!! + .getItem() != null && player!!.getPlayQueue()!!.getItem()!!.getUrl() == url + ) { + binding!!.positionView.animate(true, 100) + binding!!.detailPositionView.animate(true, 100) + } + } + } + + override fun onProgressUpdate( + currentProgress: Int, + duration: Int, + bufferPercent: Int + ) { + // Progress updates every second even if media is paused. It's useless until playing + if (!player!!.isPlaying() || playQueue == null) { + return + } + + if (player!!.getPlayQueue()!!.getItem()!!.getUrl() == url) { + updatePlaybackProgress(currentProgress.toLong(), duration.toLong()) + } + } + + override fun onMetadataUpdate(info: StreamInfo, queue: PlayQueue) { + val item = findQueueInStack(queue) + if (item != null) { + // When PlayQueue can have multiple streams (PlaylistPlayQueue or ChannelPlayQueue) + // every new played stream gives new title and url. + // StackItem contains information about first played stream. Let's update it here + item.setTitle(info.getName()) + item.setUrl(info.getUrl()) + } + // They are not equal when user watches something in popup while browsing in fragment and + // then changes screen orientation. In that case the fragment will set itself as + // a service listener and will receive initial call to onMetadataUpdate() + if (queue != playQueue) { + return + } + + updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()) + if (currentInfo != null && info.getUrl() == currentInfo!!.getUrl()) { + return + } + + currentInfo = info + setInitialData(info.getServiceId(), info.getUrl(), info.getName(), queue) + setAutoPlay(false) + // Delay execution just because it freezes the main thread, and while playing + // next/previous video you see visual glitches + // (when non-vertical video goes after vertical video) + prepareAndHandleInfoIfNeededAfterDelay(info, true, 200) + } + + override fun onPlayerError(error: PlaybackException?, isCatchableException: Boolean) { + if (!isCatchableException) { + // Properly exit from fullscreen + toggleFullscreenIfInFullscreenMode() + hideMainPlayerOnLoadingNewStream() + } + } + + override fun onServiceStopped() { + // the binding could be null at this point, if the app is finishing + if (binding != null) { + setOverlayPlayPauseImage(false) + if (currentInfo != null) { + updateOverlayData( + currentInfo!!.getName(), + currentInfo!!.getUploaderName(), + currentInfo!!.getThumbnails() + ) + } + updateOverlayPlayQueueButtonVisibility() + } + } + + override fun onFullscreenStateChanged(fullscreen: Boolean) { + setupBrightness() + if (!this.isPlayerAndPlayerServiceAvailable || player!!.UIs() + .getOpt(MainPlayerUi::class.java).isEmpty() || + this.root.map(Function { obj: View? -> obj!!.getParent() }).isEmpty() + ) { + return + } + + if (fullscreen) { + hideSystemUiIfNeeded() + binding!!.overlayPlayPauseButton.requestFocus() + } else { + showSystemUi() + } + + if (binding!!.relatedItemsLayout != null) { + binding!!.relatedItemsLayout!!.setVisibility(if (fullscreen) View.GONE else View.VISIBLE) + } + scrollToTop() + + tryAddVideoPlayerView() + } + + override fun onScreenRotationButtonClicked() { + // In tablet user experience will be better if screen will not be rotated + // from landscape to portrait every time. + // Just turn on fullscreen mode in landscape orientation + // or portrait & unlocked global orientation + val isLandscape = DeviceUtils.isLandscape(requireContext()) + if (DeviceUtils.isTablet(activity) && + (!PlayerHelper.globalScreenOrientationLocked(activity) || isLandscape) + ) { + player!!.UIs().getOpt(MainPlayerUi::class.java) + .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.toggleFullscreen() }) + return + } + + val newOrientation = if (isLandscape) + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + else + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + + activity.setRequestedOrientation(newOrientation) + } + + /* + * Will scroll down to description view after long click on moreOptionsButton + * */ + override fun onMoreOptionsLongClicked() { + val params = + binding!!.appBarLayout.getLayoutParams() as CoordinatorLayout.LayoutParams + val behavior = params.getBehavior() as AppBarLayout.Behavior? + val valueAnimator = ValueAnimator + .ofInt(0, -binding!!.playerPlaceholder.getHeight()) + valueAnimator.setInterpolator(DecelerateInterpolator()) + valueAnimator.addUpdateListener( + AnimatorUpdateListener { animation: ValueAnimator? -> + behavior!!.setTopAndBottomOffset(animation!!.getAnimatedValue() as Int) + binding!!.appBarLayout.requestLayout() + } + ) + valueAnimator.setInterpolator(DecelerateInterpolator()) + valueAnimator.setDuration(500) + valueAnimator.start() + } + + /*////////////////////////////////////////////////////////////////////////// + // Player related utils + ////////////////////////////////////////////////////////////////////////// */ + private fun showSystemUi() { + if (DEBUG) { + Log.d(TAG, "showSystemUi() called") + } + + if (activity == null) { + return + } + + // Prevent jumping of the player on devices with cutout + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + activity.getWindow().getAttributes().layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT + } + activity.getWindow().getDecorView().setSystemUiVisibility(0) + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + activity.getWindow().setStatusBarColor( + ThemeHelper.resolveColorFromAttr( + requireContext(), android.R.attr.colorPrimary + ) + ) + } + + private fun hideSystemUi() { + if (DEBUG) { + Log.d(TAG, "hideSystemUi() called") + } + + if (activity == null) { + return + } + + // Prevent jumping of the player on devices with cutout + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + activity.getWindow().getAttributes().layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } + var visibility = ( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + ) + + // In multiWindow mode status bar is not transparent for devices with cutout + // if I include this flag. So without it is better in this case + val isInMultiWindow = DeviceUtils.isInMultiWindow(activity) + if (!isInMultiWindow) { + visibility = visibility or View.SYSTEM_UI_FLAG_FULLSCREEN + } + activity.getWindow().getDecorView().setSystemUiVisibility(visibility) + + if (isInMultiWindow || this.isFullscreen) { + activity.getWindow().setStatusBarColor(Color.TRANSPARENT) + activity.getWindow().setNavigationBarColor(Color.TRANSPARENT) + } + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + } + + // Listener implementation + override fun hideSystemUiIfNeeded() { + if (this.isFullscreen && + bottomSheetBehavior!!.getState() == BottomSheetBehavior.STATE_EXPANDED + ) { + hideSystemUi() + } + } + + private val isFullscreen: Boolean + get() = this.isPlayerAvailable && player!!.UIs() + .getOpt(VideoPlayerUi::class.java) + .map(Function { obj: VideoPlayerUi? -> obj!!.isFullscreen() }) + .orElse(false) + + private fun playerIsNotStopped(): Boolean { + return this.isPlayerAvailable && !player!!.isStopped() + } + + private fun restoreDefaultBrightness() { + val lp = activity.getWindow().getAttributes() + if (lp.screenBrightness == -1f) { + return + } + + // Restore the old brightness when fragment.onPause() called or + // when a player is in portrait + lp.screenBrightness = -1f + activity.getWindow().setAttributes(lp) + } + + private fun setupBrightness() { + if (activity == null) { + return + } + + val lp = activity.getWindow().getAttributes() + if (!this.isFullscreen || bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) { + // Apply system brightness when the player is not in fullscreen + restoreDefaultBrightness() + } else { + // Do not restore if user has disabled brightness gesture + if (( + PlayerHelper.getActionForRightGestureSide(activity) + != getString(R.string.brightness_control_key) + ) && ( + PlayerHelper.getActionForLeftGestureSide( + activity + ) + != getString(R.string.brightness_control_key) + ) + ) { + return + } + // Restore already saved brightness level + val brightnessLevel = PlayerHelper.getScreenBrightness(activity) + if (brightnessLevel == lp.screenBrightness) { + return + } + lp.screenBrightness = brightnessLevel + activity.getWindow().setAttributes(lp) + } + } + + /** + * Make changes to the UI to accommodate for better usability on bigger screens such as TVs + * or in Android's desktop mode (DeX etc). + */ + private fun accommodateForTvAndDesktopMode() { + if (DeviceUtils.isTv(getContext())) { + // remove ripple effects from detail controls + val transparent = ContextCompat.getColor( + requireContext(), + R.color.transparent_background_color + ) + binding!!.detailControlsPlaylistAppend.setBackgroundColor(transparent) + binding!!.detailControlsBackground.setBackgroundColor(transparent) + binding!!.detailControlsPopup.setBackgroundColor(transparent) + binding!!.detailControlsDownload.setBackgroundColor(transparent) + binding!!.detailControlsShare.setBackgroundColor(transparent) + binding!!.detailControlsOpenInBrowser.setBackgroundColor(transparent) + binding!!.detailControlsPlayWithKodi.setBackgroundColor(transparent) + } + if (DeviceUtils.isDesktopMode(getContext()!!)) { + // Remove the "hover" overlay (since it is visible on all mouse events and interferes + // with the video content being played) + binding!!.detailThumbnailRootLayout.setForeground(null) + } + } + + private fun checkLandscape() { + if ((!player!!.isPlaying() && player!!.getPlayQueue() !== playQueue) || + player!!.getPlayQueue() == null + ) { + setAutoPlay(true) + } + + player!!.UIs().getOpt(MainPlayerUi::class.java) + .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.checkLandscape() }) + // Let's give a user time to look at video information page if video is not playing + if (PlayerHelper.globalScreenOrientationLocked(activity) && !player!!.isPlaying()) { + player!!.play() + } + } + + /* + * Means that the player fragment was swiped away via BottomSheetLayout + * and is empty but ready for any new actions. See cleanUp() + * */ + private fun wasCleared(): Boolean { + return url == null + } + + private fun findQueueInStack(queue: PlayQueue?): StackItem? { + var item: StackItem? = null + val iterator: MutableIterator = stack.descendingIterator() + while (iterator.hasNext()) { + val next = iterator.next() + if (next.getPlayQueue().equals(queue)) { + item = next + break + } + } + return item + } + + private fun replaceQueueIfUserConfirms(onAllow: Runnable) { + val activeQueue = if (this.isPlayerAvailable) player!!.getPlayQueue() else null + + // Player will have STATE_IDLE when a user pressed back button + if (PlayerHelper.isClearingQueueConfirmationRequired(activity) && + playerIsNotStopped() && + activeQueue != playQueue + ) { + showClearingQueueConfirmation(onAllow) + } else { + onAllow.run() + } + } + + private fun showClearingQueueConfirmation(onAllow: Runnable) { + AlertDialog.Builder(activity) + .setTitle(R.string.clear_queue_confirmation_description) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton( + R.string.ok, + DialogInterface.OnClickListener { dialog: DialogInterface?, which: Int -> + onAllow.run() + dialog!!.dismiss() + } + ) + .show() + } + + private fun showExternalVideoPlaybackDialog() { + if (currentInfo == null) { + return + } + + val builder = AlertDialog.Builder(activity) + builder.setTitle(R.string.select_quality_external_players) + builder.setNeutralButton( + R.string.open_in_browser, + DialogInterface.OnClickListener { dialog: DialogInterface?, i: Int -> + ShareUtils.openUrlInBrowser( + requireActivity(), + url + ) + } + ) + + val videoStreamsForExternalPlayers = + ListHelper.getSortedStreamVideosList( + activity, + ListHelper.getUrlAndNonTorrentStreams(currentInfo!!.getVideoStreams()), + ListHelper.getUrlAndNonTorrentStreams(currentInfo!!.getVideoOnlyStreams()), + false, + false + ) + + if (videoStreamsForExternalPlayers.isEmpty()) { + builder.setMessage(R.string.no_video_streams_available_for_external_players) + builder.setPositiveButton(R.string.ok, null) + } else { + val selectedVideoStreamIndexForExternalPlayers = + ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers) + val resolutions = videoStreamsForExternalPlayers.stream() + .map { obj: VideoStream? -> obj!!.getResolution() } + .toArray { _Dummy_.__Array__() } + + builder.setSingleChoiceItems( + resolutions, selectedVideoStreamIndexForExternalPlayers, + null + ) + builder.setNegativeButton(R.string.cancel, null) + builder.setPositiveButton( + R.string.ok, + DialogInterface.OnClickListener { dialog: DialogInterface?, i: Int -> + val index = (dialog as AlertDialog).getListView().getCheckedItemPosition() + // We don't have to manage the index validity because if there is no stream + // available for external players, this code will be not executed and if there is + // no stream which matches the default resolution, 0 is returned by + // ListHelper.getDefaultResolutionIndex. + // The index cannot be outside the bounds of the list as its always between 0 and + // the list size - 1, . + startOnExternalPlayer( + activity, currentInfo!!, + videoStreamsForExternalPlayers.get(index)!! + ) + } + ) + } + builder.show() + } + + private fun showExternalAudioPlaybackDialog() { + if (currentInfo == null) { + return + } + + val audioStreams = ListHelper.getUrlAndNonTorrentStreams( + currentInfo!!.getAudioStreams() + ) + val audioTracks = + ListHelper.getFilteredAudioStreams(activity, audioStreams) + + if (audioTracks.isEmpty()) { + Toast.makeText( + activity, R.string.no_audio_streams_available_for_external_players, + Toast.LENGTH_SHORT + ).show() + } else if (audioTracks.size == 1) { + startOnExternalPlayer(activity, currentInfo!!, audioTracks.get(0)!!) + } else { + val selectedAudioStream = + ListHelper.getDefaultAudioFormat(activity, audioTracks) + val trackNames = audioTracks.stream() + .map { audioStream: AudioStream? -> + Localization.audioTrackName( + activity, + audioStream + ) + } + .toArray { _Dummy_.__Array__() } + + AlertDialog.Builder(activity) + .setTitle(R.string.select_audio_track_external_players) + .setNeutralButton( + R.string.open_in_browser, + DialogInterface.OnClickListener { dialog: DialogInterface?, i: Int -> + ShareUtils.openUrlInBrowser( + requireActivity(), + url + ) + } + ) + .setSingleChoiceItems(trackNames, selectedAudioStream, null) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton( + R.string.ok, + DialogInterface.OnClickListener { dialog: DialogInterface?, i: Int -> + val index = (dialog as AlertDialog).getListView() + .getCheckedItemPosition() + startOnExternalPlayer(activity, currentInfo!!, audioTracks.get(index)!!) + } + ) + .show() + } + } + + /* + * Remove unneeded information while waiting for a next task + * */ + private fun cleanUp() { + // New beginning + stack.clear() + if (currentWorker != null) { + currentWorker!!.dispose() + } + playerHolder.stopService() + setInitialData(0, null, "", null) + currentInfo = null + updateOverlayData(null, null, mutableListOf()) + } + + /*////////////////////////////////////////////////////////////////////////// + // Bottom mini player + ////////////////////////////////////////////////////////////////////////// */ + /** + * That's for Android TV support. Move focus from main fragment to the player or back + * based on what is currently selected + * + * @param toMain if true than the main fragment will be focused or the player otherwise + */ + private fun moveFocusToMainFragment(toMain: Boolean) { + setupBrightness() + val mainFragment = requireActivity().findViewById(R.id.fragment_holder) + // Hamburger button steels a focus even under bottomSheet + val toolbar = requireActivity().findViewById(R.id.toolbar) + val afterDescendants = ViewGroup.FOCUS_AFTER_DESCENDANTS + val blockDescendants = ViewGroup.FOCUS_BLOCK_DESCENDANTS + if (toMain) { + mainFragment.setDescendantFocusability(afterDescendants) + toolbar.setDescendantFocusability(afterDescendants) + (requireView() as ViewGroup).setDescendantFocusability(blockDescendants) + // Only focus the mainFragment if the mainFragment (e.g. search-results) + // or the toolbar (e.g. Textfield for search) don't have focus. + // This was done to fix problems with the keyboard input, see also #7490 + if (!mainFragment.hasFocus() && !toolbar.hasFocus()) { + mainFragment.requestFocus() + } + } else { + mainFragment.setDescendantFocusability(blockDescendants) + toolbar.setDescendantFocusability(blockDescendants) + (requireView() as ViewGroup).setDescendantFocusability(afterDescendants) + // Only focus the player if it not already has focus + if (!binding!!.getRoot().hasFocus()) { + binding!!.detailThumbnailRootLayout.requestFocus() + } + } + } + + /** + * When the mini player exists the view underneath it is not touchable. + * Bottom padding should be equal to the mini player's height in this case + * + * @param showMore whether main fragment should be expanded or not + */ + private fun manageSpaceAtTheBottom(showMore: Boolean) { + val peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height) + val holder = requireActivity().findViewById(R.id.fragment_holder) + val newBottomPadding: Int + if (showMore) { + newBottomPadding = 0 + } else { + newBottomPadding = peekHeight + } + if (holder.getPaddingBottom() == newBottomPadding) { + return + } + holder.setPadding( + holder.getPaddingLeft(), + holder.getPaddingTop(), + holder.getPaddingRight(), + newBottomPadding + ) + } + + private fun setupBottomPlayer() { + val params = + binding!!.appBarLayout.getLayoutParams() as CoordinatorLayout.LayoutParams + val behavior = params.getBehavior() as AppBarLayout.Behavior? + + val bottomSheetLayout = activity.findViewById(R.id.fragment_player_holder) + bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout) + bottomSheetBehavior!!.setState(lastStableBottomSheetState) + updateBottomSheetState(lastStableBottomSheetState) + + val peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height) + if (bottomSheetState != BottomSheetBehavior.STATE_HIDDEN) { + manageSpaceAtTheBottom(false) + bottomSheetBehavior!!.setPeekHeight(peekHeight) + if (bottomSheetState == BottomSheetBehavior.STATE_COLLAPSED) { + binding!!.overlayLayout.setAlpha(MAX_OVERLAY_ALPHA) + } else if (bottomSheetState == BottomSheetBehavior.STATE_EXPANDED) { + binding!!.overlayLayout.setAlpha(0f) + setOverlayElementsClickable(false) + } + } + + bottomSheetCallback = object : BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + updateBottomSheetState(newState) + + when (newState) { + BottomSheetBehavior.STATE_HIDDEN -> { + moveFocusToMainFragment(true) + manageSpaceAtTheBottom(true) + + bottomSheetBehavior!!.setPeekHeight(0) + cleanUp() + } + + BottomSheetBehavior.STATE_EXPANDED -> { + moveFocusToMainFragment(false) + manageSpaceAtTheBottom(false) + + bottomSheetBehavior!!.setPeekHeight(peekHeight) + // Disable click because overlay buttons located on top of buttons + // from the player + setOverlayElementsClickable(false) + hideSystemUiIfNeeded() + // Conditions when the player should be expanded to fullscreen + if (DeviceUtils.isLandscape(requireContext()) && + this.isPlayerAvailable && + player!!.isPlaying() && + !this.isFullscreen && !DeviceUtils.isTablet(activity) + ) { + player!!.UIs().getOpt(MainPlayerUi::class.java) + .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.toggleFullscreen() }) + } + setOverlayLook(binding!!.appBarLayout, behavior, 1f) + } + + BottomSheetBehavior.STATE_COLLAPSED -> { + moveFocusToMainFragment(true) + manageSpaceAtTheBottom(false) + + bottomSheetBehavior!!.setPeekHeight(peekHeight) + + // Re-enable clicks + setOverlayElementsClickable(true) + if (this.isPlayerAvailable) { + player!!.UIs().getOpt(MainPlayerUi::class.java) + .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.closeItemsList() }) + } + setOverlayLook(binding!!.appBarLayout, behavior, 0f) + } + + BottomSheetBehavior.STATE_DRAGGING, BottomSheetBehavior.STATE_SETTLING -> { + if (this.isFullscreen) { + showSystemUi() + } + if (this.isPlayerAvailable) { + player!!.UIs().getOpt(MainPlayerUi::class.java).ifPresent( + Consumer { ui: MainPlayerUi? -> + if (ui!!.isControlsVisible()) { + ui.hideControls(0, 0) + } + } + ) + } + } + + BottomSheetBehavior.STATE_HALF_EXPANDED -> {} + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) { + setOverlayLook(binding!!.appBarLayout, behavior, slideOffset) + } + } + + bottomSheetBehavior!!.addBottomSheetCallback(bottomSheetCallback!!) + + // User opened a new page and the player will hide itself + activity.getSupportFragmentManager() + .addOnBackStackChangedListener( + FragmentManager.OnBackStackChangedListener { + if (bottomSheetBehavior!!.getState() == BottomSheetBehavior.STATE_EXPANDED) { + bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_COLLAPSED) + } + } + ) + } + + private fun updateOverlayPlayQueueButtonVisibility() { + val isPlayQueueEmpty = + player == null || // no player => no play queue :) + player!!.getPlayQueue() == null || player!!.getPlayQueue()!!.isEmpty() + if (binding != null) { + // binding is null when rotating the device... + binding!!.overlayPlayQueueButton.setVisibility( + if (isPlayQueueEmpty) View.GONE else View.VISIBLE + ) + } + } + + private fun updateOverlayData( + overlayTitle: String?, + uploader: String?, + thumbnails: MutableList + ) { + binding!!.overlayTitleTextView.setText(if (TextUtils.isEmpty(overlayTitle)) "" else overlayTitle) + binding!!.overlayChannelTextView.setText(if (TextUtils.isEmpty(uploader)) "" else uploader) + binding!!.overlayThumbnail.setImageDrawable(null) + CoilHelper.loadDetailsThumbnail(binding!!.overlayThumbnail, thumbnails) + } + + private fun setOverlayPlayPauseImage(playerIsPlaying: Boolean) { + val drawable = if (playerIsPlaying) + R.drawable.ic_pause + else + R.drawable.ic_play_arrow + binding!!.overlayPlayPauseButton.setImageResource(drawable) + } + + private fun setOverlayLook( + appBar: AppBarLayout, + behavior: AppBarLayout.Behavior?, + slideOffset: Float + ) { + // SlideOffset < 0 when mini player is about to close via swipe. + // Stop animation in this case + if (behavior == null || slideOffset < 0) { + return + } + binding!!.overlayLayout.setAlpha( + min( + MAX_OVERLAY_ALPHA.toDouble(), + (1 - slideOffset).toDouble() + ).toFloat() + ) + // These numbers are not special. They just do a cool transition + behavior.setTopAndBottomOffset( + (-binding!!.detailThumbnailImageView.getHeight() * 2 * (1 - slideOffset) / 3).toInt() + ) + appBar.requestLayout() + } + + private fun setOverlayElementsClickable(enable: Boolean) { + binding!!.overlayThumbnail.setClickable(enable) + binding!!.overlayThumbnail.setLongClickable(enable) + binding!!.overlayMetadataLayout.setClickable(enable) + binding!!.overlayMetadataLayout.setLongClickable(enable) + binding!!.overlayButtonsLayout.setClickable(enable) + binding!!.overlayPlayQueueButton.setClickable(enable) + binding!!.overlayPlayPauseButton.setClickable(enable) + binding!!.overlayCloseButton.setClickable(enable) + } + + val isPlayerAvailable: Boolean + // helpers to check the state of player and playerService + get() = player != null + + fun noPlayerServiceAvailable(): Boolean { + return playerService == null + } + + val isPlayerAndPlayerServiceAvailable: Boolean + get() = player != null && playerService != null + + val root: Optional + get() = Optional.ofNullable(player) + .flatMap( + Function { player1: Player? -> + player1!!.UIs().getOpt(VideoPlayerUi::class.java) + } + ) + .map( + Function { playerUi: VideoPlayerUi? -> + playerUi!!.getBinding().getRoot() + } + ) + + private fun updateBottomSheetState(newState: Int) { + bottomSheetState = newState + if (newState != BottomSheetBehavior.STATE_DRAGGING && + newState != BottomSheetBehavior.STATE_SETTLING + ) { + lastStableBottomSheetState = newState + } + } + + companion object { + const val KEY_SWITCHING_PLAYERS: String = "switching_players" + + private const val MAX_OVERLAY_ALPHA = 0.9f + private const val MAX_PLAYER_HEIGHT = 0.7f + + @JvmField + val ACTION_SHOW_MAIN_PLAYER: String = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER" + @JvmField + val ACTION_HIDE_MAIN_PLAYER: String = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER" + @JvmField + val ACTION_PLAYER_STARTED: String = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_PLAYER_STARTED" + @JvmField + val ACTION_VIDEO_FRAGMENT_RESUMED: String = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED" + @JvmField + val ACTION_VIDEO_FRAGMENT_STOPPED: String = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED" + + private const val COMMENTS_TAB_TAG = "COMMENTS" + private const val RELATED_TAB_TAG = "NEXT VIDEO" + private const val DESCRIPTION_TAB_TAG = "DESCRIPTION TAB" + private const val EMPTY_TAB_TAG = "EMPTY TAB" + + /*//////////////////////////////////////////////////////////////////////// */ + @JvmStatic + fun getInstance( + serviceId: Int, + url: String?, + name: String, + queue: PlayQueue? + ): VideoDetailFragment { + val instance = VideoDetailFragment() + instance.setInitialData(serviceId, url, name, queue) + return instance + } + + @JvmStatic + val instanceInCollapsedState: VideoDetailFragment + get() { + val instance = VideoDetailFragment() + instance.updateBottomSheetState(BottomSheetBehavior.STATE_COLLAPSED) + return instance + } + + /*////////////////////////////////////////////////////////////////////////// + // OwnStack + ////////////////////////////////////////////////////////////////////////// */ + /** + * Stack that contains the "navigation history".

+ * The peek is the current video. + */ + private var stack = LinkedList() + } +} From cc3ecd4169acfa49c4e9f8067107fa3c2486d2e9 Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Tue, 13 May 2025 18:06:49 +0200 Subject: [PATCH 09/18] VideoDetailFragment: convert to kotlin (mechanical, fixup) Mostly 1:1, I had to fix a few places where the automatic conversion did not infer the right kotlin types, and places where it tried to convert to `double` instead of using `float` like the original. Everything else is the result of automatic conversion. --- .../fragments/detail/VideoDetailFragment.kt | 55 +++++++++---------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index ad9d21481..d94f17e2f 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -112,6 +112,7 @@ import org.schabi.newpipe.util.ExtractorHelper import org.schabi.newpipe.util.InfoCache import org.schabi.newpipe.util.ListHelper import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NO_SERVICE_ID import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.PermissionHelper import org.schabi.newpipe.util.PlayButtonHelper @@ -229,7 +230,7 @@ class VideoDetailFragment : // It will do nothing if the player is not in fullscreen mode hideSystemUiIfNeeded() - val playerUi: Optional = + val playerUi: Optional = player!!.UIs().getOpt(MainPlayerUi::class.java) if (!player!!.videoPlayerSelected() && !playAfterConnect) { return @@ -469,8 +470,7 @@ class VideoDetailFragment : makeOnClickListener( Consumer { info: StreamInfo? -> if (getFM() != null && currentInfo != null) { - val fragment = getParentFragmentManager().findFragmentById - (R.id.fragment_holder) + val fragment = getParentFragmentManager().findFragmentById(R.id.fragment_holder) // commit previous pending changes to database if (fragment is LocalPlaylistFragment) { @@ -715,7 +715,7 @@ class VideoDetailFragment : View.GONE ) binding!!.detailControlsCrashThePlayer.setVisibility( - if (DEBUG && PreferenceManager.getDefaultSharedPreferences(getContext()!!) + if (DEBUG && PreferenceManager.getDefaultSharedPreferences(requireContext()) .getBoolean(getString(R.string.show_crash_the_player_key), false) ) View.VISIBLE @@ -887,11 +887,11 @@ class VideoDetailFragment : Handler(Looper.getMainLooper()).postDelayed( Runnable { if (activity == null) { - return@postDelayed + return@Runnable } // Data can already be drawn, don't spend time twice if (info.getName() == binding!!.detailVideoTitleView.getText().toString()) { - return@postDelayed + return@Runnable } prepareAndHandleInfo(info, scrollToTop) }, @@ -1296,7 +1296,7 @@ class VideoDetailFragment : removeVideoPlayerView() if (this.isAutoplayEnabled) { playerService!!.stopForImmediateReusing() - root.ifPresent(Consumer { view: View? -> view!!.setVisibility(View.GONE) }) + root.ifPresent(Consumer { view: View -> view.setVisibility(View.GONE) }) } else { playerHolder.stopService() } @@ -1373,7 +1373,7 @@ class VideoDetailFragment : Handler(Looper.getMainLooper()).post( Runnable { if (!this.isPlayerAvailable || getView() == null) { - return@post + return@Runnable } // setup the surface view height, so that it fits the video correctly setHeightThumbnail() @@ -1424,7 +1424,7 @@ class VideoDetailFragment : activity.getWindow().getDecorView() ).getHeight() setHeightThumbnail(height, metrics) - getView()!!.getViewTreeObserver().removeOnPreDrawListener(preDrawListener) + requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener) } return false } @@ -1627,11 +1627,11 @@ class VideoDetailFragment : binding!!.detailSubChannelThumbnailView.setImageBitmap(null) } - override fun handleResult(info: StreamInfo) { + override fun handleResult(info: StreamInfo?) { super.handleResult(info) currentInfo = info - setInitialData(info.getServiceId(), info.getOriginalUrl(), info.getName(), playQueue) + setInitialData(info!!.getServiceId(), info.getOriginalUrl(), info.getName(), playQueue) updateTabs(info) @@ -2277,7 +2277,7 @@ class VideoDetailFragment : binding!!.detailControlsOpenInBrowser.setBackgroundColor(transparent) binding!!.detailControlsPlayWithKodi.setBackgroundColor(transparent) } - if (DeviceUtils.isDesktopMode(getContext()!!)) { + if (DeviceUtils.isDesktopMode(requireContext())) { // Remove the "hover" overlay (since it is visible on all mouse events and interferes // with the video content being played) binding!!.detailThumbnailRootLayout.setForeground(null) @@ -2309,9 +2309,9 @@ class VideoDetailFragment : private fun findQueueInStack(queue: PlayQueue?): StackItem? { var item: StackItem? = null - val iterator: MutableIterator = stack.descendingIterator() + val iterator: MutableIterator = stack.descendingIterator() while (iterator.hasNext()) { - val next = iterator.next() + val next = iterator.next()!! if (next.getPlayQueue().equals(queue)) { item = next break @@ -2380,9 +2380,9 @@ class VideoDetailFragment : } else { val selectedVideoStreamIndexForExternalPlayers = ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers) - val resolutions = videoStreamsForExternalPlayers.stream() - .map { obj: VideoStream? -> obj!!.getResolution() } - .toArray { _Dummy_.__Array__() } + val resolutions = videoStreamsForExternalPlayers.map { + it!!.getResolution() as CharSequence + }.toTypedArray() builder.setSingleChoiceItems( resolutions, selectedVideoStreamIndexForExternalPlayers, @@ -2430,14 +2430,13 @@ class VideoDetailFragment : } else { val selectedAudioStream = ListHelper.getDefaultAudioFormat(activity, audioTracks) - val trackNames = audioTracks.stream() - .map { audioStream: AudioStream? -> + val trackNames = audioTracks + .map { audioStream: AudioStream? -> Localization.audioTrackName( activity, audioStream ) - } - .toArray { _Dummy_.__Array__() } + }.toTypedArray() AlertDialog.Builder(activity) .setTitle(R.string.select_audio_track_external_players) @@ -2476,7 +2475,7 @@ class VideoDetailFragment : playerHolder.stopService() setInitialData(0, null, "", null) currentInfo = null - updateOverlayData(null, null, mutableListOf()) + updateOverlayData(null, null, mutableListOf()) } /*////////////////////////////////////////////////////////////////////////// @@ -2588,9 +2587,9 @@ class VideoDetailFragment : hideSystemUiIfNeeded() // Conditions when the player should be expanded to fullscreen if (DeviceUtils.isLandscape(requireContext()) && - this.isPlayerAvailable && + this@VideoDetailFragment.isPlayerAvailable && player!!.isPlaying() && - !this.isFullscreen && !DeviceUtils.isTablet(activity) + !this@VideoDetailFragment.isFullscreen && !DeviceUtils.isTablet(activity) ) { player!!.UIs().getOpt(MainPlayerUi::class.java) .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.toggleFullscreen() }) @@ -2606,7 +2605,7 @@ class VideoDetailFragment : // Re-enable clicks setOverlayElementsClickable(true) - if (this.isPlayerAvailable) { + if (this@VideoDetailFragment.isPlayerAvailable) { player!!.UIs().getOpt(MainPlayerUi::class.java) .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.closeItemsList() }) } @@ -2614,10 +2613,10 @@ class VideoDetailFragment : } BottomSheetBehavior.STATE_DRAGGING, BottomSheetBehavior.STATE_SETTLING -> { - if (this.isFullscreen) { + if (this@VideoDetailFragment.isFullscreen) { showSystemUi() } - if (this.isPlayerAvailable) { + if (this@VideoDetailFragment.isPlayerAvailable) { player!!.UIs().getOpt(MainPlayerUi::class.java).ifPresent( Consumer { ui: MainPlayerUi? -> if (ui!!.isControlsVisible()) { @@ -2665,7 +2664,7 @@ class VideoDetailFragment : private fun updateOverlayData( overlayTitle: String?, uploader: String?, - thumbnails: MutableList + thumbnails: MutableList ) { binding!!.overlayTitleTextView.setText(if (TextUtils.isEmpty(overlayTitle)) "" else overlayTitle) binding!!.overlayChannelTextView.setText(if (TextUtils.isEmpty(uploader)) "" else uploader) From 38ed1da79e079e0cefcbf312570911c60042cdbb Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Wed, 1 Jan 2025 17:46:48 +0100 Subject: [PATCH 10/18] PlayerHolder: use object class to implement singleton pattern --- .../java/org/schabi/newpipe/MainActivity.java | 6 +-- .../org/schabi/newpipe/RouterActivity.java | 2 +- .../fragments/detail/VideoDetailFragment.kt | 25 +++++----- .../info_list/dialog/InfoItemDialog.java | 2 +- .../newpipe/player/helper/PlayerHolder.kt | 48 ++++++++----------- .../ui/components/items/stream/StreamMenu.kt | 5 +- .../schabi/newpipe/util/NavigationHelper.java | 8 ++-- 7 files changed, 43 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index 157511c9f..19ae63220 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -849,7 +849,7 @@ public class MainActivity extends AppCompatActivity { return; } - if (PlayerHolder.Companion.getInstance().isPlayerOpen()) { + if (PlayerHolder.INSTANCE.isPlayerOpen()) { // if the player is already open, no need for a broadcast receiver openMiniPlayerIfMissing(); } else { @@ -859,7 +859,7 @@ public class MainActivity extends AppCompatActivity { public void onReceive(final Context context, final Intent intent) { if (Objects.equals(intent.getAction(), VideoDetailFragment.ACTION_PLAYER_STARTED) - && PlayerHolder.Companion.getInstance().isPlayerOpen()) { + && PlayerHolder.INSTANCE.isPlayerOpen()) { openMiniPlayerIfMissing(); // At this point the player is added 100%, we can unregister. Other actions // are useless since the fragment will not be removed after that. @@ -874,7 +874,7 @@ public class MainActivity extends AppCompatActivity { // If the PlayerHolder is not bound yet, but the service is running, try to bind to it. // Once the connection is established, the ACTION_PLAYER_STARTED will be sent. - PlayerHolder.Companion.getInstance().tryBindIfNeeded(this); + PlayerHolder.INSTANCE.tryBindIfNeeded(this); } } diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index 27ae603c7..262006243 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -701,7 +701,7 @@ public class RouterActivity extends AppCompatActivity { } // ...the player is not running or in normal Video-mode/type - final PlayerType playerType = PlayerHolder.Companion.getInstance().getType(); + final PlayerType playerType = PlayerHolder.INSTANCE.getType(); return playerType == null || playerType == PlayerType.MAIN; } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index d94f17e2f..888843ec6 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -100,7 +100,7 @@ import org.schabi.newpipe.player.PlayerType import org.schabi.newpipe.player.event.OnKeyDownListener import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener import org.schabi.newpipe.player.helper.PlayerHelper -import org.schabi.newpipe.player.helper.PlayerHolder.Companion.getInstance +import org.schabi.newpipe.player.helper.PlayerHolder import org.schabi.newpipe.player.playqueue.PlayQueue import org.schabi.newpipe.player.playqueue.SinglePlayQueue import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent @@ -212,7 +212,6 @@ class VideoDetailFragment : private var settingsContentObserver: ContentObserver? = null private var playerService: PlayerService? = null private var player: Player? = null - private val playerHolder = getInstance() /*////////////////////////////////////////////////////////////////////////// // Service management @@ -367,9 +366,9 @@ class VideoDetailFragment : // Stop the service when user leaves the app with double back press // if video player is selected. Otherwise unbind if (activity.isFinishing() && this.isPlayerAvailable && player!!.videoPlayerSelected()) { - playerHolder.stopService() + PlayerHolder.stopService() } else { - playerHolder.setListener(null) + PlayerHolder.setListener(null) } PreferenceManager.getDefaultSharedPreferences(activity) @@ -768,10 +767,10 @@ class VideoDetailFragment : ) setupBottomPlayer() - if (!playerHolder.isBound) { + if (!PlayerHolder.isBound) { setHeightThumbnail() } else { - playerHolder.startService(false, this) + PlayerHolder.startService(false, this) } } @@ -1175,7 +1174,7 @@ class VideoDetailFragment : // See UI changes while remote playQueue changes if (!this.isPlayerAvailable) { - playerHolder.startService(false, this) + PlayerHolder.startService(false, this) } else { // FIXME Workaround #7427 player!!.setRecovery() @@ -1245,7 +1244,7 @@ class VideoDetailFragment : private fun openNormalBackgroundPlayer(append: Boolean) { // See UI changes while remote playQueue changes if (!this.isPlayerAvailable) { - playerHolder.startService(false, this) + PlayerHolder.startService(false, this) } val queue = setupPlayQueueForIntent(append) @@ -1263,7 +1262,7 @@ class VideoDetailFragment : private fun openMainPlayer() { if (noPlayerServiceAvailable()) { - playerHolder.startService(autoPlayEnabled, this) + PlayerHolder.startService(autoPlayEnabled, this) return } if (currentInfo == null) { @@ -1298,7 +1297,7 @@ class VideoDetailFragment : playerService!!.stopForImmediateReusing() root.ifPresent(Consumer { view: View -> view.setVisibility(View.GONE) }) } else { - playerHolder.stopService() + PlayerHolder.stopService() } } @@ -1551,8 +1550,8 @@ class VideoDetailFragment : bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_COLLAPSED) } // Rebound to the service if it was closed via notification or mini player - if (!playerHolder.isBound) { - playerHolder.startService( + if (!PlayerHolder.isBound) { + PlayerHolder.startService( false, this@VideoDetailFragment ) } @@ -2472,7 +2471,7 @@ class VideoDetailFragment : if (currentWorker != null) { currentWorker!!.dispose() } - playerHolder.stopService() + PlayerHolder.stopService() setInitialData(0, null, "", null) currentInfo = null updateOverlayData(null, null, mutableListOf()) diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java index 55d49b145..cbaae2834 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java @@ -252,7 +252,7 @@ public final class InfoItemDialog { * @return the current {@link Builder} instance */ public Builder addEnqueueEntriesIfNeeded() { - final PlayerHolder holder = PlayerHolder.Companion.getInstance(); + final PlayerHolder holder = PlayerHolder.INSTANCE; if (holder.isPlayQueueReady()) { addEntry(StreamDialogDefaultEntry.ENQUEUE); diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt index b3196aeb5..06b4f8bba 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt @@ -22,12 +22,19 @@ import org.schabi.newpipe.player.playqueue.PlayQueue import org.schabi.newpipe.util.NavigationHelper import java.util.function.Consumer -class PlayerHolder private constructor() { +private val DEBUG = MainActivity.DEBUG +private val TAG: String = PlayerHolder::class.java.getSimpleName() + +/** + * Singleton that manages a `PlayerService` + * and can be used to control the player instance through the service. + */ +object PlayerHolder { private var listener: PlayerServiceExtendedEventListener? = null - private val serviceConnection = PlayerServiceConnection() var isBound: Boolean = false private set + private var playerService: PlayerService? = null private val player: Player? @@ -110,7 +117,7 @@ class PlayerHolder private constructor() { val intent = Intent(context, PlayerService::class.java) intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true) ContextCompat.startForegroundService(context, intent) - serviceConnection.doPlayAfterConnect(playAfterConnect) + PlayerServiceConnection.doPlayAfterConnect(playAfterConnect) bind(context) } @@ -126,7 +133,7 @@ class PlayerHolder private constructor() { context.stopService(Intent(context, PlayerService::class.java)) } - internal inner class PlayerServiceConnection : ServiceConnection { + internal object PlayerServiceConnection : ServiceConnection { internal var playAfterConnect = false /** @@ -185,7 +192,7 @@ class PlayerHolder private constructor() { // BIND_AUTO_CREATE starts the service if it's not already running this.isBound = bind(context, Context.BIND_AUTO_CREATE) if (!this.isBound) { - context.unbindService(serviceConnection) + context.unbindService(PlayerServiceConnection) } } @@ -201,7 +208,7 @@ class PlayerHolder private constructor() { private fun bind(context: Context, flags: Int): Boolean { val serviceIntent = Intent(context, PlayerService::class.java) serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION) - return context.bindService(serviceIntent, serviceConnection, flags) + return context.bindService(serviceIntent, PlayerServiceConnection, flags) } private fun unbind(context: Context) { @@ -210,7 +217,7 @@ class PlayerHolder private constructor() { } if (this.isBound) { - context.unbindService(serviceConnection) + context.unbindService(PlayerServiceConnection) this.isBound = false stopPlayerListener() playerService = null @@ -223,18 +230,18 @@ class PlayerHolder private constructor() { // setting the player listener will take care of calling relevant callbacks if the // player in the service is (not) already active, also see playerStateListener below playerService?.setPlayerListener(playerStateListener) - this.player?.setFragmentListener(internalListener) + this.player?.setFragmentListener(HolderPlayerServiceEventListener) } private fun stopPlayerListener() { playerService?.setPlayerListener(null) - this.player?.removeFragmentListener(internalListener) + this.player?.removeFragmentListener(HolderPlayerServiceEventListener) } /** * This listener will be held by the players created by [PlayerService]. */ - private val internalListener: PlayerServiceEventListener = object : PlayerServiceEventListener { + private object HolderPlayerServiceEventListener : PlayerServiceEventListener { override fun onViewCreated() { listener?.onViewCreated() } @@ -307,26 +314,11 @@ class PlayerHolder private constructor() { // before setting its player to null l.onPlayerDisconnected() } else { - l.onPlayerConnected(player, serviceConnection.playAfterConnect) + l.onPlayerConnected(player, PlayerServiceConnection.playAfterConnect) // reset the value of playAfterConnect: if it was true before, it is now "consumed" - serviceConnection.playAfterConnect = false; - player.setFragmentListener(internalListener) + PlayerServiceConnection.playAfterConnect = false + player.setFragmentListener(HolderPlayerServiceEventListener) } } } - - companion object { - private var instance: PlayerHolder? = null - - @Synchronized - fun getInstance(): PlayerHolder { - if (instance == null) { - instance = PlayerHolder() - } - return instance!! - } - - private val DEBUG = MainActivity.DEBUG - private val TAG: String = PlayerHolder::class.java.getSimpleName() - } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt index 26d385518..7619515e7 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt @@ -28,10 +28,9 @@ fun StreamMenu( ) { val context = LocalContext.current val streamViewModel = viewModel() - val playerHolder = PlayerHolder.Companion.getInstance() DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { - if (playerHolder.isPlayQueueReady) { + if (PlayerHolder.isPlayQueueReady) { DropdownMenuItem( text = { Text(text = stringResource(R.string.enqueue_stream)) }, onClick = { @@ -42,7 +41,7 @@ fun StreamMenu( } ) - if (playerHolder.queuePosition < playerHolder.queueSize - 1) { + if (PlayerHolder.queuePosition < PlayerHolder.queueSize - 1) { DropdownMenuItem( text = { Text(text = stringResource(R.string.enqueue_next_stream)) }, onClick = { diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index 9d8d3c3b2..c71836609 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -200,7 +200,7 @@ public final class NavigationHelper { } public static void enqueueOnPlayer(final Context context, final PlayQueue queue) { - PlayerType playerType = PlayerHolder.Companion.getInstance().getType(); + PlayerType playerType = PlayerHolder.INSTANCE.getType(); if (playerType == null) { Log.e(TAG, "Enqueueing but no player is open; defaulting to background player"); playerType = PlayerType.AUDIO; @@ -211,7 +211,7 @@ public final class NavigationHelper { /* ENQUEUE NEXT */ public static void enqueueNextOnPlayer(final Context context, final PlayQueue queue) { - PlayerType playerType = PlayerHolder.Companion.getInstance().getType(); + PlayerType playerType = PlayerHolder.INSTANCE.getType(); if (playerType == null) { Log.e(TAG, "Enqueueing next but no player is open; defaulting to background player"); playerType = PlayerType.AUDIO; @@ -421,13 +421,13 @@ public final class NavigationHelper { final boolean switchingPlayers) { final boolean autoPlay; - @Nullable final PlayerType playerType = PlayerHolder.Companion.getInstance().getType(); + @Nullable final PlayerType playerType = PlayerHolder.INSTANCE.getType(); if (playerType == null) { // no player open autoPlay = PlayerHelper.isAutoplayAllowedByUser(context); } else if (switchingPlayers) { // switching player to main player - autoPlay = PlayerHolder.Companion.getInstance().isPlaying(); // keep play/pause state + autoPlay = PlayerHolder.INSTANCE.isPlaying(); // keep play/pause state } else if (playerType == PlayerType.MAIN) { // opening new stream while already playing in main player autoPlay = PlayerHelper.isAutoplayAllowedByUser(context); From 91aed1e240ac8beee8bb2b7ce8046152cd52f8e5 Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Wed, 1 Jan 2025 20:46:13 +0100 Subject: [PATCH 11/18] VideoDetailFragment: replace every getOpt() with get() --- .../fragments/detail/VideoDetailFragment.kt | 130 +++++++----------- 1 file changed, 50 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index 888843ec6..6cfb96438 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -31,7 +31,6 @@ import android.view.View import android.view.View.OnLongClickListener import android.view.View.OnTouchListener import android.view.ViewGroup -import android.view.ViewParent import android.view.ViewTreeObserver import android.view.WindowManager import android.view.animation.DecelerateInterpolator @@ -126,10 +125,8 @@ import org.schabi.newpipe.util.image.CoilHelper.loadDetailsThumbnail import java.util.LinkedList import java.util.List import java.util.Objects -import java.util.Optional import java.util.concurrent.TimeUnit import java.util.function.Consumer -import java.util.function.Function import kotlin.math.abs import kotlin.math.max import kotlin.math.min @@ -229,8 +226,8 @@ class VideoDetailFragment : // It will do nothing if the player is not in fullscreen mode hideSystemUiIfNeeded() - val playerUi: Optional = - player!!.UIs().getOpt(MainPlayerUi::class.java) + val playerUi: MainPlayerUi? = + player!!.UIs().get(MainPlayerUi::class.java) if (!player!!.videoPlayerSelected() && !playAfterConnect) { return } @@ -239,19 +236,22 @@ class VideoDetailFragment : // If the video is playing but orientation changed // let's make the video in fullscreen again checkLandscape() - } else if (playerUi.map(Function { ui: MainPlayerUi? -> ui!!.isFullscreen() && !ui.isVerticalVideo() }) - .orElse(false) && // Tablet UI has orientation-independent fullscreen + } else if (playerUi != null && + playerUi.isFullscreen() && + !playerUi.isVerticalVideo() && + // Tablet UI has orientation-independent fullscreen !DeviceUtils.isTablet(activity) ) { // Device is in portrait orientation after rotation but UI is in fullscreen. // Return back to non-fullscreen state - playerUi.ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.toggleFullscreen() }) + playerUi.toggleFullscreen() } if (playAfterConnect || ( - currentInfo != null && this.isAutoplayEnabled && - playerUi.isEmpty() + currentInfo != null && + this.isAutoplayEnabled && + playerUi == null ) ) { autoPlayEnabled = true // forcefully start playing @@ -572,8 +572,7 @@ class VideoDetailFragment : View.OnClickListener { v: View? -> if (playerIsNotStopped()) { player!!.playPause() - player!!.UIs().getOpt(VideoPlayerUi::class.java) - .ifPresent(Consumer { ui: VideoPlayerUi? -> ui!!.hideControls(0, 0) }) + player!!.UIs().get(VideoPlayerUi::class.java)?.hideControls(0, 0) showSystemUi() } else { autoPlayEnabled = true // forcefully start playing @@ -776,9 +775,7 @@ class VideoDetailFragment : override fun onKeyDown(keyCode: Int): Boolean { return this.isPlayerAvailable && - player!!.UIs().getOpt(VideoPlayerUi::class.java) - .map(Function { playerUi: VideoPlayerUi? -> playerUi!!.onKeyDown(keyCode) }) - .orElse(false) + player!!.UIs().get(VideoPlayerUi::class.java)?.onKeyDown(keyCode) == true } override fun onBackPressed(): Boolean { @@ -1137,14 +1134,11 @@ class VideoDetailFragment : // If a user watched video inside fullscreen mode and than chose another player // return to non-fullscreen mode if (this.isPlayerAvailable) { - player!!.UIs().getOpt(MainPlayerUi::class.java) - .ifPresent( - Consumer { playerUi: MainPlayerUi? -> - if (playerUi!!.isFullscreen()) { - playerUi.toggleFullscreen() - } - } - ) + player!!.UIs().get(MainPlayerUi::class.java)?.let { + if (it.isFullscreen) { + it.toggleFullscreen() + } + } } } @@ -1288,14 +1282,14 @@ class VideoDetailFragment : */ private fun hideMainPlayerOnLoadingNewStream() { val root = this.root - if (noPlayerServiceAvailable() || root.isEmpty() || !player!!.videoPlayerSelected()) { + if (noPlayerServiceAvailable() || root == null || !player!!.videoPlayerSelected()) { return } removeVideoPlayerView() if (this.isAutoplayEnabled) { playerService!!.stopForImmediateReusing() - root.ifPresent(Consumer { view: View -> view.setVisibility(View.GONE) }) + root.setVisibility(View.GONE) } else { PlayerHolder.stopService() } @@ -1376,18 +1370,16 @@ class VideoDetailFragment : } // setup the surface view height, so that it fits the video correctly setHeightThumbnail() - player!!.UIs().getOpt(MainPlayerUi::class.java) - .ifPresent( - Consumer { playerUi: MainPlayerUi? -> - // sometimes binding would be null here, even though getView() != null above u.u - if (binding != null) { - // prevent from re-adding a view multiple times - playerUi!!.removeViewFromParent() - binding!!.playerPlaceholder.addView(playerUi.getBinding().getRoot()) - playerUi.setupVideoSurfaceIfNeeded() - } - } - ) + player!!.UIs().get(MainPlayerUi::class.java)?.let { playerUi -> + val b = binding + // sometimes binding would be null here, even though getView() != null above u.u + if (b != null) { + // prevent from re-adding a view multiple times + playerUi.removeViewFromParent() + b.playerPlaceholder.addView(playerUi.getBinding().getRoot()) + playerUi.setupVideoSurfaceIfNeeded() + } + } } ) } @@ -1396,8 +1388,7 @@ class VideoDetailFragment : makeDefaultHeightForVideoPlaceholder() if (player != null) { - player!!.UIs().getOpt(VideoPlayerUi::class.java) - .ifPresent(Consumer { obj: VideoPlayerUi? -> obj!!.removeViewFromParent() }) + player!!.UIs().get(VideoPlayerUi::class.java)?.removeViewFromParent() } } @@ -1474,15 +1465,12 @@ class VideoDetailFragment : binding!!.detailThumbnailImageView.setMinimumHeight(newHeight) if (this.isPlayerAvailable) { val maxHeight = (metrics.heightPixels * MAX_PLAYER_HEIGHT).toInt() - player!!.UIs().getOpt(VideoPlayerUi::class.java) - .ifPresent( - Consumer { ui: VideoPlayerUi? -> - ui!!.getBinding().surfaceView.setHeights( - newHeight, - if (ui.isFullscreen()) newHeight else maxHeight - ) - } + player!!.UIs().get(VideoPlayerUi::class.java)?.let { + it.binding.surfaceView.setHeights( + newHeight, + if (it.isFullscreen) newHeight else maxHeight ) + } } } @@ -2065,9 +2053,9 @@ class VideoDetailFragment : override fun onFullscreenStateChanged(fullscreen: Boolean) { setupBrightness() - if (!this.isPlayerAndPlayerServiceAvailable || player!!.UIs() - .getOpt(MainPlayerUi::class.java).isEmpty() || - this.root.map(Function { obj: View? -> obj!!.getParent() }).isEmpty() + if (!this.isPlayerAndPlayerServiceAvailable || + player?.UIs()?.get(MainPlayerUi::class.java) == null || + this.root?.parent == null ) { return } @@ -2096,8 +2084,7 @@ class VideoDetailFragment : if (DeviceUtils.isTablet(activity) && (!PlayerHelper.globalScreenOrientationLocked(activity) || isLandscape) ) { - player!!.UIs().getOpt(MainPlayerUi::class.java) - .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.toggleFullscreen() }) + player!!.UIs().get(MainPlayerUi::class.java)?.toggleFullscreen() return } @@ -2203,10 +2190,8 @@ class VideoDetailFragment : } private val isFullscreen: Boolean - get() = this.isPlayerAvailable && player!!.UIs() - .getOpt(VideoPlayerUi::class.java) - .map(Function { obj: VideoPlayerUi? -> obj!!.isFullscreen() }) - .orElse(false) + get() = this.isPlayerAvailable && player?.UIs() + ?.get(VideoPlayerUi::class.java)?.isFullscreen() == true private fun playerIsNotStopped(): Boolean { return this.isPlayerAvailable && !player!!.isStopped() @@ -2290,8 +2275,7 @@ class VideoDetailFragment : setAutoPlay(true) } - player!!.UIs().getOpt(MainPlayerUi::class.java) - .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.checkLandscape() }) + player!!.UIs().get(MainPlayerUi::class.java)?.checkLandscape() // Let's give a user time to look at video information page if video is not playing if (PlayerHelper.globalScreenOrientationLocked(activity) && !player!!.isPlaying()) { player!!.play() @@ -2590,8 +2574,7 @@ class VideoDetailFragment : player!!.isPlaying() && !this@VideoDetailFragment.isFullscreen && !DeviceUtils.isTablet(activity) ) { - player!!.UIs().getOpt(MainPlayerUi::class.java) - .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.toggleFullscreen() }) + player!!.UIs().get(MainPlayerUi::class.java)?.toggleFullscreen() } setOverlayLook(binding!!.appBarLayout, behavior, 1f) } @@ -2605,8 +2588,7 @@ class VideoDetailFragment : // Re-enable clicks setOverlayElementsClickable(true) if (this@VideoDetailFragment.isPlayerAvailable) { - player!!.UIs().getOpt(MainPlayerUi::class.java) - .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.closeItemsList() }) + player!!.UIs().get(MainPlayerUi::class.java)?.closeItemsList() } setOverlayLook(binding!!.appBarLayout, behavior, 0f) } @@ -2616,13 +2598,11 @@ class VideoDetailFragment : showSystemUi() } if (this@VideoDetailFragment.isPlayerAvailable) { - player!!.UIs().getOpt(MainPlayerUi::class.java).ifPresent( - Consumer { ui: MainPlayerUi? -> - if (ui!!.isControlsVisible()) { - ui.hideControls(0, 0) - } + player!!.UIs().get(MainPlayerUi::class.java)?.let { + if (it.isControlsVisible) { + it.hideControls(0, 0) } - ) + } } } @@ -2724,18 +2704,8 @@ class VideoDetailFragment : val isPlayerAndPlayerServiceAvailable: Boolean get() = player != null && playerService != null - val root: Optional - get() = Optional.ofNullable(player) - .flatMap( - Function { player1: Player? -> - player1!!.UIs().getOpt(VideoPlayerUi::class.java) - } - ) - .map( - Function { playerUi: VideoPlayerUi? -> - playerUi!!.getBinding().getRoot() - } - ) + val root: View? + get() = player?.UIs()?.get(VideoPlayerUi::class.java)?.binding?.root private fun updateBottomSheetState(newState: Int) { bottomSheetState = newState From ed0051a3f62a488ab0fa2babb88357168367f67b Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Tue, 13 May 2025 18:53:02 +0200 Subject: [PATCH 12/18] Player: small class comment --- app/src/main/java/org/schabi/newpipe/player/Player.java | 4 ++++ 1 file changed, 4 insertions(+) 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 094032a06..49f02efeb 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -133,6 +133,10 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.SerialDisposable; +/** + * The ExoPlayer wrapper & Player business logic. + * Only instantiated once, from {@link PlayerService}. + */ public final class Player implements PlaybackListener, Listener { public static final boolean DEBUG = MainActivity.DEBUG; public static final String TAG = Player.class.getSimpleName(); From 73305414994f32e39dd20088f677a71cf61b9560 Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Tue, 13 May 2025 19:08:23 +0200 Subject: [PATCH 13/18] PlayerUIList: remove remaining uses of getOpt mediaSession is now `@NonNull`, so the getter is as well. --- .../org/schabi/newpipe/player/Player.java | 21 ++++++++++--------- .../mediasession/MediaSessionPlayerUi.java | 11 +++++++--- .../player/notification/NotificationUtil.java | 8 +++---- .../schabi/newpipe/player/ui/PlayerUiList.kt | 12 ----------- 4 files changed, 23 insertions(+), 29 deletions(-) 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 49f02efeb..57cdd081e 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -477,22 +477,23 @@ public final class Player implements PlaybackListener, Listener { } private void initUIsForCurrentPlayerType() { - if ((UIs.getOpt(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN) - || (UIs.getOpt(PopupPlayerUi.class).isPresent() + if ((UIs.get(MainPlayerUi.class) != null && playerType == PlayerType.MAIN) + || (UIs.get(PopupPlayerUi.class) != null && playerType == PlayerType.POPUP)) { // correct UI already in place return; } // try to reuse binding if possible - final PlayerBinding binding = UIs.getOpt(VideoPlayerUi.class).map(VideoPlayerUi::getBinding) - .orElseGet(() -> { - if (playerType == PlayerType.AUDIO) { - return null; - } else { - return PlayerBinding.inflate(LayoutInflater.from(context)); - } - }); + @Nullable final VideoPlayerUi ui = UIs.get(VideoPlayerUi.class); + final PlayerBinding binding; + if (ui != null) { + binding = ui.getBinding(); + } else if (playerType == PlayerType.AUDIO) { + binding = null; + } else { + binding = PlayerBinding.inflate(LayoutInflater.from(context)); + } switch (playerType) { case MAIN: 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 085da5eb7..850dd02e3 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 @@ -124,8 +124,10 @@ public class MediaSessionPlayerUi extends PlayerUi MediaButtonReceiver.handleIntent(mediaSession, intent); } - public Optional getSessionToken() { - return Optional.ofNullable(mediaSession).map(MediaSessionCompat::getSessionToken); + + @NonNull + public MediaSessionCompat.Token getSessionToken() { + return mediaSession.getSessionToken(); } @@ -138,7 +140,10 @@ public class MediaSessionPlayerUi extends PlayerUi public void play() { player.play(); // hide the player controls even if the play command came from the media session - player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); + final VideoPlayerUi ui = player.UIs().get(VideoPlayerUi.class); + if (ui != null) { + 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 5658693f2..cc3889973 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 @@ -101,10 +101,10 @@ public final class NotificationUtil { final int[] compactSlots = initializeNotificationSlots(); mediaStyle.setShowActionsInCompactView(compactSlots); } - player.UIs() - .getOpt(MediaSessionPlayerUi.class) - .flatMap(MediaSessionPlayerUi::getSessionToken) - .ifPresent(mediaStyle::setMediaSession); + @Nullable final MediaSessionPlayerUi ui = player.UIs().get(MediaSessionPlayerUi.class); + if (ui != null) { + mediaStyle.setMediaSession(ui.getSessionToken()); + } // setup notification builder builder.setStyle(mediaStyle) 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 ec0c85c93..ef9c6f3c2 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,7 +1,6 @@ package org.schabi.newpipe.player.ui import org.schabi.newpipe.util.GuardedByMutex -import java.util.Optional /** * Creates a [PlayerUiList] starting with the provided player uis. The provided player uis @@ -99,17 +98,6 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { 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 - * [Optional] 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 3f62ec7e5344c161429fadd4e0db99aaf8416d68 Mon Sep 17 00:00:00 2001 From: Stypox Date: Mon, 9 Jun 2025 15:22:17 +0200 Subject: [PATCH 14/18] Improve Kotlin converted from java in various places --- .../fragments/detail/VideoDetailFragment.kt | 1793 +++++++---------- .../schabi/newpipe/player/PlayerService.kt | 102 +- .../newpipe/player/helper/PlayerHolder.kt | 30 +- .../player/mediabrowser/MediaBrowserImpl.kt | 17 +- .../schabi/newpipe/player/ui/PlayerUiList.kt | 14 +- 5 files changed, 750 insertions(+), 1206 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index 6cfb96438..db87f37dc 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -1,12 +1,10 @@ package org.schabi.newpipe.fragments.detail import android.animation.ValueAnimator -import android.animation.ValueAnimator.AnimatorUpdateListener import android.annotation.SuppressLint import android.app.Activity import android.content.BroadcastReceiver import android.content.Context -import android.content.DialogInterface import android.content.Intent import android.content.IntentFilter import android.content.SharedPreferences @@ -15,7 +13,6 @@ import android.content.pm.ActivityInfo import android.database.ContentObserver import android.graphics.Color import android.graphics.Rect -import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Handler @@ -31,7 +28,7 @@ import android.view.View import android.view.View.OnLongClickListener import android.view.View.OnTouchListener import android.view.ViewGroup -import android.view.ViewTreeObserver +import android.view.ViewTreeObserver.OnPreDrawListener import android.view.WindowManager import android.view.animation.DecelerateInterpolator import android.widget.FrameLayout @@ -44,7 +41,10 @@ import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.Toolbar import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentManager +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.core.os.postDelayed +import androidx.core.view.isVisible import androidx.preference.PreferenceManager import coil3.util.CoilUtils.dispose import com.evernote.android.state.State @@ -57,12 +57,10 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCa import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.Disposable -import io.reactivex.rxjava3.functions.Action import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.App import org.schabi.newpipe.R import org.schabi.newpipe.database.stream.model.StreamEntity -import org.schabi.newpipe.database.stream.model.StreamStateEntity import org.schabi.newpipe.databinding.FragmentVideoDetailBinding import org.schabi.newpipe.download.DownloadDialog import org.schabi.newpipe.error.ErrorInfo @@ -75,12 +73,10 @@ import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException import org.schabi.newpipe.extractor.exceptions.ExtractionException -import org.schabi.newpipe.extractor.stream.AudioStream import org.schabi.newpipe.extractor.stream.Stream import org.schabi.newpipe.extractor.stream.StreamExtractor import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.extractor.stream.StreamType -import org.schabi.newpipe.extractor.stream.VideoStream import org.schabi.newpipe.fragments.BackPressable import org.schabi.newpipe.fragments.BaseStateFragment import org.schabi.newpipe.fragments.EmptyFragment @@ -102,7 +98,6 @@ import org.schabi.newpipe.player.helper.PlayerHelper import org.schabi.newpipe.player.helper.PlayerHolder import org.schabi.newpipe.player.playqueue.PlayQueue import org.schabi.newpipe.player.playqueue.SinglePlayQueue -import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent import org.schabi.newpipe.player.ui.MainPlayerUi import org.schabi.newpipe.player.ui.VideoPlayerUi import org.schabi.newpipe.util.DependentPreferenceHelper @@ -114,40 +109,53 @@ import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.NO_SERVICE_ID import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.PermissionHelper +import org.schabi.newpipe.util.PermissionHelper.checkStoragePermissions import org.schabi.newpipe.util.PlayButtonHelper import org.schabi.newpipe.util.StreamTypeUtil import org.schabi.newpipe.util.ThemeHelper import org.schabi.newpipe.util.external_communication.KoreUtils import org.schabi.newpipe.util.external_communication.ShareUtils -import org.schabi.newpipe.util.image.CoilHelper import org.schabi.newpipe.util.image.CoilHelper.loadAvatar import org.schabi.newpipe.util.image.CoilHelper.loadDetailsThumbnail import java.util.LinkedList -import java.util.List import java.util.Objects import java.util.concurrent.TimeUnit -import java.util.function.Consumer import kotlin.math.abs import kotlin.math.max import kotlin.math.min class VideoDetailFragment : - BaseStateFragment(), BackPressable, PlayerServiceExtendedEventListener, OnKeyDownListener { + + // stream info + @JvmField @State var serviceId: Int = NO_SERVICE_ID + @JvmField @State var title: String = "" + @JvmField @State var url: String? = null + private var currentInfo: StreamInfo? = null + + // player objects + private var playQueue: PlayQueue? = null + @JvmField @State var autoPlayEnabled: Boolean = true + private var playerService: PlayerService? = null + private var player: Player? = null + + // views + // can't make this lateinit because it needs to be set to null when the view is destroyed + private var nullableBinding: FragmentVideoDetailBinding? = null + private val binding: FragmentVideoDetailBinding get() = nullableBinding!! + private lateinit var pageAdapter: TabAdapter + private var settingsContentObserver: ContentObserver? = null + // tabs private var showComments = false private var showRelatedItems = false private var showDescription = false - private var selectedTabTag: String? = null - - @AttrRes - val tabIcons: MutableList = ArrayList() - - @StringRes - val tabContentDescriptions: MutableList = ArrayList() + private lateinit var selectedTabTag: String + @AttrRes val tabIcons = ArrayList() + @StringRes val tabContentDescriptions = ArrayList() private var tabSettingsChanged = false private var lastAppBarVerticalOffset = Int.Companion.MAX_VALUE // prevents useless updates @@ -165,51 +173,18 @@ class VideoDetailFragment : } } - @JvmField - @State - var serviceId: Int = NO_SERVICE_ID + // bottom sheet + @JvmField @State var bottomSheetState: Int = BottomSheetBehavior.STATE_EXPANDED + @JvmField @State var lastStableBottomSheetState: Int = BottomSheetBehavior.STATE_EXPANDED + private lateinit var bottomSheetBehavior: BottomSheetBehavior + private lateinit var bottomSheetCallback: BottomSheetCallback + private lateinit var broadcastReceiver: BroadcastReceiver - @JvmField - @State - var title: String = "" - - @JvmField - @State - var url: String? = null - private var playQueue: PlayQueue? = null - - @JvmField - @State - var bottomSheetState: Int = BottomSheetBehavior.STATE_EXPANDED - - @JvmField - @State - var lastStableBottomSheetState: Int = BottomSheetBehavior.STATE_EXPANDED - - @JvmField - @State - var autoPlayEnabled: Boolean = true - - private var currentInfo: StreamInfo? = null + // disposables private var currentWorker: Disposable? = null private val disposables = CompositeDisposable() private var positionSubscriber: Disposable? = null - private var bottomSheetBehavior: BottomSheetBehavior? = null - private var bottomSheetCallback: BottomSheetCallback? = null - private var broadcastReceiver: BroadcastReceiver? = null - - /*////////////////////////////////////////////////////////////////////////// - // Views - ////////////////////////////////////////////////////////////////////////// */ - private var binding: FragmentVideoDetailBinding? = null - - private var pageAdapter: TabAdapter? = null - - private var settingsContentObserver: ContentObserver? = null - private var playerService: PlayerService? = null - private var player: Player? = null - /*////////////////////////////////////////////////////////////////////////// // Service management ////////////////////////////////////////////////////////////////////////// */ @@ -217,18 +192,14 @@ class VideoDetailFragment : playerService = connectedPlayerService } - override fun onPlayerConnected( - connectedPlayer: Player, - playAfterConnect: Boolean - ) { + override fun onPlayerConnected(connectedPlayer: Player, playAfterConnect: Boolean) { player = connectedPlayer // It will do nothing if the player is not in fullscreen mode hideSystemUiIfNeeded() - val playerUi: MainPlayerUi? = - player!!.UIs().get(MainPlayerUi::class.java) - if (!player!!.videoPlayerSelected() && !playAfterConnect) { + val mainUi = player?.UIs()?.get(MainPlayerUi::class) + if (player?.videoPlayerSelected() != true && !playAfterConnect) { return } @@ -236,24 +207,16 @@ class VideoDetailFragment : // If the video is playing but orientation changed // let's make the video in fullscreen again checkLandscape() - } else if (playerUi != null && - playerUi.isFullscreen() && - !playerUi.isVerticalVideo() && + } else if (mainUi != null && mainUi.isFullscreen && !mainUi.isVerticalVideo && // Tablet UI has orientation-independent fullscreen !DeviceUtils.isTablet(activity) ) { // Device is in portrait orientation after rotation but UI is in fullscreen. // Return back to non-fullscreen state - playerUi.toggleFullscreen() + mainUi.toggleFullscreen() } - if (playAfterConnect || - ( - currentInfo != null && - this.isAutoplayEnabled && - playerUi == null - ) - ) { + if (playAfterConnect || (currentInfo != null && this.isAutoplayEnabled && mainUi == null)) { autoPlayEnabled = true // forcefully start playing openVideoPlayerAutoFullscreen() } @@ -263,7 +226,7 @@ class VideoDetailFragment : override fun onPlayerDisconnected() { player = null // the binding could be null at this point, if the app is finishing - if (binding != null) { + if (nullableBinding != null) { restoreDefaultBrightness() } } @@ -284,7 +247,7 @@ class VideoDetailFragment : showDescription = prefs.getBoolean(getString(R.string.show_description_key), true) selectedTabTag = prefs.getString( getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG - ) + )!! prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener) setupBroadcastReceiver() @@ -296,7 +259,7 @@ class VideoDetailFragment : } } } - activity.getContentResolver().registerContentObserver( + activity.contentResolver.registerContentObserver( Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, settingsContentObserver!! ) @@ -307,8 +270,9 @@ class VideoDetailFragment : container: ViewGroup?, savedInstanceState: Bundle? ): View { - binding = FragmentVideoDetailBinding.inflate(inflater, container, false) - return binding!!.getRoot() + val newBinding = FragmentVideoDetailBinding.inflate(inflater, container, false) + nullableBinding = newBinding + return newBinding.getRoot() } override fun onPause() { @@ -317,13 +281,12 @@ class VideoDetailFragment : currentWorker!!.dispose() } restoreDefaultBrightness() - PreferenceManager.getDefaultSharedPreferences(requireContext()) - .edit() - .putString( + PreferenceManager.getDefaultSharedPreferences(requireContext()).edit { + putString( getString(R.string.stream_info_selected_tab_key), - pageAdapter!!.getItemTitle(binding!!.viewPager.getCurrentItem()) + pageAdapter.getItemTitle(binding.viewPager.currentItem) ) - .apply() + } } override fun onResume() { @@ -355,7 +318,7 @@ class VideoDetailFragment : override fun onStop() { super.onStop() - if (!activity.isChangingConfigurations()) { + if (!activity.isChangingConfigurations) { activity.sendBroadcast(Intent(ACTION_VIDEO_FRAGMENT_STOPPED)) } } @@ -365,7 +328,7 @@ class VideoDetailFragment : // Stop the service when user leaves the app with double back press // if video player is selected. Otherwise unbind - if (activity.isFinishing() && this.isPlayerAvailable && player!!.videoPlayerSelected()) { + if (activity.isFinishing && player?.videoPlayerSelected() == true) { PlayerHolder.stopService() } else { PlayerHolder.setListener(null) @@ -374,7 +337,7 @@ class VideoDetailFragment : PreferenceManager.getDefaultSharedPreferences(activity) .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) activity.unregisterReceiver(broadcastReceiver) - activity.getContentResolver().unregisterContentObserver(settingsContentObserver!!) + activity.contentResolver.unregisterContentObserver(settingsContentObserver!!) if (positionSubscriber != null) { positionSubscriber!!.dispose() @@ -385,9 +348,9 @@ class VideoDetailFragment : disposables.clear() positionSubscriber = null currentWorker = null - bottomSheetBehavior!!.removeBottomSheetCallback(bottomSheetCallback!!) + bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback) - if (activity.isFinishing()) { + if (activity.isFinishing) { playQueue = null currentInfo = null stack = LinkedList() @@ -396,7 +359,7 @@ class VideoDetailFragment : override fun onDestroyView() { super.onDestroyView() - binding = null + nullableBinding = null } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { @@ -411,7 +374,7 @@ class VideoDetailFragment : Log.e(TAG, "ReCaptcha failed") } } else { - Log.e(TAG, "Request code from activity not supported [" + requestCode + "]") + Log.e(TAG, "Request code from activity not supported [$requestCode]") } } @@ -419,275 +382,175 @@ class VideoDetailFragment : // OnClick ////////////////////////////////////////////////////////////////////////// */ private fun setOnClickListeners() { - binding!!.detailTitleRootLayout.setOnClickListener(View.OnClickListener { v: View? -> toggleTitleAndSecondaryControls() }) - binding!!.detailUploaderRootLayout.setOnClickListener( - makeOnClickListener( - Consumer { info: StreamInfo? -> - if (TextUtils.isEmpty( - info!!.getSubChannelUrl() - ) - ) { - if (!TextUtils.isEmpty(info.getUploaderUrl())) { - openChannel(info.getUploaderUrl(), info.getUploaderName()) - } - - if (DEBUG) { - Log.i(TAG, "Can't open sub-channel because we got no channel URL") - } - } else { - openChannel(info.getSubChannelUrl(), info.getSubChannelName()) + binding.detailTitleRootLayout.setOnClickListener { toggleTitleAndSecondaryControls() } + binding.detailUploaderRootLayout.setOnClickListener( + makeOnClickListener { info -> + if (TextUtils.isEmpty(info.subChannelUrl)) { + if (!TextUtils.isEmpty(info.uploaderUrl)) { + openChannel(info.uploaderUrl, info.uploaderName, info.serviceId) + } else if (DEBUG) { + Log.i(TAG, "Can't open sub-channel because we got no channel URL") } + } else { + openChannel(info.subChannelUrl, info.subChannelName, info.serviceId) } - ) - ) - binding!!.detailThumbnailRootLayout.setOnClickListener( - View.OnClickListener { v: View? -> - autoPlayEnabled = true // forcefully start playing - // FIXME Workaround #7427 - if (this.isPlayerAvailable) { - player!!.setRecovery() - } - openVideoPlayerAutoFullscreen() } ) + binding.detailThumbnailRootLayout.setOnClickListener { + autoPlayEnabled = true // forcefully start playing + // FIXME Workaround #7427 + player?.setRecovery() + openVideoPlayerAutoFullscreen() + } - binding!!.detailControlsBackground.setOnClickListener( - View.OnClickListener { v: View? -> - openBackgroundPlayer( - false - ) - } - ) - binding!!.detailControlsPopup.setOnClickListener( - View.OnClickListener { v: View? -> - openPopupPlayer( - false - ) - } - ) - binding!!.detailControlsPlaylistAppend.setOnClickListener( - makeOnClickListener( - Consumer { info: StreamInfo? -> - if (getFM() != null && currentInfo != null) { - val fragment = getParentFragmentManager().findFragmentById(R.id.fragment_holder) + binding.detailControlsBackground.setOnClickListener { openBackgroundPlayer(false) } + binding.detailControlsPopup.setOnClickListener { openPopupPlayer(false) } + binding.detailControlsPlaylistAppend.setOnClickListener( + makeOnClickListener { info -> + if (getFM() != null) { + val fragment = getParentFragmentManager().findFragmentById(R.id.fragment_holder) - // commit previous pending changes to database - if (fragment is LocalPlaylistFragment) { - fragment.saveImmediate() - } else if (fragment is MainFragment) { - fragment.commitPlaylistTabs() - } - - disposables.add( - PlaylistDialog.createCorrespondingDialog( - requireContext(), - List.of(StreamEntity(info!!)), - Consumer { dialog: PlaylistDialog? -> - dialog!!.show( - getParentFragmentManager(), - TAG - ) - } - ) - ) + // commit previous pending changes to database + if (fragment is LocalPlaylistFragment) { + fragment.saveImmediate() + } else if (fragment is MainFragment) { + fragment.commitPlaylistTabs() } - } - ) - ) - binding!!.detailControlsDownload.setOnClickListener( - View.OnClickListener { v: View? -> - if (PermissionHelper.checkStoragePermissions( - activity, - PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE + + disposables.add( + PlaylistDialog.createCorrespondingDialog( + requireContext(), + listOf(StreamEntity(info)) + ) { dialog -> dialog.show(getParentFragmentManager(), TAG) } ) - ) { - openDownloadDialog() } } ) - binding!!.detailControlsShare.setOnClickListener( - makeOnClickListener( - Consumer { info: StreamInfo? -> - ShareUtils.shareText( - requireContext(), info!!.getName(), info.getUrl(), - info.getThumbnails() - ) - } - ) + binding.detailControlsDownload.setOnClickListener { + if (checkStoragePermissions(activity, PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { + openDownloadDialog() + } + } + binding.detailControlsShare.setOnClickListener( + makeOnClickListener { info -> + ShareUtils.shareText(requireContext(), info.name, info.url, info.thumbnails) + } ) - binding!!.detailControlsOpenInBrowser.setOnClickListener( - makeOnClickListener( - Consumer { info: StreamInfo? -> - ShareUtils.openUrlInBrowser( - requireContext(), - info!!.getUrl() - ) - } - ) + binding.detailControlsOpenInBrowser.setOnClickListener( + makeOnClickListener { info -> + ShareUtils.openUrlInBrowser(requireContext(), info.url) + } ) - binding!!.detailControlsPlayWithKodi.setOnClickListener( - makeOnClickListener( - Consumer { info: StreamInfo? -> - KoreUtils.playWithKore( - requireContext(), - Uri.parse( - info!!.getUrl() - ) - ) - } - ) + binding.detailControlsPlayWithKodi.setOnClickListener( + makeOnClickListener { info -> + KoreUtils.playWithKore(requireContext(), info.url.toUri()) + } ) if (DEBUG) { - binding!!.detailControlsCrashThePlayer.setOnClickListener( - View.OnClickListener { v: View? -> - VideoDetailPlayerCrasher.onCrashThePlayer( - requireContext(), - player - ) - } - ) + binding.detailControlsCrashThePlayer.setOnClickListener { + VideoDetailPlayerCrasher.onCrashThePlayer(requireContext(), player) + } } - val overlayListener = View.OnClickListener { v: View? -> - bottomSheetBehavior!! - .setState(BottomSheetBehavior.STATE_EXPANDED) + val overlayListener = View.OnClickListener { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED) } - binding!!.overlayThumbnail.setOnClickListener(overlayListener) - binding!!.overlayMetadataLayout.setOnClickListener(overlayListener) - binding!!.overlayButtonsLayout.setOnClickListener(overlayListener) - binding!!.overlayCloseButton.setOnClickListener( - View.OnClickListener { v: View? -> - bottomSheetBehavior!! - .setState(BottomSheetBehavior.STATE_HIDDEN) + binding.overlayThumbnail.setOnClickListener(overlayListener) + binding.overlayMetadataLayout.setOnClickListener(overlayListener) + binding.overlayButtonsLayout.setOnClickListener(overlayListener) + binding.overlayCloseButton.setOnClickListener { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } + binding.overlayPlayQueueButton.setOnClickListener { + NavigationHelper.openPlayQueue(requireContext()) + } + binding.overlayPlayPauseButton.setOnClickListener { + if (!playerIsStopped) { + player!!.playPause() + player!!.UIs().get(VideoPlayerUi::class)?.hideControls(0, 0) + showSystemUi() + } else { + autoPlayEnabled = true // forcefully start playing + openVideoPlayer(false) } - ) - binding!!.overlayPlayQueueButton.setOnClickListener( - View.OnClickListener { v: View? -> - NavigationHelper.openPlayQueue( - requireContext() - ) - } - ) - binding!!.overlayPlayPauseButton.setOnClickListener( - View.OnClickListener { v: View? -> - if (playerIsNotStopped()) { - player!!.playPause() - player!!.UIs().get(VideoPlayerUi::class.java)?.hideControls(0, 0) - showSystemUi() - } else { - autoPlayEnabled = true // forcefully start playing - openVideoPlayer(false) - } - setOverlayPlayPauseImage(this.isPlayerAvailable && player!!.isPlaying()) - } - ) + setOverlayPlayPauseImage(player?.isPlaying == true) + } } - private fun makeOnClickListener(consumer: Consumer): View.OnClickListener { - return View.OnClickListener { v: View? -> - if (!isLoading.get() && currentInfo != null) { - consumer.accept(currentInfo) - } + private fun makeOnClickListener(listener: (StreamInfo) -> Unit): View.OnClickListener { + return View.OnClickListener { + currentInfo?.takeIf { !isLoading.get() }?.let(listener) } } private fun setOnLongClickListeners() { - binding!!.detailTitleRootLayout.setOnLongClickListener( - makeOnLongClickListener( - Consumer { info: StreamInfo? -> - ShareUtils.copyToClipboard( - requireContext(), - binding!!.detailVideoTitleView.getText().toString() - ) + binding.detailTitleRootLayout.setOnLongClickListener { + binding.detailVideoTitleView.text?.toString()?.let { + if (!it.isBlank()) { + ShareUtils.copyToClipboard(requireContext(), it) + return@setOnLongClickListener true } - ) - ) - binding!!.detailUploaderRootLayout.setOnLongClickListener( - makeOnLongClickListener( - Consumer { info: StreamInfo? -> - if (TextUtils.isEmpty( - info!!.getSubChannelUrl() - ) - ) { - Log.w(TAG, "Can't open parent channel because we got no parent channel URL") - } else { - openChannel(info.getUploaderUrl(), info.getUploaderName()) - } + } + return@setOnLongClickListener false + } + binding.detailUploaderRootLayout.setOnLongClickListener( + makeOnLongClickListener { info -> + if (TextUtils.isEmpty(info.subChannelUrl)) { + Log.w(TAG, "Can't open parent channel because we got no parent channel URL") + } else { + openChannel(info.uploaderUrl, info.uploaderName, info.serviceId) } - ) - ) - - binding!!.detailControlsBackground.setOnLongClickListener( - makeOnLongClickListener( - Consumer { info: StreamInfo? -> - openBackgroundPlayer( - true - ) - } - ) - ) - binding!!.detailControlsPopup.setOnLongClickListener( - makeOnLongClickListener( - Consumer { info: StreamInfo? -> - openPopupPlayer( - true - ) - } - ) - ) - binding!!.detailControlsDownload.setOnLongClickListener( - makeOnLongClickListener( - Consumer { info: StreamInfo? -> - NavigationHelper.openDownloads( - activity - ) - } - ) - ) - - val overlayListener = makeOnLongClickListener( - Consumer { info: StreamInfo? -> - openChannel( - info!!.getUploaderUrl(), info.getUploaderName() - ) } ) - binding!!.overlayThumbnail.setOnLongClickListener(overlayListener) - binding!!.overlayMetadataLayout.setOnLongClickListener(overlayListener) + + binding.detailControlsBackground.setOnLongClickListener( + makeOnLongClickListener { info -> + openBackgroundPlayer(true) + } + ) + binding.detailControlsPopup.setOnLongClickListener( + makeOnLongClickListener { info -> + openPopupPlayer(true) + } + ) + binding.detailControlsDownload.setOnLongClickListener( + makeOnLongClickListener { info -> + NavigationHelper.openDownloads(activity) + } + ) + + val overlayListener = makeOnLongClickListener { info -> + openChannel(info.uploaderUrl, info.uploaderName, info.serviceId) + } + binding.overlayThumbnail.setOnLongClickListener(overlayListener) + binding.overlayMetadataLayout.setOnLongClickListener(overlayListener) } - private fun makeOnLongClickListener(consumer: Consumer): OnLongClickListener { - return OnLongClickListener { v: View? -> - if (isLoading.get() || currentInfo == null) { - return@OnLongClickListener false - } - consumer.accept(currentInfo) - true + private fun makeOnLongClickListener(listener: (StreamInfo) -> Unit): OnLongClickListener { + return OnLongClickListener { + currentInfo?.takeIf { !isLoading.get() }?.let(listener) != null } } - private fun openChannel(subChannelUrl: String?, subChannelName: String) { + private fun openChannel(subChannelUrl: String?, subChannelName: String, serviceId: Int) { try { - NavigationHelper.openChannelFragment( - getFM(), currentInfo!!.getServiceId(), - subChannelUrl, subChannelName - ) + NavigationHelper.openChannelFragment(getFM(), serviceId, subChannelUrl, subChannelName) } catch (e: Exception) { showUiErrorSnackbar(this, "Opening channel fragment", e) } } private fun toggleTitleAndSecondaryControls() { - if (binding!!.detailSecondaryControlPanel.getVisibility() == View.GONE) { - binding!!.detailVideoTitleView.setMaxLines(10) - binding!!.detailToggleSecondaryControlsView + if (binding.detailSecondaryControlPanel.visibility == View.GONE) { + binding.detailVideoTitleView.setMaxLines(10) + binding.detailToggleSecondaryControlsView .animateRotation(VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 180) - binding!!.detailSecondaryControlPanel.setVisibility(View.VISIBLE) + binding.detailSecondaryControlPanel.visibility = View.VISIBLE } else { - binding!!.detailVideoTitleView.setMaxLines(1) - binding!!.detailToggleSecondaryControlsView + binding.detailVideoTitleView.setMaxLines(1) + binding.detailToggleSecondaryControlsView .animateRotation(VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 0) - binding!!.detailSecondaryControlPanel.setVisibility(View.GONE) + binding.detailSecondaryControlPanel.visibility = View.GONE } // view pager height has changed, update the tab layout updateTabLayoutVisibility() @@ -701,25 +564,23 @@ class VideoDetailFragment : super.initViews(rootView, savedInstanceState) pageAdapter = TabAdapter(getChildFragmentManager()) - binding!!.viewPager.setAdapter(pageAdapter) - binding!!.tabLayout.setupWithViewPager(binding!!.viewPager) + binding.viewPager.setAdapter(pageAdapter) + binding.tabLayout.setupWithViewPager(binding.viewPager) - binding!!.detailThumbnailRootLayout.requestFocus() + binding.detailThumbnailRootLayout.requestFocus() - binding!!.detailControlsPlayWithKodi.setVisibility( + binding.detailControlsPlayWithKodi.visibility = if (KoreUtils.shouldShowPlayWithKodi(requireContext(), serviceId)) View.VISIBLE else View.GONE - ) - binding!!.detailControlsCrashThePlayer.setVisibility( + binding.detailControlsCrashThePlayer.visibility = if (DEBUG && PreferenceManager.getDefaultSharedPreferences(requireContext()) .getBoolean(getString(R.string.show_crash_the_player_key), false) ) View.VISIBLE else View.GONE - ) accommodateForTvAndDesktopMode() } @@ -731,30 +592,19 @@ class VideoDetailFragment : setOnLongClickListeners() val controlsTouchListener = OnTouchListener { view: View?, motionEvent: MotionEvent? -> - if (motionEvent!!.getAction() == MotionEvent.ACTION_DOWN && + if (motionEvent!!.action == MotionEvent.ACTION_DOWN && PlayButtonHelper.shouldShowHoldToAppendTip(activity) ) { - binding!!.touchAppendDetail.animate( - true, - 250, - AnimationType.ALPHA, - 0, - Runnable { - binding!!.touchAppendDetail.animate( - false, - 1500, - AnimationType.ALPHA, - 1000 - ) - } - ) + binding.touchAppendDetail.animate(true, 250, AnimationType.ALPHA, 0) { + binding.touchAppendDetail.animate(false, 1500, AnimationType.ALPHA, 1000) + } } false } - binding!!.detailControlsBackground.setOnTouchListener(controlsTouchListener) - binding!!.detailControlsPopup.setOnTouchListener(controlsTouchListener) + binding.detailControlsBackground.setOnTouchListener(controlsTouchListener) + binding.detailControlsPopup.setOnTouchListener(controlsTouchListener) - binding!!.appBarLayout.addOnOffsetChangedListener( + binding.appBarLayout.addOnOffsetChangedListener( OnOffsetChangedListener { layout: AppBarLayout?, verticalOffset: Int -> // prevent useless updates to tab layout visibility if nothing changed if (verticalOffset != lastAppBarVerticalOffset) { @@ -774,8 +624,7 @@ class VideoDetailFragment : } override fun onKeyDown(keyCode: Int): Boolean { - return this.isPlayerAvailable && - player!!.UIs().get(VideoPlayerUi::class.java)?.onKeyDown(keyCode) == true + return player?.UIs()?.get(VideoPlayerUi::class)?.onKeyDown(keyCode) == true } override fun onBackPressed(): Boolean { @@ -794,10 +643,7 @@ class VideoDetailFragment : } // If we have something in history of played items we replay it here - if (this.isPlayerAvailable && - player!!.getPlayQueue() != null && player!!.videoPlayerSelected() && - player!!.getPlayQueue()!!.previous() - ) { + if (player?.videoPlayerSelected() == true && player?.playQueue?.previous() == true) { return true // no code here, as previous() was used in the if } @@ -819,25 +665,18 @@ class VideoDetailFragment : setAutoPlay(false) hideMainPlayerOnLoadingNewStream() - setInitialData( - item.getServiceId(), item.getUrl(), - if (item.getTitle() == null) "" else item.getTitle(), item.getPlayQueue() - ) + setInitialData(item.serviceId, item.url, item.title ?: "", item.playQueue) startLoading(false) // Maybe an item was deleted in background activity - if (item.getPlayQueue().getItem() == null) { + if (item.playQueue.item == null) { return } - val playQueueItem = item.getPlayQueue().getItem() + val playQueueItem = item.playQueue.item // Update title, url, uploader from the last item in the stack (it's current now) - val isPlayerStopped = !this.isPlayerAvailable || player!!.isStopped() - if (playQueueItem != null && isPlayerStopped) { - updateOverlayData( - playQueueItem.getTitle(), - playQueueItem.getUploader(), playQueueItem.getThumbnails() - ) + if (playQueueItem != null && playerIsStopped) { + updateOverlayData(playQueueItem.title, playQueueItem.uploader, playQueueItem.thumbnails) } } @@ -849,26 +688,22 @@ class VideoDetailFragment : return } - if (currentInfo == null) { + currentInfo?.let { info -> + prepareAndHandleInfoIfNeededAfterDelay(info, false, 50) + } ?: { prepareAndLoadInfo() - } else { - prepareAndHandleInfoIfNeededAfterDelay(currentInfo!!, false, 50) } } fun selectAndLoadVideo( newServiceId: Int, - newUrl: String?, + newUrl: String, newTitle: String, newQueue: PlayQueue? ) { - if (this.isPlayerAvailable && newQueue != null && playQueue != null && playQueue!!.getItem() != null && ( - playQueue!!.getItem()!! - .getUrl() != newUrl - ) - ) { + if (newQueue != null && playQueue?.item?.url != newUrl) { // Preloading can be disabled since playback is surely being replaced. - player!!.disablePreloadingOfCurrentTrack() + player?.disablePreloadingOfCurrentTrack() } setInitialData(newServiceId, newUrl, newTitle, newQueue) @@ -880,30 +715,21 @@ class VideoDetailFragment : scrollToTop: Boolean, delay: Long ) { - Handler(Looper.getMainLooper()).postDelayed( - Runnable { - if (activity == null) { - return@Runnable - } - // Data can already be drawn, don't spend time twice - if (info.getName() == binding!!.detailVideoTitleView.getText().toString()) { - return@Runnable - } - prepareAndHandleInfo(info, scrollToTop) - }, - delay - ) + Handler(Looper.getMainLooper()).postDelayed(delay) { + if (activity == null) { + return@postDelayed + } + // Data can already be drawn, don't spend time twice + if (info.name == binding.detailVideoTitleView.getText().toString()) { + return@postDelayed + } + prepareAndHandleInfo(info, scrollToTop) + } } private fun prepareAndHandleInfo(info: StreamInfo, scrollToTop: Boolean) { if (DEBUG) { - Log.d( - TAG, - ( - "prepareAndHandleInfo() called with: " + - "info = [" + info + "], scrollToTop = [" + scrollToTop + "]" - ) - ) + Log.d(TAG, "prepareAndHandleInfo(info=[$info], scrollToTop=[$scrollToTop]) called") } showLoading() @@ -934,7 +760,7 @@ class VideoDetailFragment : currentWorker!!.dispose() } - runWorker(forceLoad, if (addToBackStack != null) addToBackStack else stack.isEmpty()) + runWorker(forceLoad, addToBackStack ?: stack.isEmpty()) } private fun runWorker(forceLoad: Boolean, addToBackStack: Boolean) { @@ -943,12 +769,11 @@ class VideoDetailFragment : .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( - io.reactivex.rxjava3.functions.Consumer { result: StreamInfo? -> + { result -> isLoading.set(false) hideMainPlayerOnLoadingNewStream() - if (result!!.getAgeLimit() != StreamExtractor.NO_AGE_LIMIT && !prefs.getBoolean( - getString(R.string.show_age_restricted_content), false - ) + if (result.ageLimit != StreamExtractor.NO_AGE_LIMIT && + !prefs.getBoolean(getString(R.string.show_age_restricted_content), false) ) { hideAgeRestrictedContent() } else { @@ -958,7 +783,7 @@ class VideoDetailFragment : if (playQueue == null) { playQueue = SinglePlayQueue(result) } - if (stack.isEmpty() || stack.peek()!!.getPlayQueue() != playQueue) { + if (stack.isEmpty() || stack.peek()!!.playQueue != playQueue) { stack.push(StackItem(serviceId, url, title, playQueue)) } } @@ -968,12 +793,9 @@ class VideoDetailFragment : } } }, - io.reactivex.rxjava3.functions.Consumer { throwable: Throwable? -> + { throwable -> showError( - ErrorInfo( - throwable!!, UserAction.REQUESTED_STREAM, - (if (url == null) "no url" else url)!!, serviceId - ) + ErrorInfo(throwable, UserAction.REQUESTED_STREAM, url ?: "no url", serviceId) ) } ) @@ -983,42 +805,42 @@ class VideoDetailFragment : // Tabs ////////////////////////////////////////////////////////////////////////// */ private fun initTabs() { - if (pageAdapter!!.getCount() != 0) { - selectedTabTag = pageAdapter!!.getItemTitle(binding!!.viewPager.getCurrentItem()) - } - pageAdapter!!.clearAllItems() + pageAdapter.getItemTitle(binding.viewPager.currentItem) + ?.let { tag -> selectedTabTag = tag } + + pageAdapter.clearAllItems() tabIcons.clear() tabContentDescriptions.clear() if (shouldShowComments()) { - pageAdapter!!.addFragment(getInstance(serviceId, url), COMMENTS_TAB_TAG) + pageAdapter.addFragment(getInstance(serviceId, url), COMMENTS_TAB_TAG) tabIcons.add(R.drawable.ic_comment) tabContentDescriptions.add(R.string.comments_tab_description) } - if (showRelatedItems && binding!!.relatedItemsLayout == null) { + if (showRelatedItems && binding.relatedItemsLayout == null) { // temp empty fragment. will be updated in handleResult - pageAdapter!!.addFragment(EmptyFragment.newInstance(false), RELATED_TAB_TAG) + pageAdapter.addFragment(EmptyFragment.newInstance(false), RELATED_TAB_TAG) tabIcons.add(R.drawable.ic_art_track) tabContentDescriptions.add(R.string.related_items_tab_description) } if (showDescription) { // temp empty fragment. will be updated in handleResult - pageAdapter!!.addFragment(EmptyFragment.newInstance(false), DESCRIPTION_TAB_TAG) + pageAdapter.addFragment(EmptyFragment.newInstance(false), DESCRIPTION_TAB_TAG) tabIcons.add(R.drawable.ic_description) tabContentDescriptions.add(R.string.description_tab_description) } - if (pageAdapter!!.getCount() == 0) { - pageAdapter!!.addFragment(EmptyFragment.newInstance(true), EMPTY_TAB_TAG) + if (pageAdapter.count == 0) { + pageAdapter.addFragment(EmptyFragment.newInstance(true), EMPTY_TAB_TAG) } - pageAdapter!!.notifyDataSetUpdate() + pageAdapter.notifyDataSetUpdate() - if (pageAdapter!!.getCount() >= 2) { - val position = pageAdapter!!.getItemPositionByTitle(selectedTabTag) + if (pageAdapter.count >= 2) { + val position = pageAdapter.getItemPositionByTitle(selectedTabTag) if (position != -1) { - binding!!.viewPager.setCurrentItem(position) + binding.viewPager.setCurrentItem(position) } updateTabIconsAndContentDescriptions() } @@ -1034,95 +856,87 @@ class VideoDetailFragment : */ private fun updateTabIconsAndContentDescriptions() { for (i in tabIcons.indices) { - val tab = binding!!.tabLayout.getTabAt(i) + val tab = binding.tabLayout.getTabAt(i) if (tab != null) { - tab.setIcon(tabIcons.get(i)!!) - tab.setContentDescription(tabContentDescriptions.get(i)!!) + tab.setIcon(tabIcons[i]) + tab.setContentDescription(tabContentDescriptions[i]) } } } private fun updateTabs(info: StreamInfo) { if (showRelatedItems) { - if (binding!!.relatedItemsLayout == null) { // phone - pageAdapter!!.updateItem(RELATED_TAB_TAG, getInstance(info)) + if (binding.relatedItemsLayout == null) { // phone + pageAdapter.updateItem(RELATED_TAB_TAG, getInstance(info)) } else { // tablet + TV getChildFragmentManager().beginTransaction() .replace(R.id.relatedItemsLayout, getInstance(info)) .commitAllowingStateLoss() - binding!!.relatedItemsLayout!!.setVisibility(if (this.isFullscreen) View.GONE else View.VISIBLE) + binding.relatedItemsLayout!!.isVisible = !this.isFullscreen } } if (showDescription) { - pageAdapter!!.updateItem(DESCRIPTION_TAB_TAG, DescriptionFragment(info)) + pageAdapter.updateItem(DESCRIPTION_TAB_TAG, DescriptionFragment(info)) } - binding!!.viewPager.setVisibility(View.VISIBLE) + binding.viewPager.visibility = View.VISIBLE // make sure the tab layout is visible updateTabLayoutVisibility() - pageAdapter!!.notifyDataSetUpdate() + pageAdapter.notifyDataSetUpdate() updateTabIconsAndContentDescriptions() } private fun shouldShowComments(): Boolean { - try { - return showComments && NewPipe.getService(serviceId) - .getServiceInfo() - .getMediaCapabilities() + return showComments && try { + NewPipe.getService(serviceId).serviceInfo.mediaCapabilities .contains(MediaCapability.COMMENTS) - } catch (e: ExtractionException) { - return false + } catch (_: ExtractionException) { + false } } fun updateTabLayoutVisibility() { - if (binding == null) { + if (nullableBinding == null) { // If binding is null we do not need to and should not do anything with its object(s) return } - if (pageAdapter!!.getCount() < 2 || binding!!.viewPager.getVisibility() != View.VISIBLE) { + if (pageAdapter.count < 2 || binding.viewPager.visibility != View.VISIBLE) { // hide tab layout if there is only one tab or if the view pager is also hidden - binding!!.tabLayout.setVisibility(View.GONE) + binding.tabLayout.visibility = View.GONE } else { // call `post()` to be sure `viewPager.getHitRect()` // is up to date and not being currently recomputed - binding!!.tabLayout.post( - Runnable { - val activity = getActivity() - if (activity != null) { - val pagerHitRect = Rect() - binding!!.viewPager.getHitRect(pagerHitRect) + binding.tabLayout.post { + val activity = getActivity() + if (activity != null) { + val pagerHitRect = Rect() + binding.viewPager.getHitRect(pagerHitRect) - val height = DeviceUtils.getWindowHeight(activity.getWindowManager()) - val viewPagerVisibleHeight = height - pagerHitRect.top - // see TabLayout.DEFAULT_HEIGHT, which is equal to 48dp - val tabLayoutHeight = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, 48f, getResources().getDisplayMetrics() - ) + val height = DeviceUtils.getWindowHeight(activity.windowManager) + val viewPagerVisibleHeight = height - pagerHitRect.top + // see TabLayout.DEFAULT_HEIGHT, which is equal to 48dp + val tabLayoutHeight = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 48f, resources.displayMetrics + ) - if (viewPagerVisibleHeight > tabLayoutHeight * 2) { - // no translation at all when viewPagerVisibleHeight > tabLayout.height * 3 - binding!!.tabLayout.setTranslationY( - max( - 0.0, - (tabLayoutHeight * 3 - viewPagerVisibleHeight).toDouble() - ).toFloat() - ) - binding!!.tabLayout.setVisibility(View.VISIBLE) - } else { - // view pager is not visible enough - binding!!.tabLayout.setVisibility(View.GONE) - } + if (viewPagerVisibleHeight > tabLayoutHeight * 2) { + // no translation at all when viewPagerVisibleHeight > tabLayout.height * 3 + binding.tabLayout.translationY = + max(0.0f, tabLayoutHeight * 3 - viewPagerVisibleHeight) + binding.tabLayout.visibility = View.VISIBLE + } else { + // view pager is not visible enough + binding.tabLayout.visibility = View.GONE } } - ) + } } } fun scrollToTop() { - binding!!.appBarLayout.setExpanded(true, true) + binding.appBarLayout.setExpanded(true, true) // notify tab layout of scrolling updateTabLayoutVisibility() } @@ -1133,11 +947,9 @@ class VideoDetailFragment : private fun toggleFullscreenIfInFullscreenMode() { // If a user watched video inside fullscreen mode and than chose another player // return to non-fullscreen mode - if (this.isPlayerAvailable) { - player!!.UIs().get(MainPlayerUi::class.java)?.let { - if (it.isFullscreen) { - it.toggleFullscreen() - } + player?.UIs()?.get(MainPlayerUi::class)?.let { + if (it.isFullscreen) { + it.toggleFullscreen() } } } @@ -1149,10 +961,8 @@ class VideoDetailFragment : toggleFullscreenIfInFullscreenMode() - if (this.isPlayerAvailable) { - // FIXME Workaround #7427 - player!!.setRecovery() - } + // FIXME Workaround #7427 + player?.setRecovery() if (useExternalAudioPlayer) { showExternalAudioPlaybackDialog() @@ -1167,11 +977,11 @@ class VideoDetailFragment : } // See UI changes while remote playQueue changes - if (!this.isPlayerAvailable) { + if (player == null) { PlayerHolder.startService(false, this) } else { // FIXME Workaround #7427 - player!!.setRecovery() + player?.setRecovery() } toggleFullscreenIfInFullscreenMode() @@ -1180,12 +990,7 @@ class VideoDetailFragment : if (append) { // resumePlayback: false NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.POPUP) } else { - replaceQueueIfUserConfirms( - Runnable { - NavigationHelper - .playOnPopupPlayer(activity, queue, true) - } - ) + replaceQueueIfUserConfirms { NavigationHelper.playOnPopupPlayer(activity, queue, true) } } } @@ -1198,9 +1003,8 @@ class VideoDetailFragment : */ fun openVideoPlayer(directlyFullscreenIfApplicable: Boolean) { if (directlyFullscreenIfApplicable && - !DeviceUtils.isLandscape(requireContext()) && PlayerHelper.globalScreenOrientationLocked( - requireContext() - ) + !DeviceUtils.isLandscape(requireContext()) && + PlayerHelper.globalScreenOrientationLocked(requireContext()) ) { // Make sure the bottom sheet turns out expanded. When this code kicks in the bottom // sheet could not have fully expanded yet, and thus be in the STATE_SETTLING state. @@ -1219,7 +1023,7 @@ class VideoDetailFragment : ) { showExternalVideoPlaybackDialog() } else { - replaceQueueIfUserConfirms(Runnable { this.openMainPlayer() }) + replaceQueueIfUserConfirms { this.openMainPlayer() } } } @@ -1237,7 +1041,7 @@ class VideoDetailFragment : private fun openNormalBackgroundPlayer(append: Boolean) { // See UI changes while remote playQueue changes - if (!this.isPlayerAvailable) { + if (player == null) { PlayerHolder.startService(false, this) } @@ -1245,12 +1049,9 @@ class VideoDetailFragment : if (append) { NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.AUDIO) } else { - replaceQueueIfUserConfirms( - Runnable { - NavigationHelper - .playOnBackgroundPlayer(activity, queue, true) - } - ) + replaceQueueIfUserConfirms { + NavigationHelper.playOnBackgroundPlayer(activity, queue, true) + } } } @@ -1266,9 +1067,8 @@ class VideoDetailFragment : val queue = setupPlayQueueForIntent(false) tryAddVideoPlayerView() - val playerIntent = NavigationHelper.getPlayerIntent( - requireContext(), - PlayerService::class.java, queue, true, autoPlayEnabled + val playerIntent = NavigationHelper.getPlayerIntent( + requireContext(), PlayerService::class.java, queue, true, autoPlayEnabled ) ContextCompat.startForegroundService(activity, playerIntent) } @@ -1289,7 +1089,7 @@ class VideoDetailFragment : removeVideoPlayerView() if (this.isAutoplayEnabled) { playerService!!.stopForImmediateReusing() - root.setVisibility(View.GONE) + root.visibility = View.GONE } else { PlayerHolder.stopService() } @@ -1302,7 +1102,7 @@ class VideoDetailFragment : var queue = playQueue // Size can be 0 because queue removes bad stream automatically when error occurs - if (queue == null || queue.isEmpty()) { + if (queue == null || queue.isEmpty) { queue = SinglePlayQueue(currentInfo) } @@ -1322,23 +1122,13 @@ class VideoDetailFragment : selectedStream: Stream ) { NavigationHelper.playOnExternalPlayer( - context, currentInfo!!.getName(), - currentInfo!!.getSubChannelName(), selectedStream + context, info.name, info.subChannelName, selectedStream ) val recordManager = HistoryRecordManager(requireContext()) disposables.add( recordManager.onViewed(info).onErrorComplete() - .subscribe( - io.reactivex.rxjava3.functions.Consumer { ignored: Long? -> }, - io.reactivex.rxjava3.functions.Consumer { error: Throwable? -> - Log.e( - TAG, - "Register view failure: ", - error - ) - } - ) + .subscribe({ }, { throwable -> Log.e(TAG, "Register view failure: ", throwable) }) ) } @@ -1349,13 +1139,13 @@ class VideoDetailFragment : private val isAutoplayEnabled: Boolean // This method overrides default behaviour when setAutoPlay() is called. get() = autoPlayEnabled && - !this.isExternalPlayerEnabled && (!this.isPlayerAvailable || player!!.videoPlayerSelected()) && - bottomSheetState != BottomSheetBehavior.STATE_HIDDEN && PlayerHelper.isAutoplayAllowedByUser( - requireContext() - ) + !this.isExternalPlayerEnabled && + (player?.videoPlayerSelected() != false) && + bottomSheetState != BottomSheetBehavior.STATE_HIDDEN && + PlayerHelper.isAutoplayAllowedByUser(requireContext()) private fun tryAddVideoPlayerView() { - if (this.isPlayerAvailable && getView() != null) { + if (player != null && view != null) { // Setup the surface view height, so that it fits the video correctly; this is done also // here, and not only in the Handler, to avoid a choppy fullscreen rotation animation. setHeightThumbnail() @@ -1363,62 +1153,49 @@ class VideoDetailFragment : // do all the null checks in the posted lambda, too, since the player, the binding and the // view could be set or unset before the lambda gets executed on the next main thread cycle - Handler(Looper.getMainLooper()).post( - Runnable { - if (!this.isPlayerAvailable || getView() == null) { - return@Runnable - } - // setup the surface view height, so that it fits the video correctly - setHeightThumbnail() - player!!.UIs().get(MainPlayerUi::class.java)?.let { playerUi -> - val b = binding - // sometimes binding would be null here, even though getView() != null above u.u - if (b != null) { - // prevent from re-adding a view multiple times - playerUi.removeViewFromParent() - b.playerPlaceholder.addView(playerUi.getBinding().getRoot()) - playerUi.setupVideoSurfaceIfNeeded() - } + Handler(Looper.getMainLooper()).post { + if (player == null || view == null) { + return@post + } + // setup the surface view height, so that it fits the video correctly + setHeightThumbnail() + player?.UIs()?.get(MainPlayerUi::class)?.let { playerUi -> + // sometimes binding would be null here, even though getView() != null above u.u + nullableBinding?.let { b -> + // prevent from re-adding a view multiple times + playerUi.removeViewFromParent() + b.playerPlaceholder.addView(playerUi.getBinding().getRoot()) + playerUi.setupVideoSurfaceIfNeeded() } } - ) + } } private fun removeVideoPlayerView() { makeDefaultHeightForVideoPlaceholder() - - if (player != null) { - player!!.UIs().get(VideoPlayerUi::class.java)?.removeViewFromParent() - } + player?.UIs()?.get(VideoPlayerUi::class)?.removeViewFromParent() } private fun makeDefaultHeightForVideoPlaceholder() { - if (getView() == null) { + if (view == null) { return } - binding!!.playerPlaceholder.getLayoutParams().height = FrameLayout.LayoutParams.MATCH_PARENT - binding!!.playerPlaceholder.requestLayout() + binding.playerPlaceholder.layoutParams.height = FrameLayout.LayoutParams.MATCH_PARENT + binding.playerPlaceholder.requestLayout() } - private val preDrawListener: ViewTreeObserver.OnPreDrawListener = - object : ViewTreeObserver.OnPreDrawListener { - override fun onPreDraw(): Boolean { - val metrics = getResources().getDisplayMetrics() - - if (getView() != null) { - val height = ( - if (DeviceUtils.isInMultiWindow(activity)) - requireView() - else - activity.getWindow().getDecorView() - ).getHeight() - setHeightThumbnail(height, metrics) - requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener) - } - return false - } + private val preDrawListener: OnPreDrawListener = OnPreDrawListener { + if (view != null) { + val decorView = if (DeviceUtils.isInMultiWindow(activity)) + requireView() + else + activity.window.decorView + setHeightThumbnail(decorView.height, resources.displayMetrics) + requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener) } + return@OnPreDrawListener false + } /** * Method which controls the size of thumbnail and the size of main player inside @@ -1428,8 +1205,7 @@ class VideoDetailFragment : * [.MAX_PLAYER_HEIGHT]) */ private fun setHeightThumbnail() { - val metrics = getResources().getDisplayMetrics() - val isPortrait = metrics.heightPixels > metrics.widthPixels + val metrics = resources.displayMetrics requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener) if (this.isFullscreen) { @@ -1437,8 +1213,8 @@ class VideoDetailFragment : if (DeviceUtils.isInMultiWindow(activity)) requireView() else - activity.getWindow().getDecorView() - ).getHeight() + activity.window.decorView + ).height // Height is zero when the view is not yet displayed like after orientation change if (height != 0) { setHeightThumbnail(height, metrics) @@ -1446,6 +1222,7 @@ class VideoDetailFragment : requireView().getViewTreeObserver().addOnPreDrawListener(preDrawListener) } } else { + val isPortrait = metrics.heightPixels > metrics.widthPixels val height = ( if (isPortrait) metrics.widthPixels / (16.0f / 9.0f) @@ -1457,25 +1234,21 @@ class VideoDetailFragment : } private fun setHeightThumbnail(newHeight: Int, metrics: DisplayMetrics) { - binding!!.detailThumbnailImageView.setLayoutParams( - FrameLayout.LayoutParams( - RelativeLayout.LayoutParams.MATCH_PARENT, newHeight - ) + binding.detailThumbnailImageView.setLayoutParams( + FrameLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, newHeight) ) - binding!!.detailThumbnailImageView.setMinimumHeight(newHeight) - if (this.isPlayerAvailable) { + binding.detailThumbnailImageView.setMinimumHeight(newHeight) + player?.UIs()?.get(VideoPlayerUi::class)?.let { val maxHeight = (metrics.heightPixels * MAX_PLAYER_HEIGHT).toInt() - player!!.UIs().get(VideoPlayerUi::class.java)?.let { - it.binding.surfaceView.setHeights( - newHeight, - if (it.isFullscreen) newHeight else maxHeight - ) - } + it.binding.surfaceView.setHeights( + newHeight, + if (it.isFullscreen) newHeight else maxHeight + ) } } private fun showContent() { - binding!!.detailContentRootHiding.setVisibility(View.VISIBLE) + binding.detailContentRootHiding.visibility = View.VISIBLE } private fun setInitialData( @@ -1491,30 +1264,28 @@ class VideoDetailFragment : } private fun setErrorImage() { - if (binding == null || activity == null) { + if (nullableBinding == null || activity == null) { return } - binding!!.detailThumbnailImageView.setImageDrawable( + binding.detailThumbnailImageView.setImageDrawable( AppCompatResources.getDrawable(requireContext(), R.drawable.not_available_monkey) ) - binding!!.detailThumbnailImageView.animate( - false, 0, AnimationType.ALPHA, - 0, Runnable { binding!!.detailThumbnailImageView.animate(true, 500) } - ) + binding.detailThumbnailImageView.animate(false, 0, AnimationType.ALPHA, 0) { + binding.detailThumbnailImageView.animate(true, 500) + } } override fun handleError() { super.handleError() setErrorImage() - if (binding!!.relatedItemsLayout != null) { // hide related streams for tablets - binding!!.relatedItemsLayout!!.setVisibility(View.INVISIBLE) - } + // hide related streams for tablets + binding.relatedItemsLayout?.visibility = View.INVISIBLE // hide comments / related streams / description tabs - binding!!.viewPager.setVisibility(View.GONE) - binding!!.tabLayout.setVisibility(View.GONE) + binding.viewPager.visibility = View.GONE + binding.tabLayout.visibility = View.GONE } private fun hideAgeRestrictedContent() { @@ -1529,19 +1300,17 @@ class VideoDetailFragment : private fun setupBroadcastReceiver() { broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent) { - when (intent.getAction()) { - ACTION_SHOW_MAIN_PLAYER -> bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_EXPANDED) - ACTION_HIDE_MAIN_PLAYER -> bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_HIDDEN) + when (intent.action) { + ACTION_SHOW_MAIN_PLAYER -> bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED) + ACTION_HIDE_MAIN_PLAYER -> bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) ACTION_PLAYER_STARTED -> { // If the state is not hidden we don't need to show the mini player - if (bottomSheetBehavior!!.getState() == BottomSheetBehavior.STATE_HIDDEN) { - bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_COLLAPSED) + if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN) { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED) } // Rebound to the service if it was closed via notification or mini player if (!PlayerHolder.isBound) { - PlayerHolder.startService( - false, this@VideoDetailFragment - ) + PlayerHolder.startService(false, this@VideoDetailFragment) } } } @@ -1558,7 +1327,7 @@ class VideoDetailFragment : // Orientation listener ////////////////////////////////////////////////////////////////////////// */ private fun restoreDefaultOrientation() { - if (this.isPlayerAvailable && player!!.videoPlayerSelected()) { + if (player?.videoPlayerSelected() == true) { toggleFullscreenIfInFullscreenMode() } @@ -1579,254 +1348,207 @@ class VideoDetailFragment : // if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required if (!ExtractorHelper.isCached(serviceId, url!!, InfoCache.Type.STREAM)) { - binding!!.detailContentRootHiding.setVisibility(View.INVISIBLE) + binding.detailContentRootHiding.visibility = View.INVISIBLE } - binding!!.detailThumbnailPlayButton.animate(false, 50) - binding!!.detailDurationView.animate(false, 100) - binding!!.detailPositionView.setVisibility(View.GONE) - binding!!.positionView.setVisibility(View.GONE) + binding.detailThumbnailPlayButton.animate(false, 50) + binding.detailDurationView.animate(false, 100) + binding.detailPositionView.visibility = View.GONE + binding.positionView.visibility = View.GONE - binding!!.detailVideoTitleView.setText(title) - binding!!.detailVideoTitleView.setMaxLines(1) - binding!!.detailVideoTitleView.animate(true, 0) + binding.detailVideoTitleView.text = title + binding.detailVideoTitleView.setMaxLines(1) + binding.detailVideoTitleView.animate(true, 0) - binding!!.detailToggleSecondaryControlsView.setVisibility(View.GONE) - binding!!.detailTitleRootLayout.setClickable(false) - binding!!.detailSecondaryControlPanel.setVisibility(View.GONE) + binding.detailToggleSecondaryControlsView.visibility = View.GONE + binding.detailTitleRootLayout.isClickable = false + binding.detailSecondaryControlPanel.visibility = View.GONE - if (binding!!.relatedItemsLayout != null) { - if (showRelatedItems) { - binding!!.relatedItemsLayout!!.setVisibility( - if (this.isFullscreen) View.GONE else View.INVISIBLE - ) - } else { - binding!!.relatedItemsLayout!!.setVisibility(View.GONE) - } - } + binding.relatedItemsLayout?.isVisible = showRelatedItems && !this.isFullscreen - dispose(binding!!.detailThumbnailImageView) - dispose(binding!!.detailSubChannelThumbnailView) - dispose(binding!!.overlayThumbnail) - dispose(binding!!.detailUploaderThumbnailView) + dispose(binding.detailThumbnailImageView) + dispose(binding.detailSubChannelThumbnailView) + dispose(binding.overlayThumbnail) + dispose(binding.detailUploaderThumbnailView) - binding!!.detailThumbnailImageView.setImageBitmap(null) - binding!!.detailSubChannelThumbnailView.setImageBitmap(null) + binding.detailThumbnailImageView.setImageBitmap(null) + binding.detailSubChannelThumbnailView.setImageBitmap(null) } override fun handleResult(info: StreamInfo?) { super.handleResult(info) currentInfo = info - setInitialData(info!!.getServiceId(), info.getOriginalUrl(), info.getName(), playQueue) + setInitialData(info!!.serviceId, info.originalUrl, info.name, playQueue) updateTabs(info) - binding!!.detailThumbnailPlayButton.animate(true, 200) - binding!!.detailVideoTitleView.setText(title) + binding.detailThumbnailPlayButton.animate(true, 200) + binding.detailVideoTitleView.text = title - binding!!.detailSubChannelThumbnailView.setVisibility(View.GONE) + binding.detailSubChannelThumbnailView.visibility = View.GONE - if (!TextUtils.isEmpty(info.getSubChannelName())) { + if (!TextUtils.isEmpty(info.subChannelName)) { displayBothUploaderAndSubChannel(info) } else { displayUploaderAsSubChannel(info) } - if (info.getViewCount() >= 0) { - if (info.getStreamType() == StreamType.AUDIO_LIVE_STREAM) { - binding!!.detailViewCountView.setText( - Localization.listeningCount( - activity, - info.getViewCount() - ) - ) - } else if (info.getStreamType() == StreamType.LIVE_STREAM) { - binding!!.detailViewCountView.setText( - Localization - .localizeWatchingCount(activity, info.getViewCount()) - ) - } else { - binding!!.detailViewCountView.setText( - Localization - .localizeViewCount(activity, info.getViewCount()) - ) - } - binding!!.detailViewCountView.setVisibility(View.VISIBLE) + if (info.viewCount >= 0) { + binding.detailViewCountView.text = + if (info.streamType == StreamType.AUDIO_LIVE_STREAM) { + Localization.listeningCount(activity, info.viewCount) + } else if (info.streamType == StreamType.LIVE_STREAM) { + Localization.localizeWatchingCount(activity, info.viewCount) + } else { + Localization.localizeViewCount(activity, info.viewCount) + } + binding.detailViewCountView.visibility = View.VISIBLE } else { - binding!!.detailViewCountView.setVisibility(View.GONE) + binding.detailViewCountView.visibility = View.GONE } - if (info.getDislikeCount() == -1L && info.getLikeCount() == -1L) { - binding!!.detailThumbsDownImgView.setVisibility(View.VISIBLE) - binding!!.detailThumbsUpImgView.setVisibility(View.VISIBLE) - binding!!.detailThumbsUpCountView.setVisibility(View.GONE) - binding!!.detailThumbsDownCountView.setVisibility(View.GONE) - - binding!!.detailThumbsDisabledView.setVisibility(View.VISIBLE) + if (info.dislikeCount == -1L && info.likeCount == -1L) { + binding.detailThumbsDownImgView.visibility = View.VISIBLE + binding.detailThumbsUpImgView.visibility = View.VISIBLE + binding.detailThumbsUpCountView.visibility = View.GONE + binding.detailThumbsDownCountView.visibility = View.GONE + binding.detailThumbsDisabledView.visibility = View.VISIBLE } else { - if (info.getDislikeCount() >= 0) { - binding!!.detailThumbsDownCountView.setText( - Localization - .shortCount(activity, info.getDislikeCount()) - ) - binding!!.detailThumbsDownCountView.setVisibility(View.VISIBLE) - binding!!.detailThumbsDownImgView.setVisibility(View.VISIBLE) + if (info.dislikeCount >= 0) { + binding.detailThumbsDownCountView.text = + Localization.shortCount(activity, info.dislikeCount) + binding.detailThumbsDownCountView.visibility = View.VISIBLE + binding.detailThumbsDownImgView.visibility = View.VISIBLE } else { - binding!!.detailThumbsDownCountView.setVisibility(View.GONE) - binding!!.detailThumbsDownImgView.setVisibility(View.GONE) + binding.detailThumbsDownCountView.visibility = View.GONE + binding.detailThumbsDownImgView.visibility = View.GONE } - if (info.getLikeCount() >= 0) { - binding!!.detailThumbsUpCountView.setText( - Localization.shortCount( - activity, - info.getLikeCount() - ) - ) - binding!!.detailThumbsUpCountView.setVisibility(View.VISIBLE) - binding!!.detailThumbsUpImgView.setVisibility(View.VISIBLE) + if (info.likeCount >= 0) { + binding.detailThumbsUpCountView.text = + Localization.shortCount(activity, info.likeCount) + binding.detailThumbsUpCountView.visibility = View.VISIBLE + binding.detailThumbsUpImgView.visibility = View.VISIBLE } else { - binding!!.detailThumbsUpCountView.setVisibility(View.GONE) - binding!!.detailThumbsUpImgView.setVisibility(View.GONE) + binding.detailThumbsUpCountView.visibility = View.GONE + binding.detailThumbsUpImgView.visibility = View.GONE } - binding!!.detailThumbsDisabledView.setVisibility(View.GONE) + binding.detailThumbsDisabledView.visibility = View.GONE } - if (info.getDuration() > 0) { - binding!!.detailDurationView.setText(Localization.getDurationString(info.getDuration())) - binding!!.detailDurationView.setBackgroundColor( + if (info.duration > 0) { + binding.detailDurationView.text = Localization.getDurationString(info.duration) + binding.detailDurationView.setBackgroundColor( ContextCompat.getColor(activity, R.color.duration_background_color) ) - binding!!.detailDurationView.animate(true, 100) - } else if (info.getStreamType() == StreamType.LIVE_STREAM) { - binding!!.detailDurationView.setText(R.string.duration_live) - binding!!.detailDurationView.setBackgroundColor( + binding.detailDurationView.animate(true, 100) + } else if (info.streamType == StreamType.LIVE_STREAM) { + binding.detailDurationView.setText(R.string.duration_live) + binding.detailDurationView.setBackgroundColor( ContextCompat.getColor(activity, R.color.live_duration_background_color) ) - binding!!.detailDurationView.animate(true, 100) + binding.detailDurationView.animate(true, 100) } else { - binding!!.detailDurationView.setVisibility(View.GONE) + binding.detailDurationView.visibility = View.GONE } - binding!!.detailTitleRootLayout.setClickable(true) - binding!!.detailToggleSecondaryControlsView.setRotation(0f) - binding!!.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE) - binding!!.detailSecondaryControlPanel.setVisibility(View.GONE) + binding.detailTitleRootLayout.isClickable = true + binding.detailToggleSecondaryControlsView.rotation = 0f + binding.detailToggleSecondaryControlsView.visibility = View.VISIBLE + binding.detailSecondaryControlPanel.visibility = View.GONE checkUpdateProgressInfo(info) loadDetailsThumbnail( - binding!!.detailThumbnailImageView, - info.getThumbnails() + binding.detailThumbnailImageView, + info.thumbnails ) ExtractorHelper.showMetaInfoInTextView( - info.getMetaInfo(), binding!!.detailMetaInfoTextView, - binding!!.detailMetaInfoSeparator, disposables + info.metaInfo, binding.detailMetaInfoTextView, + binding.detailMetaInfoSeparator, disposables ) - if (!this.isPlayerAvailable || player!!.isStopped()) { - updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()) + if (playerIsStopped) { + updateOverlayData(info.name, info.uploaderName, info.thumbnails) } - if (!info.getErrors().isEmpty()) { + if (!info.errors.isEmpty()) { // Bandcamp fan pages are not yet supported and thus a ContentNotAvailableException is // thrown. This is not an error and thus should not be shown to the user. - for (throwable in info.getErrors()) { + for (throwable in info.errors) { if (throwable is ContentNotSupportedException && "Fan pages are not supported" == throwable.message ) { - info.getErrors().remove(throwable) + info.errors.remove(throwable) } } - if (!info.getErrors().isEmpty()) { + if (!info.errors.isEmpty()) { showSnackBarError( - ErrorInfo( - info.getErrors(), - UserAction.REQUESTED_STREAM, info.getUrl(), info - ) + ErrorInfo(info.errors, UserAction.REQUESTED_STREAM, info.url, info) ) } } - binding!!.detailControlsDownload.setVisibility( - if (StreamTypeUtil.isLiveStream(info.getStreamType())) View.GONE else View.VISIBLE - ) - binding!!.detailControlsBackground.setVisibility( - if (info.getAudioStreams().isEmpty() && info.getVideoStreams().isEmpty()) - View.GONE - else - View.VISIBLE - ) + val hasAudioStreams = info.videoStreams.isNotEmpty() || info.audioStreams.isNotEmpty() + binding.detailControlsDownload.isVisible = !StreamTypeUtil.isLiveStream(info.streamType) + binding.detailControlsBackground.isVisible = hasAudioStreams - val noVideoStreams = - info.getVideoStreams().isEmpty() && info.getVideoOnlyStreams().isEmpty() - binding!!.detailControlsPopup.setVisibility(if (noVideoStreams) View.GONE else View.VISIBLE) - binding!!.detailThumbnailPlayButton.setImageResource( - if (noVideoStreams) R.drawable.ic_headset_shadow else R.drawable.ic_play_arrow_shadow + val hasVideoStreams = info.videoStreams.isNotEmpty() || info.videoOnlyStreams.isNotEmpty() + binding.detailControlsPopup.isVisible = hasVideoStreams + binding.detailThumbnailPlayButton.setImageResource( + if (hasVideoStreams) R.drawable.ic_play_arrow_shadow else R.drawable.ic_headset_shadow ) } private fun displayUploaderAsSubChannel(info: StreamInfo) { - binding!!.detailSubChannelTextView.setText(info.getUploaderName()) - binding!!.detailSubChannelTextView.setVisibility(View.VISIBLE) - binding!!.detailSubChannelTextView.setSelected(true) + binding.detailSubChannelTextView.text = info.uploaderName + binding.detailSubChannelTextView.visibility = View.VISIBLE + binding.detailSubChannelTextView.setSelected(true) - if (info.getUploaderSubscriberCount() > -1) { - binding!!.detailUploaderTextView.setText( - Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount()) - ) - binding!!.detailUploaderTextView.setVisibility(View.VISIBLE) + if (info.uploaderSubscriberCount > -1) { + binding.detailUploaderTextView.text = + Localization.shortSubscriberCount(activity, info.uploaderSubscriberCount) + binding.detailUploaderTextView.visibility = View.VISIBLE } else { - binding!!.detailUploaderTextView.setVisibility(View.GONE) + binding.detailUploaderTextView.visibility = View.GONE } - loadAvatar( - binding!!.detailSubChannelThumbnailView, - info.getUploaderAvatars() - ) - binding!!.detailSubChannelThumbnailView.setVisibility(View.VISIBLE) - binding!!.detailUploaderThumbnailView.setVisibility(View.GONE) + loadAvatar(binding.detailSubChannelThumbnailView, info.uploaderAvatars) + binding.detailSubChannelThumbnailView.visibility = View.VISIBLE + binding.detailUploaderThumbnailView.visibility = View.GONE } private fun displayBothUploaderAndSubChannel(info: StreamInfo) { - binding!!.detailSubChannelTextView.setText(info.getSubChannelName()) - binding!!.detailSubChannelTextView.setVisibility(View.VISIBLE) - binding!!.detailSubChannelTextView.setSelected(true) + binding.detailSubChannelTextView.text = info.subChannelName + binding.detailSubChannelTextView.visibility = View.VISIBLE + binding.detailSubChannelTextView.setSelected(true) val subText = StringBuilder() - if (!TextUtils.isEmpty(info.getUploaderName())) { - subText.append( - String.format(getString(R.string.video_detail_by), info.getUploaderName()) - ) + if (!TextUtils.isEmpty(info.uploaderName)) { + subText.append(String.format(getString(R.string.video_detail_by), info.uploaderName)) } - if (info.getUploaderSubscriberCount() > -1) { - if (subText.length > 0) { + if (info.uploaderSubscriberCount > -1) { + if (subText.isNotEmpty()) { subText.append(Localization.DOT_SEPARATOR) } subText.append( - Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount()) + Localization.shortSubscriberCount(activity, info.uploaderSubscriberCount) ) } - if (subText.length > 0) { - binding!!.detailUploaderTextView.setText(subText) - binding!!.detailUploaderTextView.setVisibility(View.VISIBLE) - binding!!.detailUploaderTextView.setSelected(true) + if (subText.isEmpty()) { + binding.detailUploaderTextView.visibility = View.GONE } else { - binding!!.detailUploaderTextView.setVisibility(View.GONE) + binding.detailUploaderTextView.text = subText + binding.detailUploaderTextView.visibility = View.VISIBLE + binding.detailUploaderTextView.setSelected(true) } - loadAvatar( - binding!!.detailSubChannelThumbnailView, - info.getSubChannelAvatars() - ) - binding!!.detailSubChannelThumbnailView.setVisibility(View.VISIBLE) - loadAvatar( - binding!!.detailUploaderThumbnailView, - info.getUploaderAvatars() - ) - binding!!.detailUploaderThumbnailView.setVisibility(View.VISIBLE) + loadAvatar(binding.detailSubChannelThumbnailView, info.subChannelAvatars) + binding.detailSubChannelThumbnailView.visibility = View.VISIBLE + loadAvatar(binding.detailUploaderThumbnailView, info.uploaderAvatars) + binding.detailUploaderThumbnailView.visibility = View.VISIBLE } fun openDownloadDialog() { @@ -1836,13 +1558,12 @@ class VideoDetailFragment : try { val downloadDialog = DownloadDialog(activity, currentInfo!!) - downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog") + downloadDialog.show(activity.supportFragmentManager, "downloadDialog") } catch (e: Exception) { showSnackbar( activity, ErrorInfo( - e, UserAction.DOWNLOAD_OPEN_DIALOG, - "Showing download dialog", currentInfo + e, UserAction.DOWNLOAD_OPEN_DIALOG, "Showing download dialog", currentInfo ) ) } @@ -1852,12 +1573,10 @@ class VideoDetailFragment : // Stream Results ////////////////////////////////////////////////////////////////////////// */ private fun checkUpdateProgressInfo(info: StreamInfo) { - if (positionSubscriber != null) { - positionSubscriber!!.dispose() - } + positionSubscriber?.dispose() if (!DependentPreferenceHelper.getResumePlaybackEnabled(activity)) { - binding!!.positionView.setVisibility(View.GONE) - binding!!.detailPositionView.setVisibility(View.GONE) + binding.positionView.visibility = View.GONE + binding.detailPositionView.visibility = View.GONE return } val recordManager = HistoryRecordManager(requireContext()) @@ -1866,15 +1585,11 @@ class VideoDetailFragment : .onErrorComplete() .observeOn(AndroidSchedulers.mainThread()) .subscribe( - io.reactivex.rxjava3.functions.Consumer { state: StreamStateEntity? -> - updatePlaybackProgress( - state!!.getProgressMillis(), info.getDuration() * 1000 - ) - }, - io.reactivex.rxjava3.functions.Consumer { e: Throwable? -> }, - Action { - binding!!.positionView.setVisibility(View.GONE) - binding!!.detailPositionView.setVisibility(View.GONE) + { state -> updatePlaybackProgress(state.progressMillis, info.duration * 1000) }, + { throwable -> /* ignore errors */ }, + { /* onComplete */ + binding.positionView.visibility = View.GONE + binding.detailPositionView.visibility = View.GONE } ) } @@ -1887,25 +1602,20 @@ class VideoDetailFragment : val durationSeconds = TimeUnit.MILLISECONDS.toSeconds(duration).toInt() // If the old and the new progress values have a big difference then use animation. // Otherwise don't because it affects CPU - val progressDifference = abs( - ( - binding!!.positionView.getProgress() - - progressSeconds - ).toDouble() - ).toInt() - binding!!.positionView.setMax(durationSeconds) + val progressDifference = abs(binding.positionView.progress - progressSeconds) + binding.positionView.setMax(durationSeconds) if (progressDifference > 2) { - binding!!.positionView.setProgressAnimated(progressSeconds) + binding.positionView.setProgressAnimated(progressSeconds) } else { - binding!!.positionView.setProgress(progressSeconds) + binding.positionView.progress = progressSeconds } val position = Localization.getDurationString(progressSeconds.toLong()) - if (position !== binding!!.detailPositionView.getText()) { - binding!!.detailPositionView.setText(position) + if (position != binding.detailPositionView.getText()) { + binding.detailPositionView.text = position } - if (binding!!.positionView.getVisibility() != View.VISIBLE) { - binding!!.positionView.animate(true, 100) - binding!!.detailPositionView.animate(true, 100) + if (binding.positionView.visibility != View.VISIBLE) { + binding.positionView.animate(true, 100) + binding.detailPositionView.animate(true, 100) } } @@ -1921,48 +1631,32 @@ class VideoDetailFragment : if (DEBUG) { Log.d( TAG, - ( - "onQueueUpdate() called with: serviceId = [" + - serviceId + "], url = [" + url + "], name = [" + - title + "], playQueue = [" + playQueue + "]" - ) + "onQueueUpdate() called with: serviceId = [$serviceId], url = [${ + url}], name = [$title], playQueue = [$playQueue]" ) } // Register broadcast receiver to listen to playQueue changes // and hide the overlayPlayQueueButton when the playQueue is empty / destroyed. - if (playQueue != null && playQueue!!.getBroadcastReceiver() != null) { - playQueue!!.getBroadcastReceiver()!!.subscribe( - io.reactivex.rxjava3.functions.Consumer { event: PlayQueueEvent? -> updateOverlayPlayQueueButtonVisibility() } - ) - } + playQueue?.broadcastReceiver?.subscribe { updateOverlayPlayQueueButtonVisibility() } // This should be the only place where we push data to stack. // It will allow to have live instance of PlayQueue with actual information about // deleted/added items inside Channel/Playlist queue and makes possible to have // a history of played items val stackPeek: StackItem? = stack.peek() - if (stackPeek != null && stackPeek.getPlayQueue() != queue) { - val playQueueItem = queue.getItem() - if (playQueueItem != null) { - stack.push( - StackItem( - playQueueItem.getServiceId(), playQueueItem.getUrl(), - playQueueItem.getTitle(), queue - ) - ) - return - } // else continue below + if (stackPeek != null && stackPeek.playQueue != queue) { + queue.item?.let { queueItem -> + stack.push(StackItem(queueItem.serviceId, queueItem.url, queueItem.title, queue)) + return@onQueueUpdate + } // if queue.item == null continue below } - val stackWithQueue = findQueueInStack(queue) - if (stackWithQueue != null) { - // On every MainPlayer service's destroy() playQueue gets disposed and - // no longer able to track progress. That's why we update our cached disposed - // queue with the new one that is active and have the same history. - // Without that the cached playQueue will have an old recovery position - stackWithQueue.setPlayQueue(queue) - } + // On every MainPlayer service's destroy() playQueue gets disposed and + // no longer able to track progress. That's why we update our cached disposed + // queue with the new one that is active and have the same history. + // Without that the cached playQueue will have an old recovery position + findQueueInStack(queue)?.playQueue = queue } override fun onPlaybackUpdate( @@ -1971,15 +1665,13 @@ class VideoDetailFragment : shuffled: Boolean, parameters: PlaybackParameters? ) { - setOverlayPlayPauseImage(player != null && player!!.isPlaying()) + setOverlayPlayPauseImage(player?.isPlaying == true) - if (state == Player.STATE_PLAYING) { - if (binding!!.positionView.getAlpha() != 1.0f && player!!.getPlayQueue() != null && player!!.getPlayQueue()!! - .getItem() != null && player!!.getPlayQueue()!!.getItem()!!.getUrl() == url - ) { - binding!!.positionView.animate(true, 100) - binding!!.detailPositionView.animate(true, 100) - } + if (state == Player.STATE_PLAYING && binding.positionView.alpha != 1.0f && + player?.playQueue?.item?.url?.equals(url) == true + ) { + binding.positionView.animate(true, 100) + binding.detailPositionView.animate(true, 100) } } @@ -1988,24 +1680,20 @@ class VideoDetailFragment : duration: Int, bufferPercent: Int ) { - // Progress updates every second even if media is paused. It's useless until playing - if (!player!!.isPlaying() || playQueue == null) { - return - } - - if (player!!.getPlayQueue()!!.getItem()!!.getUrl() == url) { + // Progress updates are received every second even if media is paused. It's useless until + // playing, hence the `player?.isPlaying == true` check. + if (player?.isPlaying == true && player?.playQueue?.item?.url?.equals(url) == true) { updatePlaybackProgress(currentProgress.toLong(), duration.toLong()) } } override fun onMetadataUpdate(info: StreamInfo, queue: PlayQueue) { - val item = findQueueInStack(queue) - if (item != null) { + findQueueInStack(queue)?.let { item -> // When PlayQueue can have multiple streams (PlaylistPlayQueue or ChannelPlayQueue) // every new played stream gives new title and url. // StackItem contains information about first played stream. Let's update it here - item.setTitle(info.getName()) - item.setUrl(info.getUrl()) + item.title = info.name + item.url = info.url } // They are not equal when user watches something in popup while browsing in fragment and // then changes screen orientation. In that case the fragment will set itself as @@ -2014,13 +1702,13 @@ class VideoDetailFragment : return } - updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()) - if (currentInfo != null && info.getUrl() == currentInfo!!.getUrl()) { + updateOverlayData(info.name, info.uploaderName, info.thumbnails) + if (currentInfo?.url == info.url) { return } currentInfo = info - setInitialData(info.getServiceId(), info.getUrl(), info.getName(), queue) + setInitialData(info.serviceId, info.url, info.name, queue) setAutoPlay(false) // Delay execution just because it freezes the main thread, and while playing // next/previous video you see visual glitches @@ -2038,23 +1726,17 @@ class VideoDetailFragment : override fun onServiceStopped() { // the binding could be null at this point, if the app is finishing - if (binding != null) { + if (nullableBinding != null) { setOverlayPlayPauseImage(false) - if (currentInfo != null) { - updateOverlayData( - currentInfo!!.getName(), - currentInfo!!.getUploaderName(), - currentInfo!!.getThumbnails() - ) - } + currentInfo?.let { updateOverlayData(it.name, it.uploaderName, it.thumbnails) } updateOverlayPlayQueueButtonVisibility() } } override fun onFullscreenStateChanged(fullscreen: Boolean) { setupBrightness() - if (!this.isPlayerAndPlayerServiceAvailable || - player?.UIs()?.get(MainPlayerUi::class.java) == null || + if (playerService == null || + player?.UIs()?.get(MainPlayerUi::class) == null || this.root?.parent == null ) { return @@ -2062,14 +1744,12 @@ class VideoDetailFragment : if (fullscreen) { hideSystemUiIfNeeded() - binding!!.overlayPlayPauseButton.requestFocus() + binding.overlayPlayPauseButton.requestFocus() } else { showSystemUi() } - if (binding!!.relatedItemsLayout != null) { - binding!!.relatedItemsLayout!!.setVisibility(if (fullscreen) View.GONE else View.VISIBLE) - } + binding.relatedItemsLayout?.isVisible = !fullscreen scrollToTop() tryAddVideoPlayerView() @@ -2084,7 +1764,7 @@ class VideoDetailFragment : if (DeviceUtils.isTablet(activity) && (!PlayerHelper.globalScreenOrientationLocked(activity) || isLandscape) ) { - player!!.UIs().get(MainPlayerUi::class.java)?.toggleFullscreen() + player!!.UIs().get(MainPlayerUi::class)?.toggleFullscreen() return } @@ -2101,18 +1781,15 @@ class VideoDetailFragment : * */ override fun onMoreOptionsLongClicked() { val params = - binding!!.appBarLayout.getLayoutParams() as CoordinatorLayout.LayoutParams - val behavior = params.getBehavior() as AppBarLayout.Behavior? - val valueAnimator = ValueAnimator - .ofInt(0, -binding!!.playerPlaceholder.getHeight()) - valueAnimator.setInterpolator(DecelerateInterpolator()) - valueAnimator.addUpdateListener( - AnimatorUpdateListener { animation: ValueAnimator? -> - behavior!!.setTopAndBottomOffset(animation!!.getAnimatedValue() as Int) - binding!!.appBarLayout.requestLayout() - } - ) - valueAnimator.setInterpolator(DecelerateInterpolator()) + binding.appBarLayout.layoutParams as CoordinatorLayout.LayoutParams + val behavior = params.behavior as AppBarLayout.Behavior + val valueAnimator = ValueAnimator.ofInt(0, -binding.playerPlaceholder.height) + valueAnimator.interpolator = DecelerateInterpolator() + valueAnimator.addUpdateListener { animation -> + behavior.setTopAndBottomOffset(animation.getAnimatedValue() as Int) + binding.appBarLayout.requestLayout() + } + valueAnimator.interpolator = DecelerateInterpolator() valueAnimator.setDuration(500) valueAnimator.start() } @@ -2131,15 +1808,13 @@ class VideoDetailFragment : // Prevent jumping of the player on devices with cutout if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - activity.getWindow().getAttributes().layoutInDisplayCutoutMode = + activity.window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT } - activity.getWindow().getDecorView().setSystemUiVisibility(0) - activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) - activity.getWindow().setStatusBarColor( - ThemeHelper.resolveColorFromAttr( - requireContext(), android.R.attr.colorPrimary - ) + activity.window.decorView.systemUiVisibility = 0 + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + activity.window.statusBarColor = ThemeHelper.resolveColorFromAttr( + requireContext(), android.R.attr.colorPrimary ) } @@ -2154,7 +1829,7 @@ class VideoDetailFragment : // Prevent jumping of the player on devices with cutout if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - activity.getWindow().getAttributes().layoutInDisplayCutoutMode = + activity.window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES } var visibility = ( @@ -2171,34 +1846,34 @@ class VideoDetailFragment : if (!isInMultiWindow) { visibility = visibility or View.SYSTEM_UI_FLAG_FULLSCREEN } - activity.getWindow().getDecorView().setSystemUiVisibility(visibility) + activity.window.decorView.systemUiVisibility = visibility if (isInMultiWindow || this.isFullscreen) { - activity.getWindow().setStatusBarColor(Color.TRANSPARENT) - activity.getWindow().setNavigationBarColor(Color.TRANSPARENT) + activity.window.statusBarColor = Color.TRANSPARENT + activity.window.navigationBarColor = Color.TRANSPARENT } - activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) } // Listener implementation override fun hideSystemUiIfNeeded() { if (this.isFullscreen && - bottomSheetBehavior!!.getState() == BottomSheetBehavior.STATE_EXPANDED + bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED ) { hideSystemUi() } } private val isFullscreen: Boolean - get() = this.isPlayerAvailable && player?.UIs() - ?.get(VideoPlayerUi::class.java)?.isFullscreen() == true + get() = player?.UIs()?.get(VideoPlayerUi::class)?.isFullscreen == true - private fun playerIsNotStopped(): Boolean { - return this.isPlayerAvailable && !player!!.isStopped() - } + @Suppress("NullableBooleanElvis") // rewriting as "!= false" creates more confusion + private val playerIsStopped + // returns true if the player is null, or if the player is nonnull but is stopped + get() = player?.isStopped ?: true private fun restoreDefaultBrightness() { - val lp = activity.getWindow().getAttributes() + val lp = activity.window.attributes if (lp.screenBrightness == -1f) { return } @@ -2206,7 +1881,7 @@ class VideoDetailFragment : // Restore the old brightness when fragment.onPause() called or // when a player is in portrait lp.screenBrightness = -1f - activity.getWindow().setAttributes(lp) + activity.window.setAttributes(lp) } private fun setupBrightness() { @@ -2214,21 +1889,15 @@ class VideoDetailFragment : return } - val lp = activity.getWindow().getAttributes() + val lp = activity.window.attributes if (!this.isFullscreen || bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) { // Apply system brightness when the player is not in fullscreen restoreDefaultBrightness() } else { // Do not restore if user has disabled brightness gesture - if (( - PlayerHelper.getActionForRightGestureSide(activity) - != getString(R.string.brightness_control_key) - ) && ( - PlayerHelper.getActionForLeftGestureSide( - activity - ) - != getString(R.string.brightness_control_key) - ) + val brightnessControlKey = getString(R.string.brightness_control_key) + if (PlayerHelper.getActionForRightGestureSide(activity) != brightnessControlKey && + PlayerHelper.getActionForLeftGestureSide(activity) != brightnessControlKey ) { return } @@ -2238,7 +1907,7 @@ class VideoDetailFragment : return } lp.screenBrightness = brightnessLevel - activity.getWindow().setAttributes(lp) + activity.window.setAttributes(lp) } } @@ -2247,37 +1916,37 @@ class VideoDetailFragment : * or in Android's desktop mode (DeX etc). */ private fun accommodateForTvAndDesktopMode() { - if (DeviceUtils.isTv(getContext())) { + if (DeviceUtils.isTv(context)) { // remove ripple effects from detail controls val transparent = ContextCompat.getColor( requireContext(), R.color.transparent_background_color ) - binding!!.detailControlsPlaylistAppend.setBackgroundColor(transparent) - binding!!.detailControlsBackground.setBackgroundColor(transparent) - binding!!.detailControlsPopup.setBackgroundColor(transparent) - binding!!.detailControlsDownload.setBackgroundColor(transparent) - binding!!.detailControlsShare.setBackgroundColor(transparent) - binding!!.detailControlsOpenInBrowser.setBackgroundColor(transparent) - binding!!.detailControlsPlayWithKodi.setBackgroundColor(transparent) + binding.detailControlsPlaylistAppend.setBackgroundColor(transparent) + binding.detailControlsBackground.setBackgroundColor(transparent) + binding.detailControlsPopup.setBackgroundColor(transparent) + binding.detailControlsDownload.setBackgroundColor(transparent) + binding.detailControlsShare.setBackgroundColor(transparent) + binding.detailControlsOpenInBrowser.setBackgroundColor(transparent) + binding.detailControlsPlayWithKodi.setBackgroundColor(transparent) } if (DeviceUtils.isDesktopMode(requireContext())) { // Remove the "hover" overlay (since it is visible on all mouse events and interferes // with the video content being played) - binding!!.detailThumbnailRootLayout.setForeground(null) + binding.detailThumbnailRootLayout.setForeground(null) } } private fun checkLandscape() { - if ((!player!!.isPlaying() && player!!.getPlayQueue() !== playQueue) || - player!!.getPlayQueue() == null + if ((!player!!.isPlaying && player!!.playQueue !== playQueue) || + player!!.playQueue == null ) { setAutoPlay(true) } - player!!.UIs().get(MainPlayerUi::class.java)?.checkLandscape() + player!!.UIs().get(MainPlayerUi::class)?.checkLandscape() // Let's give a user time to look at video information page if video is not playing - if (PlayerHelper.globalScreenOrientationLocked(activity) && !player!!.isPlaying()) { + if (PlayerHelper.globalScreenOrientationLocked(activity) && !player!!.isPlaying) { player!!.play() } } @@ -2290,26 +1959,19 @@ class VideoDetailFragment : return url == null } - private fun findQueueInStack(queue: PlayQueue?): StackItem? { - var item: StackItem? = null - val iterator: MutableIterator = stack.descendingIterator() - while (iterator.hasNext()) { - val next = iterator.next()!! - if (next.getPlayQueue().equals(queue)) { - item = next - break + private fun findQueueInStack(queue: PlayQueue): StackItem? { + stack.descendingIterator().forEach { item -> + if (item?.playQueue == queue) { + return@findQueueInStack item } } - return item + return null } private fun replaceQueueIfUserConfirms(onAllow: Runnable) { - val activeQueue = if (this.isPlayerAvailable) player!!.getPlayQueue() else null - // Player will have STATE_IDLE when a user pressed back button if (PlayerHelper.isClearingQueueConfirmationRequired(activity) && - playerIsNotStopped() && - activeQueue != playQueue + !playerIsStopped && player?.playQueue != playQueue ) { showClearingQueueConfirmation(onAllow) } else { @@ -2321,41 +1983,29 @@ class VideoDetailFragment : AlertDialog.Builder(activity) .setTitle(R.string.clear_queue_confirmation_description) .setNegativeButton(R.string.cancel, null) - .setPositiveButton( - R.string.ok, - DialogInterface.OnClickListener { dialog: DialogInterface?, which: Int -> - onAllow.run() - dialog!!.dismiss() - } - ) + .setPositiveButton(R.string.ok) { dialog, which -> + onAllow.run() + dialog?.dismiss() + } .show() } private fun showExternalVideoPlaybackDialog() { - if (currentInfo == null) { - return - } + val info = currentInfo ?: return val builder = AlertDialog.Builder(activity) builder.setTitle(R.string.select_quality_external_players) - builder.setNeutralButton( - R.string.open_in_browser, - DialogInterface.OnClickListener { dialog: DialogInterface?, i: Int -> - ShareUtils.openUrlInBrowser( - requireActivity(), - url - ) - } - ) + builder.setNeutralButton(R.string.open_in_browser) { dialog, which -> + ShareUtils.openUrlInBrowser(requireActivity(), url) + } - val videoStreamsForExternalPlayers = - ListHelper.getSortedStreamVideosList( - activity, - ListHelper.getUrlAndNonTorrentStreams(currentInfo!!.getVideoStreams()), - ListHelper.getUrlAndNonTorrentStreams(currentInfo!!.getVideoOnlyStreams()), - false, - false - ) + val videoStreamsForExternalPlayers = ListHelper.getSortedStreamVideosList( + activity, + ListHelper.getUrlAndNonTorrentStreams(info.videoStreams), + ListHelper.getUrlAndNonTorrentStreams(info.videoOnlyStreams), + false, + false + ) if (videoStreamsForExternalPlayers.isEmpty()) { builder.setMessage(R.string.no_video_streams_available_for_external_players) @@ -2363,45 +2013,33 @@ class VideoDetailFragment : } else { val selectedVideoStreamIndexForExternalPlayers = ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers) - val resolutions = videoStreamsForExternalPlayers.map { - it!!.getResolution() as CharSequence - }.toTypedArray() + val resolutions = videoStreamsForExternalPlayers + .map { it.getResolution() as CharSequence } + .toTypedArray() builder.setSingleChoiceItems( - resolutions, selectedVideoStreamIndexForExternalPlayers, - null + resolutions, selectedVideoStreamIndexForExternalPlayers, null ) builder.setNegativeButton(R.string.cancel, null) - builder.setPositiveButton( - R.string.ok, - DialogInterface.OnClickListener { dialog: DialogInterface?, i: Int -> - val index = (dialog as AlertDialog).getListView().getCheckedItemPosition() - // We don't have to manage the index validity because if there is no stream - // available for external players, this code will be not executed and if there is - // no stream which matches the default resolution, 0 is returned by - // ListHelper.getDefaultResolutionIndex. - // The index cannot be outside the bounds of the list as its always between 0 and - // the list size - 1, . - startOnExternalPlayer( - activity, currentInfo!!, - videoStreamsForExternalPlayers.get(index)!! - ) - } - ) + builder.setPositiveButton(R.string.ok) { dialog, which -> + val index = (dialog as AlertDialog).listView.getCheckedItemPosition() + // We don't have to manage the index validity because if there is no stream + // available for external players, this code will be not executed and if there is + // no stream which matches the default resolution, 0 is returned by + // ListHelper.getDefaultResolutionIndex. + // The index cannot be outside the bounds of the list as its always between 0 and + // the list size - 1, . + startOnExternalPlayer(activity, info, videoStreamsForExternalPlayers[index]) + } } builder.show() } private fun showExternalAudioPlaybackDialog() { - if (currentInfo == null) { - return - } + val info = currentInfo ?: return - val audioStreams = ListHelper.getUrlAndNonTorrentStreams( - currentInfo!!.getAudioStreams() - ) - val audioTracks = - ListHelper.getFilteredAudioStreams(activity, audioStreams) + val audioStreams = ListHelper.getUrlAndNonTorrentStreams(info.audioStreams) + val audioTracks = ListHelper.getFilteredAudioStreams(activity, audioStreams) if (audioTracks.isEmpty()) { Toast.makeText( @@ -2409,39 +2047,24 @@ class VideoDetailFragment : Toast.LENGTH_SHORT ).show() } else if (audioTracks.size == 1) { - startOnExternalPlayer(activity, currentInfo!!, audioTracks.get(0)!!) + startOnExternalPlayer(activity, info, audioTracks[0]) } else { - val selectedAudioStream = - ListHelper.getDefaultAudioFormat(activity, audioTracks) + val selectedAudioStream = ListHelper.getDefaultAudioFormat(activity, audioTracks) val trackNames = audioTracks - .map { audioStream: AudioStream? -> - Localization.audioTrackName( - activity, - audioStream - ) - }.toTypedArray() + .map { Localization.audioTrackName(activity, it) } + .toTypedArray() AlertDialog.Builder(activity) .setTitle(R.string.select_audio_track_external_players) - .setNeutralButton( - R.string.open_in_browser, - DialogInterface.OnClickListener { dialog: DialogInterface?, i: Int -> - ShareUtils.openUrlInBrowser( - requireActivity(), - url - ) - } - ) + .setNeutralButton(R.string.open_in_browser) { dialog, which -> + ShareUtils.openUrlInBrowser(requireActivity(), url) + } .setSingleChoiceItems(trackNames, selectedAudioStream, null) .setNegativeButton(R.string.cancel, null) - .setPositiveButton( - R.string.ok, - DialogInterface.OnClickListener { dialog: DialogInterface?, i: Int -> - val index = (dialog as AlertDialog).getListView() - .getCheckedItemPosition() - startOnExternalPlayer(activity, currentInfo!!, audioTracks.get(index)!!) - } - ) + .setPositiveButton(R.string.ok) { dialog, which -> + val index = (dialog as AlertDialog).listView.getCheckedItemPosition() + startOnExternalPlayer(activity, info, audioTracks[index]) + } .show() } } @@ -2452,9 +2075,7 @@ class VideoDetailFragment : private fun cleanUp() { // New beginning stack.clear() - if (currentWorker != null) { - currentWorker!!.dispose() - } + currentWorker?.dispose() PlayerHolder.stopService() setInitialData(0, null, "", null) currentInfo = null @@ -2482,7 +2103,7 @@ class VideoDetailFragment : toolbar.setDescendantFocusability(afterDescendants) (requireView() as ViewGroup).setDescendantFocusability(blockDescendants) // Only focus the mainFragment if the mainFragment (e.g. search-results) - // or the toolbar (e.g. Textfield for search) don't have focus. + // or the toolbar (e.g. TextField for search) don't have focus. // This was done to fix problems with the keyboard input, see also #7490 if (!mainFragment.hasFocus() && !toolbar.hasFocus()) { mainFragment.requestFocus() @@ -2492,8 +2113,8 @@ class VideoDetailFragment : toolbar.setDescendantFocusability(blockDescendants) (requireView() as ViewGroup).setDescendantFocusability(afterDescendants) // Only focus the player if it not already has focus - if (!binding!!.getRoot().hasFocus()) { - binding!!.detailThumbnailRootLayout.requestFocus() + if (!binding.getRoot().hasFocus()) { + binding.detailThumbnailRootLayout.requestFocus() } } } @@ -2505,43 +2126,37 @@ class VideoDetailFragment : * @param showMore whether main fragment should be expanded or not */ private fun manageSpaceAtTheBottom(showMore: Boolean) { - val peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height) + val peekHeight = resources.getDimensionPixelSize(R.dimen.mini_player_height) val holder = requireActivity().findViewById(R.id.fragment_holder) - val newBottomPadding: Int - if (showMore) { - newBottomPadding = 0 - } else { - newBottomPadding = peekHeight - } - if (holder.getPaddingBottom() == newBottomPadding) { + val newBottomPadding = if (showMore) 0 else peekHeight + if (holder.paddingBottom == newBottomPadding) { return } holder.setPadding( holder.getPaddingLeft(), - holder.getPaddingTop(), + holder.paddingTop, holder.getPaddingRight(), newBottomPadding ) } private fun setupBottomPlayer() { - val params = - binding!!.appBarLayout.getLayoutParams() as CoordinatorLayout.LayoutParams - val behavior = params.getBehavior() as AppBarLayout.Behavior? + val params = binding.appBarLayout.layoutParams as CoordinatorLayout.LayoutParams + val behavior = params.behavior as AppBarLayout.Behavior? val bottomSheetLayout = activity.findViewById(R.id.fragment_player_holder) bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout) - bottomSheetBehavior!!.setState(lastStableBottomSheetState) + bottomSheetBehavior.setState(lastStableBottomSheetState) updateBottomSheetState(lastStableBottomSheetState) - val peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height) + val peekHeight = resources.getDimensionPixelSize(R.dimen.mini_player_height) if (bottomSheetState != BottomSheetBehavior.STATE_HIDDEN) { manageSpaceAtTheBottom(false) - bottomSheetBehavior!!.setPeekHeight(peekHeight) + bottomSheetBehavior.peekHeight = peekHeight if (bottomSheetState == BottomSheetBehavior.STATE_COLLAPSED) { - binding!!.overlayLayout.setAlpha(MAX_OVERLAY_ALPHA) + binding.overlayLayout.setAlpha(MAX_OVERLAY_ALPHA) } else if (bottomSheetState == BottomSheetBehavior.STATE_EXPANDED) { - binding!!.overlayLayout.setAlpha(0f) + binding.overlayLayout.setAlpha(0f) setOverlayElementsClickable(false) } } @@ -2555,7 +2170,7 @@ class VideoDetailFragment : moveFocusToMainFragment(true) manageSpaceAtTheBottom(true) - bottomSheetBehavior!!.setPeekHeight(0) + bottomSheetBehavior.peekHeight = 0 cleanUp() } @@ -2563,45 +2178,41 @@ class VideoDetailFragment : moveFocusToMainFragment(false) manageSpaceAtTheBottom(false) - bottomSheetBehavior!!.setPeekHeight(peekHeight) + bottomSheetBehavior.peekHeight = peekHeight // Disable click because overlay buttons located on top of buttons // from the player setOverlayElementsClickable(false) hideSystemUiIfNeeded() // Conditions when the player should be expanded to fullscreen if (DeviceUtils.isLandscape(requireContext()) && - this@VideoDetailFragment.isPlayerAvailable && - player!!.isPlaying() && - !this@VideoDetailFragment.isFullscreen && !DeviceUtils.isTablet(activity) + player?.isPlaying == true && + !this@VideoDetailFragment.isFullscreen && + !DeviceUtils.isTablet(activity) ) { - player!!.UIs().get(MainPlayerUi::class.java)?.toggleFullscreen() + player!!.UIs().get(MainPlayerUi::class)?.toggleFullscreen() } - setOverlayLook(binding!!.appBarLayout, behavior, 1f) + setOverlayLook(binding.appBarLayout, behavior, 1f) } BottomSheetBehavior.STATE_COLLAPSED -> { moveFocusToMainFragment(true) manageSpaceAtTheBottom(false) - bottomSheetBehavior!!.setPeekHeight(peekHeight) + bottomSheetBehavior.peekHeight = peekHeight // Re-enable clicks setOverlayElementsClickable(true) - if (this@VideoDetailFragment.isPlayerAvailable) { - player!!.UIs().get(MainPlayerUi::class.java)?.closeItemsList() - } - setOverlayLook(binding!!.appBarLayout, behavior, 0f) + player?.UIs()?.get(MainPlayerUi::class)?.closeItemsList() + setOverlayLook(binding.appBarLayout, behavior, 0f) } BottomSheetBehavior.STATE_DRAGGING, BottomSheetBehavior.STATE_SETTLING -> { if (this@VideoDetailFragment.isFullscreen) { showSystemUi() } - if (this@VideoDetailFragment.isPlayerAvailable) { - player!!.UIs().get(MainPlayerUi::class.java)?.let { - if (it.isControlsVisible) { - it.hideControls(0, 0) - } + player?.UIs()?.get(MainPlayerUi::class)?.let { + if (it.isControlsVisible) { + it.hideControls(0, 0) } } } @@ -2611,33 +2222,23 @@ class VideoDetailFragment : } override fun onSlide(bottomSheet: View, slideOffset: Float) { - setOverlayLook(binding!!.appBarLayout, behavior, slideOffset) + setOverlayLook(binding.appBarLayout, behavior, slideOffset) } } - bottomSheetBehavior!!.addBottomSheetCallback(bottomSheetCallback!!) + bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback) // User opened a new page and the player will hide itself - activity.getSupportFragmentManager() - .addOnBackStackChangedListener( - FragmentManager.OnBackStackChangedListener { - if (bottomSheetBehavior!!.getState() == BottomSheetBehavior.STATE_EXPANDED) { - bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_COLLAPSED) - } - } - ) + activity.supportFragmentManager.addOnBackStackChangedListener { + if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED) + } + } } private fun updateOverlayPlayQueueButtonVisibility() { - val isPlayQueueEmpty = - player == null || // no player => no play queue :) - player!!.getPlayQueue() == null || player!!.getPlayQueue()!!.isEmpty() - if (binding != null) { - // binding is null when rotating the device... - binding!!.overlayPlayQueueButton.setVisibility( - if (isPlayQueueEmpty) View.GONE else View.VISIBLE - ) - } + // hide the button if the queue is empty; no player => no play queue :) + nullableBinding?.overlayPlayQueueButton?.isVisible = player?.playQueue?.isEmpty != true } private fun updateOverlayData( @@ -2645,18 +2246,15 @@ class VideoDetailFragment : uploader: String?, thumbnails: MutableList ) { - binding!!.overlayTitleTextView.setText(if (TextUtils.isEmpty(overlayTitle)) "" else overlayTitle) - binding!!.overlayChannelTextView.setText(if (TextUtils.isEmpty(uploader)) "" else uploader) - binding!!.overlayThumbnail.setImageDrawable(null) - CoilHelper.loadDetailsThumbnail(binding!!.overlayThumbnail, thumbnails) + binding.overlayTitleTextView.text = overlayTitle ?: "" + binding.overlayChannelTextView.text = uploader ?: "" + binding.overlayThumbnail.setImageDrawable(null) + loadDetailsThumbnail(binding.overlayThumbnail, thumbnails) } private fun setOverlayPlayPauseImage(playerIsPlaying: Boolean) { - val drawable = if (playerIsPlaying) - R.drawable.ic_pause - else - R.drawable.ic_play_arrow - binding!!.overlayPlayPauseButton.setImageResource(drawable) + val drawable = if (playerIsPlaying) R.drawable.ic_pause else R.drawable.ic_play_arrow + binding.overlayPlayPauseButton.setImageResource(drawable) } private fun setOverlayLook( @@ -2669,7 +2267,7 @@ class VideoDetailFragment : if (behavior == null || slideOffset < 0) { return } - binding!!.overlayLayout.setAlpha( + binding.overlayLayout.setAlpha( min( MAX_OVERLAY_ALPHA.toDouble(), (1 - slideOffset).toDouble() @@ -2677,35 +2275,28 @@ class VideoDetailFragment : ) // These numbers are not special. They just do a cool transition behavior.setTopAndBottomOffset( - (-binding!!.detailThumbnailImageView.getHeight() * 2 * (1 - slideOffset) / 3).toInt() + (-binding.detailThumbnailImageView.height * 2 * (1 - slideOffset) / 3).toInt() ) appBar.requestLayout() } private fun setOverlayElementsClickable(enable: Boolean) { - binding!!.overlayThumbnail.setClickable(enable) - binding!!.overlayThumbnail.setLongClickable(enable) - binding!!.overlayMetadataLayout.setClickable(enable) - binding!!.overlayMetadataLayout.setLongClickable(enable) - binding!!.overlayButtonsLayout.setClickable(enable) - binding!!.overlayPlayQueueButton.setClickable(enable) - binding!!.overlayPlayPauseButton.setClickable(enable) - binding!!.overlayCloseButton.setClickable(enable) + binding.overlayThumbnail.isClickable = enable + binding.overlayThumbnail.isLongClickable = enable + binding.overlayMetadataLayout.isClickable = enable + binding.overlayMetadataLayout.isLongClickable = enable + binding.overlayButtonsLayout.isClickable = enable + binding.overlayPlayQueueButton.isClickable = enable + binding.overlayPlayPauseButton.isClickable = enable + binding.overlayCloseButton.isClickable = enable } - val isPlayerAvailable: Boolean - // helpers to check the state of player and playerService - get() = player != null - fun noPlayerServiceAvailable(): Boolean { return playerService == null } - val isPlayerAndPlayerServiceAvailable: Boolean - get() = player != null && playerService != null - val root: View? - get() = player?.UIs()?.get(VideoPlayerUi::class.java)?.binding?.root + get() = player?.UIs()?.get(VideoPlayerUi::class)?.binding?.root private fun updateBottomSheetState(newState: Int) { bottomSheetState = newState @@ -2722,20 +2313,15 @@ class VideoDetailFragment : private const val MAX_OVERLAY_ALPHA = 0.9f private const val MAX_PLAYER_HEIGHT = 0.7f - @JvmField - val ACTION_SHOW_MAIN_PLAYER: String = + const val ACTION_SHOW_MAIN_PLAYER: String = App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER" - @JvmField - val ACTION_HIDE_MAIN_PLAYER: String = + const val ACTION_HIDE_MAIN_PLAYER: String = App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER" - @JvmField - val ACTION_PLAYER_STARTED: String = + const val ACTION_PLAYER_STARTED: String = App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_PLAYER_STARTED" - @JvmField - val ACTION_VIDEO_FRAGMENT_RESUMED: String = + const val ACTION_VIDEO_FRAGMENT_RESUMED: String = App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED" - @JvmField - val ACTION_VIDEO_FRAGMENT_STOPPED: String = + const val ACTION_VIDEO_FRAGMENT_STOPPED: String = App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED" private const val COMMENTS_TAB_TAG = "COMMENTS" @@ -2757,16 +2343,15 @@ class VideoDetailFragment : } @JvmStatic - val instanceInCollapsedState: VideoDetailFragment - get() { - val instance = VideoDetailFragment() - instance.updateBottomSheetState(BottomSheetBehavior.STATE_COLLAPSED) - return instance - } + fun getInstanceInCollapsedState(): VideoDetailFragment { + val instance = VideoDetailFragment() + instance.updateBottomSheetState(BottomSheetBehavior.STATE_COLLAPSED) + return instance + } /*////////////////////////////////////////////////////////////////////////// - // OwnStack - ////////////////////////////////////////////////////////////////////////// */ + // OwnStack + ////////////////////////////////////////////////////////////////////////// */ /** * Stack that contains the "navigation history".

* The peek is the current video. diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt index ad000a1cf..c03e09166 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -37,7 +37,6 @@ import org.schabi.newpipe.player.notification.NotificationPlayerUi import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.ThemeHelper import java.lang.ref.WeakReference -import java.util.function.BiConsumer import java.util.function.Consumer /** @@ -47,13 +46,13 @@ class PlayerService : MediaBrowserServiceCompat() { // These objects are used to cleanly separate the Service implementation (in this file) and the // media browser and playback preparer implementations. At the moment the playback preparer is // only used in conjunction with the media browser. - private var mediaBrowserImpl: MediaBrowserImpl? = null - private var mediaBrowserPlaybackPreparer: MediaBrowserPlaybackPreparer? = null + private lateinit var mediaBrowserImpl: MediaBrowserImpl + private lateinit var mediaBrowserPlaybackPreparer: MediaBrowserPlaybackPreparer // these are instantiated in onCreate() as per // https://developer.android.com/training/cars/media#browser_workflow - private var mediaSession: MediaSessionCompat? = null - private var sessionConnector: MediaSessionConnector? = null + private lateinit var mediaSession: MediaSessionCompat + private lateinit var sessionConnector: MediaSessionConnector /** * @return the current active player instance. May be null, since the player service can outlive @@ -68,7 +67,7 @@ class PlayerService : MediaBrowserServiceCompat() { * The parameter taken by this [Consumer] can be null to indicate the player is being * stopped. */ - private var onPlayerStartedOrStopped: Consumer? = null + private var onPlayerStartedOrStopped: ((player: Player?) -> Unit)? = null //region Service lifecycle override fun onCreate() { @@ -80,14 +79,7 @@ class PlayerService : MediaBrowserServiceCompat() { Localization.assureCorrectAppLanguage(this) ThemeHelper.setTheme(this) - mediaBrowserImpl = MediaBrowserImpl( - this, - Consumer { parentId: String -> - this.notifyChildrenChanged( - parentId - ) - } - ) + mediaBrowserImpl = MediaBrowserImpl(this, this::notifyChildrenChanged) // see https://developer.android.com/training/cars/media#browser_workflow val session = MediaSessionCompat(this, "MediaSessionPlayerServ") @@ -98,17 +90,10 @@ class PlayerService : MediaBrowserServiceCompat() { connector.setMetadataDeduplicationEnabled(true) mediaBrowserPlaybackPreparer = MediaBrowserPlaybackPreparer( - this, - BiConsumer { message: String, code: Int -> - connector.setCustomErrorMessage( - message, - code - ) - }, - Runnable { connector.setCustomErrorMessage(null) }, - Consumer { playWhenReady: Boolean? -> - player?.onPrepare() - } + context = this, + setMediaSessionError = connector::setCustomErrorMessage, + clearMediaSessionError = { connector.setCustomErrorMessage(null) }, + onPrepare = { player?.onPrepare() } ) connector.setPlaybackPreparer(mediaBrowserPlaybackPreparer) @@ -125,11 +110,8 @@ class PlayerService : MediaBrowserServiceCompat() { if (DEBUG) { Log.d( TAG, - ( - "onStartCommand() called with: intent = [" + intent + - "], extras = [" + intent.extras.toDebugString() + - "], flags = [" + flags + "], startId = [" + startId + "]" - ) + "onStartCommand() called with: intent = [$intent], extras = [${ + intent.extras.toDebugString()}], flags = [$flags], startId = [$startId]" ) } @@ -140,7 +122,7 @@ class PlayerService : MediaBrowserServiceCompat() { val playerWasNull = (player == null) if (playerWasNull) { // make sure the player exists, in case the service was resumed - player = Player(this, mediaSession!!, sessionConnector!!) + player = Player(this, mediaSession, sessionConnector) } // Be sure that the player notification is set and the service is started in foreground, @@ -150,35 +132,29 @@ class PlayerService : MediaBrowserServiceCompat() { // 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() + player?.UIs()?.get(NotificationPlayerUi::class)?.createNotificationAndStartForeground() - val startedOrStopped = onPlayerStartedOrStopped - if (playerWasNull && startedOrStopped != null) { + if (playerWasNull) { // notify that a new player was created (but do it after creating the foreground // notification just to make sure we don't incur, due to slowness, in // "Context.startForegroundService() did not then call Service.startForeground()") - startedOrStopped.accept(player) + onPlayerStartedOrStopped?.invoke(player) } } val p = player - if (Intent.ACTION_MEDIA_BUTTON == intent.action && - (p == null || p.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 - */ + if (Intent.ACTION_MEDIA_BUTTON == intent.action && p?.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 destroyPlayerAndStopService() return START_NOT_STICKY } if (p != null) { p.handleIntent(intent) - p.UIs().get(MediaSessionPlayerUi::class.java) + p.UIs().get(MediaSessionPlayerUi::class) ?.handleMediaButtonIntent(intent) } @@ -218,22 +194,22 @@ class PlayerService : MediaBrowserServiceCompat() { cleanup() - mediaBrowserPlaybackPreparer?.dispose() - mediaSession?.release() - mediaBrowserImpl?.dispose() + mediaBrowserPlaybackPreparer.dispose() + mediaSession.release() + mediaBrowserImpl.dispose() } private fun cleanup() { val p = player if (p != null) { // notify that the player is being destroyed - onPlayerStartedOrStopped?.accept(null) + onPlayerStartedOrStopped?.invoke(null) p.saveAndShutdown() player = null } // Should already be handled by MediaSessionPlayerUi, but just to be sure. - mediaSession?.setActive(false) + mediaSession.setActive(false) // Should already be handled by NotificationUtil.cancelNotificationAndStopForeground() in // NotificationPlayerUi, but let's make sure that the foreground service is stopped. @@ -273,24 +249,22 @@ class PlayerService : MediaBrowserServiceCompat() { if (DEBUG) { Log.d( TAG, - ( - "onBind() called with: intent = [" + intent + - "], extras = [" + intent.extras.toDebugString() + "]" - ) + "onBind() called with: intent = [$intent], extras = [${ + intent.extras.toDebugString()}]" ) } - if (BIND_PLAYER_HOLDER_ACTION == intent.action) { + return if (BIND_PLAYER_HOLDER_ACTION == intent.action) { // Note that this binder might be reused multiple times while the service is alive, even // after unbind() has been called: https://stackoverflow.com/a/8794930 . - return mBinder + mBinder } else if (SERVICE_INTERFACE == intent.action) { // MediaBrowserService also uses its own binder, so for actions related to the media // browser service, pass the onBind to the superclass. - return super.onBind(intent) + super.onBind(intent) } else { // This is an unknown request, avoid returning any binder to not leak objects. - return null + null } } @@ -307,9 +281,9 @@ class PlayerService : MediaBrowserServiceCompat() { * by the [Consumer] can be null to indicate that the player is stopping. * @param listener the listener to set or unset */ - fun setPlayerListener(listener: Consumer?) { + fun setPlayerListener(listener: ((player: Player?) -> Unit)?) { this.onPlayerStartedOrStopped = listener - listener?.accept(player) + listener?.invoke(player) } //endregion @@ -320,14 +294,14 @@ class PlayerService : MediaBrowserServiceCompat() { rootHints: Bundle? ): BrowserRoot? { // TODO check if the accessing package has permission to view data - return mediaBrowserImpl?.onGetRoot(clientPackageName, clientUid, rootHints) + return mediaBrowserImpl.onGetRoot(clientPackageName, clientUid, rootHints) } override fun onLoadChildren( parentId: String, result: Result> ) { - mediaBrowserImpl?.onLoadChildren(parentId, result) + mediaBrowserImpl.onLoadChildren(parentId, result) } override fun onSearch( @@ -335,7 +309,7 @@ class PlayerService : MediaBrowserServiceCompat() { extras: Bundle?, result: Result> ) { - mediaBrowserImpl?.onSearch(query, result) + mediaBrowserImpl.onSearch(query, result) } //endregion companion object { diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt index 06b4f8bba..1b0cedfc5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt @@ -20,7 +20,6 @@ import org.schabi.newpipe.player.event.PlayerServiceEventListener import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener import org.schabi.newpipe.player.playqueue.PlayQueue import org.schabi.newpipe.util.NavigationHelper -import java.util.function.Consumer private val DEBUG = MainActivity.DEBUG private val TAG: String = PlayerHolder::class.java.getSimpleName() @@ -40,9 +39,9 @@ object PlayerHolder { private val player: Player? get() = playerService?.player + // player play queue might be null e.g. while player is starting private val playQueue: PlayQueue? - get() = // player play queue might be null e.g. while player is starting - this.player?.playQueue + get() = this.player?.playQueue val type: PlayerType? /** @@ -78,8 +77,8 @@ object PlayerHolder { // Force reload data from service newListener?.let { listener -> - playerService?.let { - listener.onServiceConnected(it) + playerService?.let { service -> + listener.onServiceConnected(service) startPlayerListener() // ^ will call listener.onPlayerConnected() down the line if there is an active player } @@ -103,7 +102,7 @@ object PlayerHolder { newListener: PlayerServiceExtendedEventListener? ) { if (DEBUG) { - Log.d(TAG, "startService() called with playAfterConnect=" + playAfterConnect) + Log.d(TAG, "startService() called with playAfterConnect=$playAfterConnect") } val context = this.commonContext setListener(newListener) @@ -162,21 +161,15 @@ object PlayerHolder { val s = localBinder.service requireNotNull(s) { - ( - "PlayerService.LocalBinder.getService() must never be" + - "null after the service connects" - ) + "PlayerService.LocalBinder.getService() must never be" + + "null after the service connects" } playerService = s - val l = listener - if (l != null) { + listener?.let { l -> l.onServiceConnected(s) - player?.let { - l.onPlayerConnected(it, playAfterConnect) - } + player?.let { l.onPlayerConnected(it, playAfterConnect) } } startPlayerListener() - // ^ will call listener.onPlayerConnected() down the line if there is an active player // notify the main activity that binding the service has completed, so that it can @@ -305,9 +298,8 @@ object PlayerHolder { * or stopping. This is necessary since the service outlives the player e.g. to answer Android * Auto media browser queries. */ - private val playerStateListener = Consumer { player: Player? -> - val l = listener - if (l != null) { + private val playerStateListener: (Player?) -> Unit = { player: Player? -> + listener?.let { l -> if (player == null) { // player.fragmentListener=null is already done by player.stopActivityBinding(), // which is called by player.destroy(), which is in turn called by PlayerService diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt index f15d7ab08..c66409b69 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt @@ -36,7 +36,6 @@ import org.schabi.newpipe.local.playlist.RemotePlaylistManager import org.schabi.newpipe.util.ExtractorHelper import org.schabi.newpipe.util.ServiceHelper import org.schabi.newpipe.util.image.ImageStrategy -import java.util.function.Consumer /** * This class is used to cleanly separate the Service implementation (in @@ -46,16 +45,14 @@ import java.util.function.Consumer */ class MediaBrowserImpl( private val context: Context, - notifyChildrenChanged: Consumer, // parentId + notifyChildrenChanged: (parentId: String) -> Unit, ) { private val database = NewPipeDatabase.getInstance(context) private var disposables = CompositeDisposable() init { // this will listen to changes in the bookmarks until this MediaBrowserImpl is dispose()d - disposables.add( - getMergedPlaylists().subscribe { notifyChildrenChanged.accept(ID_BOOKMARKS) } - ) + disposables.add(getMergedPlaylists().subscribe { notifyChildrenChanged(ID_BOOKMARKS) }) } //region Cleanup @@ -204,6 +201,7 @@ class MediaBrowserImpl( val builder = MediaDescriptionCompat.Builder() builder.setMediaId(createMediaIdForInfoItem(item)) .setTitle(item.name) + .setIconUri(ImageStrategy.choosePreferredImage(item.thumbnails)?.toUri()) when (item.infoType) { InfoType.STREAM -> builder.setSubtitle((item as StreamInfoItem).uploaderName) @@ -212,10 +210,6 @@ class MediaBrowserImpl( else -> return null } - ImageStrategy.choosePreferredImage(item.thumbnails)?.let { - builder.setIconUri(imageUriOrNullIfDisabled(it)) - } - return MediaBrowserCompat.MediaItem( builder.build(), MediaBrowserCompat.MediaItem.FLAG_PLAYABLE @@ -276,10 +270,7 @@ class MediaBrowserImpl( builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index)) .setTitle(item.name) .setSubtitle(item.uploaderName) - - ImageStrategy.choosePreferredImage(item.thumbnails)?.let { - builder.setIconUri(imageUriOrNullIfDisabled(it)) - } + .setIconUri(ImageStrategy.choosePreferredImage(item.thumbnails)?.toUri()) return MediaBrowserCompat.MediaItem( builder.build(), 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 ef9c6f3c2..57271dd13 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,6 +1,8 @@ package org.schabi.newpipe.player.ui import org.schabi.newpipe.util.GuardedByMutex +import kotlin.reflect.KClass +import kotlin.reflect.safeCast /** * Creates a [PlayerUiList] starting with the provided player uis. The provided player uis @@ -84,20 +86,20 @@ 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? = + fun get(playerUiType: KClass): 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 - } + // try all UIs before returning null + playerUiType.safeCast(ui)?.let { return@runWithLockSync it } } } return@runWithLockSync null } + fun get(playerUiType: Class): T? = + get(playerUiType.kotlin) + /** * 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 317db719db8b78f48b3853e6cd19f442e130e8d8 Mon Sep 17 00:00:00 2001 From: Stypox Date: Mon, 9 Jun 2025 18:20:06 +0200 Subject: [PATCH 15/18] Fix comments in PlayerUiList --- .../org/schabi/newpipe/player/ui/PlayerUiList.kt | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) 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 57271dd13..5419027a5 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 @@ -18,15 +18,7 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { /** * Adds the provided player ui to the list and calls on it the initialization functions that - /** - * 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 - */* apply based on the current player state. The preparation step needs to be done since when UIs + * 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. @@ -97,6 +89,9 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { return@runWithLockSync null } + /** + * See [get] above + */ fun get(playerUiType: Class): T? = get(playerUiType.kotlin) From a4182b474b2df698aeb86304489b58f6d2838cad Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 10 Jun 2025 18:30:56 +0200 Subject: [PATCH 16/18] More improve Kotlin converted from java in various places --- .../fragments/detail/VideoDetailFragment.kt | 268 ++++++++---------- .../player/mediabrowser/MediaBrowserImpl.kt | 18 +- 2 files changed, 122 insertions(+), 164 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index db87f37dc..bd8a950c7 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -7,7 +7,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.SharedPreferences import android.content.SharedPreferences.OnSharedPreferenceChangeListener import android.content.pm.ActivityInfo import android.database.ContentObserver @@ -18,7 +17,6 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.provider.Settings -import android.text.TextUtils import android.util.DisplayMetrics import android.util.Log import android.util.TypedValue @@ -44,14 +42,14 @@ import androidx.core.content.ContextCompat import androidx.core.content.edit import androidx.core.net.toUri import androidx.core.os.postDelayed +import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.preference.PreferenceManager -import coil3.util.CoilUtils.dispose +import coil3.util.CoilUtils import com.evernote.android.state.State import com.google.android.exoplayer2.PlaybackException import com.google.android.exoplayer2.PlaybackParameters import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers @@ -115,17 +113,15 @@ import org.schabi.newpipe.util.StreamTypeUtil import org.schabi.newpipe.util.ThemeHelper import org.schabi.newpipe.util.external_communication.KoreUtils import org.schabi.newpipe.util.external_communication.ShareUtils -import org.schabi.newpipe.util.image.CoilHelper.loadAvatar -import org.schabi.newpipe.util.image.CoilHelper.loadDetailsThumbnail +import org.schabi.newpipe.util.image.CoilHelper import java.util.LinkedList -import java.util.Objects import java.util.concurrent.TimeUnit import kotlin.math.abs import kotlin.math.max import kotlin.math.min class VideoDetailFragment : - BaseStateFragment(), + BaseStateFragment(), BackPressable, PlayerServiceExtendedEventListener, OnKeyDownListener { @@ -160,15 +156,15 @@ class VideoDetailFragment : private var lastAppBarVerticalOffset = Int.Companion.MAX_VALUE // prevents useless updates private val preferenceChangeListener = - OnSharedPreferenceChangeListener { sharedPreferences: SharedPreferences?, key: String? -> + OnSharedPreferenceChangeListener { sharedPreferences, key -> if (getString(R.string.show_comments_key) == key) { - showComments = sharedPreferences!!.getBoolean(key, true) + showComments = sharedPreferences.getBoolean(key, true) tabSettingsChanged = true } else if (getString(R.string.show_next_video_key) == key) { - showRelatedItems = sharedPreferences!!.getBoolean(key, true) + showRelatedItems = sharedPreferences.getBoolean(key, true) tabSettingsChanged = true } else if (getString(R.string.show_description_key) == key) { - showDescription = sharedPreferences!!.getBoolean(key, true) + showDescription = sharedPreferences.getBoolean(key, true) tabSettingsChanged = true } } @@ -198,11 +194,11 @@ class VideoDetailFragment : // It will do nothing if the player is not in fullscreen mode hideSystemUiIfNeeded() - val mainUi = player?.UIs()?.get(MainPlayerUi::class) if (player?.videoPlayerSelected() != true && !playAfterConnect) { return } + val mainUi = player?.UIs()?.get(MainPlayerUi::class) if (DeviceUtils.isLandscape(requireContext())) { // If the video is playing but orientation changed // let's make the video in fullscreen again @@ -252,7 +248,7 @@ class VideoDetailFragment : setupBroadcastReceiver() - settingsContentObserver = object : ContentObserver(Handler()) { + settingsContentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(selfChange: Boolean) { if (activity != null && !PlayerHelper.globalScreenOrientationLocked(activity)) { activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) @@ -277,9 +273,7 @@ class VideoDetailFragment : override fun onPause() { super.onPause() - if (currentWorker != null) { - currentWorker!!.dispose() - } + currentWorker?.dispose() restoreDefaultBrightness() PreferenceManager.getDefaultSharedPreferences(requireContext()).edit { putString( @@ -304,9 +298,7 @@ class VideoDetailFragment : if (tabSettingsChanged) { tabSettingsChanged = false initTabs() - if (currentInfo != null) { - updateTabs(currentInfo!!) - } + currentInfo?.let { updateTabs(it) } } // Check if it was loading when the fragment was stopped/paused @@ -339,12 +331,8 @@ class VideoDetailFragment : activity.unregisterReceiver(broadcastReceiver) activity.contentResolver.unregisterContentObserver(settingsContentObserver!!) - if (positionSubscriber != null) { - positionSubscriber!!.dispose() - } - if (currentWorker != null) { - currentWorker!!.dispose() - } + positionSubscriber?.dispose() + currentWorker?.dispose() disposables.clear() positionSubscriber = null currentWorker = null @@ -353,7 +341,7 @@ class VideoDetailFragment : if (activity.isFinishing) { playQueue = null currentInfo = null - stack = LinkedList() + stack = LinkedList() } } @@ -367,8 +355,7 @@ class VideoDetailFragment : if (requestCode == ReCaptchaActivity.RECAPTCHA_REQUEST) { if (resultCode == Activity.RESULT_OK) { NavigationHelper.openVideoDetailFragment( - requireContext(), getFM(), - serviceId, url, title, null, false + requireContext(), getFM(), serviceId, url, title, null, false ) } else { Log.e(TAG, "ReCaptcha failed") @@ -385,11 +372,11 @@ class VideoDetailFragment : binding.detailTitleRootLayout.setOnClickListener { toggleTitleAndSecondaryControls() } binding.detailUploaderRootLayout.setOnClickListener( makeOnClickListener { info -> - if (TextUtils.isEmpty(info.subChannelUrl)) { - if (!TextUtils.isEmpty(info.uploaderUrl)) { + if (info.subChannelUrl.isEmpty()) { + if (info.uploaderUrl.isNotEmpty()) { openChannel(info.uploaderUrl, info.uploaderName, info.serviceId) } else if (DEBUG) { - Log.i(TAG, "Can't open sub-channel because we got no channel URL") + Log.w(TAG, "Can't open sub-channel because we got no channel URL") } } else { openChannel(info.subChannelUrl, info.subChannelName, info.serviceId) @@ -495,7 +482,7 @@ class VideoDetailFragment : } binding.detailUploaderRootLayout.setOnLongClickListener( makeOnLongClickListener { info -> - if (TextUtils.isEmpty(info.subChannelUrl)) { + if (info.subChannelUrl.isEmpty()) { Log.w(TAG, "Can't open parent channel because we got no parent channel URL") } else { openChannel(info.uploaderUrl, info.uploaderName, info.serviceId) @@ -541,7 +528,7 @@ class VideoDetailFragment : } private fun toggleTitleAndSecondaryControls() { - if (binding.detailSecondaryControlPanel.visibility == View.GONE) { + if (binding.detailSecondaryControlPanel.isGone) { binding.detailVideoTitleView.setMaxLines(10) binding.detailToggleSecondaryControlsView .animateRotation(VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 180) @@ -569,18 +556,12 @@ class VideoDetailFragment : binding.detailThumbnailRootLayout.requestFocus() - binding.detailControlsPlayWithKodi.visibility = - if (KoreUtils.shouldShowPlayWithKodi(requireContext(), serviceId)) - View.VISIBLE - else - View.GONE - binding.detailControlsCrashThePlayer.visibility = - if (DEBUG && PreferenceManager.getDefaultSharedPreferences(requireContext()) - .getBoolean(getString(R.string.show_crash_the_player_key), false) - ) - View.VISIBLE - else - View.GONE + binding.detailControlsPlayWithKodi.isVisible = + KoreUtils.shouldShowPlayWithKodi(requireContext(), serviceId) + binding.detailControlsCrashThePlayer.isVisible = + DEBUG && PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getBoolean(getString(R.string.show_crash_the_player_key), false) + accommodateForTvAndDesktopMode() } @@ -591,8 +572,8 @@ class VideoDetailFragment : setOnClickListeners() setOnLongClickListeners() - val controlsTouchListener = OnTouchListener { view: View?, motionEvent: MotionEvent? -> - if (motionEvent!!.action == MotionEvent.ACTION_DOWN && + val controlsTouchListener = OnTouchListener { view, motionEvent -> + if (motionEvent.action == MotionEvent.ACTION_DOWN && PlayButtonHelper.shouldShowHoldToAppendTip(activity) ) { binding.touchAppendDetail.animate(true, 250, AnimationType.ALPHA, 0) { @@ -604,16 +585,14 @@ class VideoDetailFragment : binding.detailControlsBackground.setOnTouchListener(controlsTouchListener) binding.detailControlsPopup.setOnTouchListener(controlsTouchListener) - binding.appBarLayout.addOnOffsetChangedListener( - OnOffsetChangedListener { layout: AppBarLayout?, verticalOffset: Int -> - // prevent useless updates to tab layout visibility if nothing changed - if (verticalOffset != lastAppBarVerticalOffset) { - lastAppBarVerticalOffset = verticalOffset - // the view was scrolled - updateTabLayoutVisibility() - } + binding.appBarLayout.addOnOffsetChangedListener { layout, verticalOffset -> + // prevent useless updates to tab layout visibility if nothing changed + if (verticalOffset != lastAppBarVerticalOffset) { + lastAppBarVerticalOffset = verticalOffset + // the view was scrolled + updateTabLayoutVisibility() } - ) + } setupBottomPlayer() if (!PlayerHolder.isBound) { @@ -656,7 +635,7 @@ class VideoDetailFragment : // Remove top stack.pop() // Get stack item from the new top - setupFromHistoryItem(Objects.requireNonNull(stack.peek())) + setupFromHistoryItem(stack.peek()!!) return true } @@ -688,10 +667,9 @@ class VideoDetailFragment : return } - currentInfo?.let { info -> - prepareAndHandleInfoIfNeededAfterDelay(info, false, 50) - } ?: { - prepareAndLoadInfo() + when (val info = currentInfo) { + null -> prepareAndLoadInfo() + else -> prepareAndHandleInfoIfNeededAfterDelay(info, false, 50) } } @@ -756,9 +734,7 @@ class VideoDetailFragment : initTabs() currentInfo = null - if (currentWorker != null) { - currentWorker!!.dispose() - } + currentWorker?.dispose() runWorker(forceLoad, addToBackStack ?: stack.isEmpty()) } @@ -783,7 +759,7 @@ class VideoDetailFragment : if (playQueue == null) { playQueue = SinglePlayQueue(result) } - if (stack.isEmpty() || stack.peek()!!.playQueue != playQueue) { + if (stack.peek()?.playQueue != playQueue) { // also if stack empty (!) stack.push(StackItem(serviceId, url, title, playQueue)) } } @@ -866,13 +842,14 @@ class VideoDetailFragment : private fun updateTabs(info: StreamInfo) { if (showRelatedItems) { - if (binding.relatedItemsLayout == null) { // phone - pageAdapter.updateItem(RELATED_TAB_TAG, getInstance(info)) - } else { // tablet + TV - getChildFragmentManager().beginTransaction() - .replace(R.id.relatedItemsLayout, getInstance(info)) - .commitAllowingStateLoss() - binding.relatedItemsLayout!!.isVisible = !this.isFullscreen + when (val relatedItemsLayout = binding.relatedItemsLayout) { + null -> pageAdapter.updateItem(RELATED_TAB_TAG, getInstance(info)) // phone + else -> { // tablet + TV + getChildFragmentManager().beginTransaction() + .replace(R.id.relatedItemsLayout, getInstance(info)) + .commitAllowingStateLoss() + relatedItemsLayout.isVisible = !this.isFullscreen + } } } @@ -909,8 +886,7 @@ class VideoDetailFragment : // call `post()` to be sure `viewPager.getHitRect()` // is up to date and not being currently recomputed binding.tabLayout.post { - val activity = getActivity() - if (activity != null) { + getActivity()?.let { activity -> val pagerHitRect = Rect() binding.viewPager.getHitRect(pagerHitRect) @@ -1056,7 +1032,7 @@ class VideoDetailFragment : } private fun openMainPlayer() { - if (noPlayerServiceAvailable()) { + if (playerService == null) { PlayerHolder.startService(autoPlayEnabled, this) return } @@ -1082,13 +1058,13 @@ class VideoDetailFragment : */ private fun hideMainPlayerOnLoadingNewStream() { val root = this.root - if (noPlayerServiceAvailable() || root == null || !player!!.videoPlayerSelected()) { + if (root == null || playerService == null || player?.videoPlayerSelected() != true) { return } removeVideoPlayerView() if (this.isAutoplayEnabled) { - playerService!!.stopForImmediateReusing() + playerService?.stopForImmediateReusing() root.visibility = View.GONE } else { PlayerHolder.stopService() @@ -1127,8 +1103,11 @@ class VideoDetailFragment : val recordManager = HistoryRecordManager(requireContext()) disposables.add( - recordManager.onViewed(info).onErrorComplete() - .subscribe({ }, { throwable -> Log.e(TAG, "Register view failure: ", throwable) }) + recordManager.onViewed(info) + .subscribe( + { /* successful */ }, + { throwable -> Log.e(TAG, "Register view failure: ", throwable) } + ) ) } @@ -1157,8 +1136,10 @@ class VideoDetailFragment : if (player == null || view == null) { return@post } + // setup the surface view height, so that it fits the video correctly setHeightThumbnail() + player?.UIs()?.get(MainPlayerUi::class)?.let { playerUi -> // sometimes binding would be null here, even though getView() != null above u.u nullableBinding?.let { b -> @@ -1186,13 +1167,13 @@ class VideoDetailFragment : } private val preDrawListener: OnPreDrawListener = OnPreDrawListener { - if (view != null) { + view?.let { view -> val decorView = if (DeviceUtils.isInMultiWindow(activity)) - requireView() + view else activity.window.decorView setHeightThumbnail(decorView.height, resources.displayMetrics) - requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener) + view.getViewTreeObserver().removeOnPreDrawListener(preDrawListener) } return@OnPreDrawListener false } @@ -1366,20 +1347,20 @@ class VideoDetailFragment : binding.relatedItemsLayout?.isVisible = showRelatedItems && !this.isFullscreen - dispose(binding.detailThumbnailImageView) - dispose(binding.detailSubChannelThumbnailView) - dispose(binding.overlayThumbnail) - dispose(binding.detailUploaderThumbnailView) + CoilUtils.dispose(binding.detailThumbnailImageView) + CoilUtils.dispose(binding.detailSubChannelThumbnailView) + CoilUtils.dispose(binding.overlayThumbnail) + CoilUtils.dispose(binding.detailUploaderThumbnailView) binding.detailThumbnailImageView.setImageBitmap(null) binding.detailSubChannelThumbnailView.setImageBitmap(null) } - override fun handleResult(info: StreamInfo?) { + override fun handleResult(info: StreamInfo) { super.handleResult(info) currentInfo = info - setInitialData(info!!.serviceId, info.originalUrl, info.name, playQueue) + setInitialData(info.serviceId, info.originalUrl, info.name, playQueue) updateTabs(info) @@ -1388,10 +1369,10 @@ class VideoDetailFragment : binding.detailSubChannelThumbnailView.visibility = View.GONE - if (!TextUtils.isEmpty(info.subChannelName)) { - displayBothUploaderAndSubChannel(info) - } else { + if (info.subChannelName.isEmpty()) { displayUploaderAsSubChannel(info) + } else { + displayBothUploaderAndSubChannel(info) } if (info.viewCount >= 0) { @@ -1459,10 +1440,7 @@ class VideoDetailFragment : binding.detailSecondaryControlPanel.visibility = View.GONE checkUpdateProgressInfo(info) - loadDetailsThumbnail( - binding.detailThumbnailImageView, - info.thumbnails - ) + CoilHelper.loadDetailsThumbnail(binding.detailThumbnailImageView, info.thumbnails) ExtractorHelper.showMetaInfoInTextView( info.metaInfo, binding.detailMetaInfoTextView, binding.detailMetaInfoSeparator, disposables @@ -1475,12 +1453,8 @@ class VideoDetailFragment : if (!info.errors.isEmpty()) { // Bandcamp fan pages are not yet supported and thus a ContentNotAvailableException is // thrown. This is not an error and thus should not be shown to the user. - for (throwable in info.errors) { - if (throwable is ContentNotSupportedException && - "Fan pages are not supported" == throwable.message - ) { - info.errors.remove(throwable) - } + info.errors.removeIf { + it is ContentNotSupportedException && "Fan pages are not supported" == it.message } if (!info.errors.isEmpty()) { @@ -1490,8 +1464,9 @@ class VideoDetailFragment : } } - val hasAudioStreams = info.videoStreams.isNotEmpty() || info.audioStreams.isNotEmpty() binding.detailControlsDownload.isVisible = !StreamTypeUtil.isLiveStream(info.streamType) + + val hasAudioStreams = info.videoStreams.isNotEmpty() || info.audioStreams.isNotEmpty() binding.detailControlsBackground.isVisible = hasAudioStreams val hasVideoStreams = info.videoStreams.isNotEmpty() || info.videoOnlyStreams.isNotEmpty() @@ -1514,7 +1489,7 @@ class VideoDetailFragment : binding.detailUploaderTextView.visibility = View.GONE } - loadAvatar(binding.detailSubChannelThumbnailView, info.uploaderAvatars) + CoilHelper.loadAvatar(binding.detailSubChannelThumbnailView, info.uploaderAvatars) binding.detailSubChannelThumbnailView.visibility = View.VISIBLE binding.detailUploaderThumbnailView.visibility = View.GONE } @@ -1525,8 +1500,8 @@ class VideoDetailFragment : binding.detailSubChannelTextView.setSelected(true) val subText = StringBuilder() - if (!TextUtils.isEmpty(info.uploaderName)) { - subText.append(String.format(getString(R.string.video_detail_by), info.uploaderName)) + if (info.uploaderName.isNotEmpty()) { + subText.append(getString(R.string.video_detail_by, info.uploaderName)) } if (info.uploaderSubscriberCount > -1) { if (subText.isNotEmpty()) { @@ -1545,26 +1520,22 @@ class VideoDetailFragment : binding.detailUploaderTextView.setSelected(true) } - loadAvatar(binding.detailSubChannelThumbnailView, info.subChannelAvatars) + CoilHelper.loadAvatar(binding.detailSubChannelThumbnailView, info.subChannelAvatars) binding.detailSubChannelThumbnailView.visibility = View.VISIBLE - loadAvatar(binding.detailUploaderThumbnailView, info.uploaderAvatars) + CoilHelper.loadAvatar(binding.detailUploaderThumbnailView, info.uploaderAvatars) binding.detailUploaderThumbnailView.visibility = View.VISIBLE } fun openDownloadDialog() { - if (currentInfo == null) { - return - } + val info = currentInfo ?: return try { - val downloadDialog = DownloadDialog(activity, currentInfo!!) + val downloadDialog = DownloadDialog(activity, info) downloadDialog.show(activity.supportFragmentManager, "downloadDialog") } catch (e: Exception) { showSnackbar( activity, - ErrorInfo( - e, UserAction.DOWNLOAD_OPEN_DIALOG, "Showing download dialog", currentInfo - ) + ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG, "Showing download dialog", info) ) } } @@ -1586,7 +1557,7 @@ class VideoDetailFragment : .observeOn(AndroidSchedulers.mainThread()) .subscribe( { state -> updatePlaybackProgress(state.progressMillis, info.duration * 1000) }, - { throwable -> /* ignore errors */ }, + { throwable -> /* impossible due to the onErrorComplete() */ }, { /* onComplete */ binding.positionView.visibility = View.GONE binding.detailPositionView.visibility = View.GONE @@ -1600,11 +1571,10 @@ class VideoDetailFragment : } val progressSeconds = TimeUnit.MILLISECONDS.toSeconds(progress).toInt() val durationSeconds = TimeUnit.MILLISECONDS.toSeconds(duration).toInt() + binding.positionView.setMax(durationSeconds) // If the old and the new progress values have a big difference then use animation. // Otherwise don't because it affects CPU - val progressDifference = abs(binding.positionView.progress - progressSeconds) - binding.positionView.setMax(durationSeconds) - if (progressDifference > 2) { + if (abs(binding.positionView.progress - progressSeconds) > 2) { binding.positionView.setProgressAnimated(progressSeconds) } else { binding.positionView.progress = progressSeconds @@ -1637,15 +1607,15 @@ class VideoDetailFragment : } // Register broadcast receiver to listen to playQueue changes - // and hide the overlayPlayQueueButton when the playQueue is empty / destroyed. + // and hide the overlayPlayQueueButton when the playQueue is empty / destroyed.7 playQueue?.broadcastReceiver?.subscribe { updateOverlayPlayQueueButtonVisibility() } + ?.let { disposables.add(it) } // This should be the only place where we push data to stack. // It will allow to have live instance of PlayQueue with actual information about // deleted/added items inside Channel/Playlist queue and makes possible to have // a history of played items - val stackPeek: StackItem? = stack.peek() - if (stackPeek != null && stackPeek.playQueue != queue) { + if (stack.peek()?.playQueue?.equals(queue) == false) { queue.item?.let { queueItem -> stack.push(StackItem(queueItem.serviceId, queueItem.url, queueItem.title, queue)) return@onQueueUpdate @@ -1703,7 +1673,7 @@ class VideoDetailFragment : } updateOverlayData(info.name, info.uploaderName, info.thumbnails) - if (currentInfo?.url == info.url) { + if (info.url == currentInfo?.url) { return } @@ -1780,17 +1750,15 @@ class VideoDetailFragment : * Will scroll down to description view after long click on moreOptionsButton * */ override fun onMoreOptionsLongClicked() { - val params = - binding.appBarLayout.layoutParams as CoordinatorLayout.LayoutParams + val params = binding.appBarLayout.layoutParams as CoordinatorLayout.LayoutParams val behavior = params.behavior as AppBarLayout.Behavior val valueAnimator = ValueAnimator.ofInt(0, -binding.playerPlaceholder.height) - valueAnimator.interpolator = DecelerateInterpolator() valueAnimator.addUpdateListener { animation -> behavior.setTopAndBottomOffset(animation.getAnimatedValue() as Int) binding.appBarLayout.requestLayout() } valueAnimator.interpolator = DecelerateInterpolator() - valueAnimator.setDuration(500) + valueAnimator.duration = 500 valueAnimator.start() } @@ -1867,9 +1835,11 @@ class VideoDetailFragment : private val isFullscreen: Boolean get() = player?.UIs()?.get(VideoPlayerUi::class)?.isFullscreen == true + /** + * @return true if the player is null, or if the player is nonnull but is stopped. + */ @Suppress("NullableBooleanElvis") // rewriting as "!= false" creates more confusion private val playerIsStopped - // returns true if the player is null, or if the player is nonnull but is stopped get() = player?.isStopped ?: true private fun restoreDefaultBrightness() { @@ -2017,9 +1987,8 @@ class VideoDetailFragment : .map { it.getResolution() as CharSequence } .toTypedArray() - builder.setSingleChoiceItems( - resolutions, selectedVideoStreamIndexForExternalPlayers, null - ) + builder + .setSingleChoiceItems(resolutions, selectedVideoStreamIndexForExternalPlayers, null) builder.setNegativeButton(R.string.cancel, null) builder.setPositiveButton(R.string.ok) { dialog, which -> val index = (dialog as AlertDialog).listView.getCheckedItemPosition() @@ -2050,16 +2019,14 @@ class VideoDetailFragment : startOnExternalPlayer(activity, info, audioTracks[0]) } else { val selectedAudioStream = ListHelper.getDefaultAudioFormat(activity, audioTracks) - val trackNames = audioTracks - .map { Localization.audioTrackName(activity, it) } - .toTypedArray() + val trackNames = audioTracks.map { Localization.audioTrackName(activity, it) } AlertDialog.Builder(activity) .setTitle(R.string.select_audio_track_external_players) .setNeutralButton(R.string.open_in_browser) { dialog, which -> ShareUtils.openUrlInBrowser(requireActivity(), url) } - .setSingleChoiceItems(trackNames, selectedAudioStream, null) + .setSingleChoiceItems(trackNames.toTypedArray(), selectedAudioStream, null) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok) { dialog, which -> val index = (dialog as AlertDialog).listView.getCheckedItemPosition() @@ -2079,7 +2046,7 @@ class VideoDetailFragment : PlayerHolder.stopService() setInitialData(0, null, "", null) currentInfo = null - updateOverlayData(null, null, mutableListOf()) + updateOverlayData(null, null, listOf()) } /*////////////////////////////////////////////////////////////////////////// @@ -2145,8 +2112,8 @@ class VideoDetailFragment : val behavior = params.behavior as AppBarLayout.Behavior? val bottomSheetLayout = activity.findViewById(R.id.fragment_player_holder) - bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout) - bottomSheetBehavior.setState(lastStableBottomSheetState) + bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout) + bottomSheetBehavior.state = lastStableBottomSheetState updateBottomSheetState(lastStableBottomSheetState) val peekHeight = resources.getDimensionPixelSize(R.dimen.mini_player_height) @@ -2154,9 +2121,9 @@ class VideoDetailFragment : manageSpaceAtTheBottom(false) bottomSheetBehavior.peekHeight = peekHeight if (bottomSheetState == BottomSheetBehavior.STATE_COLLAPSED) { - binding.overlayLayout.setAlpha(MAX_OVERLAY_ALPHA) + binding.overlayLayout.alpha = MAX_OVERLAY_ALPHA } else if (bottomSheetState == BottomSheetBehavior.STATE_EXPANDED) { - binding.overlayLayout.setAlpha(0f) + binding.overlayLayout.alpha = 0f setOverlayElementsClickable(false) } } @@ -2189,7 +2156,7 @@ class VideoDetailFragment : !this@VideoDetailFragment.isFullscreen && !DeviceUtils.isTablet(activity) ) { - player!!.UIs().get(MainPlayerUi::class)?.toggleFullscreen() + player?.UIs()?.get(MainPlayerUi::class)?.toggleFullscreen() } setOverlayLook(binding.appBarLayout, behavior, 1f) } @@ -2238,18 +2205,18 @@ class VideoDetailFragment : private fun updateOverlayPlayQueueButtonVisibility() { // hide the button if the queue is empty; no player => no play queue :) - nullableBinding?.overlayPlayQueueButton?.isVisible = player?.playQueue?.isEmpty != true + nullableBinding?.overlayPlayQueueButton?.isVisible = player?.playQueue?.isEmpty == false } private fun updateOverlayData( overlayTitle: String?, uploader: String?, - thumbnails: MutableList + thumbnails: List ) { binding.overlayTitleTextView.text = overlayTitle ?: "" binding.overlayChannelTextView.text = uploader ?: "" binding.overlayThumbnail.setImageDrawable(null) - loadDetailsThumbnail(binding.overlayThumbnail, thumbnails) + CoilHelper.loadDetailsThumbnail(binding.overlayThumbnail, thumbnails) } private fun setOverlayPlayPauseImage(playerIsPlaying: Boolean) { @@ -2267,12 +2234,7 @@ class VideoDetailFragment : if (behavior == null || slideOffset < 0) { return } - binding.overlayLayout.setAlpha( - min( - MAX_OVERLAY_ALPHA.toDouble(), - (1 - slideOffset).toDouble() - ).toFloat() - ) + binding.overlayLayout.alpha = min(MAX_OVERLAY_ALPHA, 1 - slideOffset) // These numbers are not special. They just do a cool transition behavior.setTopAndBottomOffset( (-binding.detailThumbnailImageView.height * 2 * (1 - slideOffset) / 3).toInt() @@ -2291,10 +2253,6 @@ class VideoDetailFragment : binding.overlayCloseButton.isClickable = enable } - fun noPlayerServiceAvailable(): Boolean { - return playerService == null - } - val root: View? get() = player?.UIs()?.get(VideoPlayerUi::class)?.binding?.root @@ -2356,6 +2314,6 @@ class VideoDetailFragment : * Stack that contains the "navigation history".

* The peek is the current video. */ - private var stack = LinkedList() + private var stack = LinkedList() } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt index c66409b69..9cb6496ed 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt @@ -9,6 +9,7 @@ import android.support.v4.media.MediaDescriptionCompat import android.util.Log import androidx.annotation.DrawableRes import androidx.core.net.toUri +import androidx.core.os.bundleOf import androidx.media.MediaBrowserServiceCompat import androidx.media.MediaBrowserServiceCompat.Result import androidx.media.utils.MediaConstants @@ -180,17 +181,16 @@ class MediaBrowserImpl( private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem { val builder = MediaDescriptionCompat.Builder() - builder .setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid)) .setTitle(playlist.orderingName) .setIconUri(imageUriOrNullIfDisabled(playlist.thumbnailUrl)) + .setExtras( + bundleOf( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE + to context.resources.getString(R.string.tab_bookmarks) + ) + ) - val extras = Bundle() - extras.putString( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - context.resources.getString(R.string.tab_bookmarks), - ) - builder.setExtras(extras) return MediaBrowserCompat.MediaItem( builder.build(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE, @@ -199,7 +199,7 @@ class MediaBrowserImpl( private fun createInfoItemMediaItem(item: InfoItem): MediaBrowserCompat.MediaItem? { val builder = MediaDescriptionCompat.Builder() - builder.setMediaId(createMediaIdForInfoItem(item)) + .setMediaId(createMediaIdForInfoItem(item)) .setTitle(item.name) .setIconUri(ImageStrategy.choosePreferredImage(item.thumbnails)?.toUri()) @@ -250,7 +250,7 @@ class MediaBrowserImpl( index: Int, ): MediaBrowserCompat.MediaItem { val builder = MediaDescriptionCompat.Builder() - builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index)) + .setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index)) .setTitle(item.streamEntity.title) .setSubtitle(item.streamEntity.uploader) .setIconUri(imageUriOrNullIfDisabled(item.streamEntity.thumbnailUrl)) From 39989820022d36813cbce73bc176ea2fb51e4c10 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 10 Jun 2025 18:58:09 +0200 Subject: [PATCH 17/18] Smaller style fixes --- .../schabi/newpipe/fragments/detail/VideoDetailFragment.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index bd8a950c7..d914c6b3d 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -1115,11 +1115,12 @@ class VideoDetailFragment : get() = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getBoolean(getString(R.string.use_external_video_player_key), false) + @Suppress("NullableBooleanElvis") // ?: true is clearer than != false private val isAutoplayEnabled: Boolean // This method overrides default behaviour when setAutoPlay() is called. get() = autoPlayEnabled && !this.isExternalPlayerEnabled && - (player?.videoPlayerSelected() != false) && + (player?.videoPlayerSelected() ?: true) && // if no player present, consider it video bottomSheetState != BottomSheetBehavior.STATE_HIDDEN && PlayerHelper.isAutoplayAllowedByUser(requireContext()) @@ -1607,7 +1608,7 @@ class VideoDetailFragment : } // Register broadcast receiver to listen to playQueue changes - // and hide the overlayPlayQueueButton when the playQueue is empty / destroyed.7 + // and hide the overlayPlayQueueButton when the playQueue is empty / destroyed. playQueue?.broadcastReceiver?.subscribe { updateOverlayPlayQueueButtonVisibility() } ?.let { disposables.add(it) } From 046ea7301b8abe79e222a1a73d4c93da1ab7199e Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 16 Jul 2025 15:07:04 +0200 Subject: [PATCH 18/18] Apply Kotlin suggestion by @Isira-Seneviratne --- .../newpipe/fragments/detail/VideoDetailFragment.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index d914c6b3d..2e54a491b 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -1931,12 +1931,8 @@ class VideoDetailFragment : } private fun findQueueInStack(queue: PlayQueue): StackItem? { - stack.descendingIterator().forEach { item -> - if (item?.playQueue == queue) { - return@findQueueInStack item - } - } - return null + return stack.descendingIterator().asSequence() + .firstOrNull { it?.playQueue?.equals(queue) == true } } private fun replaceQueueIfUserConfirms(onAllow: Runnable) {