diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index bf23d3d70..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.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.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.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 197c965ba..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.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.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java deleted file mode 100644 index 5e0373122..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.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..2e54a491b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -0,0 +1,2316 @@ +package org.schabi.newpipe.fragments.detail + +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.OnSharedPreferenceChangeListener +import android.content.pm.ActivityInfo +import android.database.ContentObserver +import android.graphics.Color +import android.graphics.Rect +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.View.OnLongClickListener +import android.view.View.OnTouchListener +import android.view.ViewGroup +import android.view.ViewTreeObserver.OnPreDrawListener +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.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 +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.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.schedulers.Schedulers +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.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.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.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 +import org.schabi.newpipe.player.playqueue.PlayQueue +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.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.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 java.util.LinkedList +import java.util.concurrent.TimeUnit +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 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 + + private val preferenceChangeListener = + OnSharedPreferenceChangeListener { sharedPreferences, key -> + 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 + } + } + + // 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 + + // disposables + private var currentWorker: Disposable? = null + private val disposables = CompositeDisposable() + private var positionSubscriber: Disposable? = null + + /*////////////////////////////////////////////////////////////////////////// + // 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() + + 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 + checkLandscape() + } 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 + mainUi.toggleFullscreen() + } + + if (playAfterConnect || (currentInfo != null && this.isAutoplayEnabled && mainUi == null)) { + 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 (nullableBinding != 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(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + if (activity != null && !PlayerHelper.globalScreenOrientationLocked(activity)) { + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) + } + } + } + activity.contentResolver.registerContentObserver( + Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, + settingsContentObserver!! + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val newBinding = FragmentVideoDetailBinding.inflate(inflater, container, false) + nullableBinding = newBinding + return newBinding.getRoot() + } + + override fun onPause() { + super.onPause() + currentWorker?.dispose() + restoreDefaultBrightness() + PreferenceManager.getDefaultSharedPreferences(requireContext()).edit { + putString( + getString(R.string.stream_info_selected_tab_key), + pageAdapter.getItemTitle(binding.viewPager.currentItem) + ) + } + } + + 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() + currentInfo?.let { updateTabs(it) } + } + + // 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 && player?.videoPlayerSelected() == true) { + PlayerHolder.stopService() + } else { + PlayerHolder.setListener(null) + } + + PreferenceManager.getDefaultSharedPreferences(activity) + .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + activity.unregisterReceiver(broadcastReceiver) + activity.contentResolver.unregisterContentObserver(settingsContentObserver!!) + + positionSubscriber?.dispose() + 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() + nullableBinding = 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 { toggleTitleAndSecondaryControls() } + binding.detailUploaderRootLayout.setOnClickListener( + makeOnClickListener { info -> + if (info.subChannelUrl.isEmpty()) { + if (info.uploaderUrl.isNotEmpty()) { + openChannel(info.uploaderUrl, info.uploaderName, info.serviceId) + } else if (DEBUG) { + Log.w(TAG, "Can't open sub-channel because we got no channel URL") + } + } else { + openChannel(info.subChannelUrl, info.subChannelName, info.serviceId) + } + } + ) + binding.detailThumbnailRootLayout.setOnClickListener { + autoPlayEnabled = true // forcefully start playing + // FIXME Workaround #7427 + player?.setRecovery() + openVideoPlayerAutoFullscreen() + } + + 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(), + listOf(StreamEntity(info)) + ) { dialog -> dialog.show(getParentFragmentManager(), TAG) } + ) + } + } + ) + 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 { info -> + ShareUtils.openUrlInBrowser(requireContext(), info.url) + } + ) + binding.detailControlsPlayWithKodi.setOnClickListener( + makeOnClickListener { info -> + KoreUtils.playWithKore(requireContext(), info.url.toUri()) + } + ) + if (DEBUG) { + binding.detailControlsCrashThePlayer.setOnClickListener { + VideoDetailPlayerCrasher.onCrashThePlayer(requireContext(), player) + } + } + + val overlayListener = View.OnClickListener { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED) + } + 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) + } + setOverlayPlayPauseImage(player?.isPlaying == true) + } + } + + private fun makeOnClickListener(listener: (StreamInfo) -> Unit): View.OnClickListener { + return View.OnClickListener { + currentInfo?.takeIf { !isLoading.get() }?.let(listener) + } + } + + private fun setOnLongClickListeners() { + binding.detailTitleRootLayout.setOnLongClickListener { + binding.detailVideoTitleView.text?.toString()?.let { + if (!it.isBlank()) { + ShareUtils.copyToClipboard(requireContext(), it) + return@setOnLongClickListener true + } + } + return@setOnLongClickListener false + } + binding.detailUploaderRootLayout.setOnLongClickListener( + makeOnLongClickListener { info -> + 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) + } + } + ) + + 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(listener: (StreamInfo) -> Unit): OnLongClickListener { + return OnLongClickListener { + currentInfo?.takeIf { !isLoading.get() }?.let(listener) != null + } + } + + private fun openChannel(subChannelUrl: String?, subChannelName: String, serviceId: Int) { + try { + NavigationHelper.openChannelFragment(getFM(), serviceId, subChannelUrl, subChannelName) + } catch (e: Exception) { + showUiErrorSnackbar(this, "Opening channel fragment", e) + } + } + + private fun toggleTitleAndSecondaryControls() { + if (binding.detailSecondaryControlPanel.isGone) { + binding.detailVideoTitleView.setMaxLines(10) + binding.detailToggleSecondaryControlsView + .animateRotation(VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 180) + binding.detailSecondaryControlPanel.visibility = View.VISIBLE + } else { + binding.detailVideoTitleView.setMaxLines(1) + binding.detailToggleSecondaryControlsView + .animateRotation(VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 0) + binding.detailSecondaryControlPanel.visibility = 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.isVisible = + KoreUtils.shouldShowPlayWithKodi(requireContext(), serviceId) + binding.detailControlsCrashThePlayer.isVisible = + DEBUG && PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getBoolean(getString(R.string.show_crash_the_player_key), false) + + accommodateForTvAndDesktopMode() + } + + @SuppressLint("ClickableViewAccessibility") + override fun initListeners() { + super.initListeners() + + setOnClickListeners() + setOnLongClickListeners() + + val controlsTouchListener = OnTouchListener { view, motionEvent -> + if (motionEvent.action == MotionEvent.ACTION_DOWN && + PlayButtonHelper.shouldShowHoldToAppendTip(activity) + ) { + 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.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) + } + } + + override fun onKeyDown(keyCode: Int): Boolean { + return player?.UIs()?.get(VideoPlayerUi::class)?.onKeyDown(keyCode) == true + } + + 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 (player?.videoPlayerSelected() == true && player?.playQueue?.previous() == true) { + 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(stack.peek()!!) + + return true + } + + private fun setupFromHistoryItem(item: StackItem) { + setAutoPlay(false) + hideMainPlayerOnLoadingNewStream() + + setInitialData(item.serviceId, item.url, item.title ?: "", item.playQueue) + startLoading(false) + + // Maybe an item was deleted in background activity + if (item.playQueue.item == null) { + return + } + + val playQueueItem = item.playQueue.item + // Update title, url, uploader from the last item in the stack (it's current now) + if (playQueueItem != null && playerIsStopped) { + updateOverlayData(playQueueItem.title, playQueueItem.uploader, playQueueItem.thumbnails) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Info loading and handling + ////////////////////////////////////////////////////////////////////////// */ + override fun doInitialLoadLogic() { + if (wasCleared()) { + return + } + + when (val info = currentInfo) { + null -> prepareAndLoadInfo() + else -> prepareAndHandleInfoIfNeededAfterDelay(info, false, 50) + } + } + + fun selectAndLoadVideo( + newServiceId: Int, + newUrl: String, + newTitle: String, + newQueue: PlayQueue? + ) { + if (newQueue != null && playQueue?.item?.url != 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(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(info=[$info], scrollToTop=[$scrollToTop]) called") + } + + 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 + currentWorker?.dispose() + + runWorker(forceLoad, addToBackStack ?: 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( + { result -> + isLoading.set(false) + hideMainPlayerOnLoadingNewStream() + if (result.ageLimit != 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.peek()?.playQueue != playQueue) { // also if stack empty (!) + stack.push(StackItem(serviceId, url, title, playQueue)) + } + } + + if (this.isAutoplayEnabled) { + openVideoPlayerAutoFullscreen() + } + } + }, + { throwable -> + showError( + ErrorInfo(throwable, UserAction.REQUESTED_STREAM, url ?: "no url", serviceId) + ) + } + ) + } + + /*////////////////////////////////////////////////////////////////////////// + // Tabs + ////////////////////////////////////////////////////////////////////////// */ + private fun initTabs() { + 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) + 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.count == 0) { + pageAdapter.addFragment(EmptyFragment.newInstance(true), EMPTY_TAB_TAG) + } + pageAdapter.notifyDataSetUpdate() + + if (pageAdapter.count >= 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[i]) + tab.setContentDescription(tabContentDescriptions[i]) + } + } + } + + private fun updateTabs(info: StreamInfo) { + if (showRelatedItems) { + 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 + } + } + } + + if (showDescription) { + pageAdapter.updateItem(DESCRIPTION_TAB_TAG, DescriptionFragment(info)) + } + + binding.viewPager.visibility = View.VISIBLE + // make sure the tab layout is visible + updateTabLayoutVisibility() + pageAdapter.notifyDataSetUpdate() + updateTabIconsAndContentDescriptions() + } + + private fun shouldShowComments(): Boolean { + return showComments && try { + NewPipe.getService(serviceId).serviceInfo.mediaCapabilities + .contains(MediaCapability.COMMENTS) + } catch (_: ExtractionException) { + false + } + } + + fun updateTabLayoutVisibility() { + if (nullableBinding == null) { + // If binding is null we do not need to and should not do anything with its object(s) + return + } + + 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.visibility = View.GONE + } else { + // call `post()` to be sure `viewPager.getHitRect()` + // is up to date and not being currently recomputed + binding.tabLayout.post { + getActivity()?.let { activity -> + val pagerHitRect = Rect() + binding.viewPager.getHitRect(pagerHitRect) + + 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.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) + // 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 + player?.UIs()?.get(MainPlayerUi::class)?.let { + if (it.isFullscreen) { + it.toggleFullscreen() + } + } + } + + private fun openBackgroundPlayer(append: Boolean) { + val useExternalAudioPlayer = PreferenceManager + .getDefaultSharedPreferences(activity) + .getBoolean(activity.getString(R.string.use_external_audio_player_key), false) + + toggleFullscreenIfInFullscreenMode() + + // 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 (player == null) { + 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 { 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 { 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 (player == null) { + PlayerHolder.startService(false, this) + } + + val queue = setupPlayQueueForIntent(append) + if (append) { + NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.AUDIO) + } else { + replaceQueueIfUserConfirms { + NavigationHelper.playOnBackgroundPlayer(activity, queue, true) + } + } + } + + private fun openMainPlayer() { + if (playerService == null) { + 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 (root == null || playerService == null || player?.videoPlayerSelected() != true) { + return + } + + removeVideoPlayerView() + if (this.isAutoplayEnabled) { + playerService?.stopForImmediateReusing() + root.visibility = 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, info.name, info.subChannelName, selectedStream + ) + + val recordManager = HistoryRecordManager(requireContext()) + disposables.add( + recordManager.onViewed(info) + .subscribe( + { /* successful */ }, + { throwable -> Log.e(TAG, "Register view failure: ", throwable) } + ) + ) + } + + private val isExternalPlayerEnabled: Boolean + 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() ?: true) && // if no player present, consider it video + bottomSheetState != BottomSheetBehavior.STATE_HIDDEN && + PlayerHelper.isAutoplayAllowedByUser(requireContext()) + + private fun tryAddVideoPlayerView() { + 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() + } + + // 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 { + 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() + player?.UIs()?.get(VideoPlayerUi::class)?.removeViewFromParent() + } + + private fun makeDefaultHeightForVideoPlaceholder() { + if (view == null) { + return + } + + binding.playerPlaceholder.layoutParams.height = FrameLayout.LayoutParams.MATCH_PARENT + binding.playerPlaceholder.requestLayout() + } + + private val preDrawListener: OnPreDrawListener = OnPreDrawListener { + view?.let { view -> + val decorView = if (DeviceUtils.isInMultiWindow(activity)) + view + else + activity.window.decorView + setHeightThumbnail(decorView.height, resources.displayMetrics) + view.getViewTreeObserver().removeOnPreDrawListener(preDrawListener) + } + return@OnPreDrawListener 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 = resources.displayMetrics + requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener) + + if (this.isFullscreen) { + val height = ( + if (DeviceUtils.isInMultiWindow(activity)) + requireView() + else + activity.window.decorView + ).height + // 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 isPortrait = metrics.heightPixels > metrics.widthPixels + 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) + player?.UIs()?.get(VideoPlayerUi::class)?.let { + val maxHeight = (metrics.heightPixels * MAX_PLAYER_HEIGHT).toInt() + it.binding.surfaceView.setHeights( + newHeight, + if (it.isFullscreen) newHeight else maxHeight + ) + } + } + + private fun showContent() { + binding.detailContentRootHiding.visibility = 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 (nullableBinding == null || activity == null) { + return + } + + binding.detailThumbnailImageView.setImageDrawable( + AppCompatResources.getDrawable(requireContext(), R.drawable.not_available_monkey) + ) + binding.detailThumbnailImageView.animate(false, 0, AnimationType.ALPHA, 0) { + binding.detailThumbnailImageView.animate(true, 500) + } + } + + override fun handleError() { + super.handleError() + setErrorImage() + + // hide related streams for tablets + binding.relatedItemsLayout?.visibility = View.INVISIBLE + + // hide comments / related streams / description tabs + binding.viewPager.visibility = View.GONE + binding.tabLayout.visibility = 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.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) + } + // 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 (player?.videoPlayerSelected() == true) { + 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.visibility = View.INVISIBLE + } + + binding.detailThumbnailPlayButton.animate(false, 50) + binding.detailDurationView.animate(false, 100) + binding.detailPositionView.visibility = View.GONE + binding.positionView.visibility = View.GONE + + binding.detailVideoTitleView.text = title + binding.detailVideoTitleView.setMaxLines(1) + binding.detailVideoTitleView.animate(true, 0) + + binding.detailToggleSecondaryControlsView.visibility = View.GONE + binding.detailTitleRootLayout.isClickable = false + binding.detailSecondaryControlPanel.visibility = View.GONE + + binding.relatedItemsLayout?.isVisible = showRelatedItems && !this.isFullscreen + + 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) { + super.handleResult(info) + + currentInfo = info + setInitialData(info.serviceId, info.originalUrl, info.name, playQueue) + + updateTabs(info) + + binding.detailThumbnailPlayButton.animate(true, 200) + binding.detailVideoTitleView.text = title + + binding.detailSubChannelThumbnailView.visibility = View.GONE + + if (info.subChannelName.isEmpty()) { + displayUploaderAsSubChannel(info) + } else { + displayBothUploaderAndSubChannel(info) + } + + 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.visibility = View.GONE + } + + 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.dislikeCount >= 0) { + binding.detailThumbsDownCountView.text = + Localization.shortCount(activity, info.dislikeCount) + binding.detailThumbsDownCountView.visibility = View.VISIBLE + binding.detailThumbsDownImgView.visibility = View.VISIBLE + } else { + binding.detailThumbsDownCountView.visibility = View.GONE + binding.detailThumbsDownImgView.visibility = View.GONE + } + + 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.visibility = View.GONE + binding.detailThumbsUpImgView.visibility = View.GONE + } + binding.detailThumbsDisabledView.visibility = View.GONE + } + + 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.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) + } else { + binding.detailDurationView.visibility = View.GONE + } + + binding.detailTitleRootLayout.isClickable = true + binding.detailToggleSecondaryControlsView.rotation = 0f + binding.detailToggleSecondaryControlsView.visibility = View.VISIBLE + binding.detailSecondaryControlPanel.visibility = View.GONE + + checkUpdateProgressInfo(info) + CoilHelper.loadDetailsThumbnail(binding.detailThumbnailImageView, info.thumbnails) + ExtractorHelper.showMetaInfoInTextView( + info.metaInfo, binding.detailMetaInfoTextView, + binding.detailMetaInfoSeparator, disposables + ) + + if (playerIsStopped) { + updateOverlayData(info.name, info.uploaderName, info.thumbnails) + } + + 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. + info.errors.removeIf { + it is ContentNotSupportedException && "Fan pages are not supported" == it.message + } + + if (!info.errors.isEmpty()) { + showSnackBarError( + ErrorInfo(info.errors, UserAction.REQUESTED_STREAM, info.url, info) + ) + } + } + + 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() + 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.text = info.uploaderName + binding.detailSubChannelTextView.visibility = View.VISIBLE + binding.detailSubChannelTextView.setSelected(true) + + if (info.uploaderSubscriberCount > -1) { + binding.detailUploaderTextView.text = + Localization.shortSubscriberCount(activity, info.uploaderSubscriberCount) + binding.detailUploaderTextView.visibility = View.VISIBLE + } else { + binding.detailUploaderTextView.visibility = View.GONE + } + + CoilHelper.loadAvatar(binding.detailSubChannelThumbnailView, info.uploaderAvatars) + binding.detailSubChannelThumbnailView.visibility = View.VISIBLE + binding.detailUploaderThumbnailView.visibility = View.GONE + } + + private fun displayBothUploaderAndSubChannel(info: StreamInfo) { + binding.detailSubChannelTextView.text = info.subChannelName + binding.detailSubChannelTextView.visibility = View.VISIBLE + binding.detailSubChannelTextView.setSelected(true) + + val subText = StringBuilder() + if (info.uploaderName.isNotEmpty()) { + subText.append(getString(R.string.video_detail_by, info.uploaderName)) + } + if (info.uploaderSubscriberCount > -1) { + if (subText.isNotEmpty()) { + subText.append(Localization.DOT_SEPARATOR) + } + subText.append( + Localization.shortSubscriberCount(activity, info.uploaderSubscriberCount) + ) + } + + if (subText.isEmpty()) { + binding.detailUploaderTextView.visibility = View.GONE + } else { + binding.detailUploaderTextView.text = subText + binding.detailUploaderTextView.visibility = View.VISIBLE + binding.detailUploaderTextView.setSelected(true) + } + + CoilHelper.loadAvatar(binding.detailSubChannelThumbnailView, info.subChannelAvatars) + binding.detailSubChannelThumbnailView.visibility = View.VISIBLE + CoilHelper.loadAvatar(binding.detailUploaderThumbnailView, info.uploaderAvatars) + binding.detailUploaderThumbnailView.visibility = View.VISIBLE + } + + fun openDownloadDialog() { + val info = currentInfo ?: return + + try { + val downloadDialog = DownloadDialog(activity, info) + downloadDialog.show(activity.supportFragmentManager, "downloadDialog") + } catch (e: Exception) { + showSnackbar( + activity, + ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG, "Showing download dialog", info) + ) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Stream Results + ////////////////////////////////////////////////////////////////////////// */ + private fun checkUpdateProgressInfo(info: StreamInfo) { + positionSubscriber?.dispose() + if (!DependentPreferenceHelper.getResumePlaybackEnabled(activity)) { + binding.positionView.visibility = View.GONE + binding.detailPositionView.visibility = View.GONE + return + } + val recordManager = HistoryRecordManager(requireContext()) + positionSubscriber = recordManager.loadStreamState(info) + .subscribeOn(Schedulers.io()) + .onErrorComplete() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { state -> updatePlaybackProgress(state.progressMillis, info.duration * 1000) }, + { throwable -> /* impossible due to the onErrorComplete() */ }, + { /* onComplete */ + binding.positionView.visibility = View.GONE + binding.detailPositionView.visibility = 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() + 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 + if (abs(binding.positionView.progress - progressSeconds) > 2) { + binding.positionView.setProgressAnimated(progressSeconds) + } else { + binding.positionView.progress = progressSeconds + } + val position = Localization.getDurationString(progressSeconds.toLong()) + if (position != binding.detailPositionView.getText()) { + binding.detailPositionView.text = position + } + if (binding.positionView.visibility != 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. + 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 + if (stack.peek()?.playQueue?.equals(queue) == false) { + queue.item?.let { queueItem -> + stack.push(StackItem(queueItem.serviceId, queueItem.url, queueItem.title, queue)) + return@onQueueUpdate + } // if queue.item == null continue below + } + + // 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( + state: Int, + repeatMode: Int, + shuffled: Boolean, + parameters: PlaybackParameters? + ) { + setOverlayPlayPauseImage(player?.isPlaying == true) + + 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) + } + } + + override fun onProgressUpdate( + currentProgress: Int, + duration: Int, + bufferPercent: Int + ) { + // 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) { + 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.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 + // a service listener and will receive initial call to onMetadataUpdate() + if (queue != playQueue) { + return + } + + updateOverlayData(info.name, info.uploaderName, info.thumbnails) + if (info.url == currentInfo?.url) { + return + } + + currentInfo = info + 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 + // (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 (nullableBinding != null) { + setOverlayPlayPauseImage(false) + currentInfo?.let { updateOverlayData(it.name, it.uploaderName, it.thumbnails) } + updateOverlayPlayQueueButtonVisibility() + } + } + + override fun onFullscreenStateChanged(fullscreen: Boolean) { + setupBrightness() + if (playerService == null || + player?.UIs()?.get(MainPlayerUi::class) == null || + this.root?.parent == null + ) { + return + } + + if (fullscreen) { + hideSystemUiIfNeeded() + binding.overlayPlayPauseButton.requestFocus() + } else { + showSystemUi() + } + + binding.relatedItemsLayout?.isVisible = !fullscreen + 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().get(MainPlayerUi::class)?.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.layoutParams as CoordinatorLayout.LayoutParams + val behavior = params.behavior as AppBarLayout.Behavior + val valueAnimator = ValueAnimator.ofInt(0, -binding.playerPlaceholder.height) + valueAnimator.addUpdateListener { animation -> + behavior.setTopAndBottomOffset(animation.getAnimatedValue() as Int) + binding.appBarLayout.requestLayout() + } + valueAnimator.interpolator = DecelerateInterpolator() + valueAnimator.duration = 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.window.attributes.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT + } + activity.window.decorView.systemUiVisibility = 0 + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + activity.window.statusBarColor = 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.window.attributes.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.window.decorView.systemUiVisibility = visibility + + if (isInMultiWindow || this.isFullscreen) { + activity.window.statusBarColor = Color.TRANSPARENT + activity.window.navigationBarColor = Color.TRANSPARENT + } + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + } + + // Listener implementation + override fun hideSystemUiIfNeeded() { + if (this.isFullscreen && + bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED + ) { + hideSystemUi() + } + } + + 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 + get() = player?.isStopped ?: true + + private fun restoreDefaultBrightness() { + val lp = activity.window.attributes + if (lp.screenBrightness == -1f) { + return + } + + // Restore the old brightness when fragment.onPause() called or + // when a player is in portrait + lp.screenBrightness = -1f + activity.window.setAttributes(lp) + } + + private fun setupBrightness() { + if (activity == null) { + return + } + + 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 + val brightnessControlKey = getString(R.string.brightness_control_key) + if (PlayerHelper.getActionForRightGestureSide(activity) != brightnessControlKey && + PlayerHelper.getActionForLeftGestureSide(activity) != brightnessControlKey + ) { + return + } + // Restore already saved brightness level + val brightnessLevel = PlayerHelper.getScreenBrightness(activity) + if (brightnessLevel == lp.screenBrightness) { + return + } + lp.screenBrightness = brightnessLevel + activity.window.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(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) + } + 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) + } + } + + private fun checkLandscape() { + if ((!player!!.isPlaying && player!!.playQueue !== playQueue) || + player!!.playQueue == null + ) { + setAutoPlay(true) + } + + 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) { + 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? { + return stack.descendingIterator().asSequence() + .firstOrNull { it?.playQueue?.equals(queue) == true } + } + + private fun replaceQueueIfUserConfirms(onAllow: Runnable) { + // Player will have STATE_IDLE when a user pressed back button + if (PlayerHelper.isClearingQueueConfirmationRequired(activity) && + !playerIsStopped && player?.playQueue != 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) { dialog, which -> + onAllow.run() + dialog?.dismiss() + } + .show() + } + + private fun showExternalVideoPlaybackDialog() { + val info = currentInfo ?: return + + val builder = AlertDialog.Builder(activity) + builder.setTitle(R.string.select_quality_external_players) + builder.setNeutralButton(R.string.open_in_browser) { dialog, which -> + ShareUtils.openUrlInBrowser(requireActivity(), url) + } + + 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) + builder.setPositiveButton(R.string.ok, null) + } else { + val selectedVideoStreamIndexForExternalPlayers = + ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers) + val resolutions = videoStreamsForExternalPlayers + .map { it.getResolution() as CharSequence } + .toTypedArray() + + 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() + // 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() { + val info = currentInfo ?: return + + val audioStreams = ListHelper.getUrlAndNonTorrentStreams(info.audioStreams) + 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, info, audioTracks[0]) + } else { + val selectedAudioStream = ListHelper.getDefaultAudioFormat(activity, audioTracks) + 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.toTypedArray(), selectedAudioStream, null) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.ok) { dialog, which -> + val index = (dialog as AlertDialog).listView.getCheckedItemPosition() + startOnExternalPlayer(activity, info, audioTracks[index]) + } + .show() + } + } + + /* + * Remove unneeded information while waiting for a next task + * */ + private fun cleanUp() { + // New beginning + stack.clear() + currentWorker?.dispose() + PlayerHolder.stopService() + setInitialData(0, null, "", null) + currentInfo = null + updateOverlayData(null, null, listOf()) + } + + /*////////////////////////////////////////////////////////////////////////// + // 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 = resources.getDimensionPixelSize(R.dimen.mini_player_height) + val holder = requireActivity().findViewById(R.id.fragment_holder) + val newBottomPadding = if (showMore) 0 else peekHeight + if (holder.paddingBottom == newBottomPadding) { + return + } + holder.setPadding( + holder.getPaddingLeft(), + holder.paddingTop, + holder.getPaddingRight(), + newBottomPadding + ) + } + + private fun setupBottomPlayer() { + 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.state = lastStableBottomSheetState + updateBottomSheetState(lastStableBottomSheetState) + + val peekHeight = resources.getDimensionPixelSize(R.dimen.mini_player_height) + if (bottomSheetState != BottomSheetBehavior.STATE_HIDDEN) { + manageSpaceAtTheBottom(false) + bottomSheetBehavior.peekHeight = peekHeight + if (bottomSheetState == BottomSheetBehavior.STATE_COLLAPSED) { + binding.overlayLayout.alpha = MAX_OVERLAY_ALPHA + } else if (bottomSheetState == BottomSheetBehavior.STATE_EXPANDED) { + binding.overlayLayout.alpha = 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.peekHeight = 0 + cleanUp() + } + + BottomSheetBehavior.STATE_EXPANDED -> { + moveFocusToMainFragment(false) + manageSpaceAtTheBottom(false) + + 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()) && + player?.isPlaying == true && + !this@VideoDetailFragment.isFullscreen && + !DeviceUtils.isTablet(activity) + ) { + player?.UIs()?.get(MainPlayerUi::class)?.toggleFullscreen() + } + setOverlayLook(binding.appBarLayout, behavior, 1f) + } + + BottomSheetBehavior.STATE_COLLAPSED -> { + moveFocusToMainFragment(true) + manageSpaceAtTheBottom(false) + + bottomSheetBehavior.peekHeight = peekHeight + + // Re-enable clicks + setOverlayElementsClickable(true) + player?.UIs()?.get(MainPlayerUi::class)?.closeItemsList() + setOverlayLook(binding.appBarLayout, behavior, 0f) + } + + BottomSheetBehavior.STATE_DRAGGING, BottomSheetBehavior.STATE_SETTLING -> { + if (this@VideoDetailFragment.isFullscreen) { + showSystemUi() + } + player?.UIs()?.get(MainPlayerUi::class)?.let { + if (it.isControlsVisible) { + it.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.supportFragmentManager.addOnBackStackChangedListener { + if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED) + } + } + } + + private fun updateOverlayPlayQueueButtonVisibility() { + // hide the button if the queue is empty; no player => no play queue :) + nullableBinding?.overlayPlayQueueButton?.isVisible = player?.playQueue?.isEmpty == false + } + + private fun updateOverlayData( + overlayTitle: String?, + uploader: String?, + thumbnails: List + ) { + binding.overlayTitleTextView.text = overlayTitle ?: "" + binding.overlayChannelTextView.text = 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.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() + ) + appBar.requestLayout() + } + + private fun setOverlayElementsClickable(enable: Boolean) { + 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 root: View? + get() = player?.UIs()?.get(VideoPlayerUi::class)?.binding?.root + + 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 + + const val ACTION_SHOW_MAIN_PLAYER: String = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER" + const val ACTION_HIDE_MAIN_PLAYER: String = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER" + const val ACTION_PLAYER_STARTED: String = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_PLAYER_STARTED" + const val ACTION_VIDEO_FRAGMENT_RESUMED: String = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED" + const 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 + fun getInstanceInCollapsedState(): VideoDetailFragment { + 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() + } +} 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..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.getInstance(); + final PlayerHolder holder = PlayerHolder.INSTANCE; if (holder.isPlayQueueReady()) { addEntry(StreamDialogDefaultEntry.ENQUEUE); 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 //////////////////////////////////////////////////////////////////////////// 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..57cdd081e 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(); @@ -473,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/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt index c335611b0..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,29 +249,27 @@ 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 } } class LocalBinder internal constructor(playerService: PlayerService) : Binder() { - private val playerService = WeakReference(playerService) + private val playerService = WeakReference(playerService) val service: PlayerService? get() = playerService.get() @@ -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.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..1b0cedfc5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt @@ -0,0 +1,316 @@ +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 + +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 + + var isBound: Boolean = false + private set + + private var playerService: PlayerService? = null + + private val player: Player? + get() = playerService?.player + + // player play queue might be null e.g. while player is starting + private val playQueue: PlayQueue? + get() = this.player?.playQueue + + val type: PlayerType? + /** + * Returns the current [PlayerType] of the [PlayerService] service, + * otherwise `null` if no service is running. + * + * @return Current PlayerType + */ + get() = this.player?.playerType + + val isPlaying: Boolean + get() = this.player?.isPlaying == true + + val isPlayerOpen: Boolean + get() = this.player != null + + 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 != null + + val queueSize: Int + get() = this.playQueue?.size() ?: 0 + + val queuePosition: Int + get() = this.playQueue?.index ?: 0 + + fun setListener(newListener: PlayerServiceExtendedEventListener?) { + listener = newListener + + // Force reload data from service + newListener?.let { listener -> + playerService?.let { service -> + listener.onServiceConnected(service) + 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) + PlayerServiceConnection.doPlayAfterConnect(playAfterConnect) + bind(context) + } + + fun stopService() { + if (DEBUG) { + Log.d(TAG, "stopService() called") + } + 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 object 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 + listener?.let { l -> + l.onServiceConnected(s) + 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 + // 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(PlayerServiceConnection) + } + } + + 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, PlayerServiceConnection, flags) + } + + private fun unbind(context: Context) { + if (DEBUG) { + Log.d(TAG, "unbind() called") + } + + if (this.isBound) { + context.unbindService(PlayerServiceConnection) + this.isBound = false + stopPlayerListener() + playerService = null + listener?.onPlayerDisconnected() + listener?.onServiceDisconnected() + } + } + + private fun startPlayerListener() { + // 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(HolderPlayerServiceEventListener) + } + + private fun stopPlayerListener() { + playerService?.setPlayerListener(null) + this.player?.removeFragmentListener(HolderPlayerServiceEventListener) + } + + /** + * This listener will be held by the players created by [PlayerService]. + */ + private object HolderPlayerServiceEventListener : PlayerServiceEventListener { + override fun onViewCreated() { + listener?.onViewCreated() + } + + override fun onFullscreenStateChanged(fullscreen: Boolean) { + listener?.onFullscreenStateChanged(fullscreen) + } + + override fun onScreenRotationButtonClicked() { + listener?.onScreenRotationButtonClicked() + } + + override fun onMoreOptionsLongClicked() { + listener?.onMoreOptionsLongClicked() + } + + override fun onPlayerError( + error: PlaybackException?, + isCatchableException: Boolean + ) { + listener?.onPlayerError(error, isCatchableException) + } + + override fun hideSystemUiIfNeeded() { + listener?.hideSystemUiIfNeeded() + } + + override fun onQueueUpdate(queue: PlayQueue?) { + listener?.onQueueUpdate(queue) + } + + override fun onPlaybackUpdate( + state: Int, + repeatMode: Int, + shuffled: Boolean, + parameters: PlaybackParameters? + ) { + listener?.onPlaybackUpdate(state, repeatMode, shuffled, parameters) + } + + override fun onProgressUpdate( + currentProgress: Int, + duration: Int, + bufferPercent: Int + ) { + listener?.onProgressUpdate(currentProgress, duration, bufferPercent) + } + + override fun onMetadataUpdate(info: StreamInfo?, queue: PlayQueue?) { + listener?.onMetadataUpdate(info, queue) + } + + override fun onServiceStopped() { + 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: (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 + // before setting its player to null + l.onPlayerDisconnected() + } else { + l.onPlayerConnected(player, PlayerServiceConnection.playAfterConnect) + // reset the value of playAfterConnect: if it was true before, it is now "consumed" + PlayerServiceConnection.playAfterConnect = false + player.setFragmentListener(HolderPlayerServiceEventListener) + } + } + } +} 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..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 @@ -36,7 +37,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 +46,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 @@ -183,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, @@ -202,8 +199,9 @@ 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()) 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 @@ -256,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)) @@ -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/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 190da81e6..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 @@ -1,25 +1,20 @@ package org.schabi.newpipe.player.ui import org.schabi.newpipe.util.GuardedByMutex -import java.util.Optional +import kotlin.reflect.KClass +import kotlin.reflect.safeCast +/** + * 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()) - - /** - * Creates a [PlayerUiList] starting with the provided player uis. The provided player uis - * will not be prepared like those passed to [.addAndPrepare], because when - * the [PlayerUiList] constructor is called, the player is still not running and it - * wouldn't make sense to initialize uis then. Instead the player will initialize them by doing - * proper calls to [.call]. - * - * @param initialPlayerUis the player uis this list should start with; the order will be kept - */ - init { - playerUis.runWithLockSync { - lockData.addAll(listOf(*initialPlayerUis)) - } - } + private val playerUis = GuardedByMutex(mutableListOf(*initialPlayerUis)) /** * Adds the provided player ui to the list and calls on it the initialization functions that @@ -83,30 +78,22 @@ 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 } /** - * @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)) + * See [get] above + */ + fun get(playerUiType: Class): T? = + get(playerUiType.kotlin) /** * Calls the provided consumer on all player UIs in the list, in order of addition. 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..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.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 aba27c259..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.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.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.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.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);