Merge pull request #8170 from Stypox/player-refactor

Refactor player and extract UI components
This commit is contained in:
litetex 2022-07-15 19:41:23 +02:00 committed by GitHub
commit b7a44560f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 5012 additions and 4098 deletions

View File

@ -44,7 +44,7 @@
</receiver>
<service
android:name=".player.MainPlayer"
android:name=".player.PlayerService"
android:exported="false"
android:foregroundServiceType="mediaPlayback">
<intent-filter>

View File

@ -60,7 +60,7 @@ import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.MainPlayer;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.helper.PlayerHelper;
import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
@ -630,8 +630,8 @@ public class RouterActivity extends AppCompatActivity {
}
// ...the player is not running or in normal Video-mode/type
final MainPlayer.PlayerType playerType = PlayerHolder.getInstance().getType();
return playerType == null || playerType == MainPlayer.PlayerType.VIDEO;
final PlayerType playerType = PlayerHolder.getInstance().getType();
return playerType == null || playerType == PlayerType.MAIN;
}
private void openAddToPlaylistDialog() {

View File

@ -1,5 +1,16 @@
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.player.playqueue.PlayQueueItem.RECOVERY_UNSET;
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams;
import android.animation.ValueAnimator;
import android.app.Activity;
import android.content.BroadcastReceiver;
@ -77,9 +88,9 @@ 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.player.MainPlayer;
import org.schabi.newpipe.player.MainPlayer.PlayerType;
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;
@ -87,6 +98,8 @@ 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;
@ -106,6 +119,7 @@ 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 icepick.State;
@ -114,17 +128,6 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
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.player.playqueue.PlayQueueItem.RECOVERY_UNSET;
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams;
public final class VideoDetailFragment
extends BaseStateFragment<StreamInfo>
implements BackPressable,
@ -202,7 +205,7 @@ public final class VideoDetailFragment
private ContentObserver settingsContentObserver;
@Nullable
private MainPlayer playerService;
private PlayerService playerService;
private Player player;
private final PlayerHolder playerHolder = PlayerHolder.getInstance();
@ -211,7 +214,7 @@ public final class VideoDetailFragment
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onServiceConnected(final Player connectedPlayer,
final MainPlayer connectedPlayerService,
final PlayerService connectedPlayerService,
final boolean playAfterConnect) {
player = connectedPlayer;
playerService = connectedPlayerService;
@ -219,6 +222,7 @@ public final class VideoDetailFragment
// It will do nothing if the player is not in fullscreen mode
hideSystemUiIfNeeded();
final Optional<MainPlayerUi> playerUi = player.UIs().get(MainPlayerUi.class);
if (!player.videoPlayerSelected() && !playAfterConnect) {
return;
}
@ -227,22 +231,19 @@ public final class VideoDetailFragment
// If the video is playing but orientation changed
// let's make the video in fullscreen again
checkLandscape();
} else if (player.isFullscreen() && !player.isVerticalVideo()
} 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
player.toggleFullscreen();
}
if (playerIsNotStopped() && player.videoPlayerSelected()) {
addVideoPlayerView();
playerUi.ifPresent(MainPlayerUi::toggleFullscreen);
}
//noinspection SimplifyOptionalCallChains
if (playAfterConnect
|| (currentInfo != null
&& isAutoplayEnabled()
&& player.getParentActivity() == null)) {
&& !playerUi.isPresent())) {
autoPlayEnabled = true; // forcefully start playing
openVideoPlayerAutoFullscreen();
}
@ -329,6 +330,9 @@ public final class VideoDetailFragment
@Override
public void onResume() {
super.onResume();
if (DEBUG) {
Log.d(TAG, "onResume() called");
}
activity.sendBroadcast(new Intent(ACTION_VIDEO_FRAGMENT_RESUMED));
@ -518,7 +522,7 @@ public final class VideoDetailFragment
case R.id.overlay_play_pause_button:
if (playerIsNotStopped()) {
player.playPause();
player.hideControls(0, 0);
player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0));
showSystemUi();
} else {
autoPlayEnabled = true; // forcefully start playing
@ -583,12 +587,12 @@ public final class VideoDetailFragment
if (binding.detailSecondaryControlPanel.getVisibility() == View.GONE) {
binding.detailVideoTitleView.setMaxLines(10);
animateRotation(binding.detailToggleSecondaryControlsView,
Player.DEFAULT_CONTROLS_DURATION, 180);
VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 180);
binding.detailSecondaryControlPanel.setVisibility(View.VISIBLE);
} else {
binding.detailVideoTitleView.setMaxLines(1);
animateRotation(binding.detailToggleSecondaryControlsView,
Player.DEFAULT_CONTROLS_DURATION, 0);
VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 0);
binding.detailSecondaryControlPanel.setVisibility(View.GONE);
}
// view pager height has changed, update the tab layout
@ -746,7 +750,9 @@ public final class VideoDetailFragment
@Override
public boolean onKeyDown(final int keyCode) {
return isPlayerAvailable() && player.onKeyDown(keyCode);
return isPlayerAvailable()
&& player.UIs().get(VideoPlayerUi.class)
.map(playerUi -> playerUi.onKeyDown(keyCode)).orElse(false);
}
@Override
@ -756,7 +762,7 @@ public final class VideoDetailFragment
}
// If we are in fullscreen mode just exit from it via first back press
if (isPlayerAvailable() && player.isFullscreen()) {
if (isFullscreen()) {
if (!DeviceUtils.isTablet(activity)) {
player.pause();
}
@ -1006,8 +1012,7 @@ public final class VideoDetailFragment
getChildFragmentManager().beginTransaction()
.replace(R.id.relatedItemsLayout, RelatedItemsFragment.getInstance(info))
.commitAllowingStateLoss();
binding.relatedItemsLayout.setVisibility(
isPlayerAvailable() && player.isFullscreen() ? View.GONE : View.VISIBLE);
binding.relatedItemsLayout.setVisibility(isFullscreen() ? View.GONE : View.VISIBLE);
}
}
@ -1087,8 +1092,12 @@ public final class VideoDetailFragment
private void toggleFullscreenIfInFullscreenMode() {
// If a user watched video inside fullscreen mode and than chose another player
// return to non-fullscreen mode
if (isPlayerAvailable() && player.isFullscreen()) {
player.toggleFullscreen();
if (isPlayerAvailable()) {
player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> {
if (playerUi.isFullscreen()) {
playerUi.toggleFullscreen();
}
});
}
}
@ -1214,16 +1223,10 @@ public final class VideoDetailFragment
}
final PlayQueue queue = setupPlayQueueForIntent(false);
// Video view can have elements visible from popup,
// We hide it here but once it ready the view will be shown in handleIntent()
if (playerService.getView() != null) {
playerService.getView().setVisibility(View.GONE);
}
addVideoPlayerView();
final Intent playerIntent = NavigationHelper.getPlayerIntent(requireContext(),
MainPlayer.class, queue, true, autoPlayEnabled);
PlayerService.class, queue, true, autoPlayEnabled);
ContextCompat.startForegroundService(activity, playerIntent);
}
@ -1235,8 +1238,8 @@ public final class VideoDetailFragment
* be reused in a few milliseconds and the flickering would be annoying.
*/
private void hideMainPlayerOnLoadingNewStream() {
if (!isPlayerServiceAvailable()
|| playerService.getView() == null
//noinspection SimplifyOptionalCallChains
if (!isPlayerServiceAvailable() || !getRoot().isPresent()
|| !player.videoPlayerSelected()) {
return;
}
@ -1244,7 +1247,7 @@ public final class VideoDetailFragment
removeVideoPlayerView();
if (isAutoplayEnabled()) {
playerService.stopForImmediateReusing();
playerService.getView().setVisibility(View.GONE);
getRoot().ifPresent(view -> view.setVisibility(View.GONE));
} else {
playerHolder.stopService();
}
@ -1305,23 +1308,23 @@ public final class VideoDetailFragment
if (!isPlayerAvailable() || getView() == null) {
return;
}
// Check if viewHolder already contains a child
if (player.getRootView().getParent() != binding.playerPlaceholder) {
playerService.removeViewFromParent();
}
setHeightThumbnail();
// Prevent from re-adding a view multiple times
if (player.getRootView().getParent() == null) {
binding.playerPlaceholder.addView(player.getRootView());
}
new Handler(Looper.getMainLooper()).post(() ->
player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> {
playerUi.removeViewFromParent();
binding.playerPlaceholder.addView(playerUi.getBinding().getRoot());
playerUi.setupVideoSurfaceIfNeeded();
}));
}
private void removeVideoPlayerView() {
makeDefaultHeightForVideoPlaceholder();
playerService.removeViewFromParent();
if (player != null) {
player.UIs().get(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent);
}
}
private void makeDefaultHeightForVideoPlaceholder() {
@ -1362,7 +1365,7 @@ public final class VideoDetailFragment
final boolean isPortrait = metrics.heightPixels > metrics.widthPixels;
requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener);
if (isPlayerAvailable() && player.isFullscreen()) {
if (isFullscreen()) {
final int height = (DeviceUtils.isInMultiWindow(activity)
? requireView()
: activity.getWindow().getDecorView()).getHeight();
@ -1387,8 +1390,9 @@ public final class VideoDetailFragment
binding.detailThumbnailImageView.setMinimumHeight(newHeight);
if (isPlayerAvailable()) {
final int maxHeight = (int) (metrics.heightPixels * MAX_PLAYER_HEIGHT);
player.getSurfaceView()
.setHeights(newHeight, player.isFullscreen() ? newHeight : maxHeight);
player.UIs().get(VideoPlayerUi.class).ifPresent(ui ->
ui.getBinding().surfaceView.setHeights(newHeight,
ui.isFullscreen() ? newHeight : maxHeight));
}
}
@ -1517,7 +1521,7 @@ public final class VideoDetailFragment
if (binding.relatedItemsLayout != null) {
if (showRelatedItems) {
binding.relatedItemsLayout.setVisibility(
isPlayerAvailable() && player.isFullscreen() ? View.GONE : View.INVISIBLE);
isFullscreen() ? View.GONE : View.INVISIBLE);
} else {
binding.relatedItemsLayout.setVisibility(View.GONE);
}
@ -1779,6 +1783,11 @@ public final class VideoDetailFragment
// Player event listener
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onViewCreated() {
addVideoPlayerView();
}
@Override
public void onQueueUpdate(final PlayQueue queue) {
playQueue = queue;
@ -1899,15 +1908,10 @@ public final class VideoDetailFragment
@Override
public void onFullscreenStateChanged(final boolean fullscreen) {
setupBrightness();
//noinspection SimplifyOptionalCallChains
if (!isPlayerAndPlayerServiceAvailable()
|| playerService.getView() == null
|| player.getParentActivity() == null) {
return;
}
final View view = playerService.getView();
final ViewGroup parent = (ViewGroup) view.getParent();
if (parent == null) {
|| !player.UIs().get(MainPlayerUi.class).isPresent()
|| getRoot().map(View::getParent).orElse(null) == null) {
return;
}
@ -1935,7 +1939,7 @@ public final class VideoDetailFragment
final boolean isLandscape = DeviceUtils.isLandscape(requireContext());
if (DeviceUtils.isTablet(activity)
&& (!globalScreenOrientationLocked(activity) || isLandscape)) {
player.toggleFullscreen();
player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen);
return;
}
@ -2018,7 +2022,7 @@ public final class VideoDetailFragment
}
activity.getWindow().getDecorView().setSystemUiVisibility(visibility);
if (isInMultiWindow || (isPlayerAvailable() && player.isFullscreen())) {
if (isInMultiWindow || isFullscreen()) {
activity.getWindow().setStatusBarColor(Color.TRANSPARENT);
activity.getWindow().setNavigationBarColor(Color.TRANSPARENT);
}
@ -2027,13 +2031,17 @@ public final class VideoDetailFragment
// Listener implementation
public void hideSystemUiIfNeeded() {
if (isPlayerAvailable()
&& player.isFullscreen()
if (isFullscreen()
&& bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) {
hideSystemUi();
}
}
private boolean isFullscreen() {
return isPlayerAvailable() && player.UIs().get(VideoPlayerUi.class)
.map(VideoPlayerUi::isFullscreen).orElse(false);
}
private boolean playerIsNotStopped() {
return isPlayerAvailable() && !player.isStopped();
}
@ -2056,10 +2064,7 @@ public final class VideoDetailFragment
}
final WindowManager.LayoutParams lp = activity.getWindow().getAttributes();
if (!isPlayerAvailable()
|| !player.videoPlayerSelected()
|| !player.isFullscreen()
|| bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) {
if (!isFullscreen() || bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) {
// Apply system brightness when the player is not in fullscreen
restoreDefaultBrightness();
} else {
@ -2083,7 +2088,7 @@ public final class VideoDetailFragment
setAutoPlay(true);
}
player.checkLandscape();
player.UIs().get(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();
@ -2310,10 +2315,10 @@ public final class VideoDetailFragment
if (DeviceUtils.isLandscape(requireContext())
&& isPlayerAvailable()
&& player.isPlaying()
&& !player.isFullscreen()
&& !DeviceUtils.isTablet(activity)
&& player.videoPlayerSelected()) {
player.toggleFullscreen();
&& !isFullscreen()
&& !DeviceUtils.isTablet(activity)) {
player.UIs().get(MainPlayerUi.class)
.ifPresent(MainPlayerUi::toggleFullscreen);
}
setOverlayLook(binding.appBarLayout, behavior, 1);
break;
@ -2326,17 +2331,22 @@ public final class VideoDetailFragment
// Re-enable clicks
setOverlayElementsClickable(true);
if (isPlayerAvailable()) {
player.closeItemsList();
player.UIs().get(MainPlayerUi.class)
.ifPresent(MainPlayerUi::closeItemsList);
}
setOverlayLook(binding.appBarLayout, behavior, 0);
break;
case BottomSheetBehavior.STATE_DRAGGING:
case BottomSheetBehavior.STATE_SETTLING:
if (isPlayerAvailable() && player.isFullscreen()) {
if (isFullscreen()) {
showSystemUi();
}
if (isPlayerAvailable() && player.isControlsVisible()) {
player.hideControls(0, 0);
if (isPlayerAvailable()) {
player.UIs().get(MainPlayerUi.class).ifPresent(ui -> {
if (ui.isControlsVisible()) {
ui.hideControls(0, 0);
}
});
}
break;
}
@ -2410,4 +2420,13 @@ public final class VideoDetailFragment
boolean isPlayerAndPlayerServiceAvailable() {
return (player != null && playerService != null);
}
public Optional<View> getRoot() {
if (player == null) {
return Optional.empty();
}
return player.UIs().get(VideoPlayerUi.class)
.map(playerUi -> playerUi.getBinding().getRoot());
}
}

View File

@ -43,7 +43,7 @@ import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
import org.schabi.newpipe.player.MainPlayer.PlayerType;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.util.ExtractorHelper;

View File

@ -43,7 +43,7 @@ import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import org.schabi.newpipe.player.MainPlayer.PlayerType;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
import org.schabi.newpipe.util.ExtractorHelper;

View File

@ -9,15 +9,20 @@ import android.view.Window;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.FragmentManager;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.util.StateSaver;
import java.util.List;
import java.util.Objects;
import java.util.Queue;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
@ -131,13 +136,13 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
* @param context context used for accessing the database
* @param streamEntities used for crating the dialog
* @param onExec execution that should occur after a dialog got created, e.g. showing it
* @return Disposable
* @return the disposable that was created
*/
public static Disposable createCorrespondingDialog(
final Context context,
final List<StreamEntity> streamEntities,
final Consumer<PlaylistDialog> onExec
) {
final Consumer<PlaylistDialog> onExec) {
return new LocalPlaylistManager(NewPipeDatabase.getInstance(context))
.hasPlaylists()
.observeOn(AndroidSchedulers.mainThread())
@ -147,4 +152,30 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
: PlaylistCreationDialog.newInstance(streamEntities))
);
}
/**
* Creates a {@link PlaylistAppendDialog} when playlists exists,
* otherwise a {@link PlaylistCreationDialog}. If the player's play queue is null or empty, no
* dialog will be created.
*
* @param player the player from which to extract the context and the play queue
* @param fragmentManager the fragment manager to use to show the dialog
* @return the disposable that was created
*/
public static Disposable showForPlayQueue(
final Player player,
@NonNull final FragmentManager fragmentManager) {
final List<StreamEntity> streamEntities = Stream.of(player.getPlayQueue())
.filter(Objects::nonNull)
.flatMap(playQueue -> playQueue.getStreams().stream())
.map(StreamEntity::new)
.collect(Collectors.toList());
if (streamEntities.isEmpty()) {
return Disposable.empty();
}
return PlaylistDialog.createCorrespondingDialog(player.getContext(), streamEntities,
dialog -> dialog.show(fragmentManager, "PlaylistDialog"));
}
}

View File

@ -44,7 +44,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.MainPlayer.PlayerType;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.Localization;

View File

@ -1,259 +0,0 @@
/*
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
* Part of NewPipe
*
* License: GPL-3.0+
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.player;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Binder;
import android.os.IBinder;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.schabi.newpipe.App;
import org.schabi.newpipe.databinding.PlayerBinding;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ThemeHelper;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
/**
* One service for all players.
*
* @author mauriciocolli
*/
public final class MainPlayer extends Service {
private static final String TAG = "MainPlayer";
private static final boolean DEBUG = Player.DEBUG;
private Player player;
private WindowManager windowManager;
private final IBinder mBinder = new MainPlayer.LocalBinder();
public enum PlayerType {
VIDEO,
AUDIO,
POPUP
}
/*//////////////////////////////////////////////////////////////////////////
// Notification
//////////////////////////////////////////////////////////////////////////*/
static final String ACTION_CLOSE
= App.PACKAGE_NAME + ".player.MainPlayer.CLOSE";
static final String ACTION_PLAY_PAUSE
= App.PACKAGE_NAME + ".player.MainPlayer.PLAY_PAUSE";
static final String ACTION_REPEAT
= App.PACKAGE_NAME + ".player.MainPlayer.REPEAT";
static final String ACTION_PLAY_NEXT
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_PLAY_NEXT";
static final String ACTION_PLAY_PREVIOUS
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_PLAY_PREVIOUS";
static final String ACTION_FAST_REWIND
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_FAST_REWIND";
static final String ACTION_FAST_FORWARD
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_FAST_FORWARD";
static final String ACTION_SHUFFLE
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_SHUFFLE";
public static final String ACTION_RECREATE_NOTIFICATION
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_RECREATE_NOTIFICATION";
/*//////////////////////////////////////////////////////////////////////////
// Service's LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate() {
if (DEBUG) {
Log.d(TAG, "onCreate() called");
}
assureCorrectAppLanguage(this);
windowManager = ContextCompat.getSystemService(this, WindowManager.class);
ThemeHelper.setTheme(this);
createView();
}
private void createView() {
final PlayerBinding binding = PlayerBinding.inflate(LayoutInflater.from(this));
player = new Player(this);
player.setupFromView(binding);
NotificationUtil.getInstance().createNotificationAndStartForeground(player, this);
}
@Override
public int onStartCommand(final Intent intent, final int flags, final int startId) {
if (DEBUG) {
Log.d(TAG, "onStartCommand() called with: intent = [" + intent
+ "], flags = [" + flags + "], startId = [" + startId + "]");
}
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
&& player.getPlayQueue() == null) {
// Player is not working, no need to process media button's action
return START_NOT_STICKY;
}
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
|| intent.getStringExtra(Player.PLAY_QUEUE_KEY) != null) {
NotificationUtil.getInstance().createNotificationAndStartForeground(player, this);
}
player.handleIntent(intent);
if (player.getMediaSessionManager() != null) {
player.getMediaSessionManager().handleMediaButtonIntent(intent);
}
return START_NOT_STICKY;
}
public void stopForImmediateReusing() {
if (DEBUG) {
Log.d(TAG, "stopForImmediateReusing() called");
}
if (!player.exoPlayerIsNull()) {
player.saveWasPlaying();
// Releases wifi & cpu, disables keepScreenOn, etc.
// We can't just pause the player here because it will make transition
// from one stream to a new stream not smooth
player.smoothStopPlayer();
player.setRecovery();
// Android TV will handle back button in case controls will be visible
// (one more additional unneeded click while the player is hidden)
player.hideControls(0, 0);
player.closeItemsList();
// Notification shows information about old stream but if a user selects
// a stream from backStack it's not actual anymore
// So we should hide the notification at all.
// When autoplay enabled such notification flashing is annoying so skip this case
}
}
@Override
public void onTaskRemoved(final Intent rootIntent) {
super.onTaskRemoved(rootIntent);
if (!player.videoPlayerSelected()) {
return;
}
onDestroy();
// Unload from memory completely
Runtime.getRuntime().halt(0);
}
@Override
public void onDestroy() {
if (DEBUG) {
Log.d(TAG, "destroy() called");
}
cleanup();
}
private void cleanup() {
if (player != null) {
// Exit from fullscreen when user closes the player via notification
if (player.isFullscreen()) {
player.toggleFullscreen();
}
removeViewFromParent();
player.saveStreamProgressState();
player.setRecovery();
player.stopActivityBinding();
player.removePopupFromView();
player.destroy();
player = null;
}
}
public void stopService() {
NotificationUtil.getInstance().cancelNotificationAndStopForeground(this);
cleanup();
stopSelf();
}
@Override
protected void attachBaseContext(final Context base) {
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base));
}
@Override
public IBinder onBind(final Intent intent) {
return mBinder;
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
boolean isLandscape() {
// DisplayMetrics from activity context knows about MultiWindow feature
// while DisplayMetrics from app context doesn't
return DeviceUtils.isLandscape(player != null && player.getParentActivity() != null
? player.getParentActivity() : this);
}
@Nullable
public View getView() {
if (player == null) {
return null;
}
return player.getRootView();
}
public void removeViewFromParent() {
if (getView() != null && getView().getParent() != null) {
if (player.getParentActivity() != null) {
// This means view was added to fragment
final ViewGroup parent = (ViewGroup) getView().getParent();
parent.removeView(getView());
} else {
// This means view was added by windowManager for popup player
windowManager.removeViewImmediate(getView());
}
}
}
public class LocalBinder extends Binder {
public MainPlayer getService() {
return MainPlayer.this;
}
public Player getPlayer() {
return MainPlayer.this.player;
}
}
}

View File

@ -29,6 +29,7 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ActivityPlayerQueueControlBinding;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.event.PlayerEventListener;
import org.schabi.newpipe.player.helper.PlaybackParameterDialog;
import org.schabi.newpipe.player.playqueue.PlayQueue;
@ -51,7 +52,7 @@ public final class PlayQueueActivity extends AppCompatActivity
private static final int SMOOTH_SCROLL_MAXIMUM_DISTANCE = 80;
protected Player player;
private Player player;
private boolean serviceBound;
private ServiceConnection serviceConnection;
@ -126,13 +127,13 @@ public final class PlayQueueActivity extends AppCompatActivity
NavigationHelper.openSettings(this);
return true;
case R.id.action_append_playlist:
player.onAddToPlaylistClicked(getSupportFragmentManager());
PlaylistDialog.showForPlayQueue(player, getSupportFragmentManager());
return true;
case R.id.action_playback_speed:
openPlaybackParameterDialog();
return true;
case R.id.action_mute:
player.onMuteUnmuteButtonClicked();
player.toggleMute();
return true;
case R.id.action_system_audio:
startActivity(new Intent(Settings.ACTION_SOUND_SETTINGS));
@ -168,7 +169,7 @@ public final class PlayQueueActivity extends AppCompatActivity
////////////////////////////////////////////////////////////////////////////
private void bind() {
final Intent bindIntent = new Intent(this, MainPlayer.class);
final Intent bindIntent = new Intent(this, PlayerService.class);
final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE);
if (!success) {
unbindService(serviceConnection);
@ -184,10 +185,7 @@ public final class PlayQueueActivity extends AppCompatActivity
player.removeActivityListener(this);
}
if (player != null && player.getPlayQueueAdapter() != null) {
player.getPlayQueueAdapter().unsetSelectedListener();
}
queueControlBinding.playQueue.setAdapter(null);
onQueueUpdate(null);
if (itemTouchHelper != null) {
itemTouchHelper.attachToRecyclerView(null);
}
@ -208,17 +206,15 @@ public final class PlayQueueActivity extends AppCompatActivity
public void onServiceConnected(final ComponentName name, final IBinder service) {
Log.d(TAG, "Player service is connected");
if (service instanceof PlayerServiceBinder) {
player = ((PlayerServiceBinder) service).getPlayerInstance();
} else if (service instanceof MainPlayer.LocalBinder) {
player = ((MainPlayer.LocalBinder) service).getPlayer();
if (service instanceof PlayerService.LocalBinder) {
player = ((PlayerService.LocalBinder) service).getPlayer();
}
if (player == null || player.getPlayQueue() == null
|| player.getPlayQueueAdapter() == null || player.exoPlayerIsNull()) {
if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {
unbind();
finish();
} else {
onQueueUpdate(player.getPlayQueue());
buildComponents();
if (player != null) {
player.setActivityListener(PlayQueueActivity.this);
@ -241,7 +237,6 @@ public final class PlayQueueActivity extends AppCompatActivity
private void buildQueue() {
queueControlBinding.playQueue.setLayoutManager(new LinearLayoutManager(this));
queueControlBinding.playQueue.setAdapter(player.getPlayQueueAdapter());
queueControlBinding.playQueue.setClickable(true);
queueControlBinding.playQueue.setLongClickable(true);
queueControlBinding.playQueue.clearOnScrollListeners();
@ -249,8 +244,6 @@ public final class PlayQueueActivity extends AppCompatActivity
itemTouchHelper = new ItemTouchHelper(getItemTouchCallback());
itemTouchHelper.attachToRecyclerView(queueControlBinding.playQueue);
player.getPlayQueueAdapter().setSelectedListener(getOnSelectedListener());
}
private void buildMetadata() {
@ -370,7 +363,7 @@ public final class PlayQueueActivity extends AppCompatActivity
}
if (view.getId() == queueControlBinding.controlRepeat.getId()) {
player.onRepeatClicked();
player.cycleNextRepeatMode();
} else if (view.getId() == queueControlBinding.controlBackward.getId()) {
player.playPrevious();
} else if (view.getId() == queueControlBinding.controlFastRewind.getId()) {
@ -382,7 +375,7 @@ public final class PlayQueueActivity extends AppCompatActivity
} else if (view.getId() == queueControlBinding.controlForward.getId()) {
player.playNext();
} else if (view.getId() == queueControlBinding.controlShuffle.getId()) {
player.onShuffleClicked();
player.toggleShuffleModeEnabled();
} else if (view.getId() == queueControlBinding.metadata.getId()) {
scrollToSelected();
} else if (view.getId() == queueControlBinding.liveSync.getId()) {
@ -445,7 +438,14 @@ public final class PlayQueueActivity extends AppCompatActivity
////////////////////////////////////////////////////////////////////////////
@Override
public void onQueueUpdate(final PlayQueue queue) {
public void onQueueUpdate(@Nullable final PlayQueue queue) {
if (queue == null) {
queueControlBinding.playQueue.setAdapter(null);
} else {
final PlayQueueAdapter adapter = new PlayQueueAdapter(this, queue);
adapter.setSelectedListener(getOnSelectedListener());
queueControlBinding.playQueue.setAdapter(adapter);
}
}
@Override
@ -454,7 +454,6 @@ public final class PlayQueueActivity extends AppCompatActivity
onStateChanged(state);
onPlayModeChanged(repeatMode, shuffled);
onPlaybackParameterChanged(parameters);
onMaybePlaybackAdapterChanged();
onMaybeMuteChanged();
}
@ -582,17 +581,6 @@ public final class PlayQueueActivity extends AppCompatActivity
}
}
private void onMaybePlaybackAdapterChanged() {
if (player == null) {
return;
}
final PlayQueueAdapter maybeNewAdapter = player.getPlayQueueAdapter();
if (maybeNewAdapter != null
&& queueControlBinding.playQueue.getAdapter() != maybeNewAdapter) {
queueControlBinding.playQueue.setAdapter(maybeNewAdapter);
}
}
private void onMaybeMuteChanged() {
if (menu != null && player != null) {
final MenuItem item = menu.findItem(R.id.action_mute);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,149 @@
/*
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
* Part of NewPipe
*
* License: GPL-3.0+
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.player;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Binder;
import android.os.IBinder;
import android.util.Log;
import org.schabi.newpipe.util.ThemeHelper;
/**
* One service for all players.
*/
public final class PlayerService extends Service {
private static final String TAG = PlayerService.class.getSimpleName();
private static final boolean DEBUG = Player.DEBUG;
private Player player;
private final IBinder mBinder = new PlayerService.LocalBinder();
/*//////////////////////////////////////////////////////////////////////////
// Service's LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate() {
if (DEBUG) {
Log.d(TAG, "onCreate() called");
}
assureCorrectAppLanguage(this);
ThemeHelper.setTheme(this);
player = new Player(this);
}
@Override
public int onStartCommand(final Intent intent, final int flags, final int startId) {
if (DEBUG) {
Log.d(TAG, "onStartCommand() called with: intent = [" + intent
+ "], flags = [" + flags + "], startId = [" + startId + "]");
}
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
&& player.getPlayQueue() == null) {
// No need to process media button's actions if the player is not working, otherwise the
// player service would strangely start with nothing to play
return START_NOT_STICKY;
}
player.handleIntent(intent);
if (player.getMediaSessionManager() != null) {
player.getMediaSessionManager().handleMediaButtonIntent(intent);
}
return START_NOT_STICKY;
}
public void stopForImmediateReusing() {
if (DEBUG) {
Log.d(TAG, "stopForImmediateReusing() called");
}
if (!player.exoPlayerIsNull()) {
player.saveWasPlaying();
// Releases wifi & cpu, disables keepScreenOn, etc.
// We can't just pause the player here because it will make transition
// from one stream to a new stream not smooth
player.smoothStopForImmediateReusing();
}
}
@Override
public void onTaskRemoved(final Intent rootIntent) {
super.onTaskRemoved(rootIntent);
if (!player.videoPlayerSelected()) {
return;
}
onDestroy();
// Unload from memory completely
Runtime.getRuntime().halt(0);
}
@Override
public void onDestroy() {
if (DEBUG) {
Log.d(TAG, "destroy() called");
}
cleanup();
}
private void cleanup() {
if (player != null) {
player.destroy();
player = null;
}
}
public void stopService() {
cleanup();
stopSelf();
}
@Override
protected void attachBaseContext(final Context base) {
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base));
}
@Override
public IBinder onBind(final Intent intent) {
return mBinder;
}
public class LocalBinder extends Binder {
public PlayerService getService() {
return PlayerService.this;
}
public Player getPlayer() {
return PlayerService.this.player;
}
}
}

View File

@ -1,17 +0,0 @@
package org.schabi.newpipe.player;
import android.os.Binder;
import androidx.annotation.NonNull;
class PlayerServiceBinder extends Binder {
private final Player player;
PlayerServiceBinder(@NonNull final Player player) {
this.player = player;
}
Player getPlayerInstance() {
return player;
}
}

View File

@ -1,79 +0,0 @@
package org.schabi.newpipe.player;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import java.io.Serializable;
public class PlayerState implements Serializable {
@NonNull
private final PlayQueue playQueue;
private final int repeatMode;
private final float playbackSpeed;
private final float playbackPitch;
@Nullable
private final String playbackQuality;
private final boolean playbackSkipSilence;
private final boolean wasPlaying;
PlayerState(@NonNull final PlayQueue playQueue, final int repeatMode,
final float playbackSpeed, final float playbackPitch,
final boolean playbackSkipSilence, final boolean wasPlaying) {
this(playQueue, repeatMode, playbackSpeed, playbackPitch, null,
playbackSkipSilence, wasPlaying);
}
PlayerState(@NonNull final PlayQueue playQueue, final int repeatMode,
final float playbackSpeed, final float playbackPitch,
@Nullable final String playbackQuality, final boolean playbackSkipSilence,
final boolean wasPlaying) {
this.playQueue = playQueue;
this.repeatMode = repeatMode;
this.playbackSpeed = playbackSpeed;
this.playbackPitch = playbackPitch;
this.playbackQuality = playbackQuality;
this.playbackSkipSilence = playbackSkipSilence;
this.wasPlaying = wasPlaying;
}
/*//////////////////////////////////////////////////////////////////////////
// Serdes
//////////////////////////////////////////////////////////////////////////*/
/*//////////////////////////////////////////////////////////////////////////
// Getters
//////////////////////////////////////////////////////////////////////////*/
@NonNull
public PlayQueue getPlayQueue() {
return playQueue;
}
public int getRepeatMode() {
return repeatMode;
}
public float getPlaybackSpeed() {
return playbackSpeed;
}
public float getPlaybackPitch() {
return playbackPitch;
}
@Nullable
public String getPlaybackQuality() {
return playbackQuality;
}
public boolean isPlaybackSkipSilence() {
return playbackSkipSilence;
}
public boolean wasPlaying() {
return wasPlaying;
}
}

View File

@ -0,0 +1,32 @@
package org.schabi.newpipe.player;
import static org.schabi.newpipe.player.Player.PLAYER_TYPE;
import android.content.Intent;
public enum PlayerType {
MAIN,
AUDIO,
POPUP;
/**
* @return an integer representing this {@link PlayerType}, to be used to save it in intents
* @see #retrieveFromIntent(Intent) Use retrieveFromIntent() to retrieve and convert player type
* integers from an intent
*/
public int valueForIntent() {
return ordinal();
}
/**
* @param intent the intent to retrieve a player type from
* @return the player type integer retrieved from the intent, converted back into a {@link
* PlayerType}, or {@link PlayerType#MAIN} if there is no player type extra in the
* intent
* @throws ArrayIndexOutOfBoundsException if the intent contains an invalid player type integer
* @see #valueForIntent() Use valueForIntent() to obtain valid player type integers
*/
public static PlayerType retrieveFromIntent(final Intent intent) {
return values()[intent.getIntExtra(PLAYER_TYPE, MAIN.valueForIntent())];
}
}

View File

@ -1,520 +0,0 @@
package org.schabi.newpipe.player.event
import android.content.Context
import android.os.Handler
import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.player.MainPlayer
import org.schabi.newpipe.player.Player
import org.schabi.newpipe.player.helper.PlayerHelper
import org.schabi.newpipe.player.helper.PlayerHelper.savePopupPositionAndSizeToPrefs
import kotlin.math.abs
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min
/**
* Base gesture handling for [Player]
*
* This class contains the logic for the player gestures like View preparations
* and provides some abstract methods to make it easier separating the logic from the UI.
*/
abstract class BasePlayerGestureListener(
@JvmField
protected val player: Player,
@JvmField
protected val service: MainPlayer
) : GestureDetector.SimpleOnGestureListener(), View.OnTouchListener {
// ///////////////////////////////////////////////////////////////////
// Abstract methods for VIDEO and POPUP
// ///////////////////////////////////////////////////////////////////
abstract fun onDoubleTap(event: MotionEvent, portion: DisplayPortion)
abstract fun onSingleTap(playerType: MainPlayer.PlayerType)
abstract fun onScroll(
playerType: MainPlayer.PlayerType,
portion: DisplayPortion,
initialEvent: MotionEvent,
movingEvent: MotionEvent,
distanceX: Float,
distanceY: Float
)
abstract fun onScrollEnd(playerType: MainPlayer.PlayerType, event: MotionEvent)
// ///////////////////////////////////////////////////////////////////
// Abstract methods for POPUP (exclusive)
// ///////////////////////////////////////////////////////////////////
abstract fun onPopupResizingStart()
abstract fun onPopupResizingEnd()
private var initialPopupX: Int = -1
private var initialPopupY: Int = -1
private var isMovingInMain = false
private var isMovingInPopup = false
private var isResizing = false
private val tossFlingVelocity = PlayerHelper.getTossFlingVelocity()
// [popup] initial coordinates and distance between fingers
private var initPointerDistance = -1.0
private var initFirstPointerX = -1f
private var initFirstPointerY = -1f
private var initSecPointerX = -1f
private var initSecPointerY = -1f
// ///////////////////////////////////////////////////////////////////
// onTouch implementation
// ///////////////////////////////////////////////////////////////////
override fun onTouch(v: View, event: MotionEvent): Boolean {
return if (player.popupPlayerSelected()) {
onTouchInPopup(v, event)
} else {
onTouchInMain(v, event)
}
}
private fun onTouchInMain(v: View, event: MotionEvent): Boolean {
player.gestureDetector.onTouchEvent(event)
if (event.action == MotionEvent.ACTION_UP && isMovingInMain) {
isMovingInMain = false
onScrollEnd(MainPlayer.PlayerType.VIDEO, event)
}
return when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
v.parent.requestDisallowInterceptTouchEvent(player.isFullscreen)
true
}
MotionEvent.ACTION_UP -> {
v.parent.requestDisallowInterceptTouchEvent(false)
false
}
else -> true
}
}
private fun onTouchInPopup(v: View, event: MotionEvent): Boolean {
player.gestureDetector.onTouchEvent(event)
if (event.pointerCount == 2 && !isMovingInPopup && !isResizing) {
if (DEBUG) {
Log.d(TAG, "onTouch() 2 finger pointer detected, enabling resizing.")
}
onPopupResizingStart()
// record coordinates of fingers
initFirstPointerX = event.getX(0)
initFirstPointerY = event.getY(0)
initSecPointerX = event.getX(1)
initSecPointerY = event.getY(1)
// record distance between fingers
initPointerDistance = hypot(
initFirstPointerX - initSecPointerX.toDouble(),
initFirstPointerY - initSecPointerY.toDouble()
)
isResizing = true
}
if (event.action == MotionEvent.ACTION_MOVE && !isMovingInPopup && isResizing) {
if (DEBUG) {
Log.d(
TAG,
"onTouch() ACTION_MOVE > v = [$v], e1.getRaw =" +
"[${event.rawX}, ${event.rawY}]"
)
}
return handleMultiDrag(event)
}
if (event.action == MotionEvent.ACTION_UP) {
if (DEBUG) {
Log.d(
TAG,
"onTouch() ACTION_UP > v = [$v], e1.getRaw =" +
" [${event.rawX}, ${event.rawY}]"
)
}
if (isMovingInPopup) {
isMovingInPopup = false
onScrollEnd(MainPlayer.PlayerType.POPUP, event)
}
if (isResizing) {
isResizing = false
initPointerDistance = (-1).toDouble()
initFirstPointerX = (-1).toFloat()
initFirstPointerY = (-1).toFloat()
initSecPointerX = (-1).toFloat()
initSecPointerY = (-1).toFloat()
onPopupResizingEnd()
player.changeState(player.currentState)
}
if (!player.isPopupClosing) {
savePopupPositionAndSizeToPrefs(player)
}
}
v.performClick()
return true
}
private fun handleMultiDrag(event: MotionEvent): Boolean {
if (initPointerDistance != -1.0 && event.pointerCount == 2) {
// get the movements of the fingers
val firstPointerMove = hypot(
event.getX(0) - initFirstPointerX.toDouble(),
event.getY(0) - initFirstPointerY.toDouble()
)
val secPointerMove = hypot(
event.getX(1) - initSecPointerX.toDouble(),
event.getY(1) - initSecPointerY.toDouble()
)
// minimum threshold beyond which pinch gesture will work
val minimumMove = ViewConfiguration.get(service).scaledTouchSlop
if (max(firstPointerMove, secPointerMove) > minimumMove) {
// calculate current distance between the pointers
val currentPointerDistance = hypot(
event.getX(0) - event.getX(1).toDouble(),
event.getY(0) - event.getY(1).toDouble()
)
val popupWidth = player.popupLayoutParams!!.width.toDouble()
// change co-ordinates of popup so the center stays at the same position
val newWidth = popupWidth * currentPointerDistance / initPointerDistance
initPointerDistance = currentPointerDistance
player.popupLayoutParams!!.x += ((popupWidth - newWidth) / 2.0).toInt()
player.checkPopupPositionBounds()
player.updateScreenSize()
player.changePopupSize(min(player.screenWidth.toDouble(), newWidth).toInt())
return true
}
}
return false
}
// ///////////////////////////////////////////////////////////////////
// Simple gestures
// ///////////////////////////////////////////////////////////////////
override fun onDown(e: MotionEvent): Boolean {
if (DEBUG)
Log.d(TAG, "onDown called with e = [$e]")
if (isDoubleTapping && isDoubleTapEnabled) {
doubleTapControls?.onDoubleTapProgressDown(getDisplayPortion(e))
return true
}
return if (player.popupPlayerSelected())
onDownInPopup(e)
else
true
}
private fun onDownInPopup(e: MotionEvent): Boolean {
// Fix popup position when the user touch it, it may have the wrong one
// because the soft input is visible (the draggable area is currently resized).
player.updateScreenSize()
player.checkPopupPositionBounds()
player.popupLayoutParams?.let {
initialPopupX = it.x
initialPopupY = it.y
}
return super.onDown(e)
}
override fun onDoubleTap(e: MotionEvent): Boolean {
if (DEBUG)
Log.d(TAG, "onDoubleTap called with e = [$e]")
onDoubleTap(e, getDisplayPortion(e))
return true
}
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (DEBUG)
Log.d(TAG, "onSingleTapConfirmed() called with: e = [$e]")
if (isDoubleTapping)
return true
if (player.popupPlayerSelected()) {
if (player.exoPlayerIsNull())
return false
onSingleTap(MainPlayer.PlayerType.POPUP)
return true
} else {
super.onSingleTapConfirmed(e)
if (player.currentState == Player.STATE_BLOCKED)
return true
onSingleTap(MainPlayer.PlayerType.VIDEO)
}
return true
}
override fun onLongPress(e: MotionEvent?) {
if (player.popupPlayerSelected()) {
player.updateScreenSize()
player.checkPopupPositionBounds()
player.changePopupSize(player.screenWidth.toInt())
}
}
override fun onScroll(
initialEvent: MotionEvent,
movingEvent: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
return if (player.popupPlayerSelected()) {
onScrollInPopup(initialEvent, movingEvent, distanceX, distanceY)
} else {
onScrollInMain(initialEvent, movingEvent, distanceX, distanceY)
}
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
return if (player.popupPlayerSelected()) {
val absVelocityX = abs(velocityX)
val absVelocityY = abs(velocityY)
if (absVelocityX.coerceAtLeast(absVelocityY) > tossFlingVelocity) {
if (absVelocityX > tossFlingVelocity) {
player.popupLayoutParams!!.x = velocityX.toInt()
}
if (absVelocityY > tossFlingVelocity) {
player.popupLayoutParams!!.y = velocityY.toInt()
}
player.checkPopupPositionBounds()
player.windowManager!!.updateViewLayout(player.rootView, player.popupLayoutParams)
return true
}
return false
} else {
true
}
}
private fun onScrollInMain(
initialEvent: MotionEvent,
movingEvent: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
if (!player.isFullscreen) {
return false
}
val isTouchingStatusBar: Boolean = initialEvent.y < getStatusBarHeight(service)
val isTouchingNavigationBar: Boolean =
initialEvent.y > (player.rootView.height - getNavigationBarHeight(service))
if (isTouchingStatusBar || isTouchingNavigationBar) {
return false
}
val insideThreshold = abs(movingEvent.y - initialEvent.y) <= MOVEMENT_THRESHOLD
if (
!isMovingInMain && (insideThreshold || abs(distanceX) > abs(distanceY)) ||
player.currentState == Player.STATE_COMPLETED
) {
return false
}
isMovingInMain = true
onScroll(
MainPlayer.PlayerType.VIDEO,
getDisplayHalfPortion(initialEvent),
initialEvent,
movingEvent,
distanceX,
distanceY
)
return true
}
private fun onScrollInPopup(
initialEvent: MotionEvent,
movingEvent: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
if (isResizing) {
return super.onScroll(initialEvent, movingEvent, distanceX, distanceY)
}
if (!isMovingInPopup) {
player.closeOverlayButton.animate(true, 200)
}
isMovingInPopup = true
val diffX: Float = (movingEvent.rawX - initialEvent.rawX)
var posX: Float = (initialPopupX + diffX)
val diffY: Float = (movingEvent.rawY - initialEvent.rawY)
var posY: Float = (initialPopupY + diffY)
if (posX > player.screenWidth - player.popupLayoutParams!!.width) {
posX = (player.screenWidth - player.popupLayoutParams!!.width)
} else if (posX < 0) {
posX = 0f
}
if (posY > player.screenHeight - player.popupLayoutParams!!.height) {
posY = (player.screenHeight - player.popupLayoutParams!!.height)
} else if (posY < 0) {
posY = 0f
}
player.popupLayoutParams!!.x = posX.toInt()
player.popupLayoutParams!!.y = posY.toInt()
onScroll(
MainPlayer.PlayerType.POPUP,
getDisplayHalfPortion(initialEvent),
initialEvent,
movingEvent,
distanceX,
distanceY
)
player.windowManager!!.updateViewLayout(player.rootView, player.popupLayoutParams)
return true
}
// ///////////////////////////////////////////////////////////////////
// Multi double tapping
// ///////////////////////////////////////////////////////////////////
var doubleTapControls: DoubleTapListener? = null
private set
private val isDoubleTapEnabled: Boolean
get() = doubleTapDelay > 0
var isDoubleTapping = false
private set
fun doubleTapControls(listener: DoubleTapListener) = apply {
doubleTapControls = listener
}
private var doubleTapDelay = DOUBLE_TAP_DELAY
private val doubleTapHandler: Handler = Handler()
private val doubleTapRunnable = Runnable {
if (DEBUG)
Log.d(TAG, "doubleTapRunnable called")
isDoubleTapping = false
doubleTapControls?.onDoubleTapFinished()
}
fun startMultiDoubleTap(e: MotionEvent) {
if (!isDoubleTapping) {
if (DEBUG)
Log.d(TAG, "startMultiDoubleTap called with e = [$e]")
keepInDoubleTapMode()
doubleTapControls?.onDoubleTapStarted(getDisplayPortion(e))
}
}
fun keepInDoubleTapMode() {
if (DEBUG)
Log.d(TAG, "keepInDoubleTapMode called")
isDoubleTapping = true
doubleTapHandler.removeCallbacks(doubleTapRunnable)
doubleTapHandler.postDelayed(doubleTapRunnable, doubleTapDelay)
}
fun endMultiDoubleTap() {
if (DEBUG)
Log.d(TAG, "endMultiDoubleTap called")
isDoubleTapping = false
doubleTapHandler.removeCallbacks(doubleTapRunnable)
doubleTapControls?.onDoubleTapFinished()
}
// ///////////////////////////////////////////////////////////////////
// Utils
// ///////////////////////////////////////////////////////////////////
private fun getDisplayPortion(e: MotionEvent): DisplayPortion {
return if (player.playerType == MainPlayer.PlayerType.POPUP && player.popupLayoutParams != null) {
when {
e.x < player.popupLayoutParams!!.width / 3.0 -> DisplayPortion.LEFT
e.x > player.popupLayoutParams!!.width * 2.0 / 3.0 -> DisplayPortion.RIGHT
else -> DisplayPortion.MIDDLE
}
} else /* MainPlayer.PlayerType.VIDEO */ {
when {
e.x < player.rootView.width / 3.0 -> DisplayPortion.LEFT
e.x > player.rootView.width * 2.0 / 3.0 -> DisplayPortion.RIGHT
else -> DisplayPortion.MIDDLE
}
}
}
// Currently needed for scrolling since there is no action more the middle portion
private fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion {
return if (player.playerType == MainPlayer.PlayerType.POPUP) {
when {
e.x < player.popupLayoutParams!!.width / 2.0 -> DisplayPortion.LEFT_HALF
else -> DisplayPortion.RIGHT_HALF
}
} else /* MainPlayer.PlayerType.VIDEO */ {
when {
e.x < player.rootView.width / 2.0 -> DisplayPortion.LEFT_HALF
else -> DisplayPortion.RIGHT_HALF
}
}
}
private fun getNavigationBarHeight(context: Context): Int {
val resId = context.resources
.getIdentifier("navigation_bar_height", "dimen", "android")
return if (resId > 0) {
context.resources.getDimensionPixelSize(resId)
} else 0
}
private fun getStatusBarHeight(context: Context): Int {
val resId = context.resources
.getIdentifier("status_bar_height", "dimen", "android")
return if (resId > 0) {
context.resources.getDimensionPixelSize(resId)
} else 0
}
companion object {
private const val TAG = "BasePlayerGestListener"
private val DEBUG = Player.DEBUG
private const val DOUBLE_TAP_DELAY = 550L
private const val MOVEMENT_THRESHOLD = 40
}
}

View File

@ -1,7 +0,0 @@
package org.schabi.newpipe.player.event
interface DoubleTapListener {
fun onDoubleTapStarted(portion: DisplayPortion) {}
fun onDoubleTapProgressDown(portion: DisplayPortion) {}
fun onDoubleTapFinished() {}
}

View File

@ -1,6 +1,5 @@
package org.schabi.newpipe.player.event;
import com.google.android.exoplayer2.PlaybackParameters;
import org.schabi.newpipe.extractor.stream.StreamInfo;

View File

@ -1,256 +0,0 @@
package org.schabi.newpipe.player.event;
import static org.schabi.newpipe.ktx.AnimationType.ALPHA;
import static org.schabi.newpipe.ktx.AnimationType.SCALE_AND_ALPHA;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.player.Player.DEFAULT_CONTROLS_DURATION;
import static org.schabi.newpipe.player.Player.DEFAULT_CONTROLS_HIDE_TIME;
import static org.schabi.newpipe.player.Player.STATE_PLAYING;
import android.app.Activity;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import android.widget.ProgressBar;
import androidx.annotation.NonNull;
import androidx.appcompat.content.res.AppCompatResources;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.player.MainPlayer;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.helper.PlayerHelper;
/**
* GestureListener for the player
*
* While {@link BasePlayerGestureListener} contains the logic behind the single gestures
* this class focuses on the visual aspect like hiding and showing the controls or changing
* volume/brightness during scrolling for specific events.
*/
public class PlayerGestureListener
extends BasePlayerGestureListener
implements View.OnTouchListener {
private static final String TAG = PlayerGestureListener.class.getSimpleName();
private static final boolean DEBUG = MainActivity.DEBUG;
private final int maxVolume;
public PlayerGestureListener(final Player player, final MainPlayer service) {
super(player, service);
maxVolume = player.getAudioReactor().getMaxVolume();
}
@Override
public void onDoubleTap(@NonNull final MotionEvent event,
@NonNull final DisplayPortion portion) {
if (DEBUG) {
Log.d(TAG, "onDoubleTap called with playerType = ["
+ player.getPlayerType() + "], portion = [" + portion + "]");
}
if (player.isSomePopupMenuVisible()) {
player.hideControls(0, 0);
}
if (portion == DisplayPortion.LEFT || portion == DisplayPortion.RIGHT) {
startMultiDoubleTap(event);
} else if (portion == DisplayPortion.MIDDLE) {
player.playPause();
}
}
@Override
public void onSingleTap(@NonNull final MainPlayer.PlayerType playerType) {
if (DEBUG) {
Log.d(TAG, "onSingleTap called with playerType = [" + player.getPlayerType() + "]");
}
if (player.isControlsVisible()) {
player.hideControls(150, 0);
return;
}
// -- Controls are not visible --
// When player is completed show controls and don't hide them later
if (player.getCurrentState() == Player.STATE_COMPLETED) {
player.showControls(0);
} else {
player.showControlsThenHide();
}
}
@Override
public void onScroll(@NonNull final MainPlayer.PlayerType playerType,
@NonNull final DisplayPortion portion,
@NonNull final MotionEvent initialEvent,
@NonNull final MotionEvent movingEvent,
final float distanceX, final float distanceY) {
if (DEBUG) {
Log.d(TAG, "onScroll called with playerType = ["
+ player.getPlayerType() + "], portion = [" + portion + "]");
}
if (playerType == MainPlayer.PlayerType.VIDEO) {
// -- Brightness and Volume control --
final boolean isBrightnessGestureEnabled =
PlayerHelper.isBrightnessGestureEnabled(service);
final boolean isVolumeGestureEnabled = PlayerHelper.isVolumeGestureEnabled(service);
if (isBrightnessGestureEnabled && isVolumeGestureEnabled) {
if (portion == DisplayPortion.LEFT_HALF) {
onScrollMainBrightness(distanceX, distanceY);
} else /* DisplayPortion.RIGHT_HALF */ {
onScrollMainVolume(distanceX, distanceY);
}
} else if (isBrightnessGestureEnabled) {
onScrollMainBrightness(distanceX, distanceY);
} else if (isVolumeGestureEnabled) {
onScrollMainVolume(distanceX, distanceY);
}
} else /* MainPlayer.PlayerType.POPUP */ {
// -- Determine if the ClosingOverlayView (red X) has to be shown or hidden --
final View closingOverlayView = player.getClosingOverlayView();
final boolean showClosingOverlayView = player.isInsideClosingRadius(movingEvent);
// Check if an view is in expected state and if not animate it into the correct state
final int expectedVisibility = showClosingOverlayView ? View.VISIBLE : View.GONE;
if (closingOverlayView.getVisibility() != expectedVisibility) {
animate(closingOverlayView, showClosingOverlayView, 200);
}
}
}
private void onScrollMainVolume(final float distanceX, final float distanceY) {
// If we just started sliding, change the progress bar to match the system volume
if (player.getVolumeRelativeLayout().getVisibility() != View.VISIBLE) {
final float volumePercent = player
.getAudioReactor().getVolume() / (float) maxVolume;
player.getVolumeProgressBar().setProgress(
(int) (volumePercent * player.getMaxGestureLength()));
}
player.getVolumeProgressBar().incrementProgressBy((int) distanceY);
final float currentProgressPercent = (float) player
.getVolumeProgressBar().getProgress() / player.getMaxGestureLength();
final int currentVolume = (int) (maxVolume * currentProgressPercent);
player.getAudioReactor().setVolume(currentVolume);
if (DEBUG) {
Log.d(TAG, "onScroll().volumeControl, currentVolume = " + currentVolume);
}
player.getVolumeImageView().setImageDrawable(
AppCompatResources.getDrawable(service, currentProgressPercent <= 0
? R.drawable.ic_volume_off
: currentProgressPercent < 0.25 ? R.drawable.ic_volume_mute
: currentProgressPercent < 0.75 ? R.drawable.ic_volume_down
: R.drawable.ic_volume_up)
);
if (player.getVolumeRelativeLayout().getVisibility() != View.VISIBLE) {
animate(player.getVolumeRelativeLayout(), true, 200, SCALE_AND_ALPHA);
}
if (player.getBrightnessRelativeLayout().getVisibility() == View.VISIBLE) {
player.getBrightnessRelativeLayout().setVisibility(View.GONE);
}
}
private void onScrollMainBrightness(final float distanceX, final float distanceY) {
final Activity parent = player.getParentActivity();
if (parent == null) {
return;
}
final Window window = parent.getWindow();
final WindowManager.LayoutParams layoutParams = window.getAttributes();
final ProgressBar bar = player.getBrightnessProgressBar();
final float oldBrightness = layoutParams.screenBrightness;
bar.setProgress((int) (bar.getMax() * Math.max(0, Math.min(1, oldBrightness))));
bar.incrementProgressBy((int) distanceY);
final float currentProgressPercent = (float) bar.getProgress() / bar.getMax();
layoutParams.screenBrightness = currentProgressPercent;
window.setAttributes(layoutParams);
// Save current brightness level
PlayerHelper.setScreenBrightness(parent, currentProgressPercent);
if (DEBUG) {
Log.d(TAG, "onScroll().brightnessControl, "
+ "currentBrightness = " + currentProgressPercent);
}
player.getBrightnessImageView().setImageDrawable(
AppCompatResources.getDrawable(service,
currentProgressPercent < 0.25
? R.drawable.ic_brightness_low
: currentProgressPercent < 0.75
? R.drawable.ic_brightness_medium
: R.drawable.ic_brightness_high)
);
if (player.getBrightnessRelativeLayout().getVisibility() != View.VISIBLE) {
animate(player.getBrightnessRelativeLayout(), true, 200, SCALE_AND_ALPHA);
}
if (player.getVolumeRelativeLayout().getVisibility() == View.VISIBLE) {
player.getVolumeRelativeLayout().setVisibility(View.GONE);
}
}
@Override
public void onScrollEnd(@NonNull final MainPlayer.PlayerType playerType,
@NonNull final MotionEvent event) {
if (DEBUG) {
Log.d(TAG, "onScrollEnd called with playerType = ["
+ player.getPlayerType() + "]");
}
if (player.isControlsVisible() && player.getCurrentState() == STATE_PLAYING) {
player.hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME);
}
if (playerType == MainPlayer.PlayerType.VIDEO) {
if (player.getVolumeRelativeLayout().getVisibility() == View.VISIBLE) {
animate(player.getVolumeRelativeLayout(), false, 200, SCALE_AND_ALPHA,
200);
}
if (player.getBrightnessRelativeLayout().getVisibility() == View.VISIBLE) {
animate(player.getBrightnessRelativeLayout(), false, 200, SCALE_AND_ALPHA,
200);
}
} else /* Popup-Player */ {
if (player.isInsideClosingRadius(event)) {
player.closePopup();
} else if (!player.isPopupClosing()) {
animate(player.getCloseOverlayButton(), false, 200);
animate(player.getClosingOverlayView(), false, 200);
}
}
}
@Override
public void onPopupResizingStart() {
if (DEBUG) {
Log.d(TAG, "onPopupResizingStart called");
}
player.getLoadingPanel().setVisibility(View.GONE);
player.hideControls(0, 0);
animate(player.getFastSeekOverlay(), false, 0);
animate(player.getCurrentDisplaySeek(), false, 0, ALPHA, 0);
}
@Override
public void onPopupResizingEnd() {
if (DEBUG) {
Log.d(TAG, "onPopupResizingEnd called");
}
}
}

View File

@ -3,6 +3,8 @@ package org.schabi.newpipe.player.event;
import com.google.android.exoplayer2.PlaybackException;
public interface PlayerServiceEventListener extends PlayerEventListener {
void onViewCreated();
void onFullscreenStateChanged(boolean fullscreen);
void onScreenRotationButtonClicked();

View File

@ -1,11 +1,11 @@
package org.schabi.newpipe.player.event;
import org.schabi.newpipe.player.MainPlayer;
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.Player;
public interface PlayerServiceExtendedEventListener extends PlayerServiceEventListener {
void onServiceConnected(Player player,
MainPlayer playerService,
PlayerService playerService,
boolean playAfterConnect);
void onServiceDisconnected();
}

View File

@ -0,0 +1,186 @@
package org.schabi.newpipe.player.gesture
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import org.schabi.newpipe.databinding.PlayerBinding
import org.schabi.newpipe.player.Player
import org.schabi.newpipe.player.ui.VideoPlayerUi
/**
* Base gesture handling for [Player]
*
* This class contains the logic for the player gestures like View preparations
* and provides some abstract methods to make it easier separating the logic from the UI.
*/
abstract class BasePlayerGestureListener(
private val playerUi: VideoPlayerUi,
) : GestureDetector.SimpleOnGestureListener(), View.OnTouchListener {
protected val player: Player = playerUi.player
protected val binding: PlayerBinding = playerUi.binding
override fun onTouch(v: View, event: MotionEvent): Boolean {
playerUi.gestureDetector.onTouchEvent(event)
return false
}
private fun onDoubleTap(
event: MotionEvent,
portion: DisplayPortion
) {
if (DEBUG) {
Log.d(
TAG,
"onDoubleTap called with playerType = [" +
player.playerType + "], portion = [" + portion + "]"
)
}
if (playerUi.isSomePopupMenuVisible) {
playerUi.hideControls(0, 0)
}
if (portion === DisplayPortion.LEFT || portion === DisplayPortion.RIGHT) {
startMultiDoubleTap(event)
} else if (portion === DisplayPortion.MIDDLE) {
player.playPause()
}
}
protected fun onSingleTap() {
if (playerUi.isControlsVisible) {
playerUi.hideControls(150, 0)
return
}
// -- Controls are not visible --
// When player is completed show controls and don't hide them later
if (player.currentState == Player.STATE_COMPLETED) {
playerUi.showControls(0)
} else {
playerUi.showControlsThenHide()
}
}
open fun onScrollEnd(event: MotionEvent) {
if (DEBUG) {
Log.d(
TAG,
"onScrollEnd called with playerType = [" +
player.playerType + "]"
)
}
if (playerUi.isControlsVisible && player.currentState == Player.STATE_PLAYING) {
playerUi.hideControls(
VideoPlayerUi.DEFAULT_CONTROLS_DURATION,
VideoPlayerUi.DEFAULT_CONTROLS_HIDE_TIME
)
}
}
// ///////////////////////////////////////////////////////////////////
// Simple gestures
// ///////////////////////////////////////////////////////////////////
override fun onDown(e: MotionEvent): Boolean {
if (DEBUG)
Log.d(TAG, "onDown called with e = [$e]")
if (isDoubleTapping && isDoubleTapEnabled) {
doubleTapControls?.onDoubleTapProgressDown(getDisplayPortion(e))
return true
}
if (onDownNotDoubleTapping(e)) {
return super.onDown(e)
}
return true
}
/**
* @return true if `super.onDown(e)` should be called, false otherwise
*/
open fun onDownNotDoubleTapping(e: MotionEvent): Boolean {
return false // do not call super.onDown(e) by default, overridden for popup player
}
override fun onDoubleTap(e: MotionEvent): Boolean {
if (DEBUG)
Log.d(TAG, "onDoubleTap called with e = [$e]")
onDoubleTap(e, getDisplayPortion(e))
return true
}
// ///////////////////////////////////////////////////////////////////
// Multi double tapping
// ///////////////////////////////////////////////////////////////////
private var doubleTapControls: DoubleTapListener? = null
private val isDoubleTapEnabled: Boolean
get() = doubleTapDelay > 0
var isDoubleTapping = false
private set
fun doubleTapControls(listener: DoubleTapListener) = apply {
doubleTapControls = listener
}
private var doubleTapDelay = DOUBLE_TAP_DELAY
private val doubleTapHandler: Handler = Handler(Looper.getMainLooper())
private val doubleTapRunnable = Runnable {
if (DEBUG)
Log.d(TAG, "doubleTapRunnable called")
isDoubleTapping = false
doubleTapControls?.onDoubleTapFinished()
}
private fun startMultiDoubleTap(e: MotionEvent) {
if (!isDoubleTapping) {
if (DEBUG)
Log.d(TAG, "startMultiDoubleTap called with e = [$e]")
keepInDoubleTapMode()
doubleTapControls?.onDoubleTapStarted(getDisplayPortion(e))
}
}
fun keepInDoubleTapMode() {
if (DEBUG)
Log.d(TAG, "keepInDoubleTapMode called")
isDoubleTapping = true
doubleTapHandler.removeCallbacks(doubleTapRunnable)
doubleTapHandler.postDelayed(doubleTapRunnable, doubleTapDelay)
}
fun endMultiDoubleTap() {
if (DEBUG)
Log.d(TAG, "endMultiDoubleTap called")
isDoubleTapping = false
doubleTapHandler.removeCallbacks(doubleTapRunnable)
doubleTapControls?.onDoubleTapFinished()
}
// ///////////////////////////////////////////////////////////////////
// Utils
// ///////////////////////////////////////////////////////////////////
abstract fun getDisplayPortion(e: MotionEvent): DisplayPortion
// Currently needed for scrolling since there is no action more the middle portion
abstract fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion
companion object {
private const val TAG = "BasePlayerGestListener"
private val DEBUG = Player.DEBUG
private const val DOUBLE_TAP_DELAY = 550L
}
}

View File

@ -1,4 +1,4 @@
package org.schabi.newpipe.player.event;
package org.schabi.newpipe.player.gesture;
import android.content.Context;
import android.graphics.Rect;

View File

@ -1,4 +1,4 @@
package org.schabi.newpipe.player.event
package org.schabi.newpipe.player.gesture
enum class DisplayPortion {
LEFT, MIDDLE, RIGHT, LEFT_HALF, RIGHT_HALF

View File

@ -0,0 +1,7 @@
package org.schabi.newpipe.player.gesture
interface DoubleTapListener {
fun onDoubleTapStarted(portion: DisplayPortion)
fun onDoubleTapProgressDown(portion: DisplayPortion)
fun onDoubleTapFinished()
}

View File

@ -0,0 +1,234 @@
package org.schabi.newpipe.player.gesture
import android.util.Log
import android.view.MotionEvent
import android.view.View
import android.view.View.OnTouchListener
import android.widget.ProgressBar
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.view.isVisible
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R
import org.schabi.newpipe.ktx.AnimationType
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.player.Player
import org.schabi.newpipe.player.helper.AudioReactor
import org.schabi.newpipe.player.helper.PlayerHelper
import org.schabi.newpipe.player.ui.MainPlayerUi
import org.schabi.newpipe.util.ThemeHelper.getAndroidDimenPx
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
/**
* GestureListener for the player
*
* While [BasePlayerGestureListener] contains the logic behind the single gestures
* this class focuses on the visual aspect like hiding and showing the controls or changing
* volume/brightness during scrolling for specific events.
*/
class MainPlayerGestureListener(
private val playerUi: MainPlayerUi
) : BasePlayerGestureListener(playerUi), OnTouchListener {
private var isMoving = false
override fun onTouch(v: View, event: MotionEvent): Boolean {
super.onTouch(v, event)
if (event.action == MotionEvent.ACTION_UP && isMoving) {
isMoving = false
onScrollEnd(event)
}
return when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
v.parent?.requestDisallowInterceptTouchEvent(playerUi.isFullscreen)
true
}
MotionEvent.ACTION_UP -> {
v.parent?.requestDisallowInterceptTouchEvent(false)
false
}
else -> true
}
}
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (DEBUG)
Log.d(TAG, "onSingleTapConfirmed() called with: e = [$e]")
if (isDoubleTapping)
return true
super.onSingleTapConfirmed(e)
if (player.currentState != Player.STATE_BLOCKED)
onSingleTap()
return true
}
private fun onScrollVolume(distanceY: Float) {
val bar: ProgressBar = binding.volumeProgressBar
val audioReactor: AudioReactor = player.audioReactor
// If we just started sliding, change the progress bar to match the system volume
if (!binding.volumeRelativeLayout.isVisible) {
val volumePercent: Float = audioReactor.volume / audioReactor.maxVolume.toFloat()
bar.progress = (volumePercent * bar.max).toInt()
}
// Update progress bar
binding.volumeProgressBar.incrementProgressBy(distanceY.toInt())
// Update volume
val currentProgressPercent: Float = bar.progress / bar.max.toFloat()
val currentVolume = (audioReactor.maxVolume * currentProgressPercent).toInt()
audioReactor.volume = currentVolume
if (DEBUG) {
Log.d(TAG, "onScroll().volumeControl, currentVolume = $currentVolume")
}
// Update player center image
binding.volumeImageView.setImageDrawable(
AppCompatResources.getDrawable(
player.context,
when {
currentProgressPercent <= 0 -> R.drawable.ic_volume_off
currentProgressPercent < 0.25 -> R.drawable.ic_volume_mute
currentProgressPercent < 0.75 -> R.drawable.ic_volume_down
else -> R.drawable.ic_volume_up
}
)
)
// Make sure the correct layout is visible
if (!binding.volumeRelativeLayout.isVisible) {
binding.volumeRelativeLayout.animate(true, 200, AnimationType.SCALE_AND_ALPHA)
}
binding.brightnessRelativeLayout.isVisible = false
}
private fun onScrollBrightness(distanceY: Float) {
val parent: AppCompatActivity = playerUi.parentActivity.orElse(null) ?: return
val window = parent.window
val layoutParams = window.attributes
val bar: ProgressBar = binding.brightnessProgressBar
// Update progress bar
val oldBrightness = layoutParams.screenBrightness
bar.progress = (bar.max * max(0f, min(1f, oldBrightness))).toInt()
bar.incrementProgressBy(distanceY.toInt())
// Update brightness
val currentProgressPercent = bar.progress.toFloat() / bar.max
layoutParams.screenBrightness = currentProgressPercent
window.attributes = layoutParams
// Save current brightness level
PlayerHelper.setScreenBrightness(parent, currentProgressPercent)
if (DEBUG) {
Log.d(
TAG,
"onScroll().brightnessControl, " +
"currentBrightness = " + currentProgressPercent
)
}
// Update player center image
binding.brightnessImageView.setImageDrawable(
AppCompatResources.getDrawable(
player.context,
when {
currentProgressPercent < 0.25 -> R.drawable.ic_brightness_low
currentProgressPercent < 0.75 -> R.drawable.ic_brightness_medium
else -> R.drawable.ic_brightness_high
}
)
)
// Make sure the correct layout is visible
if (!binding.brightnessRelativeLayout.isVisible) {
binding.brightnessRelativeLayout.animate(true, 200, AnimationType.SCALE_AND_ALPHA)
}
binding.volumeRelativeLayout.isVisible = false
}
override fun onScrollEnd(event: MotionEvent) {
super.onScrollEnd(event)
if (binding.volumeRelativeLayout.isVisible) {
binding.volumeRelativeLayout.animate(false, 200, AnimationType.SCALE_AND_ALPHA, 200)
}
if (binding.brightnessRelativeLayout.isVisible) {
binding.brightnessRelativeLayout.animate(false, 200, AnimationType.SCALE_AND_ALPHA, 200)
}
}
override fun onScroll(
initialEvent: MotionEvent,
movingEvent: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
if (!playerUi.isFullscreen) {
return false
}
// Calculate heights of status and navigation bars
val statusBarHeight = getAndroidDimenPx(player.context, "status_bar_height")
val navigationBarHeight = getAndroidDimenPx(player.context, "navigation_bar_height")
// Do not handle this event if initially it started from status or navigation bars
val isTouchingStatusBar = initialEvent.y < statusBarHeight
val isTouchingNavigationBar = initialEvent.y > (binding.root.height - navigationBarHeight)
if (isTouchingStatusBar || isTouchingNavigationBar) {
return false
}
val insideThreshold = abs(movingEvent.y - initialEvent.y) <= MOVEMENT_THRESHOLD
if (
!isMoving && (insideThreshold || abs(distanceX) > abs(distanceY)) ||
player.currentState == Player.STATE_COMPLETED
) {
return false
}
isMoving = true
// -- Brightness and Volume control --
val isBrightnessGestureEnabled = PlayerHelper.isBrightnessGestureEnabled(player.context)
val isVolumeGestureEnabled = PlayerHelper.isVolumeGestureEnabled(player.context)
if (isBrightnessGestureEnabled && isVolumeGestureEnabled) {
if (getDisplayHalfPortion(initialEvent) === DisplayPortion.LEFT_HALF) {
onScrollBrightness(distanceY)
} else /* DisplayPortion.RIGHT_HALF */ {
onScrollVolume(distanceY)
}
} else if (isBrightnessGestureEnabled) {
onScrollBrightness(distanceY)
} else if (isVolumeGestureEnabled) {
onScrollVolume(distanceY)
}
return true
}
override fun getDisplayPortion(e: MotionEvent): DisplayPortion {
return when {
e.x < binding.root.width / 3.0 -> DisplayPortion.LEFT
e.x > binding.root.width * 2.0 / 3.0 -> DisplayPortion.RIGHT
else -> DisplayPortion.MIDDLE
}
}
override fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion {
return when {
e.x < binding.root.width / 2.0 -> DisplayPortion.LEFT_HALF
else -> DisplayPortion.RIGHT_HALF
}
}
companion object {
private val TAG = MainPlayerGestureListener::class.java.simpleName
private val DEBUG = MainActivity.DEBUG
private const val MOVEMENT_THRESHOLD = 40
}
}

View File

@ -0,0 +1,288 @@
package org.schabi.newpipe.player.gesture
import android.util.Log
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.ktx.AnimationType
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.player.ui.PopupPlayerUi
import kotlin.math.abs
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min
class PopupPlayerGestureListener(
private val playerUi: PopupPlayerUi,
) : BasePlayerGestureListener(playerUi) {
private var isMoving = false
private var initialPopupX: Int = -1
private var initialPopupY: Int = -1
private var isResizing = false
// initial coordinates and distance between fingers
private var initPointerDistance = -1.0
private var initFirstPointerX = -1f
private var initFirstPointerY = -1f
private var initSecPointerX = -1f
private var initSecPointerY = -1f
override fun onTouch(v: View, event: MotionEvent): Boolean {
super.onTouch(v, event)
if (event.pointerCount == 2 && !isMoving && !isResizing) {
if (DEBUG) {
Log.d(TAG, "onTouch() 2 finger pointer detected, enabling resizing.")
}
onPopupResizingStart()
// record coordinates of fingers
initFirstPointerX = event.getX(0)
initFirstPointerY = event.getY(0)
initSecPointerX = event.getX(1)
initSecPointerY = event.getY(1)
// record distance between fingers
initPointerDistance = hypot(
initFirstPointerX - initSecPointerX.toDouble(),
initFirstPointerY - initSecPointerY.toDouble()
)
isResizing = true
}
if (event.action == MotionEvent.ACTION_MOVE && !isMoving && isResizing) {
if (DEBUG) {
Log.d(
TAG,
"onTouch() ACTION_MOVE > v = [$v], e1.getRaw =" +
"[${event.rawX}, ${event.rawY}]"
)
}
return handleMultiDrag(event)
}
if (event.action == MotionEvent.ACTION_UP) {
if (DEBUG) {
Log.d(
TAG,
"onTouch() ACTION_UP > v = [$v], e1.getRaw =" +
" [${event.rawX}, ${event.rawY}]"
)
}
if (isMoving) {
isMoving = false
onScrollEnd(event)
}
if (isResizing) {
isResizing = false
initPointerDistance = (-1).toDouble()
initFirstPointerX = (-1).toFloat()
initFirstPointerY = (-1).toFloat()
initSecPointerX = (-1).toFloat()
initSecPointerY = (-1).toFloat()
onPopupResizingEnd()
player.changeState(player.currentState)
}
if (!playerUi.isPopupClosing) {
playerUi.savePopupPositionAndSizeToPrefs()
}
}
v.performClick()
return true
}
override fun onScrollEnd(event: MotionEvent) {
super.onScrollEnd(event)
if (playerUi.isInsideClosingRadius(event)) {
playerUi.closePopup()
} else if (!playerUi.isPopupClosing) {
playerUi.closeOverlayBinding.closeButton.animate(false, 200)
binding.closingOverlay.animate(false, 200)
}
}
private fun handleMultiDrag(event: MotionEvent): Boolean {
if (initPointerDistance == -1.0 || event.pointerCount != 2) {
return false
}
// get the movements of the fingers
val firstPointerMove = hypot(
event.getX(0) - initFirstPointerX.toDouble(),
event.getY(0) - initFirstPointerY.toDouble()
)
val secPointerMove = hypot(
event.getX(1) - initSecPointerX.toDouble(),
event.getY(1) - initSecPointerY.toDouble()
)
// minimum threshold beyond which pinch gesture will work
val minimumMove = ViewConfiguration.get(player.context).scaledTouchSlop
if (max(firstPointerMove, secPointerMove) <= minimumMove) {
return false
}
// calculate current distance between the pointers
val currentPointerDistance = hypot(
event.getX(0) - event.getX(1).toDouble(),
event.getY(0) - event.getY(1).toDouble()
)
val popupWidth = playerUi.popupLayoutParams.width.toDouble()
// change co-ordinates of popup so the center stays at the same position
val newWidth = popupWidth * currentPointerDistance / initPointerDistance
initPointerDistance = currentPointerDistance
playerUi.popupLayoutParams.x += ((popupWidth - newWidth) / 2.0).toInt()
playerUi.checkPopupPositionBounds()
playerUi.updateScreenSize()
playerUi.changePopupSize(min(playerUi.screenWidth.toDouble(), newWidth).toInt())
return true
}
private fun onPopupResizingStart() {
if (DEBUG) {
Log.d(TAG, "onPopupResizingStart called")
}
binding.loadingPanel.visibility = View.GONE
playerUi.hideControls(0, 0)
binding.fastSeekOverlay.animate(false, 0)
binding.currentDisplaySeek.animate(false, 0, AnimationType.ALPHA, 0)
}
private fun onPopupResizingEnd() {
if (DEBUG) {
Log.d(TAG, "onPopupResizingEnd called")
}
}
override fun onLongPress(e: MotionEvent?) {
playerUi.updateScreenSize()
playerUi.checkPopupPositionBounds()
playerUi.changePopupSize(playerUi.screenWidth)
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
return if (player.popupPlayerSelected()) {
val absVelocityX = abs(velocityX)
val absVelocityY = abs(velocityY)
if (absVelocityX.coerceAtLeast(absVelocityY) > TOSS_FLING_VELOCITY) {
if (absVelocityX > TOSS_FLING_VELOCITY) {
playerUi.popupLayoutParams.x = velocityX.toInt()
}
if (absVelocityY > TOSS_FLING_VELOCITY) {
playerUi.popupLayoutParams.y = velocityY.toInt()
}
playerUi.checkPopupPositionBounds()
playerUi.windowManager.updateViewLayout(binding.root, playerUi.popupLayoutParams)
return true
}
return false
} else {
true
}
}
override fun onDownNotDoubleTapping(e: MotionEvent): Boolean {
// Fix popup position when the user touch it, it may have the wrong one
// because the soft input is visible (the draggable area is currently resized).
playerUi.updateScreenSize()
playerUi.checkPopupPositionBounds()
playerUi.popupLayoutParams.let {
initialPopupX = it.x
initialPopupY = it.y
}
return true // we want `super.onDown(e)` to be called
}
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (DEBUG)
Log.d(TAG, "onSingleTapConfirmed() called with: e = [$e]")
if (isDoubleTapping)
return true
if (player.exoPlayerIsNull())
return false
onSingleTap()
return true
}
override fun onScroll(
initialEvent: MotionEvent,
movingEvent: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
if (isResizing) {
return super.onScroll(initialEvent, movingEvent, distanceX, distanceY)
}
if (!isMoving) {
playerUi.closeOverlayBinding.closeButton.animate(true, 200)
}
isMoving = true
val diffX: Float = (movingEvent.rawX - initialEvent.rawX)
var posX: Float = (initialPopupX + diffX)
val diffY: Float = (movingEvent.rawY - initialEvent.rawY)
var posY: Float = (initialPopupY + diffY)
if (posX > playerUi.screenWidth - playerUi.popupLayoutParams.width) {
posX = (playerUi.screenWidth - playerUi.popupLayoutParams.width).toFloat()
} else if (posX < 0) {
posX = 0f
}
if (posY > playerUi.screenHeight - playerUi.popupLayoutParams.height) {
posY = (playerUi.screenHeight - playerUi.popupLayoutParams.height).toFloat()
} else if (posY < 0) {
posY = 0f
}
playerUi.popupLayoutParams.x = posX.toInt()
playerUi.popupLayoutParams.y = posY.toInt()
// -- Determine if the ClosingOverlayView (red X) has to be shown or hidden --
val showClosingOverlayView: Boolean = playerUi.isInsideClosingRadius(movingEvent)
// Check if an view is in expected state and if not animate it into the correct state
val expectedVisibility = if (showClosingOverlayView) View.VISIBLE else View.GONE
if (binding.closingOverlay.visibility != expectedVisibility) {
binding.closingOverlay.animate(showClosingOverlayView, 200)
}
playerUi.windowManager.updateViewLayout(binding.root, playerUi.popupLayoutParams)
return true
}
override fun getDisplayPortion(e: MotionEvent): DisplayPortion {
return when {
e.x < playerUi.popupLayoutParams.width / 3.0 -> DisplayPortion.LEFT
e.x > playerUi.popupLayoutParams.width * 2.0 / 3.0 -> DisplayPortion.RIGHT
else -> DisplayPortion.MIDDLE
}
}
override fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion {
return when {
e.x < playerUi.popupLayoutParams.width / 2.0 -> DisplayPortion.LEFT_HALF
else -> DisplayPortion.RIGHT_HALF
}
}
companion object {
private val TAG = PopupPlayerGestureListener::class.java.simpleName
private val DEBUG = MainActivity.DEBUG
private const val TOSS_FLING_VELOCITY = 2500
}
}

View File

@ -26,7 +26,7 @@ import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.DialogPlaybackParameterBinding;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.ui.VideoPlayerUi;
import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
import org.schabi.newpipe.util.SliderStrategy;
@ -207,7 +207,7 @@ public class PlaybackParameterDialog extends DialogFragment {
? View.VISIBLE
: View.GONE);
animateRotation(binding.pitchToogleControlModes,
Player.DEFAULT_CONTROLS_DURATION,
VideoPlayerUi.DEFAULT_CONTROLS_DURATION,
isCurrentlyVisible ? 180 : 0);
});

View File

@ -3,8 +3,6 @@ package org.schabi.newpipe.player.helper;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
import static org.schabi.newpipe.player.Player.IDLE_WINDOW_FLAGS;
import static org.schabi.newpipe.player.Player.PLAYER_TYPE;
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_ALWAYS;
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_NEVER;
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_WIFI;
@ -15,14 +13,8 @@ import static java.lang.annotation.RetentionPolicy.SOURCE;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.PixelFormat;
import android.os.Build;
import android.provider.Settings;
import android.view.Gravity;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.accessibility.CaptioningManager;
import androidx.annotation.IntDef;
@ -49,7 +41,6 @@ import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.stream.SubtitlesStream;
import org.schabi.newpipe.extractor.utils.Utils;
import org.schabi.newpipe.player.MainPlayer;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
@ -76,20 +67,6 @@ public final class PlayerHelper {
private static final NumberFormat SPEED_FORMATTER = new DecimalFormat("0.##x");
private static final NumberFormat PITCH_FORMATTER = new DecimalFormat("##%");
/**
* Maximum opacity allowed for Android 12 and higher to allow touches on other apps when using
* NewPipe's popup player.
*
* <p>
* This value is hardcoded instead of being get dynamically with the method linked of the
* constant documentation below, because it is not static and popup player layout parameters
* are generated with static methods.
* </p>
*
* @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE
*/
private static final float MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER = 0.8f;
@Retention(SOURCE)
@IntDef({AUTOPLAY_TYPE_ALWAYS, AUTOPLAY_TYPE_WIFI,
AUTOPLAY_TYPE_NEVER})
@ -339,10 +316,6 @@ public final class PlayerHelper {
return true;
}
public static int getTossFlingVelocity() {
return 2500;
}
@NonNull
public static CaptionStyleCompat getCaptionStyle(@NonNull final Context context) {
final CaptioningManager captioningManager = ContextCompat.getSystemService(context,
@ -452,12 +425,6 @@ public final class PlayerHelper {
// Utils used by player
////////////////////////////////////////////////////////////////////////////
public static MainPlayer.PlayerType retrievePlayerTypeFromIntent(final Intent intent) {
// If you want to open popup from the app just include Constants.POPUP_ONLY into an extra
return MainPlayer.PlayerType.values()[
intent.getIntExtra(PLAYER_TYPE, MainPlayer.PlayerType.VIDEO.ordinal())];
}
public static boolean isPlaybackResumeEnabled(final Player player) {
return player.getPrefs().getBoolean(
player.getContext().getString(R.string.enable_watch_history_key), true)
@ -528,90 +495,10 @@ public final class PlayerHelper {
.apply();
}
/**
* @param player {@code screenWidth} and {@code screenHeight} must have been initialized
* @return the popup starting layout params
*/
@SuppressLint("RtlHardcoded")
public static WindowManager.LayoutParams retrievePopupLayoutParamsFromPrefs(
final Player player) {
final boolean popupRememberSizeAndPos = player.getPrefs().getBoolean(
player.getContext().getString(R.string.popup_remember_size_pos_key), true);
final float defaultSize =
player.getContext().getResources().getDimension(R.dimen.popup_default_width);
final float popupWidth = popupRememberSizeAndPos
? player.getPrefs().getFloat(player.getContext().getString(
R.string.popup_saved_width_key), defaultSize)
: defaultSize;
final float popupHeight = getMinimumVideoHeight(popupWidth);
final WindowManager.LayoutParams popupLayoutParams = new WindowManager.LayoutParams(
(int) popupWidth, (int) popupHeight,
popupLayoutParamType(),
IDLE_WINDOW_FLAGS,
PixelFormat.TRANSLUCENT);
popupLayoutParams.gravity = Gravity.LEFT | Gravity.TOP;
popupLayoutParams.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
final int centerX = (int) (player.getScreenWidth() / 2f - popupWidth / 2f);
final int centerY = (int) (player.getScreenHeight() / 2f - popupHeight / 2f);
popupLayoutParams.x = popupRememberSizeAndPos
? player.getPrefs().getInt(player.getContext().getString(
R.string.popup_saved_x_key), centerX) : centerX;
popupLayoutParams.y = popupRememberSizeAndPos
? player.getPrefs().getInt(player.getContext().getString(
R.string.popup_saved_y_key), centerY) : centerY;
return popupLayoutParams;
}
public static void savePopupPositionAndSizeToPrefs(final Player player) {
if (player.getPopupLayoutParams() != null) {
player.getPrefs().edit()
.putFloat(player.getContext().getString(R.string.popup_saved_width_key),
player.getPopupLayoutParams().width)
.putInt(player.getContext().getString(R.string.popup_saved_x_key),
player.getPopupLayoutParams().x)
.putInt(player.getContext().getString(R.string.popup_saved_y_key),
player.getPopupLayoutParams().y)
.apply();
}
}
public static float getMinimumVideoHeight(final float width) {
return width / (16.0f / 9.0f); // Respect the 16:9 ratio that most videos have
}
@SuppressLint("RtlHardcoded")
public static WindowManager.LayoutParams buildCloseOverlayLayoutParams() {
final int flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
| WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM;
final WindowManager.LayoutParams closeOverlayLayoutParams = new WindowManager.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
popupLayoutParamType(),
flags,
PixelFormat.TRANSLUCENT);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Setting maximum opacity allowed for touch events to other apps for Android 12 and
// higher to prevent non interaction when using other apps with the popup player
closeOverlayLayoutParams.alpha = MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER;
}
closeOverlayLayoutParams.gravity = Gravity.LEFT | Gravity.TOP;
closeOverlayLayoutParams.softInputMode =
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
return closeOverlayLayoutParams;
}
public static int popupLayoutParamType() {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.O
? WindowManager.LayoutParams.TYPE_PHONE
: WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
}
public static int retrieveSeekDurationFromPreferences(final Player player) {
return Integer.parseInt(Objects.requireNonNull(player.getPrefs().getString(
player.getContext().getString(R.string.seek_duration_key),

View File

@ -16,8 +16,9 @@ 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.MainPlayer;
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;
@ -42,17 +43,17 @@ public final class PlayerHolder {
private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection();
private boolean bound;
@Nullable private MainPlayer playerService;
@Nullable private PlayerService playerService;
@Nullable private Player player;
/**
* Returns the current {@link MainPlayer.PlayerType} of the {@link MainPlayer} service,
* otherwise `null` if no service running.
* Returns the current {@link PlayerType} of the {@link PlayerService} service,
* otherwise `null` if no service is running.
*
* @return Current PlayerType
*/
@Nullable
public MainPlayer.PlayerType getType() {
public PlayerType getType() {
if (player == null) {
return null;
}
@ -122,7 +123,7 @@ public final class PlayerHolder {
// and NullPointerExceptions inside the service because the service will be
// bound twice. Prevent it with unbinding first
unbind(context);
ContextCompat.startForegroundService(context, new Intent(context, MainPlayer.class));
ContextCompat.startForegroundService(context, new Intent(context, PlayerService.class));
serviceConnection.doPlayAfterConnect(playAfterConnect);
bind(context);
}
@ -130,7 +131,7 @@ public final class PlayerHolder {
public void stopService() {
final Context context = getCommonContext();
unbind(context);
context.stopService(new Intent(context, MainPlayer.class));
context.stopService(new Intent(context, PlayerService.class));
}
class PlayerServiceConnection implements ServiceConnection {
@ -156,7 +157,7 @@ public final class PlayerHolder {
if (DEBUG) {
Log.d(TAG, "Player service is connected");
}
final MainPlayer.LocalBinder localBinder = (MainPlayer.LocalBinder) service;
final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service;
playerService = localBinder.getService();
player = localBinder.getPlayer();
@ -172,7 +173,7 @@ public final class PlayerHolder {
Log.d(TAG, "bind() called");
}
final Intent serviceIntent = new Intent(context, MainPlayer.class);
final Intent serviceIntent = new Intent(context, PlayerService.class);
bound = context.bindService(serviceIntent, serviceConnection,
Context.BIND_AUTO_CREATE);
if (!bound) {
@ -211,6 +212,13 @@ public final class PlayerHolder {
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) {

View File

@ -1,47 +0,0 @@
package org.schabi.newpipe.player.listeners.view
import android.util.Log
import android.view.View
import androidx.appcompat.widget.PopupMenu
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.player.Player
import org.schabi.newpipe.player.helper.PlaybackParameterDialog
/**
* Click listener for the playbackSpeed textview of the player
*/
class PlaybackSpeedClickListener(
private val player: Player,
private val playbackSpeedPopupMenu: PopupMenu
) : View.OnClickListener {
companion object {
private const val TAG: String = "PlaybSpeedClickListener"
}
override fun onClick(v: View) {
if (MainActivity.DEBUG) {
Log.d(TAG, "onPlaybackSpeedClicked() called")
}
if (player.videoPlayerSelected()) {
PlaybackParameterDialog.newInstance(
player.playbackSpeed.toDouble(),
player.playbackPitch.toDouble(),
player.playbackSkipSilence
) { speed: Float, pitch: Float, skipSilence: Boolean ->
player.setPlaybackParameters(
speed,
pitch,
skipSilence
)
}
.show(player.parentActivity!!.supportFragmentManager, null)
} else {
playbackSpeedPopupMenu.show()
player.isSomePopupMenuVisible = true
}
player.manageControlsAfterOnClick(v)
}
}

View File

@ -1,41 +0,0 @@
package org.schabi.newpipe.player.listeners.view
import android.annotation.SuppressLint
import android.util.Log
import android.view.View
import androidx.appcompat.widget.PopupMenu
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.extractor.MediaFormat
import org.schabi.newpipe.player.Player
/**
* Click listener for the qualityTextView of the player
*/
class QualityClickListener(
private val player: Player,
private val qualityPopupMenu: PopupMenu
) : View.OnClickListener {
companion object {
private const val TAG: String = "QualityClickListener"
}
@SuppressLint("SetTextI18n") // we don't need I18N because of a " "
override fun onClick(v: View) {
if (MainActivity.DEBUG) {
Log.d(TAG, "onQualitySelectorClicked() called")
}
qualityPopupMenu.show()
player.isSomePopupMenuVisible = true
val videoStream = player.selectedVideoStream
if (videoStream != null) {
player.binding.qualityTextView.text =
MediaFormat.getNameById(videoStream.formatId) + " " + videoStream.getResolution()
}
player.saveWasPlaying()
player.manageControlsAfterOnClick(v)
}
}

View File

@ -1,4 +1,4 @@
package org.schabi.newpipe.player;
package org.schabi.newpipe.player.notification;
import android.content.Context;
import android.content.SharedPreferences;
@ -7,6 +7,7 @@ import androidx.annotation.DrawableRes;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
import org.schabi.newpipe.util.Localization;
@ -20,7 +21,34 @@ import java.util.TreeSet;
public final class NotificationConstants {
private NotificationConstants() { }
private NotificationConstants() {
}
/*//////////////////////////////////////////////////////////////////////////
// Intent actions
//////////////////////////////////////////////////////////////////////////*/
public static final String ACTION_CLOSE
= App.PACKAGE_NAME + ".player.MainPlayer.CLOSE";
public static final String ACTION_PLAY_PAUSE
= App.PACKAGE_NAME + ".player.MainPlayer.PLAY_PAUSE";
public static final String ACTION_REPEAT
= App.PACKAGE_NAME + ".player.MainPlayer.REPEAT";
public static final String ACTION_PLAY_NEXT
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_PLAY_NEXT";
public static final String ACTION_PLAY_PREVIOUS
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_PLAY_PREVIOUS";
public static final String ACTION_FAST_REWIND
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_FAST_REWIND";
public static final String ACTION_FAST_FORWARD
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_FAST_FORWARD";
public static final String ACTION_SHUFFLE
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_SHUFFLE";
public static final String ACTION_RECREATE_NOTIFICATION
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_RECREATE_NOTIFICATION";
public static final int NOTHING = 0;

View File

@ -0,0 +1,125 @@
package org.schabi.newpipe.player.notification;
import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION;
import android.content.Intent;
import android.graphics.Bitmap;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.Player.RepeatMode;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.helper.PlayerHelper;
import org.schabi.newpipe.player.ui.PlayerUi;
public final class NotificationPlayerUi extends PlayerUi {
private boolean foregroundNotificationAlreadyCreated = false;
private final NotificationUtil notificationUtil;
public NotificationPlayerUi(@NonNull final Player player) {
super(player);
notificationUtil = new NotificationUtil(player);
}
@Override
public void initPlayer() {
super.initPlayer();
if (!foregroundNotificationAlreadyCreated) {
notificationUtil.createNotificationAndStartForeground();
foregroundNotificationAlreadyCreated = true;
}
}
@Override
public void destroy() {
super.destroy();
notificationUtil.cancelNotificationAndStopForeground();
}
@Override
public void onThumbnailLoaded(@Nullable final Bitmap bitmap) {
super.onThumbnailLoaded(bitmap);
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
@Override
public void onBlocked() {
super.onBlocked();
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
@Override
public void onPlaying() {
super.onPlaying();
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
@Override
public void onBuffering() {
super.onBuffering();
if (notificationUtil.shouldUpdateBufferingSlot()) {
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
}
@Override
public void onPaused() {
super.onPaused();
// Remove running notification when user does not want minimization to background or popup
if (PlayerHelper.getMinimizeOnExitAction(context) == MINIMIZE_ON_EXIT_MODE_NONE
&& player.videoPlayerSelected()) {
notificationUtil.cancelNotificationAndStopForeground();
} else {
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
}
@Override
public void onPausedSeek() {
super.onPausedSeek();
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
@Override
public void onCompleted() {
super.onCompleted();
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
@Override
public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
super.onRepeatModeChanged(repeatMode);
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
@Override
public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) {
super.onShuffleModeEnabledChanged(shuffleModeEnabled);
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
@Override
public void onBroadcastReceived(final Intent intent) {
super.onBroadcastReceived(intent);
if (ACTION_RECREATE_NOTIFICATION.equals(intent.getAction())) {
notificationUtil.createNotificationIfNeededAndUpdate(true);
}
}
@Override
public void onMetadataChanged(@NonNull final StreamInfo info) {
super.onMetadataChanged(info);
notificationUtil.createNotificationIfNeededAndUpdate(true);
}
@Override
public void onPlayQueueEdited() {
super.onPlayQueueEdited();
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
}

View File

@ -1,8 +1,7 @@
package org.schabi.newpipe.player;
package org.schabi.newpipe.player.notification;
import android.annotation.SuppressLint;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.graphics.Bitmap;
@ -19,6 +18,7 @@ import androidx.core.content.ContextCompat;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.util.NavigationHelper;
import java.util.List;
@ -26,14 +26,14 @@ import java.util.List;
import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
import static org.schabi.newpipe.player.MainPlayer.ACTION_CLOSE;
import static org.schabi.newpipe.player.MainPlayer.ACTION_FAST_FORWARD;
import static org.schabi.newpipe.player.MainPlayer.ACTION_FAST_REWIND;
import static org.schabi.newpipe.player.MainPlayer.ACTION_PLAY_NEXT;
import static org.schabi.newpipe.player.MainPlayer.ACTION_PLAY_PAUSE;
import static org.schabi.newpipe.player.MainPlayer.ACTION_PLAY_PREVIOUS;
import static org.schabi.newpipe.player.MainPlayer.ACTION_REPEAT;
import static org.schabi.newpipe.player.MainPlayer.ACTION_SHUFFLE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_FORWARD;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_REWIND;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_NEXT;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PREVIOUS;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_REPEAT;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE;
/**
* This is a utility class for player notifications.
@ -45,22 +45,16 @@ public final class NotificationUtil {
private static final boolean DEBUG = Player.DEBUG;
private static final int NOTIFICATION_ID = 123789;
@Nullable private static NotificationUtil instance = null;
@NotificationConstants.Action
private final int[] notificationSlots = NotificationConstants.SLOT_DEFAULTS.clone();
private NotificationManagerCompat notificationManager;
private NotificationCompat.Builder notificationBuilder;
private NotificationUtil() {
}
private final Player player;
public static NotificationUtil getInstance() {
if (instance == null) {
instance = new NotificationUtil();
}
return instance;
public NotificationUtil(final Player player) {
this.player = player;
}
@ -71,20 +65,18 @@ public final class NotificationUtil {
/**
* Creates the notification if it does not exist already and recreates it if forceRecreate is
* true. Updates the notification with the data in the player.
* @param player the player currently open, to take data from
* @param forceRecreate whether to force the recreation of the notification even if it already
* exists
*/
synchronized void createNotificationIfNeededAndUpdate(final Player player,
final boolean forceRecreate) {
public synchronized void createNotificationIfNeededAndUpdate(final boolean forceRecreate) {
if (forceRecreate || notificationBuilder == null) {
notificationBuilder = createNotification(player);
notificationBuilder = createNotification();
}
updateNotification(player);
updateNotification();
notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build());
}
private synchronized NotificationCompat.Builder createNotification(final Player player) {
private synchronized NotificationCompat.Builder createNotification() {
if (DEBUG) {
Log.d(TAG, "createNotification()");
}
@ -93,7 +85,7 @@ public final class NotificationUtil {
new NotificationCompat.Builder(player.getContext(),
player.getContext().getString(R.string.notification_channel_id));
initializeNotificationSlots(player);
initializeNotificationSlots();
// count the number of real slots, to make sure compact slots indices are not out of bound
int nonNothingSlotCount = 5;
@ -132,30 +124,29 @@ public final class NotificationUtil {
/**
* Updates the notification builder and the button icons depending on the playback state.
* @param player the player currently open, to take data from
*/
private synchronized void updateNotification(final Player player) {
private synchronized void updateNotification() {
if (DEBUG) {
Log.d(TAG, "updateNotification()");
}
// also update content intent, in case the user switched players
notificationBuilder.setContentIntent(PendingIntent.getActivity(player.getContext(),
NOTIFICATION_ID, getIntentForNotification(player), FLAG_UPDATE_CURRENT));
NOTIFICATION_ID, getIntentForNotification(), FLAG_UPDATE_CURRENT));
notificationBuilder.setContentTitle(player.getVideoTitle());
notificationBuilder.setContentText(player.getUploaderName());
notificationBuilder.setTicker(player.getVideoTitle());
updateActions(notificationBuilder, player);
updateActions(notificationBuilder);
final boolean showThumbnail = player.getPrefs().getBoolean(
player.getContext().getString(R.string.show_thumbnail_key), true);
if (showThumbnail) {
setLargeIcon(notificationBuilder, player);
setLargeIcon(notificationBuilder);
}
}
@SuppressLint("RestrictedApi")
boolean shouldUpdateBufferingSlot() {
public boolean shouldUpdateBufferingSlot() {
if (notificationBuilder == null) {
// if there is no notification active, there is no point in updating it
return false;
@ -173,22 +164,22 @@ public final class NotificationUtil {
}
void createNotificationAndStartForeground(final Player player, final Service service) {
public void createNotificationAndStartForeground() {
if (notificationBuilder == null) {
notificationBuilder = createNotification(player);
notificationBuilder = createNotification();
}
updateNotification(player);
updateNotification();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
service.startForeground(NOTIFICATION_ID, notificationBuilder.build(),
player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
} else {
service.startForeground(NOTIFICATION_ID, notificationBuilder.build());
player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build());
}
}
void cancelNotificationAndStopForeground(final Service service) {
ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE);
public void cancelNotificationAndStopForeground() {
ServiceCompat.stopForeground(player.getService(), ServiceCompat.STOP_FOREGROUND_REMOVE);
if (notificationManager != null) {
notificationManager.cancel(NOTIFICATION_ID);
@ -202,7 +193,7 @@ public final class NotificationUtil {
// ACTIONS
/////////////////////////////////////////////////////
private void initializeNotificationSlots(final Player player) {
private void initializeNotificationSlots() {
for (int i = 0; i < 5; ++i) {
notificationSlots[i] = player.getPrefs().getInt(
player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
@ -211,17 +202,16 @@ public final class NotificationUtil {
}
@SuppressLint("RestrictedApi")
private void updateActions(final NotificationCompat.Builder builder, final Player player) {
private void updateActions(final NotificationCompat.Builder builder) {
builder.mActions.clear();
for (int i = 0; i < 5; ++i) {
addAction(builder, player, notificationSlots[i]);
addAction(builder, notificationSlots[i]);
}
}
private void addAction(final NotificationCompat.Builder builder,
final Player player,
@NotificationConstants.Action final int slot) {
final NotificationCompat.Action action = getAction(player, slot);
final NotificationCompat.Action action = getAction(slot);
if (action != null) {
builder.addAction(action);
}
@ -229,41 +219,40 @@ public final class NotificationUtil {
@Nullable
private NotificationCompat.Action getAction(
final Player player,
@NotificationConstants.Action final int selectedAction) {
final int baseActionIcon = NotificationConstants.ACTION_ICONS[selectedAction];
switch (selectedAction) {
case NotificationConstants.PREVIOUS:
return getAction(player, baseActionIcon,
return getAction(baseActionIcon,
R.string.exo_controls_previous_description, ACTION_PLAY_PREVIOUS);
case NotificationConstants.NEXT:
return getAction(player, baseActionIcon,
return getAction(baseActionIcon,
R.string.exo_controls_next_description, ACTION_PLAY_NEXT);
case NotificationConstants.REWIND:
return getAction(player, baseActionIcon,
return getAction(baseActionIcon,
R.string.exo_controls_rewind_description, ACTION_FAST_REWIND);
case NotificationConstants.FORWARD:
return getAction(player, baseActionIcon,
return getAction(baseActionIcon,
R.string.exo_controls_fastforward_description, ACTION_FAST_FORWARD);
case NotificationConstants.SMART_REWIND_PREVIOUS:
if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
return getAction(player, R.drawable.exo_notification_previous,
return getAction(R.drawable.exo_notification_previous,
R.string.exo_controls_previous_description, ACTION_PLAY_PREVIOUS);
} else {
return getAction(player, R.drawable.exo_controls_rewind,
return getAction(R.drawable.exo_controls_rewind,
R.string.exo_controls_rewind_description, ACTION_FAST_REWIND);
}
case NotificationConstants.SMART_FORWARD_NEXT:
if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
return getAction(player, R.drawable.exo_notification_next,
return getAction(R.drawable.exo_notification_next,
R.string.exo_controls_next_description, ACTION_PLAY_NEXT);
} else {
return getAction(player, R.drawable.exo_controls_fastforward,
return getAction(R.drawable.exo_controls_fastforward,
R.string.exo_controls_fastforward_description, ACTION_FAST_FORWARD);
}
@ -277,44 +266,45 @@ public final class NotificationUtil {
null);
}
// fallthrough
case NotificationConstants.PLAY_PAUSE:
if (player.getCurrentState() == Player.STATE_COMPLETED) {
return getAction(player, R.drawable.ic_replay,
return getAction(R.drawable.ic_replay,
R.string.exo_controls_pause_description, ACTION_PLAY_PAUSE);
} else if (player.isPlaying()
|| player.getCurrentState() == Player.STATE_PREFLIGHT
|| player.getCurrentState() == Player.STATE_BLOCKED
|| player.getCurrentState() == Player.STATE_BUFFERING) {
return getAction(player, R.drawable.exo_notification_pause,
return getAction(R.drawable.exo_notification_pause,
R.string.exo_controls_pause_description, ACTION_PLAY_PAUSE);
} else {
return getAction(player, R.drawable.exo_notification_play,
return getAction(R.drawable.exo_notification_play,
R.string.exo_controls_play_description, ACTION_PLAY_PAUSE);
}
case NotificationConstants.REPEAT:
if (player.getRepeatMode() == REPEAT_MODE_ALL) {
return getAction(player, R.drawable.exo_media_action_repeat_all,
return getAction(R.drawable.exo_media_action_repeat_all,
R.string.exo_controls_repeat_all_description, ACTION_REPEAT);
} else if (player.getRepeatMode() == REPEAT_MODE_ONE) {
return getAction(player, R.drawable.exo_media_action_repeat_one,
return getAction(R.drawable.exo_media_action_repeat_one,
R.string.exo_controls_repeat_one_description, ACTION_REPEAT);
} else /* player.getRepeatMode() == REPEAT_MODE_OFF */ {
return getAction(player, R.drawable.exo_media_action_repeat_off,
return getAction(R.drawable.exo_media_action_repeat_off,
R.string.exo_controls_repeat_off_description, ACTION_REPEAT);
}
case NotificationConstants.SHUFFLE:
if (player.getPlayQueue() != null && player.getPlayQueue().isShuffled()) {
return getAction(player, R.drawable.exo_controls_shuffle_on,
return getAction(R.drawable.exo_controls_shuffle_on,
R.string.exo_controls_shuffle_on_description, ACTION_SHUFFLE);
} else {
return getAction(player, R.drawable.exo_controls_shuffle_off,
return getAction(R.drawable.exo_controls_shuffle_off,
R.string.exo_controls_shuffle_off_description, ACTION_SHUFFLE);
}
case NotificationConstants.CLOSE:
return getAction(player, R.drawable.ic_close,
return getAction(R.drawable.ic_close,
R.string.close, ACTION_CLOSE);
case NotificationConstants.NOTHING:
@ -324,8 +314,7 @@ public final class NotificationUtil {
}
}
private NotificationCompat.Action getAction(final Player player,
@DrawableRes final int drawable,
private NotificationCompat.Action getAction(@DrawableRes final int drawable,
@StringRes final int title,
final String intentAction) {
return new NotificationCompat.Action(drawable, player.getContext().getString(title),
@ -333,7 +322,7 @@ public final class NotificationUtil {
new Intent(intentAction), FLAG_UPDATE_CURRENT));
}
private Intent getIntentForNotification(final Player player) {
private Intent getIntentForNotification() {
if (player.audioPlayerSelected() || player.popupPlayerSelected()) {
// Means we play in popup or audio only. Let's show the play queue
return NavigationHelper.getPlayQueueActivityIntent(player.getContext());
@ -353,7 +342,7 @@ public final class NotificationUtil {
// BITMAP
/////////////////////////////////////////////////////
private void setLargeIcon(final NotificationCompat.Builder builder, final Player player) {
private void setLargeIcon(final NotificationCompat.Builder builder) {
final boolean scaleImageToSquareAspectRatio = player.getPrefs().getBoolean(
player.getContext().getString(R.string.scale_to_square_image_in_notifications_key),
false);

View File

@ -8,6 +8,7 @@ import android.support.v4.media.MediaMetadataCompat;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.mediasession.MediaSessionCallback;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.player.ui.VideoPlayerUi;
public class PlayerMediaSession implements MediaSessionCallback {
private final Player player;
@ -89,7 +90,7 @@ public class PlayerMediaSession implements MediaSessionCallback {
public void play() {
player.play();
// hide the player controls even if the play command came from the media session
player.hideControls(0, 0);
player.UIs().get(VideoPlayerUi.class).ifPresent(playerUi -> playerUi.hideControls(0, 0));
}
@Override

View File

@ -0,0 +1,979 @@
package org.schabi.newpipe.player.ui;
import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
import static org.schabi.newpipe.MainActivity.DEBUG;
import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.player.Player.STATE_COMPLETED;
import static org.schabi.newpipe.player.Player.STATE_PAUSED;
import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_BACKGROUND;
import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE;
import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_POPUP;
import static org.schabi.newpipe.player.helper.PlayerHelper.getMinimizeOnExitAction;
import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString;
import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.database.ContentObserver;
import android.graphics.Bitmap;
import android.graphics.Color;
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.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.fragment.app.FragmentActivity;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.exoplayer2.video.VideoSize;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.PlayerBinding;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamSegment;
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
import org.schabi.newpipe.info_list.StreamSegmentAdapter;
import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.event.PlayerServiceEventListener;
import org.schabi.newpipe.player.gesture.BasePlayerGestureListener;
import org.schabi.newpipe.player.gesture.MainPlayerGestureListener;
import org.schabi.newpipe.player.helper.PlaybackParameterDialog;
import org.schabi.newpipe.player.helper.PlayerHelper;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueAdapter;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.player.playqueue.PlayQueueItemBuilder;
import org.schabi.newpipe.player.playqueue.PlayQueueItemHolder;
import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.KoreUtils;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutChangeListener {
private static final String TAG = MainPlayerUi.class.getSimpleName();
// see the Javadoc of calculateMaxEndScreenThumbnailHeight for information
private static final int DETAIL_ROOT_MINIMUM_HEIGHT = 85; // dp
private static final int DETAIL_TITLE_TEXT_SIZE_TV = 16; // sp
private static final int DETAIL_TITLE_TEXT_SIZE_TABLET = 15; // sp
private boolean isFullscreen = false;
private boolean isVerticalVideo = false;
private boolean fragmentIsVisible = false;
private ContentObserver settingsContentObserver;
private PlayQueueAdapter playQueueAdapter;
private StreamSegmentAdapter segmentAdapter;
private boolean isQueueVisible = false;
private boolean areSegmentsVisible = false;
// fullscreen player
private ItemTouchHelper itemTouchHelper;
/*//////////////////////////////////////////////////////////////////////////
// Constructor, setup, destroy
//////////////////////////////////////////////////////////////////////////*/
//region Constructor, setup, destroy
public MainPlayerUi(@NonNull final Player player,
@NonNull final PlayerBinding playerBinding) {
super(player, playerBinding);
}
/**
* Open fullscreen on tablets where the option to have the main player start automatically in
* fullscreen mode is on. Rotating the device to landscape is already done in {@link
* VideoDetailFragment#openVideoPlayer(boolean)} when the thumbnail is clicked, and that's
* enough for phones, but not for tablets since the mini player can be also shown in landscape.
*/
private void directlyOpenFullscreenIfNeeded() {
if (PlayerHelper.isStartMainPlayerFullscreenEnabled(player.getService())
&& DeviceUtils.isTablet(player.getService())
&& PlayerHelper.globalScreenOrientationLocked(player.getService())) {
player.getFragmentListener().ifPresent(
PlayerServiceEventListener::onScreenRotationButtonClicked);
}
}
@Override
public void setupAfterIntent() {
// needed for tablets, check the function for a better explanation
directlyOpenFullscreenIfNeeded();
super.setupAfterIntent();
initVideoPlayer();
// Android TV: without it focus will frame the whole player
binding.playPauseButton.requestFocus();
// Note: This is for automatically playing (when "Resume playback" is off), see #6179
if (player.getPlayWhenReady()) {
player.play();
} else {
player.pause();
}
}
@Override
BasePlayerGestureListener buildGestureListener() {
return new MainPlayerGestureListener(this);
}
@Override
protected void initListeners() {
super.initListeners();
binding.queueButton.setOnClickListener(v -> onQueueClicked());
binding.segmentsButton.setOnClickListener(v -> onSegmentsClicked());
binding.addToPlaylistButton.setOnClickListener(v ->
getParentActivity().map(FragmentActivity::getSupportFragmentManager)
.ifPresent(fragmentManager ->
PlaylistDialog.showForPlayQueue(player, fragmentManager)));
settingsContentObserver = new ContentObserver(new Handler(Looper.getMainLooper())) {
@Override
public void onChange(final boolean selfChange) {
setupScreenRotationButton();
}
};
context.getContentResolver().registerContentObserver(
Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false,
settingsContentObserver);
binding.getRoot().addOnLayoutChangeListener(this);
}
@Override
protected void deinitListeners() {
super.deinitListeners();
binding.queueButton.setOnClickListener(null);
binding.segmentsButton.setOnClickListener(null);
binding.addToPlaylistButton.setOnClickListener(null);
context.getContentResolver().unregisterContentObserver(settingsContentObserver);
binding.getRoot().removeOnLayoutChangeListener(this);
}
@Override
public void initPlayback() {
super.initPlayback();
if (playQueueAdapter != null) {
playQueueAdapter.dispose();
}
playQueueAdapter = new PlayQueueAdapter(context,
Objects.requireNonNull(player.getPlayQueue()));
segmentAdapter = new StreamSegmentAdapter(getStreamSegmentListener());
}
@Override
public void removeViewFromParent() {
// view was added to fragment
final ViewParent parent = binding.getRoot().getParent();
if (parent instanceof ViewGroup) {
((ViewGroup) parent).removeView(binding.getRoot());
}
}
@Override
public void destroy() {
super.destroy();
// Exit from fullscreen when user closes the player via notification
if (isFullscreen) {
toggleFullscreen();
}
removeViewFromParent();
}
@Override
public void destroyPlayer() {
super.destroyPlayer();
if (playQueueAdapter != null) {
playQueueAdapter.unsetSelectedListener();
playQueueAdapter.dispose();
}
}
@Override
public void smoothStopForImmediateReusing() {
super.smoothStopForImmediateReusing();
// Android TV will handle back button in case controls will be visible
// (one more additional unneeded click while the player is hidden)
hideControls(0, 0);
closeItemsList();
}
private void initVideoPlayer() {
// restore last resize mode
setResizeMode(PlayerHelper.retrieveResizeModeFromPrefs(player));
binding.getRoot().setLayoutParams(new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT));
}
@Override
protected void setupElementsVisibility() {
super.setupElementsVisibility();
closeItemsList();
showHideKodiButton();
binding.fullScreenButton.setVisibility(View.GONE);
setupScreenRotationButton();
binding.resizeTextView.setVisibility(View.VISIBLE);
binding.getRoot().findViewById(R.id.metadataView).setVisibility(View.VISIBLE);
binding.moreOptionsButton.setVisibility(View.VISIBLE);
binding.topControls.setOrientation(LinearLayout.VERTICAL);
binding.primaryControls.getLayoutParams().width = MATCH_PARENT;
binding.secondaryControls.setVisibility(View.INVISIBLE);
binding.moreOptionsButton.setImageDrawable(AppCompatResources.getDrawable(context,
R.drawable.ic_expand_more));
binding.share.setVisibility(View.VISIBLE);
binding.openInBrowser.setVisibility(View.VISIBLE);
binding.switchMute.setVisibility(View.VISIBLE);
binding.playerCloseButton.setVisibility(isFullscreen ? View.GONE : View.VISIBLE);
// Top controls have a large minHeight which is allows to drag the player
// down in fullscreen mode (just larger area to make easy to locate by finger)
binding.topControls.setClickable(true);
binding.topControls.setFocusable(true);
binding.titleTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE);
binding.channelTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE);
}
@Override
protected void setupElementsSize(final Resources resources) {
setupElementsSize(
resources.getDimensionPixelSize(R.dimen.player_main_buttons_min_width),
resources.getDimensionPixelSize(R.dimen.player_main_top_padding),
resources.getDimensionPixelSize(R.dimen.player_main_controls_padding),
resources.getDimensionPixelSize(R.dimen.player_main_buttons_padding)
);
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Broadcast receiver
//////////////////////////////////////////////////////////////////////////*/
//region Broadcast receiver
@Override
public void onBroadcastReceived(final Intent intent) {
super.onBroadcastReceived(intent);
if (Intent.ACTION_CONFIGURATION_CHANGED.equals(intent.getAction())) {
// Close it because when changing orientation from portrait
// (in fullscreen mode) the size of queue layout can be larger than the screen size
closeItemsList();
} else if (ACTION_PLAY_PAUSE.equals(intent.getAction())) {
// Ensure that we have audio-only stream playing when a user
// started to play from notification's play button from outside of the app
if (!fragmentIsVisible) {
onFragmentStopped();
}
} else if (VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED.equals(intent.getAction())) {
fragmentIsVisible = false;
onFragmentStopped();
} else if (VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED.equals(intent.getAction())) {
// Restore video source when user returns to the fragment
fragmentIsVisible = true;
player.useVideoSource(true);
// When a user returns from background, the system UI will always be shown even if
// controls are invisible: hide it in that case
if (!isControlsVisible()) {
hideSystemUIIfNeeded();
}
}
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Fragment binding
//////////////////////////////////////////////////////////////////////////*/
//region Fragment binding
@Override
public void onFragmentListenerSet() {
super.onFragmentListenerSet();
fragmentIsVisible = true;
// Apply window insets because Android will not do it when orientation changes
// from landscape to portrait
if (!isFullscreen) {
binding.playbackControlRoot.setPadding(0, 0, 0, 0);
}
binding.itemsListPanel.setPadding(0, 0, 0, 0);
player.getFragmentListener().ifPresent(PlayerServiceEventListener::onViewCreated);
}
/**
* This will be called when a user goes to another app/activity, turns off a screen.
* We don't want to interrupt playback and don't want to see notification so
* next lines of code will enable audio-only playback only if needed
*/
private void onFragmentStopped() {
if (player.isPlaying() || player.isLoading()) {
switch (getMinimizeOnExitAction(context)) {
case MINIMIZE_ON_EXIT_MODE_BACKGROUND:
player.useVideoSource(false);
break;
case MINIMIZE_ON_EXIT_MODE_POPUP:
getParentActivity().ifPresent(activity -> {
player.setRecovery();
NavigationHelper.playOnPopupPlayer(activity, player.getPlayQueue(), true);
});
break;
case MINIMIZE_ON_EXIT_MODE_NONE: default:
player.pause();
break;
}
}
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Playback states
//////////////////////////////////////////////////////////////////////////*/
//region Playback states
@Override
public void onUpdateProgress(final int currentProgress,
final int duration,
final int bufferPercent) {
super.onUpdateProgress(currentProgress, duration, bufferPercent);
if (areSegmentsVisible) {
segmentAdapter.selectSegmentAt(getNearestStreamSegmentPosition(currentProgress));
}
if (isQueueVisible) {
updateQueueTime(currentProgress);
}
}
@Override
public void onPlaying() {
super.onPlaying();
checkLandscape();
}
@Override
public void onCompleted() {
super.onCompleted();
if (isFullscreen) {
toggleFullscreen();
}
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Controls showing / hiding
//////////////////////////////////////////////////////////////////////////*/
//region Controls showing / hiding
@Override
protected void showOrHideButtons() {
super.showOrHideButtons();
@Nullable final PlayQueue playQueue = player.getPlayQueue();
if (playQueue == null) {
return;
}
final boolean showQueue = playQueue.getStreams().size() > 1;
final boolean showSegment = !player.getCurrentStreamInfo()
.map(StreamInfo::getStreamSegments)
.map(List::isEmpty)
.orElse(/*no stream info=*/true);
binding.queueButton.setVisibility(showQueue ? View.VISIBLE : View.GONE);
binding.queueButton.setAlpha(showQueue ? 1.0f : 0.0f);
binding.segmentsButton.setVisibility(showSegment ? View.VISIBLE : View.GONE);
binding.segmentsButton.setAlpha(showSegment ? 1.0f : 0.0f);
}
@Override
public void showSystemUIPartially() {
if (isFullscreen) {
getParentActivity().map(Activity::getWindow).ifPresent(window -> {
window.setStatusBarColor(Color.TRANSPARENT);
window.setNavigationBarColor(Color.TRANSPARENT);
final int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION;
window.getDecorView().setSystemUiVisibility(visibility);
window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
});
}
}
@Override
public void hideSystemUIIfNeeded() {
player.getFragmentListener().ifPresent(PlayerServiceEventListener::hideSystemUiIfNeeded);
}
/**
* Calculate the maximum allowed height for the {@link R.id.endScreen}
* to prevent it from enlarging the player.
* <p>
* The calculating follows these rules:
* <ul>
* <li>
* Show at least stream title and content creator on TVs and tablets when in landscape
* (always the case for TVs) and not in fullscreen mode. This requires to have at least
* {@link #DETAIL_ROOT_MINIMUM_HEIGHT} free space for {@link R.id.detail_root} and
* additional space for the stream title text size ({@link R.id.detail_title_root_layout}).
* The text size is {@link #DETAIL_TITLE_TEXT_SIZE_TABLET} on tablets and
* {@link #DETAIL_TITLE_TEXT_SIZE_TV} on TVs, see {@link R.id.titleTextView}.
* </li>
* <li>
* Otherwise, the max thumbnail height is the screen height.
* </li>
* </ul>
*
* @param bitmap the bitmap that needs to be resized to fit the end screen
* @return the maximum height for the end screen thumbnail
*/
@Override
protected float calculateMaxEndScreenThumbnailHeight(@NonNull final Bitmap bitmap) {
final int screenHeight = context.getResources().getDisplayMetrics().heightPixels;
if (DeviceUtils.isTv(context) && !isFullscreen()) {
final int videoInfoHeight = DeviceUtils.dpToPx(DETAIL_ROOT_MINIMUM_HEIGHT, context)
+ DeviceUtils.spToPx(DETAIL_TITLE_TEXT_SIZE_TV, context);
return Math.min(bitmap.getHeight(), screenHeight - videoInfoHeight);
} else if (DeviceUtils.isTablet(context) && isLandscape() && !isFullscreen()) {
final int videoInfoHeight = DeviceUtils.dpToPx(DETAIL_ROOT_MINIMUM_HEIGHT, context)
+ DeviceUtils.spToPx(DETAIL_TITLE_TEXT_SIZE_TABLET, context);
return Math.min(bitmap.getHeight(), screenHeight - videoInfoHeight);
} else { // fullscreen player: max height is the device height
return Math.min(bitmap.getHeight(), screenHeight);
}
}
private void showHideKodiButton() {
// show kodi button if it supports the current service and it is enabled in settings
@Nullable final PlayQueue playQueue = player.getPlayQueue();
binding.playWithKodi.setVisibility(playQueue != null && playQueue.getItem() != null
&& KoreUtils.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId())
? View.VISIBLE : View.GONE);
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Captions (text tracks)
//////////////////////////////////////////////////////////////////////////*/
//region Captions (text tracks)
@Override
protected void setupSubtitleView(final float captionScale) {
final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
final int minimumLength = Math.min(metrics.heightPixels, metrics.widthPixels);
final float captionRatioInverse = 20f + 4f * (1.0f - captionScale);
binding.subtitleView.setFixedTextSize(
TypedValue.COMPLEX_UNIT_PX, minimumLength / captionRatioInverse);
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Gestures
//////////////////////////////////////////////////////////////////////////*/
//region Gestures
@SuppressWarnings("checkstyle:ParameterNumber")
@Override
public void onLayoutChange(final View view, final int l, final int t, final int r, final int b,
final int ol, final int ot, final int or, final int ob) {
if (l != ol || t != ot || r != or || b != ob) {
// Use a smaller value to be consistent across screen orientations, and to make usage
// easier. Multiply by 3/4 to ensure the user does not need to move the finger up to the
// screen border, in order to reach the maximum volume/brightness.
final int width = r - l;
final int height = b - t;
final int min = Math.min(width, height);
final int maxGestureLength = (int) (min * 0.75);
if (DEBUG) {
Log.d(TAG, "maxGestureLength = " + maxGestureLength);
}
binding.volumeProgressBar.setMax(maxGestureLength);
binding.brightnessProgressBar.setMax(maxGestureLength);
setInitialGestureValues();
binding.itemsListPanel.getLayoutParams().height
= height - binding.itemsListPanel.getTop();
}
}
private void setInitialGestureValues() {
if (player.getAudioReactor() != null) {
final float currentVolumeNormalized = (float) player.getAudioReactor().getVolume()
/ player.getAudioReactor().getMaxVolume();
binding.volumeProgressBar.setProgress(
(int) (binding.volumeProgressBar.getMax() * currentVolumeNormalized));
}
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Play queue, segments and streams
//////////////////////////////////////////////////////////////////////////*/
//region Play queue, segments and streams
@Override
public void onMetadataChanged(@NonNull final StreamInfo info) {
super.onMetadataChanged(info);
showHideKodiButton();
if (areSegmentsVisible) {
if (segmentAdapter.setItems(info)) {
final int adapterPosition = getNearestStreamSegmentPosition(
player.getExoPlayer().getCurrentPosition());
segmentAdapter.selectSegmentAt(adapterPosition);
binding.itemsList.scrollToPosition(adapterPosition);
} else {
closeItemsList();
}
}
}
@Override
public void onPlayQueueEdited() {
super.onPlayQueueEdited();
showOrHideButtons();
}
private void onQueueClicked() {
isQueueVisible = true;
hideSystemUIIfNeeded();
buildQueue();
binding.itemsListHeaderTitle.setVisibility(View.GONE);
binding.itemsListHeaderDuration.setVisibility(View.VISIBLE);
binding.shuffleButton.setVisibility(View.VISIBLE);
binding.repeatButton.setVisibility(View.VISIBLE);
binding.addToPlaylistButton.setVisibility(View.VISIBLE);
hideControls(0, 0);
binding.itemsListPanel.requestFocus();
animate(binding.itemsListPanel, true, DEFAULT_CONTROLS_DURATION,
AnimationType.SLIDE_AND_ALPHA);
@Nullable final PlayQueue playQueue = player.getPlayQueue();
if (playQueue != null) {
binding.itemsList.scrollToPosition(playQueue.getIndex());
}
updateQueueTime((int) player.getExoPlayer().getCurrentPosition());
}
private void buildQueue() {
binding.itemsList.setAdapter(playQueueAdapter);
binding.itemsList.setClickable(true);
binding.itemsList.setLongClickable(true);
binding.itemsList.clearOnScrollListeners();
binding.itemsList.addOnScrollListener(getQueueScrollListener());
itemTouchHelper = new ItemTouchHelper(getItemTouchCallback());
itemTouchHelper.attachToRecyclerView(binding.itemsList);
playQueueAdapter.setSelectedListener(getOnSelectedListener());
binding.itemsListClose.setOnClickListener(view -> closeItemsList());
}
private void onSegmentsClicked() {
areSegmentsVisible = true;
hideSystemUIIfNeeded();
buildSegments();
binding.itemsListHeaderTitle.setVisibility(View.VISIBLE);
binding.itemsListHeaderDuration.setVisibility(View.GONE);
binding.shuffleButton.setVisibility(View.GONE);
binding.repeatButton.setVisibility(View.GONE);
binding.addToPlaylistButton.setVisibility(View.GONE);
hideControls(0, 0);
binding.itemsListPanel.requestFocus();
animate(binding.itemsListPanel, true, DEFAULT_CONTROLS_DURATION,
AnimationType.SLIDE_AND_ALPHA);
final int adapterPosition = getNearestStreamSegmentPosition(
player.getExoPlayer().getCurrentPosition());
segmentAdapter.selectSegmentAt(adapterPosition);
binding.itemsList.scrollToPosition(adapterPosition);
}
private void buildSegments() {
binding.itemsList.setAdapter(segmentAdapter);
binding.itemsList.setClickable(true);
binding.itemsList.setLongClickable(false);
binding.itemsList.clearOnScrollListeners();
if (itemTouchHelper != null) {
itemTouchHelper.attachToRecyclerView(null);
}
player.getCurrentStreamInfo().ifPresent(segmentAdapter::setItems);
binding.shuffleButton.setVisibility(View.GONE);
binding.repeatButton.setVisibility(View.GONE);
binding.addToPlaylistButton.setVisibility(View.GONE);
binding.itemsListClose.setOnClickListener(view -> closeItemsList());
}
public void closeItemsList() {
if (isQueueVisible || areSegmentsVisible) {
isQueueVisible = false;
areSegmentsVisible = false;
if (itemTouchHelper != null) {
itemTouchHelper.attachToRecyclerView(null);
}
animate(binding.itemsListPanel, false, DEFAULT_CONTROLS_DURATION,
AnimationType.SLIDE_AND_ALPHA, 0, () ->
// Even when queueLayout is GONE it receives touch events
// and ruins normal behavior of the app. This line fixes it
binding.itemsListPanel.setTranslationY(
-binding.itemsListPanel.getHeight() * 5.0f));
// clear focus, otherwise a white rectangle remains on top of the player
binding.itemsListClose.clearFocus();
binding.playPauseButton.requestFocus();
}
}
private OnScrollBelowItemsListener getQueueScrollListener() {
return new OnScrollBelowItemsListener() {
@Override
public void onScrolledDown(final RecyclerView recyclerView) {
@Nullable final PlayQueue playQueue = player.getPlayQueue();
if (playQueue != null && !playQueue.isComplete()) {
playQueue.fetch();
} else if (binding != null) {
binding.itemsList.clearOnScrollListeners();
}
}
};
}
private StreamSegmentAdapter.StreamSegmentListener getStreamSegmentListener() {
return (item, seconds) -> {
segmentAdapter.selectSegment(item);
player.seekTo(seconds * 1000L);
player.triggerProgressUpdate();
};
}
private int getNearestStreamSegmentPosition(final long playbackPosition) {
//noinspection SimplifyOptionalCallChains
if (!player.getCurrentStreamInfo().isPresent()) {
return 0;
}
int nearestPosition = 0;
final List<StreamSegment> segments
= player.getCurrentStreamInfo().get().getStreamSegments();
for (int i = 0; i < segments.size(); i++) {
if (segments.get(i).getStartTimeSeconds() * 1000L > playbackPosition) {
break;
}
nearestPosition++;
}
return Math.max(0, nearestPosition - 1);
}
private ItemTouchHelper.SimpleCallback getItemTouchCallback() {
return new PlayQueueItemTouchCallback() {
@Override
public void onMove(final int sourceIndex, final int targetIndex) {
@Nullable final PlayQueue playQueue = player.getPlayQueue();
if (playQueue != null) {
playQueue.move(sourceIndex, targetIndex);
}
}
@Override
public void onSwiped(final int index) {
@Nullable final PlayQueue playQueue = player.getPlayQueue();
if (playQueue != null && index != -1) {
playQueue.remove(index);
}
}
};
}
private PlayQueueItemBuilder.OnSelectedListener getOnSelectedListener() {
return new PlayQueueItemBuilder.OnSelectedListener() {
@Override
public void selected(final PlayQueueItem item, final View view) {
player.selectQueueItem(item);
}
@Override
public void held(final PlayQueueItem item, final View view) {
@Nullable final PlayQueue playQueue = player.getPlayQueue();
@Nullable final AppCompatActivity parentActivity = getParentActivity().orElse(null);
if (playQueue != null && parentActivity != null && playQueue.indexOf(item) != -1) {
openPopupMenu(player.getPlayQueue(), item, view, true,
parentActivity.getSupportFragmentManager(), context);
}
}
@Override
public void onStartDrag(final PlayQueueItemHolder viewHolder) {
if (itemTouchHelper != null) {
itemTouchHelper.startDrag(viewHolder);
}
}
};
}
private void updateQueueTime(final int currentTime) {
@Nullable final PlayQueue playQueue = player.getPlayQueue();
if (playQueue == null) {
return;
}
final int currentStream = playQueue.getIndex();
int before = 0;
int after = 0;
final List<PlayQueueItem> streams = playQueue.getStreams();
final int nStreams = streams.size();
for (int i = 0; i < nStreams; i++) {
if (i < currentStream) {
before += streams.get(i).getDuration();
} else {
after += streams.get(i).getDuration();
}
}
before *= 1000;
after *= 1000;
binding.itemsListHeaderDuration.setText(
String.format("%s/%s",
getTimeString(currentTime + before),
getTimeString(before + after)
));
}
@Override
protected boolean isAnyListViewOpen() {
return isQueueVisible || areSegmentsVisible;
}
@Override
public boolean isFullscreen() {
return isFullscreen;
}
public boolean isVerticalVideo() {
return isVerticalVideo;
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Click listeners
//////////////////////////////////////////////////////////////////////////*/
//region Click listeners
@Override
public void onClick(final View v) {
if (v.getId() == binding.screenRotationButton.getId()) {
// Only if it's not a vertical video or vertical video but in landscape with locked
// orientation a screen orientation can be changed automatically
if (!isVerticalVideo || (isLandscape() && globalScreenOrientationLocked(context))) {
player.getFragmentListener().ifPresent(
PlayerServiceEventListener::onScreenRotationButtonClicked);
} else {
toggleFullscreen();
}
}
// call it later since it calls manageControlsAfterOnClick at the end
super.onClick(v);
}
@Override
protected void onPlaybackSpeedClicked() {
final AppCompatActivity activity = getParentActivity().orElse(null);
if (activity == null) {
return;
}
PlaybackParameterDialog.newInstance(player.getPlaybackSpeed(), player.getPlaybackPitch(),
player.getPlaybackSkipSilence(), player::setPlaybackParameters)
.show(activity.getSupportFragmentManager(), null);
}
@Override
public boolean onLongClick(final View v) {
if (v.getId() == binding.moreOptionsButton.getId() && isFullscreen) {
player.getFragmentListener().ifPresent(
PlayerServiceEventListener::onMoreOptionsLongClicked);
hideControls(0, 0);
hideSystemUIIfNeeded();
return true;
}
return super.onLongClick(v);
}
@Override
public boolean onKeyDown(final int keyCode) {
if (keyCode == KeyEvent.KEYCODE_SPACE && isFullscreen) {
player.playPause();
if (player.isPlaying()) {
hideControls(0, 0);
}
return true;
}
return super.onKeyDown(keyCode);
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Video size, orientation, fullscreen
//////////////////////////////////////////////////////////////////////////*/
//region Video size, orientation, fullscreen
private void setupScreenRotationButton() {
binding.screenRotationButton.setVisibility(globalScreenOrientationLocked(context)
|| isVerticalVideo || DeviceUtils.isTablet(context)
? View.VISIBLE : View.GONE);
binding.screenRotationButton.setImageDrawable(AppCompatResources.getDrawable(context,
isFullscreen ? R.drawable.ic_fullscreen_exit
: R.drawable.ic_fullscreen));
}
@Override
public void onVideoSizeChanged(@NonNull final VideoSize videoSize) {
super.onVideoSizeChanged(videoSize);
isVerticalVideo = videoSize.width < videoSize.height;
if (globalScreenOrientationLocked(context)
&& isFullscreen
&& isLandscape() == isVerticalVideo
&& !DeviceUtils.isTv(context)
&& !DeviceUtils.isTablet(context)) {
// set correct orientation
player.getFragmentListener().ifPresent(
PlayerServiceEventListener::onScreenRotationButtonClicked);
}
setupScreenRotationButton();
}
public void toggleFullscreen() {
if (DEBUG) {
Log.d(TAG, "toggleFullscreen() called");
}
final PlayerServiceEventListener fragmentListener
= player.getFragmentListener().orElse(null);
if (fragmentListener == null || player.exoPlayerIsNull()) {
return;
}
isFullscreen = !isFullscreen;
if (isFullscreen) {
// Android needs tens milliseconds to send new insets but a user is able to see
// how controls changes it's position from `0` to `nav bar height` padding.
// So just hide the controls to hide this visual inconsistency
hideControls(0, 0);
} else {
// Apply window insets because Android will not do it when orientation changes
// from landscape to portrait (open vertical video to reproduce)
binding.playbackControlRoot.setPadding(0, 0, 0, 0);
}
fragmentListener.onFullscreenStateChanged(isFullscreen);
binding.titleTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE);
binding.channelTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE);
binding.playerCloseButton.setVisibility(isFullscreen ? View.GONE : View.VISIBLE);
setupScreenRotationButton();
}
public void checkLandscape() {
// check if landscape is correct
final boolean videoInLandscapeButNotInFullscreen
= isLandscape() && !isFullscreen && !player.isAudioOnly();
final boolean notPaused = player.getCurrentState() != STATE_COMPLETED
&& player.getCurrentState() != STATE_PAUSED;
if (videoInLandscapeButNotInFullscreen
&& notPaused
&& !DeviceUtils.isTablet(context)) {
toggleFullscreen();
}
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Getters
//////////////////////////////////////////////////////////////////////////*/
//region Getters
public Optional<AppCompatActivity> getParentActivity() {
final ViewParent rootParent = binding.getRoot().getParent();
if (rootParent instanceof ViewGroup) {
final Context activity = ((ViewGroup) rootParent).getContext();
if (activity instanceof AppCompatActivity) {
return Optional.of((AppCompatActivity) activity);
}
}
return Optional.empty();
}
public boolean isLandscape() {
// DisplayMetrics from activity context knows about MultiWindow feature
// while DisplayMetrics from app context doesn't
return DeviceUtils.isLandscape(
getParentActivity().map(Context.class::cast).orElse(player.getService()));
}
//endregion
}

View File

@ -0,0 +1,211 @@
package org.schabi.newpipe.player.ui;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player.RepeatMode;
import com.google.android.exoplayer2.Tracks;
import com.google.android.exoplayer2.text.Cue;
import com.google.android.exoplayer2.video.VideoSize;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.player.Player;
import java.util.List;
/**
* A player UI is a component that can seamlessly connect and disconnect from the {@link Player} and
* provide a user interface of some sort. Try to extend this class instead of adding more code to
* {@link Player}!
*/
public abstract class PlayerUi {
@NonNull protected final Context context;
@NonNull protected final Player player;
/**
* @param player the player instance that will be usable throughout the lifetime of this UI
*/
protected PlayerUi(@NonNull final Player player) {
this.context = player.getContext();
this.player = player;
}
/**
* @return the player instance this UI was constructed with
*/
@NonNull
public Player getPlayer() {
return player;
}
/**
* Called after the player received an intent and processed it.
*/
public void setupAfterIntent() {
}
/**
* Called right after the exoplayer instance is constructed, or right after this UI is
* constructed if the exoplayer is already available then. Note that the exoplayer instance
* could be built and destroyed multiple times during the lifetime of the player, so this method
* might be called multiple times.
*/
public void initPlayer() {
}
/**
* Called when playback in the exoplayer is about to start, or right after this UI is
* constructed if the exoplayer and the play queue are already available then. The play queue
* will therefore always be not null.
*/
public void initPlayback() {
}
/**
* Called when the exoplayer instance is about to be destroyed. Note that the exoplayer instance
* could be built and destroyed multiple times during the lifetime of the player, so this method
* might be called multiple times. Be sure to unset any video surface view or play queue
* listeners! This will also be called when this UI is being discarded, just before {@link
* #destroy()}.
*/
public void destroyPlayer() {
}
/**
* Called when this UI is being discarded, either because the player is switching to a different
* UI or because the player is shutting down completely.
*/
public void destroy() {
}
/**
* Called when the player is smooth-stopping, that is, transitioning smoothly to a new play
* queue after the user tapped on a new video stream while a stream was playing in the video
* detail fragment.
*/
public void smoothStopForImmediateReusing() {
}
/**
* Called when the video detail fragment listener is connected with the player, or right after
* this UI is constructed if the listener is already connected then.
*/
public void onFragmentListenerSet() {
}
/**
* Broadcasts that the player receives will also be notified to UIs here. If you want to
* register new broadcast actions to receive here, add them to {@link
* Player#setupBroadcastReceiver()}.
* @param intent the broadcast intent received by the player
*/
public void onBroadcastReceived(final Intent intent) {
}
/**
* Called when stream progress (i.e. the current time in the seekbar) or stream duration change.
* Will surely be called every {@link Player#PROGRESS_LOOP_INTERVAL_MILLIS} while a stream is
* playing.
* @param currentProgress the current progress in milliseconds
* @param duration the duration of the stream being played
* @param bufferPercent the percentage of stream already buffered, see {@link
* com.google.android.exoplayer2.BasePlayer#getBufferedPercentage()}
*/
public void onUpdateProgress(final int currentProgress,
final int duration,
final int bufferPercent) {
}
public void onPrepared() {
}
public void onBlocked() {
}
public void onPlaying() {
}
public void onBuffering() {
}
public void onPaused() {
}
public void onPausedSeek() {
}
public void onCompleted() {
}
public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
}
public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) {
}
public void onMuteUnmuteChanged(final boolean isMuted) {
}
/**
* @see com.google.android.exoplayer2.Player.Listener#onTracksChanged(Tracks)
* @param currentTracks the available tracks information
*/
public void onTextTracksChanged(@NonNull final Tracks currentTracks) {
}
/**
* @see com.google.android.exoplayer2.Player.Listener#onPlaybackParametersChanged
* @param playbackParameters the new playback parameters
*/
public void onPlaybackParametersChanged(@NonNull final PlaybackParameters playbackParameters) {
}
/**
* @see com.google.android.exoplayer2.Player.Listener#onRenderedFirstFrame
*/
public void onRenderedFirstFrame() {
}
/**
* @see com.google.android.exoplayer2.text.TextOutput#onCues
* @param cues the cues to pass to the subtitle view
*/
public void onCues(@NonNull final List<Cue> cues) {
}
/**
* Called when the stream being played changes.
* @param info the {@link StreamInfo} metadata object, along with data about the selected and
* available video streams (to be used to build the resolution menus, for example)
*/
public void onMetadataChanged(@NonNull final StreamInfo info) {
}
/**
* Called when the thumbnail for the current metadata was loaded.
* @param bitmap the thumbnail to process, or null if there is no thumbnail or there was an
* error when loading the thumbnail
*/
public void onThumbnailLoaded(@Nullable final Bitmap bitmap) {
}
/**
* Called when the play queue was edited: a stream was appended, moved or removed.
*/
public void onPlayQueueEdited() {
}
/**
* @param videoSize the new video size, useful to set the surface aspect ratio
* @see com.google.android.exoplayer2.Player.Listener#onVideoSizeChanged
*/
public void onVideoSizeChanged(@NonNull final VideoSize videoSize) {
}
}

View File

@ -0,0 +1,77 @@
package org.schabi.newpipe.player.ui;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
public final class PlayerUiList {
final List<PlayerUi> playerUis = new ArrayList<>();
/**
* Adds the provided player ui to the list and calls on it the initialization functions that
* apply based on the current player state. The preparation step needs to be done since when UIs
* are removed and re-added, the player will not call e.g. initPlayer again since the exoplayer
* is already initialized, but we need to notify the newly built UI that the player is ready
* nonetheless.
* @param playerUi the player ui to prepare and add to the list; its {@link
* PlayerUi#getPlayer()} will be used to query information about the player
* state
*/
public void addAndPrepare(final PlayerUi playerUi) {
if (playerUi.getPlayer().getFragmentListener().isPresent()) {
// make sure UIs know whether a service is connected or not
playerUi.onFragmentListenerSet();
}
if (!playerUi.getPlayer().exoPlayerIsNull()) {
playerUi.initPlayer();
if (playerUi.getPlayer().getPlayQueue() != null) {
playerUi.initPlayback();
}
}
playerUis.add(playerUi);
}
/**
* Destroys all matching player UIs and removes them from the list.
* @param playerUiType the class of the player UI to destroy; the {@link
* Class#isInstance(Object)} method will be used, so even subclasses will be
* destroyed and removed
* @param <T> the class type parameter
*/
public <T> void destroyAll(final Class<T> playerUiType) {
playerUis.stream()
.filter(playerUiType::isInstance)
.forEach(playerUi -> {
playerUi.destroyPlayer();
playerUi.destroy();
});
playerUis.removeIf(playerUiType::isInstance);
}
/**
* @param playerUiType the class of the player UI to return; the {@link
* Class#isInstance(Object)} method will be used, so even subclasses could
* be returned
* @param <T> the class type parameter
* @return the first player UI of the required type found in the list, or an empty {@link
* Optional} otherwise
*/
public <T> Optional<T> get(final Class<T> playerUiType) {
return playerUis.stream()
.filter(playerUiType::isInstance)
.map(playerUiType::cast)
.findFirst();
}
/**
* Calls the provided consumer on all player UIs in the list.
* @param consumer the consumer to call with player UIs
*/
public void call(final Consumer<PlayerUi> consumer) {
//noinspection SimplifyStreamApiCallChains
playerUis.stream().forEach(consumer);
}
}

View File

@ -0,0 +1,588 @@
package org.schabi.newpipe.player.ui;
import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
import static org.schabi.newpipe.MainActivity.DEBUG;
import static org.schabi.newpipe.player.helper.PlayerHelper.getMinimumVideoHeight;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.PixelFormat;
import android.os.Build;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.animation.AnticipateInterpolator;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
import com.google.android.exoplayer2.ui.SubtitleView;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.PlayerBinding;
import org.schabi.newpipe.databinding.PlayerPopupCloseOverlayBinding;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.gesture.BasePlayerGestureListener;
import org.schabi.newpipe.player.gesture.PopupPlayerGestureListener;
import org.schabi.newpipe.player.helper.PlayerHelper;
public final class PopupPlayerUi extends VideoPlayerUi {
private static final String TAG = PopupPlayerUi.class.getSimpleName();
/**
* Maximum opacity allowed for Android 12 and higher to allow touches on other apps when using
* NewPipe's popup player.
*
* <p>
* This value is hardcoded instead of being get dynamically with the method linked of the
* constant documentation below, because it is not static and popup player layout parameters
* are generated with static methods.
* </p>
*
* @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE
*/
private static final float MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER = 0.8f;
/*//////////////////////////////////////////////////////////////////////////
// Popup player
//////////////////////////////////////////////////////////////////////////*/
private PlayerPopupCloseOverlayBinding closeOverlayBinding;
private boolean isPopupClosing = false;
private int screenWidth;
private int screenHeight;
/*//////////////////////////////////////////////////////////////////////////
// Popup player window manager
//////////////////////////////////////////////////////////////////////////*/
public static final int IDLE_WINDOW_FLAGS = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM;
public static final int ONGOING_PLAYBACK_WINDOW_FLAGS = IDLE_WINDOW_FLAGS
| WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON;
private WindowManager.LayoutParams popupLayoutParams; // null if player is not popup
private final WindowManager windowManager;
/*//////////////////////////////////////////////////////////////////////////
// Constructor, setup, destroy
//////////////////////////////////////////////////////////////////////////*/
//region Constructor, setup, destroy
public PopupPlayerUi(@NonNull final Player player,
@NonNull final PlayerBinding playerBinding) {
super(player, playerBinding);
windowManager = ContextCompat.getSystemService(context, WindowManager.class);
}
@Override
public void setupAfterIntent() {
super.setupAfterIntent();
initPopup();
initPopupCloseOverlay();
}
@Override
BasePlayerGestureListener buildGestureListener() {
return new PopupPlayerGestureListener(this);
}
@SuppressLint("RtlHardcoded")
private void initPopup() {
if (DEBUG) {
Log.d(TAG, "initPopup() called");
}
// Popup is already added to windowManager
if (popupHasParent()) {
return;
}
updateScreenSize();
popupLayoutParams = retrievePopupLayoutParamsFromPrefs();
binding.surfaceView.setHeights(popupLayoutParams.height, popupLayoutParams.height);
checkPopupPositionBounds();
binding.loadingPanel.setMinimumWidth(popupLayoutParams.width);
binding.loadingPanel.setMinimumHeight(popupLayoutParams.height);
windowManager.addView(binding.getRoot(), popupLayoutParams);
setupVideoSurfaceIfNeeded(); // now there is a parent, we can setup video surface
// Popup doesn't have aspectRatio selector, using FIT automatically
setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIT);
}
@SuppressLint("RtlHardcoded")
private void initPopupCloseOverlay() {
if (DEBUG) {
Log.d(TAG, "initPopupCloseOverlay() called");
}
// closeOverlayView is already added to windowManager
if (closeOverlayBinding != null) {
return;
}
closeOverlayBinding = PlayerPopupCloseOverlayBinding.inflate(LayoutInflater.from(context));
final WindowManager.LayoutParams closeOverlayLayoutParams = buildCloseOverlayLayoutParams();
closeOverlayBinding.closeButton.setVisibility(View.GONE);
windowManager.addView(closeOverlayBinding.getRoot(), closeOverlayLayoutParams);
}
@Override
protected void setupElementsVisibility() {
binding.fullScreenButton.setVisibility(View.VISIBLE);
binding.screenRotationButton.setVisibility(View.GONE);
binding.resizeTextView.setVisibility(View.GONE);
binding.getRoot().findViewById(R.id.metadataView).setVisibility(View.GONE);
binding.queueButton.setVisibility(View.GONE);
binding.segmentsButton.setVisibility(View.GONE);
binding.moreOptionsButton.setVisibility(View.GONE);
binding.topControls.setOrientation(LinearLayout.HORIZONTAL);
binding.primaryControls.getLayoutParams().width = WRAP_CONTENT;
binding.secondaryControls.setAlpha(1.0f);
binding.secondaryControls.setVisibility(View.VISIBLE);
binding.secondaryControls.setTranslationY(0);
binding.share.setVisibility(View.GONE);
binding.playWithKodi.setVisibility(View.GONE);
binding.openInBrowser.setVisibility(View.GONE);
binding.switchMute.setVisibility(View.GONE);
binding.playerCloseButton.setVisibility(View.GONE);
binding.topControls.bringToFront();
binding.topControls.setClickable(false);
binding.topControls.setFocusable(false);
binding.bottomControls.bringToFront();
super.setupElementsVisibility();
}
@Override
protected void setupElementsSize(final Resources resources) {
setupElementsSize(
0,
0,
resources.getDimensionPixelSize(R.dimen.player_popup_controls_padding),
resources.getDimensionPixelSize(R.dimen.player_popup_buttons_padding)
);
}
@Override
public void removeViewFromParent() {
// view was added by windowManager for popup player
windowManager.removeViewImmediate(binding.getRoot());
}
@Override
public void destroy() {
super.destroy();
removePopupFromView();
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Broadcast receiver
//////////////////////////////////////////////////////////////////////////*/
//region Broadcast receiver
@Override
public void onBroadcastReceived(final Intent intent) {
super.onBroadcastReceived(intent);
if (Intent.ACTION_CONFIGURATION_CHANGED.equals(intent.getAction())) {
updateScreenSize();
changePopupSize(popupLayoutParams.width);
checkPopupPositionBounds();
} else if (player.isPlaying() || player.isLoading()) {
if (Intent.ACTION_SCREEN_OFF.equals(intent.getAction())) {
// Use only audio source when screen turns off while popup player is playing
player.useVideoSource(false);
} else if (Intent.ACTION_SCREEN_ON.equals(intent.getAction())) {
// Restore video source when screen turns on and user was watching video in popup
player.useVideoSource(true);
}
}
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Popup position and size
//////////////////////////////////////////////////////////////////////////*/
//region Popup position and size
/**
* Check if {@link #popupLayoutParams}' position is within a arbitrary boundary
* that goes from (0, 0) to (screenWidth, screenHeight).
* <p>
* If it's out of these boundaries, {@link #popupLayoutParams}' position is changed
* and {@code true} is returned to represent this change.
* </p>
*/
public void checkPopupPositionBounds() {
if (DEBUG) {
Log.d(TAG, "checkPopupPositionBounds() called with: "
+ "screenWidth = [" + screenWidth + "], "
+ "screenHeight = [" + screenHeight + "]");
}
if (popupLayoutParams == null) {
return;
}
if (popupLayoutParams.x < 0) {
popupLayoutParams.x = 0;
} else if (popupLayoutParams.x > screenWidth - popupLayoutParams.width) {
popupLayoutParams.x = screenWidth - popupLayoutParams.width;
}
if (popupLayoutParams.y < 0) {
popupLayoutParams.y = 0;
} else if (popupLayoutParams.y > screenHeight - popupLayoutParams.height) {
popupLayoutParams.y = screenHeight - popupLayoutParams.height;
}
}
public void updateScreenSize() {
final DisplayMetrics metrics = new DisplayMetrics();
windowManager.getDefaultDisplay().getMetrics(metrics);
screenWidth = metrics.widthPixels;
screenHeight = metrics.heightPixels;
if (DEBUG) {
Log.d(TAG, "updateScreenSize() called: screenWidth = ["
+ screenWidth + "], screenHeight = [" + screenHeight + "]");
}
}
/**
* Changes the size of the popup based on the width.
* @param width the new width, height is calculated with
* {@link PlayerHelper#getMinimumVideoHeight(float)}
*/
public void changePopupSize(final int width) {
if (DEBUG) {
Log.d(TAG, "changePopupSize() called with: width = [" + width + "]");
}
if (anyPopupViewIsNull()) {
return;
}
final float minimumWidth = context.getResources().getDimension(R.dimen.popup_minimum_width);
final int actualWidth = Math.min((int) Math.max(width, minimumWidth), screenWidth);
final int actualHeight = (int) getMinimumVideoHeight(width);
if (DEBUG) {
Log.d(TAG, "updatePopupSize() updated values:"
+ " width = [" + actualWidth + "], height = [" + actualHeight + "]");
}
popupLayoutParams.width = actualWidth;
popupLayoutParams.height = actualHeight;
binding.surfaceView.setHeights(popupLayoutParams.height, popupLayoutParams.height);
windowManager.updateViewLayout(binding.getRoot(), popupLayoutParams);
}
@Override
protected float calculateMaxEndScreenThumbnailHeight(@NonNull final Bitmap bitmap) {
// no need for the end screen thumbnail to be resized on popup player: it's only needed
// for the main player so that it is enlarged correctly inside the fragment
return bitmap.getHeight();
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Popup closing
//////////////////////////////////////////////////////////////////////////*/
//region Popup closing
public void closePopup() {
if (DEBUG) {
Log.d(TAG, "closePopup() called, isPopupClosing = " + isPopupClosing);
}
if (isPopupClosing) {
return;
}
isPopupClosing = true;
player.saveStreamProgressState();
windowManager.removeView(binding.getRoot());
animatePopupOverlayAndFinishService();
}
public boolean isPopupClosing() {
return isPopupClosing;
}
public void removePopupFromView() {
// wrap in try-catch since it could sometimes generate errors randomly
try {
if (popupHasParent()) {
windowManager.removeView(binding.getRoot());
}
} catch (final IllegalArgumentException e) {
Log.w(TAG, "Failed to remove popup from window manager", e);
}
try {
final boolean closeOverlayHasParent = closeOverlayBinding != null
&& closeOverlayBinding.getRoot().getParent() != null;
if (closeOverlayHasParent) {
windowManager.removeView(closeOverlayBinding.getRoot());
}
} catch (final IllegalArgumentException e) {
Log.w(TAG, "Failed to remove popup overlay from window manager", e);
}
}
private void animatePopupOverlayAndFinishService() {
final int targetTranslationY =
(int) (closeOverlayBinding.closeButton.getRootView().getHeight()
- closeOverlayBinding.closeButton.getY());
closeOverlayBinding.closeButton.animate().setListener(null).cancel();
closeOverlayBinding.closeButton.animate()
.setInterpolator(new AnticipateInterpolator())
.translationY(targetTranslationY)
.setDuration(400)
.setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationCancel(final Animator animation) {
end();
}
@Override
public void onAnimationEnd(final Animator animation) {
end();
}
private void end() {
windowManager.removeView(closeOverlayBinding.getRoot());
closeOverlayBinding = null;
player.getService().stopService();
}
}).start();
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Playback states
//////////////////////////////////////////////////////////////////////////*/
//region Playback states
private void changePopupWindowFlags(final int flags) {
if (DEBUG) {
Log.d(TAG, "changePopupWindowFlags() called with: flags = [" + flags + "]");
}
if (!anyPopupViewIsNull()) {
popupLayoutParams.flags = flags;
windowManager.updateViewLayout(binding.getRoot(), popupLayoutParams);
}
}
@Override
public void onPlaying() {
super.onPlaying();
changePopupWindowFlags(ONGOING_PLAYBACK_WINDOW_FLAGS);
}
@Override
public void onPaused() {
super.onPaused();
changePopupWindowFlags(IDLE_WINDOW_FLAGS);
}
@Override
public void onCompleted() {
super.onCompleted();
changePopupWindowFlags(IDLE_WINDOW_FLAGS);
}
@Override
protected void setupSubtitleView(final float captionScale) {
final float captionRatio = (captionScale - 1.0f) / 5.0f + 1.0f;
binding.subtitleView.setFractionalTextSize(
SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionRatio);
}
@Override
protected void onPlaybackSpeedClicked() {
playbackSpeedPopupMenu.show();
isSomePopupMenuVisible = true;
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Gestures
//////////////////////////////////////////////////////////////////////////*/
//region Gestures
private int distanceFromCloseButton(@NonNull final MotionEvent popupMotionEvent) {
final int closeOverlayButtonX = closeOverlayBinding.closeButton.getLeft()
+ closeOverlayBinding.closeButton.getWidth() / 2;
final int closeOverlayButtonY = closeOverlayBinding.closeButton.getTop()
+ closeOverlayBinding.closeButton.getHeight() / 2;
final float fingerX = popupLayoutParams.x + popupMotionEvent.getX();
final float fingerY = popupLayoutParams.y + popupMotionEvent.getY();
return (int) Math.sqrt(Math.pow(closeOverlayButtonX - fingerX, 2)
+ Math.pow(closeOverlayButtonY - fingerY, 2));
}
private float getClosingRadius() {
final int buttonRadius = closeOverlayBinding.closeButton.getWidth() / 2;
// 20% wider than the button itself
return buttonRadius * 1.2f;
}
public boolean isInsideClosingRadius(@NonNull final MotionEvent popupMotionEvent) {
return distanceFromCloseButton(popupMotionEvent) <= getClosingRadius();
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Popup & closing overlay layout params + saving popup position and size
//////////////////////////////////////////////////////////////////////////*/
//region Popup & closing overlay layout params + saving popup position and size
/**
* {@code screenWidth} and {@code screenHeight} must have been initialized.
* @return the popup starting layout params
*/
@SuppressLint("RtlHardcoded")
public WindowManager.LayoutParams retrievePopupLayoutParamsFromPrefs() {
final SharedPreferences prefs = getPlayer().getPrefs();
final Context context = getPlayer().getContext();
final boolean popupRememberSizeAndPos = prefs.getBoolean(
context.getString(R.string.popup_remember_size_pos_key), true);
final float defaultSize = context.getResources().getDimension(R.dimen.popup_default_width);
final float popupWidth = popupRememberSizeAndPos
? prefs.getFloat(context.getString(R.string.popup_saved_width_key), defaultSize)
: defaultSize;
final float popupHeight = getMinimumVideoHeight(popupWidth);
final WindowManager.LayoutParams params = new WindowManager.LayoutParams(
(int) popupWidth, (int) popupHeight,
popupLayoutParamType(),
IDLE_WINDOW_FLAGS,
PixelFormat.TRANSLUCENT);
params.gravity = Gravity.LEFT | Gravity.TOP;
params.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
final int centerX = (int) (screenWidth / 2f - popupWidth / 2f);
final int centerY = (int) (screenHeight / 2f - popupHeight / 2f);
params.x = popupRememberSizeAndPos
? prefs.getInt(context.getString(R.string.popup_saved_x_key), centerX) : centerX;
params.y = popupRememberSizeAndPos
? prefs.getInt(context.getString(R.string.popup_saved_y_key), centerY) : centerY;
return params;
}
public void savePopupPositionAndSizeToPrefs() {
if (getPopupLayoutParams() != null) {
final Context context = getPlayer().getContext();
getPlayer().getPrefs().edit()
.putFloat(context.getString(R.string.popup_saved_width_key),
popupLayoutParams.width)
.putInt(context.getString(R.string.popup_saved_x_key),
popupLayoutParams.x)
.putInt(context.getString(R.string.popup_saved_y_key),
popupLayoutParams.y)
.apply();
}
}
@SuppressLint("RtlHardcoded")
public static WindowManager.LayoutParams buildCloseOverlayLayoutParams() {
final int flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
| WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM;
final WindowManager.LayoutParams closeOverlayLayoutParams = new WindowManager.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
popupLayoutParamType(),
flags,
PixelFormat.TRANSLUCENT);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Setting maximum opacity allowed for touch events to other apps for Android 12 and
// higher to prevent non interaction when using other apps with the popup player
closeOverlayLayoutParams.alpha = MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER;
}
closeOverlayLayoutParams.gravity = Gravity.LEFT | Gravity.TOP;
closeOverlayLayoutParams.softInputMode =
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
return closeOverlayLayoutParams;
}
public static int popupLayoutParamType() {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.O
? WindowManager.LayoutParams.TYPE_PHONE
: WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Getters
//////////////////////////////////////////////////////////////////////////*/
//region Getters
private boolean popupHasParent() {
return binding != null
&& binding.getRoot().getLayoutParams() instanceof WindowManager.LayoutParams
&& binding.getRoot().getParent() != null;
}
private boolean anyPopupViewIsNull() {
return popupLayoutParams == null || windowManager == null
|| binding.getRoot().getParent() == null;
}
public PlayerPopupCloseOverlayBinding getCloseOverlayBinding() {
return closeOverlayBinding;
}
public WindowManager.LayoutParams getPopupLayoutParams() {
return popupLayoutParams;
}
public WindowManager getWindowManager() {
return windowManager;
}
public int getScreenHeight() {
return screenHeight;
}
public int getScreenWidth() {
return screenWidth;
}
//endregion
}

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,7 @@
package org.schabi.newpipe.settings.custom;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
@ -23,11 +25,11 @@ import androidx.core.graphics.drawable.DrawableCompat;
import androidx.preference.Preference;
import androidx.preference.PreferenceViewHolder;
import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding;
import org.schabi.newpipe.player.MainPlayer;
import org.schabi.newpipe.player.NotificationConstants;
import org.schabi.newpipe.player.notification.NotificationConstants;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.views.FocusOverlayView;
@ -61,7 +63,9 @@ public class NotificationActionsPreference extends Preference {
public void onDetached() {
super.onDetached();
saveChanges();
getContext().sendBroadcast(new Intent(MainPlayer.ACTION_RECREATE_NOTIFICATION));
// set package to this app's package to prevent the intent from being seen outside
getContext().sendBroadcast(new Intent(ACTION_RECREATE_NOTIFICATION)
.setPackage(App.PACKAGE_NAME));
}

View File

@ -50,10 +50,10 @@ import org.schabi.newpipe.local.history.StatisticsPlaylistFragment;
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
import org.schabi.newpipe.local.subscription.SubscriptionFragment;
import org.schabi.newpipe.local.subscription.SubscriptionsImportFragment;
import org.schabi.newpipe.player.MainPlayer;
import org.schabi.newpipe.player.MainPlayer.PlayerType;
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.PlayQueueActivity;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.helper.PlayerHelper;
import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue;
@ -91,7 +91,7 @@ public final class NavigationHelper {
intent.putExtra(Player.PLAY_QUEUE_KEY, cacheKey);
}
}
intent.putExtra(Player.PLAYER_TYPE, MainPlayer.PlayerType.VIDEO.ordinal());
intent.putExtra(Player.PLAYER_TYPE, PlayerType.MAIN.valueForIntent());
intent.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
return intent;
@ -163,8 +163,8 @@ public final class NavigationHelper {
Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerIntent(context, MainPlayer.class, queue, resumePlayback);
intent.putExtra(Player.PLAYER_TYPE, MainPlayer.PlayerType.POPUP.ordinal());
final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback);
intent.putExtra(Player.PLAYER_TYPE, PlayerType.POPUP.valueForIntent());
ContextCompat.startForegroundService(context, intent);
}
@ -174,8 +174,8 @@ public final class NavigationHelper {
Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT)
.show();
final Intent intent = getPlayerIntent(context, MainPlayer.class, queue, resumePlayback);
intent.putExtra(Player.PLAYER_TYPE, MainPlayer.PlayerType.AUDIO.ordinal());
final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback);
intent.putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO.valueForIntent());
ContextCompat.startForegroundService(context, intent);
}
@ -184,17 +184,17 @@ public final class NavigationHelper {
final PlayQueue queue,
final PlayerType playerType) {
Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerEnqueueIntent(context, MainPlayer.class, queue);
final Intent intent = getPlayerEnqueueIntent(context, PlayerService.class, queue);
intent.putExtra(Player.PLAYER_TYPE, playerType.ordinal());
intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent());
ContextCompat.startForegroundService(context, intent);
}
public static void enqueueOnPlayer(final Context context, final PlayQueue queue) {
PlayerType playerType = PlayerHolder.getInstance().getType();
if (!PlayerHolder.getInstance().isPlayerOpen()) {
if (playerType == null) {
Log.e(TAG, "Enqueueing but no player is open; defaulting to background player");
playerType = MainPlayer.PlayerType.AUDIO;
playerType = PlayerType.AUDIO;
}
enqueueOnPlayer(context, queue, playerType);
@ -203,14 +203,14 @@ public final class NavigationHelper {
/* ENQUEUE NEXT */
public static void enqueueNextOnPlayer(final Context context, final PlayQueue queue) {
PlayerType playerType = PlayerHolder.getInstance().getType();
if (!PlayerHolder.getInstance().isPlayerOpen()) {
if (playerType == null) {
Log.e(TAG, "Enqueueing next but no player is open; defaulting to background player");
playerType = MainPlayer.PlayerType.AUDIO;
playerType = PlayerType.AUDIO;
}
Toast.makeText(context, R.string.enqueued_next, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerEnqueueNextIntent(context, MainPlayer.class, queue);
final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue);
intent.putExtra(Player.PLAYER_TYPE, playerType.ordinal());
intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent());
ContextCompat.startForegroundService(context, intent);
}
@ -414,14 +414,14 @@ public final class NavigationHelper {
final boolean switchingPlayers) {
final boolean autoPlay;
@Nullable final MainPlayer.PlayerType playerType = PlayerHolder.getInstance().getType();
if (!PlayerHolder.getInstance().isPlayerOpen()) {
@Nullable final PlayerType playerType = PlayerHolder.getInstance().getType();
if (playerType == null) {
// no player open
autoPlay = PlayerHelper.isAutoplayAllowedByUser(context);
} else if (switchingPlayers) {
// switching player to main player
autoPlay = PlayerHolder.getInstance().isPlaying(); // keep play/pause state
} else if (playerType == MainPlayer.PlayerType.VIDEO) {
} else if (playerType == PlayerType.MAIN) {
// opening new stream while already playing in main player
autoPlay = PlayerHelper.isAutoplayAllowedByUser(context);
} else {
@ -436,7 +436,7 @@ public final class NavigationHelper {
// Situation when user switches from players to main player. All needed data is
// here, we can start watching (assuming newQueue equals playQueue).
// Starting directly in fullscreen if the previous player type was popup.
detailFragment.openVideoPlayer(playerType == MainPlayer.PlayerType.POPUP
detailFragment.openVideoPlayer(playerType == PlayerType.POPUP
|| PlayerHelper.isStartMainPlayerFullscreenEnabled(context));
} else {
detailFragment.selectAndLoadVideo(serviceId, url, title, playQueue);

View File

@ -244,6 +244,22 @@ public final class ThemeHelper {
return AppCompatResources.getDrawable(context, typedValue.resourceId);
}
/**
* Gets a runtime dimen from the {@code android} package. Should be used for dimens for which
* normal accessing with {@code R.dimen.} is not available.
*
* @param context context
* @param name dimen resource name (e.g. navigation_bar_height)
* @return the obtained dimension, in pixels, or 0 if the resource could not be resolved
*/
public static int getAndroidDimenPx(@NonNull final Context context, final String name) {
final int resId = context.getResources().getIdentifier(name, "dimen", "android");
if (resId <= 0) {
return 0;
}
return context.getResources().getDimensionPixelSize(resId);
}
private static String getSelectedThemeKey(final Context context) {
final String themeKey = context.getString(R.string.theme_key);
final String defaultTheme = context.getResources().getString(R.string.default_theme_value);

View File

@ -12,8 +12,8 @@ import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.START
import androidx.constraintlayout.widget.ConstraintSet
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R
import org.schabi.newpipe.player.event.DisplayPortion
import org.schabi.newpipe.player.event.DoubleTapListener
import org.schabi.newpipe.player.gesture.DisplayPortion
import org.schabi.newpipe.player.gesture.DoubleTapListener
class PlayerFastSeekOverlay(context: Context, attrs: AttributeSet?) :
ConstraintLayout(context, attrs), DoubleTapListener {
@ -38,14 +38,14 @@ class PlayerFastSeekOverlay(context: Context, attrs: AttributeSet?) :
private var performListener: PerformListener? = null
fun performListener(listener: PerformListener) = apply {
fun performListener(listener: PerformListener?) = apply {
performListener = listener
}
private var seekSecondsSupplier: () -> Int = { 0 }
fun seekSecondsSupplier(supplier: () -> Int) = apply {
seekSecondsSupplier = supplier
fun seekSecondsSupplier(supplier: (() -> Int)?) = apply {
seekSecondsSupplier = supplier ?: { 0 }
}
// Indicates whether this (double) tap is the first of a series

View File

@ -25,7 +25,7 @@
android:layout_gravity="center_horizontal"
app:behavior_hideable="true"
app:behavior_peekHeight="0dp"
app:layout_behavior="org.schabi.newpipe.player.event.CustomBottomSheetBehavior" />
app:layout_behavior="org.schabi.newpipe.player.gesture.CustomBottomSheetBehavior" />
</org.schabi.newpipe.views.FocusAwareCoordinator>