mirror of https://github.com/TeamNewPipe/NewPipe
4570 lines
179 KiB
Java
4570 lines
179 KiB
Java
package org.schabi.newpipe.player;
|
||
|
||
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_AUTO_TRANSITION;
|
||
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_INTERNAL;
|
||
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_REMOVE;
|
||
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK;
|
||
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT;
|
||
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SKIP;
|
||
import static com.google.android.exoplayer2.Player.DiscontinuityReason;
|
||
import static com.google.android.exoplayer2.Player.Listener;
|
||
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 com.google.android.exoplayer2.Player.RepeatMode;
|
||
import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu;
|
||
import static org.schabi.newpipe.extractor.ServiceList.YouTube;
|
||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||
import static org.schabi.newpipe.ktx.ViewUtils.animateRotation;
|
||
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_RECREATE_NOTIFICATION;
|
||
import static org.schabi.newpipe.player.MainPlayer.ACTION_REPEAT;
|
||
import static org.schabi.newpipe.player.MainPlayer.ACTION_SHUFFLE;
|
||
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.buildCloseOverlayLayoutParams;
|
||
import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed;
|
||
import static org.schabi.newpipe.player.helper.PlayerHelper.getMinimizeOnExitAction;
|
||
import static org.schabi.newpipe.player.helper.PlayerHelper.getMinimumVideoHeight;
|
||
import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString;
|
||
import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked;
|
||
import static org.schabi.newpipe.player.helper.PlayerHelper.isPlaybackResumeEnabled;
|
||
import static org.schabi.newpipe.player.helper.PlayerHelper.nextRepeatMode;
|
||
import static org.schabi.newpipe.player.helper.PlayerHelper.nextResizeModeAndSaveToPrefs;
|
||
import static org.schabi.newpipe.player.helper.PlayerHelper.retrievePlaybackParametersFromPrefs;
|
||
import static org.schabi.newpipe.player.helper.PlayerHelper.retrievePlayerTypeFromIntent;
|
||
import static org.schabi.newpipe.player.helper.PlayerHelper.retrievePopupLayoutParamsFromPrefs;
|
||
import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences;
|
||
import static org.schabi.newpipe.player.helper.PlayerHelper.savePlaybackParametersToPrefs;
|
||
import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex;
|
||
import static org.schabi.newpipe.util.ListHelper.getResolutionIndex;
|
||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||
import static org.schabi.newpipe.util.Localization.containsCaseInsensitive;
|
||
import static java.util.concurrent.TimeUnit.MILLISECONDS;
|
||
|
||
import android.animation.Animator;
|
||
import android.animation.AnimatorListenerAdapter;
|
||
import android.annotation.SuppressLint;
|
||
import android.content.BroadcastReceiver;
|
||
import android.content.Context;
|
||
import android.content.Intent;
|
||
import android.content.IntentFilter;
|
||
import android.content.SharedPreferences;
|
||
import android.content.res.Resources;
|
||
import android.database.ContentObserver;
|
||
import android.graphics.Bitmap;
|
||
import android.graphics.BitmapFactory;
|
||
import android.graphics.Color;
|
||
import android.graphics.PorterDuff;
|
||
import android.graphics.PorterDuffColorFilter;
|
||
import android.graphics.drawable.Drawable;
|
||
import android.media.AudioManager;
|
||
import android.net.Uri;
|
||
import android.os.Build;
|
||
import android.os.Handler;
|
||
import android.provider.Settings;
|
||
import android.util.DisplayMetrics;
|
||
import android.util.Log;
|
||
import android.util.TypedValue;
|
||
import android.view.Gravity;
|
||
import android.view.KeyEvent;
|
||
import android.view.LayoutInflater;
|
||
import android.view.Menu;
|
||
import android.view.MenuItem;
|
||
import android.view.MotionEvent;
|
||
import android.view.Surface;
|
||
import android.view.View;
|
||
import android.view.ViewGroup;
|
||
import android.view.WindowManager;
|
||
import android.view.animation.AnticipateInterpolator;
|
||
import android.widget.FrameLayout;
|
||
import android.widget.ImageButton;
|
||
import android.widget.ImageView;
|
||
import android.widget.LinearLayout;
|
||
import android.widget.ProgressBar;
|
||
import android.widget.RelativeLayout;
|
||
import android.widget.SeekBar;
|
||
import android.widget.TextView;
|
||
|
||
import androidx.annotation.NonNull;
|
||
import androidx.annotation.Nullable;
|
||
import androidx.appcompat.app.AppCompatActivity;
|
||
import androidx.appcompat.content.res.AppCompatResources;
|
||
import androidx.appcompat.view.ContextThemeWrapper;
|
||
import androidx.appcompat.widget.AppCompatImageButton;
|
||
import androidx.appcompat.widget.PopupMenu;
|
||
import androidx.core.content.ContextCompat;
|
||
import androidx.core.graphics.Insets;
|
||
import androidx.core.view.GestureDetectorCompat;
|
||
import androidx.core.view.ViewCompat;
|
||
import androidx.core.view.WindowInsetsCompat;
|
||
import androidx.fragment.app.FragmentManager;
|
||
import androidx.preference.PreferenceManager;
|
||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||
import androidx.recyclerview.widget.RecyclerView;
|
||
|
||
import com.google.android.exoplayer2.C;
|
||
import com.google.android.exoplayer2.DefaultRenderersFactory;
|
||
import com.google.android.exoplayer2.ExoPlaybackException;
|
||
import com.google.android.exoplayer2.MediaItem;
|
||
import com.google.android.exoplayer2.PlaybackParameters;
|
||
import com.google.android.exoplayer2.Player.PositionInfo;
|
||
import com.google.android.exoplayer2.RenderersFactory;
|
||
import com.google.android.exoplayer2.SimpleExoPlayer;
|
||
import com.google.android.exoplayer2.Timeline;
|
||
import com.google.android.exoplayer2.source.BehindLiveWindowException;
|
||
import com.google.android.exoplayer2.source.MediaSource;
|
||
import com.google.android.exoplayer2.source.TrackGroup;
|
||
import com.google.android.exoplayer2.source.TrackGroupArray;
|
||
import com.google.android.exoplayer2.text.Cue;
|
||
import com.google.android.exoplayer2.trackselection.MappingTrackSelector;
|
||
import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
|
||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
|
||
import com.google.android.exoplayer2.ui.CaptionStyleCompat;
|
||
import com.google.android.exoplayer2.ui.SubtitleView;
|
||
import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter;
|
||
import com.google.android.exoplayer2.util.Util;
|
||
import com.google.android.exoplayer2.video.VideoSize;
|
||
import com.google.android.material.floatingactionbutton.FloatingActionButton;
|
||
import com.squareup.picasso.Picasso;
|
||
import com.squareup.picasso.Target;
|
||
|
||
import org.schabi.newpipe.DownloaderImpl;
|
||
import org.schabi.newpipe.MainActivity;
|
||
import org.schabi.newpipe.R;
|
||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||
import org.schabi.newpipe.databinding.PlayerBinding;
|
||
import org.schabi.newpipe.databinding.PlayerPopupCloseOverlayBinding;
|
||
import org.schabi.newpipe.error.ErrorInfo;
|
||
import org.schabi.newpipe.error.ErrorUtil;
|
||
import org.schabi.newpipe.error.UserAction;
|
||
import org.schabi.newpipe.extractor.MediaFormat;
|
||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||
import org.schabi.newpipe.extractor.stream.StreamSegment;
|
||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||
import org.schabi.newpipe.extractor.stream.VideoStream;
|
||
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.local.history.HistoryRecordManager;
|
||
import org.schabi.newpipe.player.MainPlayer.PlayerType;
|
||
import org.schabi.newpipe.player.event.DisplayPortion;
|
||
import org.schabi.newpipe.player.event.PlayerEventListener;
|
||
import org.schabi.newpipe.player.event.PlayerGestureListener;
|
||
import org.schabi.newpipe.player.event.PlayerServiceEventListener;
|
||
import org.schabi.newpipe.player.helper.AudioReactor;
|
||
import org.schabi.newpipe.player.helper.LoadController;
|
||
import org.schabi.newpipe.player.helper.MediaSessionManager;
|
||
import org.schabi.newpipe.player.helper.PlayerDataSource;
|
||
import org.schabi.newpipe.player.helper.PlayerHelper;
|
||
import org.schabi.newpipe.player.listeners.view.PlaybackSpeedClickListener;
|
||
import org.schabi.newpipe.player.listeners.view.QualityClickListener;
|
||
import org.schabi.newpipe.player.playback.CustomTrackSelector;
|
||
import org.schabi.newpipe.player.playback.MediaSourceManager;
|
||
import org.schabi.newpipe.player.playback.PlaybackListener;
|
||
import org.schabi.newpipe.player.playback.PlayerMediaSession;
|
||
import org.schabi.newpipe.player.playback.SurfaceHolderCallback;
|
||
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.player.resolver.AudioPlaybackResolver;
|
||
import org.schabi.newpipe.player.resolver.MediaSourceTag;
|
||
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver;
|
||
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.SourceType;
|
||
import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper;
|
||
import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHolder;
|
||
import org.schabi.newpipe.util.DeviceUtils;
|
||
import org.schabi.newpipe.util.ListHelper;
|
||
import org.schabi.newpipe.util.NavigationHelper;
|
||
import org.schabi.newpipe.util.PicassoHelper;
|
||
import org.schabi.newpipe.util.SerializedCache;
|
||
import org.schabi.newpipe.util.StreamTypeUtil;
|
||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||
import org.schabi.newpipe.views.ExpandableSurfaceView;
|
||
import org.schabi.newpipe.views.player.PlayerFastSeekOverlay;
|
||
|
||
import java.io.IOException;
|
||
import java.util.ArrayList;
|
||
import java.util.List;
|
||
import java.util.Objects;
|
||
import java.util.Optional;
|
||
import java.util.stream.Collectors;
|
||
import java.util.stream.IntStream;
|
||
|
||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||
import io.reactivex.rxjava3.core.Observable;
|
||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||
import io.reactivex.rxjava3.disposables.Disposable;
|
||
import io.reactivex.rxjava3.disposables.SerialDisposable;
|
||
|
||
public final class Player implements
|
||
PlaybackListener,
|
||
Listener,
|
||
SeekBar.OnSeekBarChangeListener,
|
||
View.OnClickListener,
|
||
PopupMenu.OnMenuItemClickListener,
|
||
PopupMenu.OnDismissListener,
|
||
View.OnLongClickListener {
|
||
public static final boolean DEBUG = MainActivity.DEBUG;
|
||
public static final String TAG = Player.class.getSimpleName();
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// States
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
|
||
public static final int STATE_PREFLIGHT = -1;
|
||
public static final int STATE_BLOCKED = 123;
|
||
public static final int STATE_PLAYING = 124;
|
||
public static final int STATE_BUFFERING = 125;
|
||
public static final int STATE_PAUSED = 126;
|
||
public static final int STATE_PAUSED_SEEK = 127;
|
||
public static final int STATE_COMPLETED = 128;
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Intent
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
|
||
public static final String REPEAT_MODE = "repeat_mode";
|
||
public static final String PLAYBACK_QUALITY = "playback_quality";
|
||
public static final String PLAY_QUEUE_KEY = "play_queue_key";
|
||
public static final String ENQUEUE = "enqueue";
|
||
public static final String ENQUEUE_NEXT = "enqueue_next";
|
||
public static final String RESUME_PLAYBACK = "resume_playback";
|
||
public static final String PLAY_WHEN_READY = "play_when_ready";
|
||
public static final String PLAYER_TYPE = "player_type";
|
||
public static final String IS_MUTED = "is_muted";
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Time constants
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
|
||
public static final int PLAY_PREV_ACTIVATION_LIMIT_MILLIS = 5000; // 5 seconds
|
||
public static final int PROGRESS_LOOP_INTERVAL_MILLIS = 1000; // 1 second
|
||
public static final int DEFAULT_CONTROLS_DURATION = 300; // 300 millis
|
||
public static final int DEFAULT_CONTROLS_HIDE_TIME = 2000; // 2 Seconds
|
||
public static final int DPAD_CONTROLS_HIDE_TIME = 7000; // 7 Seconds
|
||
public static final int SEEK_OVERLAY_DURATION = 450; // 450 millis
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Other constants
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
|
||
private static final float[] PLAYBACK_SPEEDS = {0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f};
|
||
|
||
private static final int RENDERER_UNAVAILABLE = -1;
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Playback
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
|
||
// play queue might be null e.g. while player is starting
|
||
@Nullable private PlayQueue playQueue;
|
||
private PlayQueueAdapter playQueueAdapter;
|
||
private StreamSegmentAdapter segmentAdapter;
|
||
|
||
@Nullable private MediaSourceManager playQueueManager;
|
||
|
||
@Nullable private PlayQueueItem currentItem;
|
||
@Nullable private MediaSourceTag currentMetadata;
|
||
@Nullable private Bitmap currentThumbnail;
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Player
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
|
||
private SimpleExoPlayer simpleExoPlayer;
|
||
private AudioReactor audioReactor;
|
||
private MediaSessionManager mediaSessionManager;
|
||
@Nullable private SurfaceHolderCallback surfaceHolderCallback;
|
||
|
||
@NonNull private final CustomTrackSelector trackSelector;
|
||
@NonNull private final LoadController loadController;
|
||
@NonNull private final RenderersFactory renderFactory;
|
||
|
||
@NonNull private final VideoPlaybackResolver videoResolver;
|
||
@NonNull private final AudioPlaybackResolver audioResolver;
|
||
|
||
private final MainPlayer service; //TODO try to remove and replace everything with context
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Player states
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
|
||
private PlayerType playerType = PlayerType.VIDEO;
|
||
private int currentState = STATE_PREFLIGHT;
|
||
|
||
// audio only mode does not mean that player type is background, but that the player was
|
||
// minimized to background but will resume automatically to the original player type
|
||
private boolean isAudioOnly = false;
|
||
private boolean isPrepared = false;
|
||
private boolean wasPlaying = false;
|
||
private boolean isFullscreen = false;
|
||
private boolean isVerticalVideo = false;
|
||
private boolean fragmentIsVisible = false;
|
||
|
||
private List<VideoStream> availableStreams;
|
||
private int selectedStreamIndex;
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Views
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
|
||
private PlayerBinding binding;
|
||
|
||
private final Handler controlsVisibilityHandler = new Handler();
|
||
|
||
// fullscreen player
|
||
private boolean isQueueVisible = false;
|
||
private boolean areSegmentsVisible = false;
|
||
private ItemTouchHelper itemTouchHelper;
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Popup menus ("popup" means that they pop up, not that they belong to the popup player)
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
|
||
private static final int POPUP_MENU_ID_QUALITY = 69;
|
||
private static final int POPUP_MENU_ID_PLAYBACK_SPEED = 79;
|
||
private static final int POPUP_MENU_ID_CAPTION = 89;
|
||
|
||
private boolean isSomePopupMenuVisible = false;
|
||
private PopupMenu qualityPopupMenu;
|
||
private PopupMenu playbackSpeedPopupMenu;
|
||
private PopupMenu captionPopupMenu;
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Popup player
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
|
||
private PlayerPopupCloseOverlayBinding closeOverlayBinding;
|
||
|
||
private boolean isPopupClosing = false;
|
||
|
||
private float screenWidth;
|
||
private float 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;
|
||
|
||
@Nullable private WindowManager.LayoutParams popupLayoutParams; // null if player is not popup
|
||
@Nullable private final WindowManager windowManager;
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Gestures
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
|
||
private static final float MAX_GESTURE_LENGTH = 0.75f;
|
||
|
||
private int maxGestureLength; // scaled
|
||
private GestureDetectorCompat gestureDetector;
|
||
private PlayerGestureListener playerGestureListener;
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Listeners and disposables
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
|
||
private BroadcastReceiver broadcastReceiver;
|
||
private IntentFilter intentFilter;
|
||
private PlayerServiceEventListener fragmentListener;
|
||
private PlayerEventListener activityListener;
|
||
private ContentObserver settingsContentObserver;
|
||
|
||
@NonNull private final SerialDisposable progressUpdateDisposable = new SerialDisposable();
|
||
@NonNull private final CompositeDisposable databaseUpdateDisposable = new CompositeDisposable();
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Utils
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
|
||
@NonNull private final Context context;
|
||
@NonNull private final SharedPreferences prefs;
|
||
@NonNull private final HistoryRecordManager recordManager;
|
||
|
||
@NonNull private final SeekbarPreviewThumbnailHolder seekbarPreviewThumbnailHolder =
|
||
new SeekbarPreviewThumbnailHolder();
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Constructor
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Constructor
|
||
|
||
public Player(@NonNull final MainPlayer service) {
|
||
this.service = service;
|
||
context = service;
|
||
prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||
recordManager = new HistoryRecordManager(context);
|
||
|
||
setupBroadcastReceiver();
|
||
|
||
trackSelector = new CustomTrackSelector(context, PlayerHelper.getQualitySelector());
|
||
final PlayerDataSource dataSource = new PlayerDataSource(context, DownloaderImpl.USER_AGENT,
|
||
new DefaultBandwidthMeter.Builder(context).build());
|
||
loadController = new LoadController();
|
||
renderFactory = new DefaultRenderersFactory(context);
|
||
|
||
videoResolver = new VideoPlaybackResolver(context, dataSource, getQualityResolver());
|
||
audioResolver = new AudioPlaybackResolver(context, dataSource);
|
||
|
||
windowManager = ContextCompat.getSystemService(context, WindowManager.class);
|
||
}
|
||
|
||
private VideoPlaybackResolver.QualityResolver getQualityResolver() {
|
||
return new VideoPlaybackResolver.QualityResolver() {
|
||
@Override
|
||
public int getDefaultResolutionIndex(final List<VideoStream> sortedVideos) {
|
||
return videoPlayerSelected()
|
||
? ListHelper.getDefaultResolutionIndex(context, sortedVideos)
|
||
: ListHelper.getPopupDefaultResolutionIndex(context, sortedVideos);
|
||
}
|
||
|
||
@Override
|
||
public int getOverrideResolutionIndex(final List<VideoStream> sortedVideos,
|
||
final String playbackQuality) {
|
||
return videoPlayerSelected()
|
||
? getResolutionIndex(context, sortedVideos, playbackQuality)
|
||
: getPopupResolutionIndex(context, sortedVideos, playbackQuality);
|
||
}
|
||
};
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Setup and initialization
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Setup and initialization
|
||
|
||
public void setupFromView(@NonNull final PlayerBinding playerBinding) {
|
||
initViews(playerBinding);
|
||
if (exoPlayerIsNull()) {
|
||
initPlayer(true);
|
||
}
|
||
initListeners();
|
||
|
||
setupPlayerSeekOverlay();
|
||
}
|
||
|
||
private void initViews(@NonNull final PlayerBinding playerBinding) {
|
||
binding = playerBinding;
|
||
setupSubtitleView();
|
||
|
||
binding.resizeTextView
|
||
.setText(PlayerHelper.resizeTypeOf(context, binding.surfaceView.getResizeMode()));
|
||
|
||
binding.playbackSeekBar.getThumb()
|
||
.setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN));
|
||
binding.playbackSeekBar.getProgressDrawable()
|
||
.setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.MULTIPLY));
|
||
|
||
final ContextThemeWrapper themeWrapper = new ContextThemeWrapper(getContext(),
|
||
R.style.DarkPopupMenu);
|
||
|
||
qualityPopupMenu = new PopupMenu(themeWrapper, binding.qualityTextView);
|
||
playbackSpeedPopupMenu = new PopupMenu(context, binding.playbackSpeed);
|
||
captionPopupMenu = new PopupMenu(themeWrapper, binding.captionTextView);
|
||
|
||
binding.progressBarLoadingPanel.getIndeterminateDrawable()
|
||
.setColorFilter(new PorterDuffColorFilter(Color.WHITE, PorterDuff.Mode.MULTIPLY));
|
||
|
||
binding.titleTextView.setSelected(true);
|
||
binding.channelTextView.setSelected(true);
|
||
|
||
// Prevent hiding of bottom sheet via swipe inside queue
|
||
binding.itemsList.setNestedScrollingEnabled(false);
|
||
}
|
||
|
||
private void initPlayer(final boolean playOnReady) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "initPlayer() called with: playOnReady = [" + playOnReady + "]");
|
||
}
|
||
|
||
simpleExoPlayer = new SimpleExoPlayer.Builder(context, renderFactory)
|
||
.setTrackSelector(trackSelector)
|
||
.setLoadControl(loadController)
|
||
.build();
|
||
simpleExoPlayer.addListener(this);
|
||
simpleExoPlayer.setPlayWhenReady(playOnReady);
|
||
simpleExoPlayer.setSeekParameters(PlayerHelper.getSeekParameters(context));
|
||
simpleExoPlayer.setWakeMode(C.WAKE_MODE_NETWORK);
|
||
simpleExoPlayer.setHandleAudioBecomingNoisy(true);
|
||
|
||
audioReactor = new AudioReactor(context, simpleExoPlayer);
|
||
mediaSessionManager = new MediaSessionManager(context, simpleExoPlayer,
|
||
new PlayerMediaSession(this));
|
||
|
||
registerBroadcastReceiver();
|
||
|
||
// Setup video view
|
||
setupVideoSurface();
|
||
|
||
// enable media tunneling
|
||
if (DEBUG && PreferenceManager.getDefaultSharedPreferences(context)
|
||
.getBoolean(context.getString(R.string.disable_media_tunneling_key), false)) {
|
||
Log.d(TAG, "[" + Util.DEVICE_DEBUG_INFO + "] "
|
||
+ "media tunneling disabled in debug preferences");
|
||
} else if (DeviceUtils.shouldSupportMediaTunneling()) {
|
||
trackSelector.setParameters(trackSelector.buildUponParameters()
|
||
.setTunnelingEnabled(true));
|
||
} else if (DEBUG) {
|
||
Log.d(TAG, "[" + Util.DEVICE_DEBUG_INFO + "] does not support media tunneling");
|
||
}
|
||
}
|
||
|
||
private void initListeners() {
|
||
binding.qualityTextView.setOnClickListener(
|
||
new QualityClickListener(this, qualityPopupMenu));
|
||
binding.playbackSpeed.setOnClickListener(
|
||
new PlaybackSpeedClickListener(this, playbackSpeedPopupMenu));
|
||
|
||
binding.playbackSeekBar.setOnSeekBarChangeListener(this);
|
||
binding.captionTextView.setOnClickListener(this);
|
||
binding.resizeTextView.setOnClickListener(this);
|
||
binding.playbackLiveSync.setOnClickListener(this);
|
||
|
||
playerGestureListener = new PlayerGestureListener(this, service);
|
||
gestureDetector = new GestureDetectorCompat(context, playerGestureListener);
|
||
binding.getRoot().setOnTouchListener(playerGestureListener);
|
||
|
||
binding.queueButton.setOnClickListener(v -> onQueueClicked());
|
||
binding.segmentsButton.setOnClickListener(v -> onSegmentsClicked());
|
||
binding.repeatButton.setOnClickListener(v -> onRepeatClicked());
|
||
binding.shuffleButton.setOnClickListener(v -> onShuffleClicked());
|
||
binding.addToPlaylistButton.setOnClickListener(v -> {
|
||
if (getParentActivity() != null) {
|
||
onAddToPlaylistClicked(getParentActivity().getSupportFragmentManager());
|
||
}
|
||
});
|
||
|
||
binding.playPauseButton.setOnClickListener(this);
|
||
binding.playPreviousButton.setOnClickListener(this);
|
||
binding.playNextButton.setOnClickListener(this);
|
||
|
||
binding.moreOptionsButton.setOnClickListener(this);
|
||
binding.moreOptionsButton.setOnLongClickListener(this);
|
||
binding.share.setOnClickListener(this);
|
||
binding.share.setOnLongClickListener(this);
|
||
binding.fullScreenButton.setOnClickListener(this);
|
||
binding.screenRotationButton.setOnClickListener(this);
|
||
binding.playWithKodi.setOnClickListener(this);
|
||
binding.openInBrowser.setOnClickListener(this);
|
||
binding.playerCloseButton.setOnClickListener(this);
|
||
binding.switchMute.setOnClickListener(this);
|
||
|
||
settingsContentObserver = new ContentObserver(new Handler()) {
|
||
@Override
|
||
public void onChange(final boolean selfChange) {
|
||
setupScreenRotationButton();
|
||
}
|
||
};
|
||
context.getContentResolver().registerContentObserver(
|
||
Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false,
|
||
settingsContentObserver);
|
||
binding.getRoot().addOnLayoutChangeListener(this::onLayoutChange);
|
||
|
||
ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel, (view, windowInsets) -> {
|
||
final Insets cutout = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout());
|
||
if (!cutout.equals(Insets.NONE)) {
|
||
view.setPadding(cutout.left, cutout.top, cutout.right, cutout.bottom);
|
||
}
|
||
return windowInsets;
|
||
});
|
||
|
||
// PlaybackControlRoot already consumed window insets but we should pass them to
|
||
// player_overlays and fast_seek_overlay too. Without it they will be off-centered.
|
||
binding.playbackControlRoot.addOnLayoutChangeListener(
|
||
(v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
|
||
binding.playerOverlays.setPadding(
|
||
v.getPaddingLeft(),
|
||
v.getPaddingTop(),
|
||
v.getPaddingRight(),
|
||
v.getPaddingBottom());
|
||
|
||
// If we added padding to the fast seek overlay, too, it would not go under the
|
||
// system ui. Instead we apply negative margins equal to the window insets of
|
||
// the opposite side, so that the view covers all of the player (overflowing on
|
||
// some sides) and its center coincides with the center of other controls.
|
||
final RelativeLayout.LayoutParams fastSeekParams = (RelativeLayout.LayoutParams)
|
||
binding.fastSeekOverlay.getLayoutParams();
|
||
fastSeekParams.leftMargin = -v.getPaddingRight();
|
||
fastSeekParams.topMargin = -v.getPaddingBottom();
|
||
fastSeekParams.rightMargin = -v.getPaddingLeft();
|
||
fastSeekParams.bottomMargin = -v.getPaddingTop();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Initializes the Fast-For/Backward overlay.
|
||
*/
|
||
private void setupPlayerSeekOverlay() {
|
||
binding.fastSeekOverlay
|
||
.seekSecondsSupplier(() -> retrieveSeekDurationFromPreferences(this) / 1000)
|
||
.performListener(new PlayerFastSeekOverlay.PerformListener() {
|
||
|
||
@Override
|
||
public void onDoubleTap() {
|
||
animate(binding.fastSeekOverlay, true, SEEK_OVERLAY_DURATION);
|
||
}
|
||
|
||
@Override
|
||
public void onDoubleTapEnd() {
|
||
animate(binding.fastSeekOverlay, false, SEEK_OVERLAY_DURATION);
|
||
}
|
||
|
||
@NonNull
|
||
@Override
|
||
public FastSeekDirection getFastSeekDirection(
|
||
@NonNull final DisplayPortion portion
|
||
) {
|
||
if (exoPlayerIsNull()) {
|
||
// Abort seeking
|
||
playerGestureListener.endMultiDoubleTap();
|
||
return FastSeekDirection.NONE;
|
||
}
|
||
if (portion == DisplayPortion.LEFT) {
|
||
// Check if it's possible to rewind
|
||
// Small puffer to eliminate infinite rewind seeking
|
||
if (simpleExoPlayer.getCurrentPosition() < 500L) {
|
||
return FastSeekDirection.NONE;
|
||
}
|
||
return FastSeekDirection.BACKWARD;
|
||
} else if (portion == DisplayPortion.RIGHT) {
|
||
// Check if it's possible to fast-forward
|
||
if (currentState == STATE_COMPLETED
|
||
|| simpleExoPlayer.getCurrentPosition()
|
||
>= simpleExoPlayer.getDuration()) {
|
||
return FastSeekDirection.NONE;
|
||
}
|
||
return FastSeekDirection.FORWARD;
|
||
}
|
||
/* portion == DisplayPortion.MIDDLE */
|
||
return FastSeekDirection.NONE;
|
||
}
|
||
|
||
@Override
|
||
public void seek(final boolean forward) {
|
||
playerGestureListener.keepInDoubleTapMode();
|
||
if (forward) {
|
||
fastForward();
|
||
} else {
|
||
fastRewind();
|
||
}
|
||
}
|
||
});
|
||
playerGestureListener.doubleTapControls(binding.fastSeekOverlay);
|
||
}
|
||
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Playback initialization via intent
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Playback initialization via intent
|
||
|
||
@SuppressWarnings("MethodLength")
|
||
public void handleIntent(@NonNull final Intent intent) {
|
||
// fail fast if no play queue was provided
|
||
final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY);
|
||
if (queueCache == null) {
|
||
return;
|
||
}
|
||
final PlayQueue newQueue = SerializedCache.getInstance().take(queueCache, PlayQueue.class);
|
||
if (newQueue == null) {
|
||
return;
|
||
}
|
||
|
||
final PlayerType oldPlayerType = playerType;
|
||
playerType = retrievePlayerTypeFromIntent(intent);
|
||
// We need to setup audioOnly before super(), see "sourceOf"
|
||
isAudioOnly = audioPlayerSelected();
|
||
|
||
if (intent.hasExtra(PLAYBACK_QUALITY)) {
|
||
setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY));
|
||
}
|
||
|
||
// Resolve enqueue intents
|
||
if (intent.getBooleanExtra(ENQUEUE, false) && playQueue != null) {
|
||
playQueue.append(newQueue.getStreams());
|
||
return;
|
||
|
||
// Resolve enqueue next intents
|
||
} else if (intent.getBooleanExtra(ENQUEUE_NEXT, false) && playQueue != null) {
|
||
final int currentIndex = playQueue.getIndex();
|
||
playQueue.append(newQueue.getStreams());
|
||
playQueue.move(playQueue.size() - 1, currentIndex + 1);
|
||
return;
|
||
}
|
||
|
||
// needed for tablets, check the function for a better explanation
|
||
directlyOpenFullscreenIfNeeded();
|
||
|
||
final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this);
|
||
final float playbackSpeed = savedParameters.speed;
|
||
final float playbackPitch = savedParameters.pitch;
|
||
final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString(
|
||
R.string.playback_skip_silence_key), getPlaybackSkipSilence());
|
||
|
||
final boolean samePlayQueue = playQueue != null && playQueue.equals(newQueue);
|
||
final int repeatMode = intent.getIntExtra(REPEAT_MODE, getRepeatMode());
|
||
final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true);
|
||
final boolean isMuted = intent.getBooleanExtra(IS_MUTED, isMuted());
|
||
|
||
/*
|
||
* TODO As seen in #7427 this does not work:
|
||
* There are 3 situations when playback shouldn't be started from scratch (zero timestamp):
|
||
* 1. User pressed on a timestamp link and the same video should be rewound to the timestamp
|
||
* 2. User changed a player from, for example. main to popup, or from audio to main, etc
|
||
* 3. User chose to resume a video based on a saved timestamp from history of played videos
|
||
* In those cases time will be saved because re-init of the play queue is a not an instant
|
||
* task and requires network calls
|
||
* */
|
||
// seek to timestamp if stream is already playing
|
||
if (!exoPlayerIsNull()
|
||
&& newQueue.size() == 1 && newQueue.getItem() != null
|
||
&& playQueue != null && playQueue.size() == 1 && playQueue.getItem() != null
|
||
&& newQueue.getItem().getUrl().equals(playQueue.getItem().getUrl())
|
||
&& newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) {
|
||
// Player can have state = IDLE when playback is stopped or failed
|
||
// and we should retry in this case
|
||
if (simpleExoPlayer.getPlaybackState()
|
||
== com.google.android.exoplayer2.Player.STATE_IDLE) {
|
||
simpleExoPlayer.prepare();
|
||
}
|
||
simpleExoPlayer.seekTo(playQueue.getIndex(), newQueue.getItem().getRecoveryPosition());
|
||
simpleExoPlayer.setPlayWhenReady(playWhenReady);
|
||
|
||
} else if (!exoPlayerIsNull()
|
||
&& samePlayQueue
|
||
&& playQueue != null
|
||
&& !playQueue.isDisposed()) {
|
||
// Do not re-init the same PlayQueue. Save time
|
||
// Player can have state = IDLE when playback is stopped or failed
|
||
// and we should retry in this case
|
||
if (simpleExoPlayer.getPlaybackState()
|
||
== com.google.android.exoplayer2.Player.STATE_IDLE) {
|
||
simpleExoPlayer.prepare();
|
||
}
|
||
simpleExoPlayer.setPlayWhenReady(playWhenReady);
|
||
|
||
} else if (intent.getBooleanExtra(RESUME_PLAYBACK, false)
|
||
&& isPlaybackResumeEnabled(this)
|
||
&& !samePlayQueue
|
||
&& !newQueue.isEmpty()
|
||
&& newQueue.getItem() != null
|
||
&& newQueue.getItem().getRecoveryPosition() == PlayQueueItem.RECOVERY_UNSET) {
|
||
databaseUpdateDisposable.add(recordManager.loadStreamState(newQueue.getItem())
|
||
.observeOn(AndroidSchedulers.mainThread())
|
||
// Do not place initPlayback() in doFinally() because
|
||
// it restarts playback after destroy()
|
||
//.doFinally()
|
||
.subscribe(
|
||
state -> {
|
||
if (!state.isFinished(newQueue.getItem().getDuration())) {
|
||
// resume playback only if the stream was not played to the end
|
||
newQueue.setRecovery(newQueue.getIndex(),
|
||
state.getProgressMillis());
|
||
}
|
||
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
|
||
playbackSkipSilence, playWhenReady, isMuted);
|
||
},
|
||
error -> {
|
||
if (DEBUG) {
|
||
Log.w(TAG, "Failed to start playback", error);
|
||
}
|
||
// In case any error we can start playback without history
|
||
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
|
||
playbackSkipSilence, playWhenReady, isMuted);
|
||
},
|
||
() -> {
|
||
// Completed but not found in history
|
||
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
|
||
playbackSkipSilence, playWhenReady, isMuted);
|
||
}
|
||
));
|
||
} else {
|
||
// Good to go...
|
||
// In a case of equal PlayQueues we can re-init old one but only when it is disposed
|
||
initPlayback(samePlayQueue ? playQueue : newQueue, repeatMode, playbackSpeed,
|
||
playbackPitch, playbackSkipSilence, playWhenReady, isMuted);
|
||
}
|
||
|
||
if (oldPlayerType != playerType && playQueue != null) {
|
||
// If playerType changes from one to another we should reload the player
|
||
// (to disable/enable video stream or to set quality)
|
||
setRecovery();
|
||
reloadPlayQueueManager();
|
||
}
|
||
|
||
setupElementsVisibility();
|
||
setupElementsSize();
|
||
|
||
if (audioPlayerSelected()) {
|
||
service.removeViewFromParent();
|
||
} else if (popupPlayerSelected()) {
|
||
binding.getRoot().setVisibility(View.VISIBLE);
|
||
initPopup();
|
||
initPopupCloseOverlay();
|
||
binding.playPauseButton.requestFocus();
|
||
} else {
|
||
binding.getRoot().setVisibility(View.VISIBLE);
|
||
initVideoPlayer();
|
||
closeItemsList();
|
||
// 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 (getPlayWhenReady()) {
|
||
play();
|
||
} else {
|
||
pause();
|
||
}
|
||
}
|
||
NavigationHelper.sendPlayerStartedEvent(context);
|
||
}
|
||
|
||
/**
|
||
* 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 (fragmentListener != null
|
||
&& PlayerHelper.isStartMainPlayerFullscreenEnabled(service)
|
||
&& DeviceUtils.isTablet(service)
|
||
&& videoPlayerSelected()
|
||
&& PlayerHelper.globalScreenOrientationLocked(service)) {
|
||
fragmentListener.onScreenRotationButtonClicked();
|
||
}
|
||
}
|
||
|
||
private void initPlayback(@NonNull final PlayQueue queue,
|
||
@RepeatMode final int repeatMode,
|
||
final float playbackSpeed,
|
||
final float playbackPitch,
|
||
final boolean playbackSkipSilence,
|
||
final boolean playOnReady,
|
||
final boolean isMuted) {
|
||
destroyPlayer();
|
||
initPlayer(playOnReady);
|
||
setRepeatMode(repeatMode);
|
||
// #6825 - Ensure that the shuffle-button is in the correct state on the UI
|
||
setShuffleButton(binding.shuffleButton, simpleExoPlayer.getShuffleModeEnabled());
|
||
setPlaybackParameters(playbackSpeed, playbackPitch, playbackSkipSilence);
|
||
|
||
playQueue = queue;
|
||
playQueue.init();
|
||
reloadPlayQueueManager();
|
||
|
||
if (playQueueAdapter != null) {
|
||
playQueueAdapter.dispose();
|
||
}
|
||
playQueueAdapter = new PlayQueueAdapter(context, playQueue);
|
||
segmentAdapter = new StreamSegmentAdapter(getStreamSegmentListener());
|
||
|
||
simpleExoPlayer.setVolume(isMuted ? 0 : 1);
|
||
notifyQueueUpdateToListeners();
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Destroy and recovery
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Destroy and recovery
|
||
|
||
private void destroyPlayer() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "destroyPlayer() called");
|
||
}
|
||
|
||
cleanupVideoSurface();
|
||
|
||
if (!exoPlayerIsNull()) {
|
||
simpleExoPlayer.removeListener(this);
|
||
simpleExoPlayer.stop();
|
||
simpleExoPlayer.release();
|
||
}
|
||
if (isProgressLoopRunning()) {
|
||
stopProgressLoop();
|
||
}
|
||
if (playQueue != null) {
|
||
playQueue.dispose();
|
||
}
|
||
if (audioReactor != null) {
|
||
audioReactor.dispose();
|
||
}
|
||
if (playQueueManager != null) {
|
||
playQueueManager.dispose();
|
||
}
|
||
if (mediaSessionManager != null) {
|
||
mediaSessionManager.dispose();
|
||
}
|
||
|
||
if (playQueueAdapter != null) {
|
||
playQueueAdapter.unsetSelectedListener();
|
||
playQueueAdapter.dispose();
|
||
}
|
||
}
|
||
|
||
public void destroy() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "destroy() called");
|
||
}
|
||
destroyPlayer();
|
||
unregisterBroadcastReceiver();
|
||
|
||
databaseUpdateDisposable.clear();
|
||
progressUpdateDisposable.set(null);
|
||
PicassoHelper.cancelTag(PicassoHelper.PLAYER_THUMBNAIL_TAG); // cancel thumbnail loading
|
||
|
||
if (binding != null) {
|
||
binding.endScreen.setImageBitmap(null);
|
||
}
|
||
|
||
context.getContentResolver().unregisterContentObserver(settingsContentObserver);
|
||
}
|
||
|
||
public void setRecovery() {
|
||
if (playQueue == null || exoPlayerIsNull()) {
|
||
return;
|
||
}
|
||
|
||
final int queuePos = playQueue.getIndex();
|
||
final long windowPos = simpleExoPlayer.getCurrentPosition();
|
||
final long duration = simpleExoPlayer.getDuration();
|
||
|
||
// No checks due to https://github.com/TeamNewPipe/NewPipe/pull/7195#issuecomment-962624380
|
||
setRecovery(queuePos, Math.max(0, Math.min(windowPos, duration)));
|
||
}
|
||
|
||
private void setRecovery(final int queuePos, final long windowPos) {
|
||
if (playQueue.size() <= queuePos) {
|
||
return;
|
||
}
|
||
|
||
if (DEBUG) {
|
||
Log.d(TAG, "Setting recovery, queue: " + queuePos + ", pos: " + windowPos);
|
||
}
|
||
playQueue.setRecovery(queuePos, windowPos);
|
||
}
|
||
|
||
private void reloadPlayQueueManager() {
|
||
if (playQueueManager != null) {
|
||
playQueueManager.dispose();
|
||
}
|
||
|
||
if (playQueue != null) {
|
||
playQueueManager = new MediaSourceManager(this, playQueue);
|
||
}
|
||
}
|
||
|
||
@Override // own playback listener
|
||
public void onPlaybackShutdown() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onPlaybackShutdown() called");
|
||
}
|
||
// destroys the service, which in turn will destroy the player
|
||
service.stopService();
|
||
}
|
||
|
||
public void smoothStopPlayer() {
|
||
// Pausing would make transition from one stream to a new stream not smooth, so only stop
|
||
simpleExoPlayer.stop();
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Player type specific setup
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Player type specific setup
|
||
|
||
private void initVideoPlayer() {
|
||
// restore last resize mode
|
||
setResizeMode(PlayerHelper.retrieveResizeModeFromPrefs(this));
|
||
binding.getRoot().setLayoutParams(new FrameLayout.LayoutParams(
|
||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||
}
|
||
|
||
@SuppressLint("RtlHardcoded")
|
||
private void initPopup() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "initPopup() called");
|
||
}
|
||
|
||
// Popup is already added to windowManager
|
||
if (popupHasParent()) {
|
||
return;
|
||
}
|
||
|
||
updateScreenSize();
|
||
|
||
popupLayoutParams = retrievePopupLayoutParamsFromPrefs(this);
|
||
binding.surfaceView.setHeights(popupLayoutParams.height, popupLayoutParams.height);
|
||
|
||
checkPopupPositionBounds();
|
||
|
||
binding.loadingPanel.setMinimumWidth(popupLayoutParams.width);
|
||
binding.loadingPanel.setMinimumHeight(popupLayoutParams.height);
|
||
|
||
service.removeViewFromParent();
|
||
Objects.requireNonNull(windowManager).addView(binding.getRoot(), popupLayoutParams);
|
||
|
||
// 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);
|
||
Objects.requireNonNull(windowManager).addView(
|
||
closeOverlayBinding.getRoot(), closeOverlayLayoutParams);
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Elements visibility and size: popup and main players have different look
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Elements visibility and size: popup and main players have different look
|
||
|
||
/**
|
||
* This method ensures that popup and main players have different look.
|
||
* We use one layout for both players and need to decide what to show and what to hide.
|
||
* Additional measuring should be done inside {@link #setupElementsSize}.
|
||
*/
|
||
private void setupElementsVisibility() {
|
||
if (popupPlayerSelected()) {
|
||
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
|
||
= LinearLayout.LayoutParams.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();
|
||
closeItemsList();
|
||
} else if (videoPlayerSelected()) {
|
||
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
|
||
= LinearLayout.LayoutParams.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);
|
||
}
|
||
showHideKodiButton();
|
||
|
||
if (isFullscreen) {
|
||
binding.titleTextView.setVisibility(View.VISIBLE);
|
||
binding.channelTextView.setVisibility(View.VISIBLE);
|
||
} else {
|
||
binding.titleTextView.setVisibility(View.GONE);
|
||
binding.channelTextView.setVisibility(View.GONE);
|
||
}
|
||
setMuteButton(binding.switchMute, isMuted());
|
||
|
||
animateRotation(binding.moreOptionsButton, DEFAULT_CONTROLS_DURATION, 0);
|
||
}
|
||
|
||
/**
|
||
* Changes padding, size of elements based on player selected right now.
|
||
* Popup player has small padding in comparison with the main player
|
||
*/
|
||
private void setupElementsSize() {
|
||
final Resources res = context.getResources();
|
||
final int buttonsMinWidth;
|
||
final int playerTopPad;
|
||
final int controlsPad;
|
||
final int buttonsPad;
|
||
|
||
if (popupPlayerSelected()) {
|
||
buttonsMinWidth = 0;
|
||
playerTopPad = 0;
|
||
controlsPad = res.getDimensionPixelSize(R.dimen.player_popup_controls_padding);
|
||
buttonsPad = res.getDimensionPixelSize(R.dimen.player_popup_buttons_padding);
|
||
} else if (videoPlayerSelected()) {
|
||
buttonsMinWidth = res.getDimensionPixelSize(R.dimen.player_main_buttons_min_width);
|
||
playerTopPad = res.getDimensionPixelSize(R.dimen.player_main_top_padding);
|
||
controlsPad = res.getDimensionPixelSize(R.dimen.player_main_controls_padding);
|
||
buttonsPad = res.getDimensionPixelSize(R.dimen.player_main_buttons_padding);
|
||
} else {
|
||
return;
|
||
}
|
||
|
||
binding.topControls.setPaddingRelative(controlsPad, playerTopPad, controlsPad, 0);
|
||
binding.bottomControls.setPaddingRelative(controlsPad, 0, controlsPad, 0);
|
||
binding.qualityTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad);
|
||
binding.playbackSpeed.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad);
|
||
binding.playbackSpeed.setMinimumWidth(buttonsMinWidth);
|
||
binding.captionTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad);
|
||
}
|
||
|
||
private void showHideKodiButton() {
|
||
// show kodi button if it supports the current service and it is enabled in settings
|
||
binding.playWithKodi.setVisibility(videoPlayerSelected()
|
||
&& playQueue != null && playQueue.getItem() != null
|
||
&& KoreUtils.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId())
|
||
? View.VISIBLE : View.GONE);
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Broadcast receiver
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Broadcast receiver
|
||
|
||
private void setupBroadcastReceiver() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "setupBroadcastReceiver() called");
|
||
}
|
||
|
||
broadcastReceiver = new BroadcastReceiver() {
|
||
@Override
|
||
public void onReceive(final Context ctx, final Intent intent) {
|
||
onBroadcastReceived(intent);
|
||
}
|
||
};
|
||
intentFilter = new IntentFilter();
|
||
|
||
intentFilter.addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY);
|
||
|
||
intentFilter.addAction(ACTION_CLOSE);
|
||
intentFilter.addAction(ACTION_PLAY_PAUSE);
|
||
intentFilter.addAction(ACTION_PLAY_PREVIOUS);
|
||
intentFilter.addAction(ACTION_PLAY_NEXT);
|
||
intentFilter.addAction(ACTION_FAST_REWIND);
|
||
intentFilter.addAction(ACTION_FAST_FORWARD);
|
||
intentFilter.addAction(ACTION_REPEAT);
|
||
intentFilter.addAction(ACTION_SHUFFLE);
|
||
intentFilter.addAction(ACTION_RECREATE_NOTIFICATION);
|
||
|
||
intentFilter.addAction(VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED);
|
||
intentFilter.addAction(VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED);
|
||
|
||
intentFilter.addAction(Intent.ACTION_CONFIGURATION_CHANGED);
|
||
intentFilter.addAction(Intent.ACTION_SCREEN_ON);
|
||
intentFilter.addAction(Intent.ACTION_SCREEN_OFF);
|
||
intentFilter.addAction(Intent.ACTION_HEADSET_PLUG);
|
||
}
|
||
|
||
private void onBroadcastReceived(final Intent intent) {
|
||
if (intent == null || intent.getAction() == null) {
|
||
return;
|
||
}
|
||
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onBroadcastReceived() called with: intent = [" + intent + "]");
|
||
}
|
||
|
||
switch (intent.getAction()) {
|
||
case AudioManager.ACTION_AUDIO_BECOMING_NOISY:
|
||
pause();
|
||
break;
|
||
case ACTION_CLOSE:
|
||
service.stopService();
|
||
break;
|
||
case ACTION_PLAY_PAUSE:
|
||
playPause();
|
||
if (!fragmentIsVisible) {
|
||
// Ensure that we have audio-only stream playing when a user
|
||
// started to play from notification's play button from outside of the app
|
||
onFragmentStopped();
|
||
}
|
||
break;
|
||
case ACTION_PLAY_PREVIOUS:
|
||
playPrevious();
|
||
break;
|
||
case ACTION_PLAY_NEXT:
|
||
playNext();
|
||
break;
|
||
case ACTION_FAST_REWIND:
|
||
fastRewind();
|
||
break;
|
||
case ACTION_FAST_FORWARD:
|
||
fastForward();
|
||
break;
|
||
case ACTION_REPEAT:
|
||
onRepeatClicked();
|
||
break;
|
||
case ACTION_SHUFFLE:
|
||
onShuffleClicked();
|
||
break;
|
||
case ACTION_RECREATE_NOTIFICATION:
|
||
NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, true);
|
||
break;
|
||
case VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED:
|
||
fragmentIsVisible = true;
|
||
useVideoSource(true);
|
||
break;
|
||
case VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED:
|
||
fragmentIsVisible = false;
|
||
onFragmentStopped();
|
||
break;
|
||
case Intent.ACTION_CONFIGURATION_CHANGED:
|
||
assureCorrectAppLanguage(service);
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onConfigurationChanged() called");
|
||
}
|
||
if (popupPlayerSelected()) {
|
||
updateScreenSize();
|
||
changePopupSize(popupLayoutParams.width);
|
||
checkPopupPositionBounds();
|
||
}
|
||
// Close it because when changing orientation from portrait
|
||
// (in fullscreen mode) the size of queue layout can be larger than the screen size
|
||
closeItemsList();
|
||
// When the orientation changed, the screen height might be smaller.
|
||
// If the end screen thumbnail is not re-scaled,
|
||
// it can be larger than the current screen height
|
||
// and thus enlarging the whole player.
|
||
// This causes the seekbar to be ouf the visible area.
|
||
updateEndScreenThumbnail();
|
||
break;
|
||
case Intent.ACTION_SCREEN_ON:
|
||
// Interrupt playback only when screen turns on
|
||
// and user is watching video in popup player.
|
||
// Same actions for video player will be handled in ACTION_VIDEO_FRAGMENT_RESUMED
|
||
if (popupPlayerSelected() && (isPlaying() || isLoading())) {
|
||
useVideoSource(true);
|
||
}
|
||
break;
|
||
case Intent.ACTION_SCREEN_OFF:
|
||
// Interrupt playback only when screen turns off with popup player working
|
||
if (popupPlayerSelected() && (isPlaying() || isLoading())) {
|
||
useVideoSource(false);
|
||
}
|
||
break;
|
||
case Intent.ACTION_HEADSET_PLUG: //FIXME
|
||
/*notificationManager.cancel(NOTIFICATION_ID);
|
||
mediaSessionManager.dispose();
|
||
mediaSessionManager.enable(getBaseContext(), basePlayerImpl.simpleExoPlayer);*/
|
||
break;
|
||
}
|
||
}
|
||
|
||
private void registerBroadcastReceiver() {
|
||
// Try to unregister current first
|
||
unregisterBroadcastReceiver();
|
||
context.registerReceiver(broadcastReceiver, intentFilter);
|
||
}
|
||
|
||
private void unregisterBroadcastReceiver() {
|
||
try {
|
||
context.unregisterReceiver(broadcastReceiver);
|
||
} catch (final IllegalArgumentException unregisteredException) {
|
||
Log.w(TAG, "Broadcast receiver already unregistered: "
|
||
+ unregisteredException.getMessage());
|
||
}
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Thumbnail loading
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Thumbnail loading
|
||
|
||
private void initThumbnail(final String url) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "Thumbnail - initThumbnail() called with url = ["
|
||
+ (url == null ? "null" : url) + "]");
|
||
}
|
||
if (isNullOrEmpty(url)) {
|
||
return;
|
||
}
|
||
|
||
// scale down the notification thumbnail for performance
|
||
PicassoHelper.loadScaledDownThumbnail(context, url).into(new Target() {
|
||
@Override
|
||
public void onBitmapLoaded(final Bitmap bitmap, final Picasso.LoadedFrom from) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "Thumbnail - onLoadingComplete() called with: url = [" + url
|
||
+ "], " + "loadedImage = [" + bitmap + " -> " + bitmap.getWidth() + "x"
|
||
+ bitmap.getHeight() + "], from = [" + from + "]");
|
||
}
|
||
|
||
currentThumbnail = bitmap;
|
||
NotificationUtil.getInstance()
|
||
.createNotificationIfNeededAndUpdate(Player.this, false);
|
||
// there is a new thumbnail, so changed the end screen thumbnail, too.
|
||
updateEndScreenThumbnail();
|
||
}
|
||
|
||
@Override
|
||
public void onBitmapFailed(final Exception e, final Drawable errorDrawable) {
|
||
Log.e(TAG, "Thumbnail - onBitmapFailed() called with: url = [" + url + "]", e);
|
||
currentThumbnail = null;
|
||
NotificationUtil.getInstance()
|
||
.createNotificationIfNeededAndUpdate(Player.this, false);
|
||
}
|
||
|
||
@Override
|
||
public void onPrepareLoad(final Drawable placeHolderDrawable) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "Thumbnail - onLoadingStarted() called with: url = [" + url + "]");
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Scale the player audio / end screen thumbnail down if necessary.
|
||
* <p>
|
||
* This is necessary when the thumbnail's height is larger than the device's height
|
||
* and thus is enlarging the player's height
|
||
* causing the bottom playback controls to be out of the visible screen.
|
||
* </p>
|
||
*/
|
||
public void updateEndScreenThumbnail() {
|
||
if (currentThumbnail == null) {
|
||
return;
|
||
}
|
||
|
||
final float endScreenHeight = calculateMaxEndScreenThumbnailHeight();
|
||
|
||
final Bitmap endScreenBitmap = Bitmap.createScaledBitmap(
|
||
currentThumbnail,
|
||
(int) (currentThumbnail.getWidth()
|
||
/ (currentThumbnail.getHeight() / endScreenHeight)),
|
||
(int) endScreenHeight,
|
||
true);
|
||
|
||
if (DEBUG) {
|
||
Log.d(TAG, "Thumbnail - updateEndScreenThumbnail() called with: "
|
||
+ "currentThumbnail = [" + currentThumbnail + "], "
|
||
+ currentThumbnail.getWidth() + "x" + currentThumbnail.getHeight()
|
||
+ ", scaled end screen height = " + endScreenHeight
|
||
+ ", scaled end screen width = " + endScreenBitmap.getWidth());
|
||
}
|
||
|
||
binding.endScreen.setImageBitmap(endScreenBitmap);
|
||
}
|
||
|
||
/**
|
||
* 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 <code>85dp</code> 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 <code>15sp</code> on tablets and <code>16sp</code> on TVs,
|
||
* see {@link R.id.titleTextView}.
|
||
* </li>
|
||
* <li>
|
||
* Otherwise, the max thumbnail height is the screen height.
|
||
* </li>
|
||
* </ul>
|
||
*
|
||
* @return the maximum height for the end screen thumbnail
|
||
*/
|
||
private float calculateMaxEndScreenThumbnailHeight() {
|
||
// ensure that screenHeight is initialized and thus not 0
|
||
updateScreenSize();
|
||
|
||
if (DeviceUtils.isTv(context) && !isFullscreen) {
|
||
final int videoInfoHeight =
|
||
DeviceUtils.dpToPx(85, context) + DeviceUtils.spToPx(16, context);
|
||
return Math.min(currentThumbnail.getHeight(), screenHeight - videoInfoHeight);
|
||
} else if (DeviceUtils.isTablet(context) && service.isLandscape() && !isFullscreen) {
|
||
final int videoInfoHeight =
|
||
DeviceUtils.dpToPx(85, context) + DeviceUtils.spToPx(15, context);
|
||
return Math.min(currentThumbnail.getHeight(), screenHeight - videoInfoHeight);
|
||
} else { // fullscreen player: max height is the device height
|
||
return Math.min(currentThumbnail.getHeight(), screenHeight);
|
||
}
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Popup player utils
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Popup player utils
|
||
|
||
/**
|
||
* 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 = (int) (screenWidth - popupLayoutParams.width);
|
||
}
|
||
|
||
if (popupLayoutParams.y < 0) {
|
||
popupLayoutParams.y = 0;
|
||
} else if (popupLayoutParams.y > screenHeight - popupLayoutParams.height) {
|
||
popupLayoutParams.y = (int) (screenHeight - popupLayoutParams.height);
|
||
}
|
||
}
|
||
|
||
public void updateScreenSize() {
|
||
if (windowManager != null) {
|
||
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 = (int) (width > screenWidth ? screenWidth
|
||
: (width < minimumWidth ? minimumWidth : width));
|
||
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);
|
||
Objects.requireNonNull(windowManager)
|
||
.updateViewLayout(binding.getRoot(), popupLayoutParams);
|
||
}
|
||
|
||
private void changePopupWindowFlags(final int flags) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "changePopupWindowFlags() called with: flags = [" + flags + "]");
|
||
}
|
||
|
||
if (!anyPopupViewIsNull()) {
|
||
popupLayoutParams.flags = flags;
|
||
Objects.requireNonNull(windowManager)
|
||
.updateViewLayout(binding.getRoot(), popupLayoutParams);
|
||
}
|
||
}
|
||
|
||
public void closePopup() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "closePopup() called, isPopupClosing = " + isPopupClosing);
|
||
}
|
||
if (isPopupClosing) {
|
||
return;
|
||
}
|
||
isPopupClosing = true;
|
||
|
||
saveStreamProgressState();
|
||
Objects.requireNonNull(windowManager).removeView(binding.getRoot());
|
||
|
||
animatePopupOverlayAndFinishService();
|
||
}
|
||
|
||
public void removePopupFromView() {
|
||
if (windowManager != null) {
|
||
// 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() {
|
||
Objects.requireNonNull(windowManager)
|
||
.removeView(closeOverlayBinding.getRoot());
|
||
closeOverlayBinding = null;
|
||
service.stopService();
|
||
}
|
||
}).start();
|
||
}
|
||
|
||
private boolean popupHasParent() {
|
||
return binding != null
|
||
&& binding.getRoot().getLayoutParams() instanceof WindowManager.LayoutParams
|
||
&& binding.getRoot().getParent() != null;
|
||
}
|
||
|
||
private boolean anyPopupViewIsNull() {
|
||
// TODO understand why checking getParentActivity() != null
|
||
return popupLayoutParams == null || windowManager == null
|
||
|| getParentActivity() != null || binding.getRoot().getParent() == null;
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Playback parameters
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Playback parameters
|
||
|
||
public float getPlaybackSpeed() {
|
||
return getPlaybackParameters().speed;
|
||
}
|
||
|
||
private void setPlaybackSpeed(final float speed) {
|
||
setPlaybackParameters(speed, getPlaybackPitch(), getPlaybackSkipSilence());
|
||
}
|
||
|
||
public float getPlaybackPitch() {
|
||
return getPlaybackParameters().pitch;
|
||
}
|
||
|
||
public boolean getPlaybackSkipSilence() {
|
||
return !exoPlayerIsNull() && simpleExoPlayer.getAudioComponent() != null
|
||
&& simpleExoPlayer.getAudioComponent().getSkipSilenceEnabled();
|
||
}
|
||
|
||
public PlaybackParameters getPlaybackParameters() {
|
||
if (exoPlayerIsNull()) {
|
||
return PlaybackParameters.DEFAULT;
|
||
}
|
||
return simpleExoPlayer.getPlaybackParameters();
|
||
}
|
||
|
||
/**
|
||
* Sets the playback parameters of the player, and also saves them to shared preferences.
|
||
* Speed and pitch are rounded up to 2 decimal places before being used or saved.
|
||
*
|
||
* @param speed the playback speed, will be rounded to up to 2 decimal places
|
||
* @param pitch the playback pitch, will be rounded to up to 2 decimal places
|
||
* @param skipSilence skip silence during playback
|
||
*/
|
||
public void setPlaybackParameters(final float speed, final float pitch,
|
||
final boolean skipSilence) {
|
||
final float roundedSpeed = Math.round(speed * 100.0f) / 100.0f;
|
||
final float roundedPitch = Math.round(pitch * 100.0f) / 100.0f;
|
||
|
||
savePlaybackParametersToPrefs(this, roundedSpeed, roundedPitch, skipSilence);
|
||
simpleExoPlayer.setPlaybackParameters(
|
||
new PlaybackParameters(roundedSpeed, roundedPitch));
|
||
if (simpleExoPlayer.getAudioComponent() != null) {
|
||
simpleExoPlayer.getAudioComponent().setSkipSilenceEnabled(skipSilence);
|
||
}
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Progress loop and updates
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Progress loop and updates
|
||
|
||
private void onUpdateProgress(final int currentProgress,
|
||
final int duration,
|
||
final int bufferPercent) {
|
||
if (!isPrepared) {
|
||
return;
|
||
}
|
||
|
||
if (duration != binding.playbackSeekBar.getMax()) {
|
||
setVideoDurationToControls(duration);
|
||
}
|
||
if (currentState != STATE_PAUSED) {
|
||
updatePlayBackElementsCurrentDuration(currentProgress);
|
||
}
|
||
if (simpleExoPlayer.isLoading() || bufferPercent > 90) {
|
||
binding.playbackSeekBar.setSecondaryProgress(
|
||
(int) (binding.playbackSeekBar.getMax() * ((float) bufferPercent / 100)));
|
||
}
|
||
if (DEBUG && bufferPercent % 20 == 0) { //Limit log
|
||
Log.d(TAG, "notifyProgressUpdateToListeners() called with: "
|
||
+ "isVisible = " + isControlsVisible() + ", "
|
||
+ "currentProgress = [" + currentProgress + "], "
|
||
+ "duration = [" + duration + "], bufferPercent = [" + bufferPercent + "]");
|
||
}
|
||
binding.playbackLiveSync.setClickable(!isLiveEdge());
|
||
|
||
notifyProgressUpdateToListeners(currentProgress, duration, bufferPercent);
|
||
|
||
if (areSegmentsVisible) {
|
||
segmentAdapter.selectSegmentAt(getNearestStreamSegmentPosition(currentProgress));
|
||
}
|
||
|
||
if (isQueueVisible) {
|
||
updateQueueTime(currentProgress);
|
||
}
|
||
}
|
||
|
||
private void startProgressLoop() {
|
||
progressUpdateDisposable.set(getProgressUpdateDisposable());
|
||
}
|
||
|
||
private void stopProgressLoop() {
|
||
progressUpdateDisposable.set(null);
|
||
}
|
||
|
||
private boolean isProgressLoopRunning() {
|
||
return progressUpdateDisposable.get() != null;
|
||
}
|
||
|
||
private void triggerProgressUpdate() {
|
||
if (exoPlayerIsNull()) {
|
||
return;
|
||
}
|
||
// Use duration of currentItem for non-live streams,
|
||
// because HLS streams are fragmented
|
||
// and thus the whole duration is not available to the player
|
||
// TODO: revert #6307 when introducing proper HLS support
|
||
final int duration;
|
||
if (currentItem != null
|
||
&& !StreamTypeUtil.isLiveStream(currentItem.getStreamType())
|
||
) {
|
||
// convert seconds to milliseconds
|
||
duration = (int) (currentItem.getDuration() * 1000);
|
||
} else {
|
||
duration = (int) simpleExoPlayer.getDuration();
|
||
}
|
||
onUpdateProgress(
|
||
Math.max((int) simpleExoPlayer.getCurrentPosition(), 0),
|
||
duration,
|
||
simpleExoPlayer.getBufferedPercentage()
|
||
);
|
||
}
|
||
|
||
private Disposable getProgressUpdateDisposable() {
|
||
return Observable.interval(PROGRESS_LOOP_INTERVAL_MILLIS, MILLISECONDS,
|
||
AndroidSchedulers.mainThread())
|
||
.observeOn(AndroidSchedulers.mainThread())
|
||
.subscribe(ignored -> triggerProgressUpdate(),
|
||
error -> Log.e(TAG, "Progress update failure: ", error));
|
||
}
|
||
|
||
@Override // seekbar listener
|
||
public void onProgressChanged(final SeekBar seekBar, final int progress,
|
||
final boolean fromUser) {
|
||
// Currently we don't need method execution when fromUser is false
|
||
if (!fromUser) {
|
||
return;
|
||
}
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onProgressChanged() called with: "
|
||
+ "seekBar = [" + seekBar + "], progress = [" + progress + "]");
|
||
}
|
||
|
||
binding.currentDisplaySeek.setText(getTimeString(progress));
|
||
|
||
// Seekbar Preview Thumbnail
|
||
SeekbarPreviewThumbnailHelper
|
||
.tryResizeAndSetSeekbarPreviewThumbnail(
|
||
getContext(),
|
||
seekbarPreviewThumbnailHolder.getBitmapAt(progress),
|
||
binding.currentSeekbarPreviewThumbnail,
|
||
binding.subtitleView::getWidth);
|
||
|
||
adjustSeekbarPreviewContainer();
|
||
}
|
||
|
||
private void adjustSeekbarPreviewContainer() {
|
||
try {
|
||
// Should only be required when an error occurred before
|
||
// and the layout was positioned in the center
|
||
binding.bottomSeekbarPreviewLayout.setGravity(Gravity.NO_GRAVITY);
|
||
|
||
// Calculate the current left position of seekbar progress in px
|
||
// More info: https://stackoverflow.com/q/20493577
|
||
final int currentSeekbarLeft =
|
||
binding.playbackSeekBar.getLeft()
|
||
+ binding.playbackSeekBar.getPaddingLeft()
|
||
+ binding.playbackSeekBar.getThumb().getBounds().left;
|
||
|
||
// Calculate the (unchecked) left position of the container
|
||
final int uncheckedContainerLeft =
|
||
currentSeekbarLeft - (binding.seekbarPreviewContainer.getWidth() / 2);
|
||
|
||
// Fix the position so it's within the boundaries
|
||
final int checkedContainerLeft =
|
||
Math.max(
|
||
Math.min(
|
||
uncheckedContainerLeft,
|
||
// Max left
|
||
binding.playbackWindowRoot.getWidth()
|
||
- binding.seekbarPreviewContainer.getWidth()
|
||
),
|
||
0 // Min left
|
||
);
|
||
|
||
// See also: https://stackoverflow.com/a/23249734
|
||
final LinearLayout.LayoutParams params =
|
||
new LinearLayout.LayoutParams(
|
||
binding.seekbarPreviewContainer.getLayoutParams());
|
||
params.setMarginStart(checkedContainerLeft);
|
||
binding.seekbarPreviewContainer.setLayoutParams(params);
|
||
} catch (final Exception ex) {
|
||
Log.e(TAG, "Failed to adjust seekbarPreviewContainer", ex);
|
||
// Fallback - position in the middle
|
||
binding.bottomSeekbarPreviewLayout.setGravity(Gravity.CENTER);
|
||
}
|
||
}
|
||
|
||
@Override // seekbar listener
|
||
public void onStartTrackingTouch(final SeekBar seekBar) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onStartTrackingTouch() called with: seekBar = [" + seekBar + "]");
|
||
}
|
||
if (currentState != STATE_PAUSED_SEEK) {
|
||
changeState(STATE_PAUSED_SEEK);
|
||
}
|
||
|
||
saveWasPlaying();
|
||
if (isPlaying()) {
|
||
simpleExoPlayer.pause();
|
||
}
|
||
|
||
showControls(0);
|
||
animate(binding.currentDisplaySeek, true, DEFAULT_CONTROLS_DURATION,
|
||
AnimationType.SCALE_AND_ALPHA);
|
||
animate(binding.currentSeekbarPreviewThumbnail, true, DEFAULT_CONTROLS_DURATION,
|
||
AnimationType.SCALE_AND_ALPHA);
|
||
}
|
||
|
||
@Override // seekbar listener
|
||
public void onStopTrackingTouch(final SeekBar seekBar) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onStopTrackingTouch() called with: seekBar = [" + seekBar + "]");
|
||
}
|
||
|
||
seekTo(seekBar.getProgress());
|
||
if (wasPlaying || simpleExoPlayer.getDuration() == seekBar.getProgress()) {
|
||
simpleExoPlayer.play();
|
||
}
|
||
|
||
binding.playbackCurrentTime.setText(getTimeString(seekBar.getProgress()));
|
||
animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA);
|
||
animate(binding.currentSeekbarPreviewThumbnail, false, 200, AnimationType.SCALE_AND_ALPHA);
|
||
|
||
if (currentState == STATE_PAUSED_SEEK) {
|
||
changeState(STATE_BUFFERING);
|
||
}
|
||
if (!isProgressLoopRunning()) {
|
||
startProgressLoop();
|
||
}
|
||
if (wasPlaying) {
|
||
showControlsThenHide();
|
||
}
|
||
}
|
||
|
||
public void saveWasPlaying() {
|
||
this.wasPlaying = getPlayWhenReady();
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Controls showing / hiding
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Controls showing / hiding
|
||
|
||
public boolean isControlsVisible() {
|
||
return binding != null && binding.playbackControlRoot.getVisibility() == View.VISIBLE;
|
||
}
|
||
|
||
public void showControlsThenHide() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "showControlsThenHide() called");
|
||
}
|
||
showOrHideButtons();
|
||
showSystemUIPartially();
|
||
|
||
final int hideTime = binding.playbackControlRoot.isInTouchMode()
|
||
? DEFAULT_CONTROLS_HIDE_TIME
|
||
: DPAD_CONTROLS_HIDE_TIME;
|
||
|
||
showHideShadow(true, DEFAULT_CONTROLS_DURATION);
|
||
animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION,
|
||
AnimationType.ALPHA, 0, () -> hideControls(DEFAULT_CONTROLS_DURATION, hideTime));
|
||
}
|
||
|
||
public void showControls(final long duration) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "showControls() called");
|
||
}
|
||
showOrHideButtons();
|
||
showSystemUIPartially();
|
||
controlsVisibilityHandler.removeCallbacksAndMessages(null);
|
||
showHideShadow(true, duration);
|
||
animate(binding.playbackControlRoot, true, duration);
|
||
}
|
||
|
||
public void hideControls(final long duration, final long delay) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "hideControls() called with: duration = [" + duration
|
||
+ "], delay = [" + delay + "]");
|
||
}
|
||
|
||
showOrHideButtons();
|
||
|
||
controlsVisibilityHandler.removeCallbacksAndMessages(null);
|
||
controlsVisibilityHandler.postDelayed(() -> {
|
||
showHideShadow(false, duration);
|
||
animate(binding.playbackControlRoot, false, duration, AnimationType.ALPHA,
|
||
0, this::hideSystemUIIfNeeded);
|
||
}, delay);
|
||
}
|
||
|
||
public void showHideShadow(final boolean show, final long duration) {
|
||
animate(binding.playbackControlsShadow, show, duration, AnimationType.ALPHA, 0, null);
|
||
animate(binding.playerTopShadow, show, duration, AnimationType.ALPHA, 0, null);
|
||
animate(binding.playerBottomShadow, show, duration, AnimationType.ALPHA, 0, null);
|
||
}
|
||
|
||
private void showOrHideButtons() {
|
||
if (playQueue == null) {
|
||
return;
|
||
}
|
||
|
||
final boolean showPrev = playQueue.getIndex() != 0;
|
||
final boolean showNext = playQueue.getIndex() + 1 != playQueue.getStreams().size();
|
||
final boolean showQueue = playQueue.getStreams().size() > 1 && !popupPlayerSelected();
|
||
boolean showSegment = false;
|
||
if (currentMetadata != null) {
|
||
showSegment = !currentMetadata.getMetadata().getStreamSegments().isEmpty()
|
||
&& !popupPlayerSelected();
|
||
}
|
||
|
||
binding.playPreviousButton.setVisibility(showPrev ? View.VISIBLE : View.INVISIBLE);
|
||
binding.playPreviousButton.setAlpha(showPrev ? 1.0f : 0.0f);
|
||
binding.playNextButton.setVisibility(showNext ? View.VISIBLE : View.INVISIBLE);
|
||
binding.playNextButton.setAlpha(showNext ? 1.0f : 0.0f);
|
||
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);
|
||
}
|
||
|
||
private void showSystemUIPartially() {
|
||
final AppCompatActivity activity = getParentActivity();
|
||
if (isFullscreen && activity != null) {
|
||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||
activity.getWindow().setStatusBarColor(Color.TRANSPARENT);
|
||
activity.getWindow().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;
|
||
activity.getWindow().getDecorView().setSystemUiVisibility(visibility);
|
||
activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
|
||
}
|
||
}
|
||
|
||
private void hideSystemUIIfNeeded() {
|
||
if (fragmentListener != null) {
|
||
fragmentListener.hideSystemUiIfNeeded();
|
||
}
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Playback states
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Playback states
|
||
|
||
@Override // exoplayer listener
|
||
public void onPlayerStateChanged(final boolean playWhenReady, final int playbackState) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "ExoPlayer - onPlayerStateChanged() called with: "
|
||
+ "playWhenReady = [" + playWhenReady + "], "
|
||
+ "playbackState = [" + playbackState + "]");
|
||
}
|
||
|
||
if (currentState == STATE_PAUSED_SEEK) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "ExoPlayer - onPlayerStateChanged() is currently blocked");
|
||
}
|
||
return;
|
||
}
|
||
|
||
switch (playbackState) {
|
||
case com.google.android.exoplayer2.Player.STATE_IDLE: // 1
|
||
isPrepared = false;
|
||
break;
|
||
case com.google.android.exoplayer2.Player.STATE_BUFFERING: // 2
|
||
if (isPrepared) {
|
||
changeState(STATE_BUFFERING);
|
||
}
|
||
break;
|
||
case com.google.android.exoplayer2.Player.STATE_READY: //3
|
||
maybeUpdateCurrentMetadata();
|
||
maybeCorrectSeekPosition();
|
||
if (!isPrepared) {
|
||
isPrepared = true;
|
||
onPrepared(playWhenReady);
|
||
}
|
||
changeState(playWhenReady ? STATE_PLAYING : STATE_PAUSED);
|
||
break;
|
||
case com.google.android.exoplayer2.Player.STATE_ENDED: // 4
|
||
changeState(STATE_COMPLETED);
|
||
saveStreamProgressStateCompleted();
|
||
isPrepared = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
@Override // exoplayer listener
|
||
public void onIsLoadingChanged(final boolean isLoading) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "ExoPlayer - onLoadingChanged() called with: "
|
||
+ "isLoading = [" + isLoading + "]");
|
||
}
|
||
|
||
if (!isLoading && currentState == STATE_PAUSED && isProgressLoopRunning()) {
|
||
stopProgressLoop();
|
||
} else if (isLoading && !isProgressLoopRunning()) {
|
||
startProgressLoop();
|
||
}
|
||
|
||
maybeUpdateCurrentMetadata();
|
||
}
|
||
|
||
@Override // own playback listener
|
||
public void onPlaybackBlock() {
|
||
if (exoPlayerIsNull()) {
|
||
return;
|
||
}
|
||
if (DEBUG) {
|
||
Log.d(TAG, "Playback - onPlaybackBlock() called");
|
||
}
|
||
|
||
currentItem = null;
|
||
currentMetadata = null;
|
||
simpleExoPlayer.stop();
|
||
isPrepared = false;
|
||
|
||
changeState(STATE_BLOCKED);
|
||
}
|
||
|
||
@Override // own playback listener
|
||
public void onPlaybackUnblock(final MediaSource mediaSource) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "Playback - onPlaybackUnblock() called");
|
||
}
|
||
|
||
if (exoPlayerIsNull()) {
|
||
return;
|
||
}
|
||
if (currentState == STATE_BLOCKED) {
|
||
changeState(STATE_BUFFERING);
|
||
}
|
||
simpleExoPlayer.setMediaSource(mediaSource, false);
|
||
simpleExoPlayer.prepare();
|
||
}
|
||
|
||
public void changeState(final int state) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "changeState() called with: state = [" + state + "]");
|
||
}
|
||
currentState = state;
|
||
switch (state) {
|
||
case STATE_BLOCKED:
|
||
onBlocked();
|
||
break;
|
||
case STATE_PLAYING:
|
||
onPlaying();
|
||
break;
|
||
case STATE_BUFFERING:
|
||
onBuffering();
|
||
break;
|
||
case STATE_PAUSED:
|
||
onPaused();
|
||
break;
|
||
case STATE_PAUSED_SEEK:
|
||
onPausedSeek();
|
||
break;
|
||
case STATE_COMPLETED:
|
||
onCompleted();
|
||
break;
|
||
}
|
||
notifyPlaybackUpdateToListeners();
|
||
}
|
||
|
||
private void onPrepared(final boolean playWhenReady) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onPrepared() called with: playWhenReady = [" + playWhenReady + "]");
|
||
}
|
||
|
||
setVideoDurationToControls((int) simpleExoPlayer.getDuration());
|
||
|
||
binding.playbackSpeed.setText(formatSpeed(getPlaybackSpeed()));
|
||
|
||
if (playWhenReady) {
|
||
audioReactor.requestAudioFocus();
|
||
}
|
||
}
|
||
|
||
private void onBlocked() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onBlocked() called");
|
||
}
|
||
if (!isProgressLoopRunning()) {
|
||
startProgressLoop();
|
||
}
|
||
|
||
// if we are e.g. switching players, hide controls
|
||
hideControls(DEFAULT_CONTROLS_DURATION, 0);
|
||
|
||
binding.playbackSeekBar.setEnabled(false);
|
||
binding.playbackSeekBar.getThumb()
|
||
.setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN));
|
||
|
||
binding.loadingPanel.setBackgroundColor(Color.BLACK);
|
||
animate(binding.loadingPanel, true, 0);
|
||
animate(binding.surfaceForeground, true, 100);
|
||
|
||
binding.playPauseButton.setImageResource(R.drawable.ic_play_arrow);
|
||
animatePlayButtons(false, 100);
|
||
binding.getRoot().setKeepScreenOn(false);
|
||
|
||
NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false);
|
||
}
|
||
|
||
private void onPlaying() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onPlaying() called");
|
||
}
|
||
if (!isProgressLoopRunning()) {
|
||
startProgressLoop();
|
||
}
|
||
|
||
updateStreamRelatedViews();
|
||
|
||
binding.playbackSeekBar.setEnabled(true);
|
||
binding.playbackSeekBar.getThumb()
|
||
.setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN));
|
||
|
||
binding.loadingPanel.setVisibility(View.GONE);
|
||
|
||
animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA);
|
||
|
||
animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0,
|
||
() -> {
|
||
binding.playPauseButton.setImageResource(R.drawable.ic_pause);
|
||
animatePlayButtons(true, 200);
|
||
if (!isQueueVisible) {
|
||
binding.playPauseButton.requestFocus();
|
||
}
|
||
});
|
||
|
||
changePopupWindowFlags(ONGOING_PLAYBACK_WINDOW_FLAGS);
|
||
checkLandscape();
|
||
binding.getRoot().setKeepScreenOn(true);
|
||
|
||
NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false);
|
||
}
|
||
|
||
private void onBuffering() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onBuffering() called");
|
||
}
|
||
binding.loadingPanel.setBackgroundColor(Color.TRANSPARENT);
|
||
binding.loadingPanel.setVisibility(View.VISIBLE);
|
||
|
||
binding.getRoot().setKeepScreenOn(true);
|
||
|
||
if (NotificationUtil.getInstance().shouldUpdateBufferingSlot()) {
|
||
NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false);
|
||
}
|
||
}
|
||
|
||
private void onPaused() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onPaused() called");
|
||
}
|
||
|
||
if (isProgressLoopRunning()) {
|
||
stopProgressLoop();
|
||
}
|
||
|
||
// Don't let UI elements popup during double tap seeking. This state is entered sometimes
|
||
// during seeking/loading. This if-else check ensures that the controls aren't popping up.
|
||
if (!playerGestureListener.isDoubleTapping()) {
|
||
showControls(400);
|
||
binding.loadingPanel.setVisibility(View.GONE);
|
||
|
||
animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0,
|
||
() -> {
|
||
binding.playPauseButton.setImageResource(R.drawable.ic_play_arrow);
|
||
animatePlayButtons(true, 200);
|
||
if (!isQueueVisible) {
|
||
binding.playPauseButton.requestFocus();
|
||
}
|
||
});
|
||
}
|
||
changePopupWindowFlags(IDLE_WINDOW_FLAGS);
|
||
|
||
// Remove running notification when user does not want minimization to background or popup
|
||
if (PlayerHelper.getMinimizeOnExitAction(context) == MINIMIZE_ON_EXIT_MODE_NONE
|
||
&& videoPlayerSelected()) {
|
||
NotificationUtil.getInstance().cancelNotificationAndStopForeground(service);
|
||
} else {
|
||
NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false);
|
||
}
|
||
|
||
binding.getRoot().setKeepScreenOn(false);
|
||
}
|
||
|
||
private void onPausedSeek() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onPausedSeek() called");
|
||
}
|
||
|
||
animatePlayButtons(false, 100);
|
||
binding.getRoot().setKeepScreenOn(true);
|
||
|
||
NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false);
|
||
}
|
||
|
||
private void onCompleted() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onCompleted() called" + (playQueue == null ? ". playQueue is null" : ""));
|
||
}
|
||
if (playQueue == null) {
|
||
return;
|
||
}
|
||
|
||
animate(binding.playPauseButton, false, 0, AnimationType.SCALE_AND_ALPHA, 0,
|
||
() -> {
|
||
binding.playPauseButton.setImageResource(R.drawable.ic_replay);
|
||
animatePlayButtons(true, DEFAULT_CONTROLS_DURATION);
|
||
});
|
||
|
||
binding.getRoot().setKeepScreenOn(false);
|
||
changePopupWindowFlags(IDLE_WINDOW_FLAGS);
|
||
|
||
NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false);
|
||
if (isFullscreen) {
|
||
toggleFullscreen();
|
||
}
|
||
|
||
if (playQueue.getIndex() < playQueue.size() - 1) {
|
||
playQueue.offsetIndex(+1);
|
||
}
|
||
if (isProgressLoopRunning()) {
|
||
stopProgressLoop();
|
||
}
|
||
|
||
// When a (short) video ends the elements have to display the correct values - see #6180
|
||
updatePlayBackElementsCurrentDuration(binding.playbackSeekBar.getMax());
|
||
|
||
showControls(500);
|
||
animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA);
|
||
binding.loadingPanel.setVisibility(View.GONE);
|
||
animate(binding.surfaceForeground, true, 100);
|
||
}
|
||
|
||
private void animatePlayButtons(final boolean show, final int duration) {
|
||
animate(binding.playPauseButton, show, duration, AnimationType.SCALE_AND_ALPHA);
|
||
|
||
boolean showQueueButtons = show;
|
||
if (playQueue == null) {
|
||
showQueueButtons = false;
|
||
}
|
||
|
||
if (!showQueueButtons || playQueue.getIndex() > 0) {
|
||
animate(
|
||
binding.playPreviousButton,
|
||
showQueueButtons,
|
||
duration,
|
||
AnimationType.SCALE_AND_ALPHA);
|
||
}
|
||
if (!showQueueButtons || playQueue.getIndex() + 1 < playQueue.getStreams().size()) {
|
||
animate(
|
||
binding.playNextButton,
|
||
showQueueButtons,
|
||
duration,
|
||
AnimationType.SCALE_AND_ALPHA);
|
||
}
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Repeat and shuffle
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Repeat and shuffle
|
||
|
||
public void onRepeatClicked() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onRepeatClicked() called");
|
||
}
|
||
setRepeatMode(nextRepeatMode(getRepeatMode()));
|
||
}
|
||
|
||
public void onShuffleClicked() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onShuffleClicked() called");
|
||
}
|
||
|
||
if (exoPlayerIsNull()) {
|
||
return;
|
||
}
|
||
simpleExoPlayer.setShuffleModeEnabled(!simpleExoPlayer.getShuffleModeEnabled());
|
||
}
|
||
|
||
@RepeatMode
|
||
public int getRepeatMode() {
|
||
return exoPlayerIsNull() ? REPEAT_MODE_OFF : simpleExoPlayer.getRepeatMode();
|
||
}
|
||
|
||
private void setRepeatMode(@RepeatMode final int repeatMode) {
|
||
if (!exoPlayerIsNull()) {
|
||
simpleExoPlayer.setRepeatMode(repeatMode);
|
||
}
|
||
}
|
||
|
||
@Override
|
||
public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "ExoPlayer - onRepeatModeChanged() called with: "
|
||
+ "repeatMode = [" + repeatMode + "]");
|
||
}
|
||
setRepeatModeButton(binding.repeatButton, repeatMode);
|
||
onShuffleOrRepeatModeChanged();
|
||
}
|
||
|
||
@Override
|
||
public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "ExoPlayer - onShuffleModeEnabledChanged() called with: "
|
||
+ "mode = [" + shuffleModeEnabled + "]");
|
||
}
|
||
|
||
if (playQueue != null) {
|
||
if (shuffleModeEnabled) {
|
||
playQueue.shuffle();
|
||
} else {
|
||
playQueue.unshuffle();
|
||
}
|
||
}
|
||
|
||
setShuffleButton(binding.shuffleButton, shuffleModeEnabled);
|
||
onShuffleOrRepeatModeChanged();
|
||
}
|
||
|
||
private void onShuffleOrRepeatModeChanged() {
|
||
notifyPlaybackUpdateToListeners();
|
||
NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false);
|
||
}
|
||
|
||
private void setRepeatModeButton(final AppCompatImageButton imageButton,
|
||
@RepeatMode final int repeatMode) {
|
||
switch (repeatMode) {
|
||
case REPEAT_MODE_OFF:
|
||
imageButton.setImageResource(R.drawable.exo_controls_repeat_off);
|
||
break;
|
||
case REPEAT_MODE_ONE:
|
||
imageButton.setImageResource(R.drawable.exo_controls_repeat_one);
|
||
break;
|
||
case REPEAT_MODE_ALL:
|
||
imageButton.setImageResource(R.drawable.exo_controls_repeat_all);
|
||
break;
|
||
}
|
||
}
|
||
|
||
private void setShuffleButton(@NonNull final ImageButton button, final boolean shuffled) {
|
||
button.setImageAlpha(shuffled ? 255 : 77);
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Playlist append
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Playlist append
|
||
|
||
public void onAddToPlaylistClicked(@NonNull final FragmentManager fragmentManager) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onAddToPlaylistClicked() called");
|
||
}
|
||
|
||
if (getPlayQueue() != null) {
|
||
PlaylistDialog.createCorrespondingDialog(
|
||
getContext(),
|
||
getPlayQueue()
|
||
.getStreams()
|
||
.stream()
|
||
.map(StreamEntity::new)
|
||
.collect(Collectors.toList()),
|
||
dialog -> dialog.show(fragmentManager, TAG)
|
||
);
|
||
}
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Mute / Unmute
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Mute / Unmute
|
||
|
||
public void onMuteUnmuteButtonClicked() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onMuteUnmuteButtonClicked() called");
|
||
}
|
||
simpleExoPlayer.setVolume(isMuted() ? 1 : 0);
|
||
notifyPlaybackUpdateToListeners();
|
||
setMuteButton(binding.switchMute, isMuted());
|
||
}
|
||
|
||
boolean isMuted() {
|
||
return !exoPlayerIsNull() && simpleExoPlayer.getVolume() == 0;
|
||
}
|
||
|
||
private void setMuteButton(@NonNull final ImageButton button, final boolean isMuted) {
|
||
button.setImageDrawable(AppCompatResources.getDrawable(context, isMuted
|
||
? R.drawable.ic_volume_off : R.drawable.ic_volume_up));
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// ExoPlayer listeners (that didn't fit in other categories)
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region ExoPlayer listeners (that didn't fit in other categories)
|
||
|
||
@Override
|
||
public void onTimelineChanged(@NonNull final Timeline timeline, final int reason) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "ExoPlayer - onTimelineChanged() called with "
|
||
+ "timeline size = [" + timeline.getWindowCount() + "], "
|
||
+ "reason = [" + reason + "]");
|
||
}
|
||
|
||
maybeUpdateCurrentMetadata();
|
||
// force recreate notification to ensure seek bar is shown when preparation finishes
|
||
NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, true);
|
||
}
|
||
|
||
@Override
|
||
public void onTracksChanged(@NonNull final TrackGroupArray trackGroups,
|
||
@NonNull final TrackSelectionArray trackSelections) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "ExoPlayer - onTracksChanged(), "
|
||
+ "track group size = " + trackGroups.length);
|
||
}
|
||
maybeUpdateCurrentMetadata();
|
||
onTextTracksChanged();
|
||
}
|
||
|
||
@Override
|
||
public void onPlaybackParametersChanged(@NonNull final PlaybackParameters playbackParameters) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "ExoPlayer - playbackParameters(), speed = [" + playbackParameters.speed
|
||
+ "], pitch = [" + playbackParameters.pitch + "]");
|
||
}
|
||
binding.playbackSpeed.setText(formatSpeed(playbackParameters.speed));
|
||
}
|
||
|
||
@Override
|
||
public void onPositionDiscontinuity(@NonNull final PositionInfo oldPosition,
|
||
@NonNull final PositionInfo newPosition,
|
||
@DiscontinuityReason final int discontinuityReason) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "ExoPlayer - onPositionDiscontinuity() called with "
|
||
+ "discontinuityReason = [" + discontinuityReason + "]");
|
||
}
|
||
if (playQueue == null) {
|
||
return;
|
||
}
|
||
|
||
// Refresh the playback if there is a transition to the next video
|
||
final int newWindowIndex = simpleExoPlayer.getCurrentWindowIndex();
|
||
switch (discontinuityReason) {
|
||
case DISCONTINUITY_REASON_AUTO_TRANSITION:
|
||
case DISCONTINUITY_REASON_REMOVE:
|
||
// When player is in single repeat mode and a period transition occurs,
|
||
// we need to register a view count here since no metadata has changed
|
||
if (getRepeatMode() == REPEAT_MODE_ONE && newWindowIndex == playQueue.getIndex()) {
|
||
registerStreamViewed();
|
||
break;
|
||
}
|
||
case DISCONTINUITY_REASON_SEEK:
|
||
if (DEBUG) {
|
||
Log.d(TAG, "ExoPlayer - onSeekProcessed() called");
|
||
}
|
||
if (isPrepared) {
|
||
saveStreamProgressState();
|
||
}
|
||
case DISCONTINUITY_REASON_SEEK_ADJUSTMENT:
|
||
case DISCONTINUITY_REASON_INTERNAL:
|
||
if (playQueue.getIndex() != newWindowIndex) {
|
||
saveStreamProgressStateCompleted(); // current stream has ended
|
||
playQueue.setIndex(newWindowIndex);
|
||
}
|
||
break;
|
||
case DISCONTINUITY_REASON_SKIP:
|
||
break; // only makes Android Studio linter happy, as there are no ads
|
||
}
|
||
|
||
maybeUpdateCurrentMetadata();
|
||
}
|
||
|
||
@Override
|
||
public void onRenderedFirstFrame() {
|
||
//TODO check if this causes black screen when switching to fullscreen
|
||
animate(binding.surfaceForeground, false, DEFAULT_CONTROLS_DURATION);
|
||
}
|
||
|
||
@Override
|
||
public void onCues(@NonNull final List<Cue> cues) {
|
||
binding.subtitleView.onCues(cues);
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Errors
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Errors
|
||
/**
|
||
* Process exceptions produced by {@link com.google.android.exoplayer2.ExoPlayer ExoPlayer}.
|
||
* <p>There are multiple types of errors:</p>
|
||
* <ul>
|
||
* <li>{@link ExoPlaybackException#TYPE_SOURCE TYPE_SOURCE}</li>
|
||
* <li>{@link ExoPlaybackException#TYPE_UNEXPECTED TYPE_UNEXPECTED}:
|
||
* If a runtime error occurred, then we can try to recover it by restarting the playback
|
||
* after setting the timestamp recovery.</li>
|
||
* <li>{@link ExoPlaybackException#TYPE_RENDERER TYPE_RENDERER}:
|
||
* If the renderer failed, treat the error as unrecoverable.</li>
|
||
* </ul>
|
||
*
|
||
* @see #processSourceError(IOException)
|
||
* @see com.google.android.exoplayer2.Player.Listener#onPlayerError(ExoPlaybackException)
|
||
*/
|
||
@Override
|
||
public void onPlayerError(@NonNull final ExoPlaybackException error) {
|
||
Log.e(TAG, "ExoPlayer - onPlayerError() called with:", error);
|
||
|
||
saveStreamProgressState();
|
||
boolean isCatchableException = false;
|
||
|
||
switch (error.type) {
|
||
case ExoPlaybackException.TYPE_SOURCE:
|
||
isCatchableException = processSourceError(error.getSourceException());
|
||
break;
|
||
case ExoPlaybackException.TYPE_UNEXPECTED:
|
||
setRecovery();
|
||
reloadPlayQueueManager();
|
||
break;
|
||
case ExoPlaybackException.TYPE_REMOTE:
|
||
case ExoPlaybackException.TYPE_RENDERER:
|
||
default:
|
||
onPlaybackShutdown();
|
||
break;
|
||
}
|
||
|
||
if (isCatchableException) {
|
||
return;
|
||
}
|
||
|
||
createErrorNotification(error);
|
||
|
||
if (fragmentListener != null) {
|
||
fragmentListener.onPlayerError(error);
|
||
}
|
||
}
|
||
|
||
private void createErrorNotification(@NonNull final ExoPlaybackException error) {
|
||
final ErrorInfo errorInfo;
|
||
if (currentMetadata == null) {
|
||
errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM,
|
||
"Player error[type=" + error.type + "] occurred, currentMetadata is null");
|
||
} else {
|
||
errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM,
|
||
"Player error[type=" + error.type + "] occurred while playing "
|
||
+ currentMetadata.getMetadata().getUrl(),
|
||
currentMetadata.getMetadata());
|
||
}
|
||
ErrorUtil.createNotification(context, errorInfo);
|
||
}
|
||
|
||
/**
|
||
* Process an {@link IOException} returned by {@link ExoPlaybackException#getSourceException()}
|
||
* for {@link ExoPlaybackException#TYPE_SOURCE} exceptions.
|
||
*
|
||
* <p>
|
||
* This method sets the recovery position and sends an error message to the play queue if the
|
||
* exception is not a {@link BehindLiveWindowException}.
|
||
* </p>
|
||
* @param error the source error which was thrown by ExoPlayer
|
||
* @return whether the exception thrown is a {@link BehindLiveWindowException} ({@code false}
|
||
* is always returned if ExoPlayer or the play queue is null)
|
||
*/
|
||
private boolean processSourceError(final IOException error) {
|
||
if (exoPlayerIsNull() || playQueue == null) {
|
||
return false;
|
||
}
|
||
|
||
setRecovery();
|
||
|
||
if (error instanceof BehindLiveWindowException) {
|
||
simpleExoPlayer.seekToDefaultPosition();
|
||
simpleExoPlayer.prepare();
|
||
// Inform the user that we are reloading the stream by switching to the buffering state
|
||
onBuffering();
|
||
return true;
|
||
}
|
||
|
||
playQueue.error();
|
||
return false;
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Playback position and seek
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Playback position and seek
|
||
|
||
/**
|
||
* Sets the current duration into the corresponding elements.
|
||
* @param currentProgress
|
||
*/
|
||
private void updatePlayBackElementsCurrentDuration(final int currentProgress) {
|
||
// Don't set seekbar progress while user is seeking
|
||
if (currentState != STATE_PAUSED_SEEK) {
|
||
binding.playbackSeekBar.setProgress(currentProgress);
|
||
}
|
||
binding.playbackCurrentTime.setText(getTimeString(currentProgress));
|
||
}
|
||
|
||
@Override // own playback listener (this is a getter)
|
||
public boolean isApproachingPlaybackEdge(final long timeToEndMillis) {
|
||
// If live, then not near playback edge
|
||
// If not playing, then not approaching playback edge
|
||
if (exoPlayerIsNull() || isLive() || !isPlaying()) {
|
||
return false;
|
||
}
|
||
|
||
final long currentPositionMillis = simpleExoPlayer.getCurrentPosition();
|
||
final long currentDurationMillis = simpleExoPlayer.getDuration();
|
||
return currentDurationMillis - currentPositionMillis < timeToEndMillis;
|
||
}
|
||
|
||
/**
|
||
* Checks if the current playback is a livestream AND is playing at or beyond the live edge.
|
||
*
|
||
* @return whether the livestream is playing at or beyond the edge
|
||
*/
|
||
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
|
||
public boolean isLiveEdge() {
|
||
if (exoPlayerIsNull() || !isLive()) {
|
||
return false;
|
||
}
|
||
|
||
final Timeline currentTimeline = simpleExoPlayer.getCurrentTimeline();
|
||
final int currentWindowIndex = simpleExoPlayer.getCurrentWindowIndex();
|
||
if (currentTimeline.isEmpty() || currentWindowIndex < 0
|
||
|| currentWindowIndex >= currentTimeline.getWindowCount()) {
|
||
return false;
|
||
}
|
||
|
||
final Timeline.Window timelineWindow = new Timeline.Window();
|
||
currentTimeline.getWindow(currentWindowIndex, timelineWindow);
|
||
return timelineWindow.getDefaultPositionMs() <= simpleExoPlayer.getCurrentPosition();
|
||
}
|
||
|
||
@Override // own playback listener
|
||
public void onPlaybackSynchronize(@NonNull final PlayQueueItem item) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "Playback - onPlaybackSynchronize() called with "
|
||
+ "item=[" + item.getTitle() + "], url=[" + item.getUrl() + "]");
|
||
}
|
||
if (exoPlayerIsNull() || playQueue == null) {
|
||
return;
|
||
}
|
||
|
||
final boolean onPlaybackInitial = currentItem == null;
|
||
final boolean hasPlayQueueItemChanged = currentItem != item;
|
||
|
||
final int currentPlayQueueIndex = playQueue.indexOf(item);
|
||
final int currentPlaylistIndex = simpleExoPlayer.getCurrentWindowIndex();
|
||
final int currentPlaylistSize = simpleExoPlayer.getCurrentTimeline().getWindowCount();
|
||
|
||
// If nothing to synchronize
|
||
if (!hasPlayQueueItemChanged) {
|
||
return;
|
||
}
|
||
currentItem = item;
|
||
|
||
// Check if on wrong window
|
||
if (currentPlayQueueIndex != playQueue.getIndex()) {
|
||
Log.e(TAG, "Playback - Play Queue may be desynchronized: item "
|
||
+ "index=[" + currentPlayQueueIndex + "], "
|
||
+ "queue index=[" + playQueue.getIndex() + "]");
|
||
|
||
// Check if bad seek position
|
||
} else if ((currentPlaylistSize > 0 && currentPlayQueueIndex >= currentPlaylistSize)
|
||
|| currentPlayQueueIndex < 0) {
|
||
Log.e(TAG, "Playback - Trying to seek to invalid "
|
||
+ "index=[" + currentPlayQueueIndex + "] with "
|
||
+ "playlist length=[" + currentPlaylistSize + "]");
|
||
|
||
} else if (currentPlaylistIndex != currentPlayQueueIndex || onPlaybackInitial
|
||
|| !isPlaying()) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "Playback - Rewinding to correct "
|
||
+ "index=[" + currentPlayQueueIndex + "], "
|
||
+ "from=[" + currentPlaylistIndex + "], "
|
||
+ "size=[" + currentPlaylistSize + "].");
|
||
}
|
||
|
||
if (item.getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) {
|
||
simpleExoPlayer.seekTo(currentPlayQueueIndex, item.getRecoveryPosition());
|
||
playQueue.unsetRecovery(currentPlayQueueIndex);
|
||
} else {
|
||
simpleExoPlayer.seekToDefaultPosition(currentPlayQueueIndex);
|
||
}
|
||
}
|
||
}
|
||
|
||
private void maybeCorrectSeekPosition() {
|
||
if (playQueue == null || exoPlayerIsNull() || currentMetadata == null) {
|
||
return;
|
||
}
|
||
|
||
final PlayQueueItem currentSourceItem = playQueue.getItem();
|
||
if (currentSourceItem == null) {
|
||
return;
|
||
}
|
||
|
||
final StreamInfo currentInfo = currentMetadata.getMetadata();
|
||
final long presetStartPositionMillis = currentInfo.getStartPosition() * 1000;
|
||
if (presetStartPositionMillis > 0L) {
|
||
// Has another start position?
|
||
if (DEBUG) {
|
||
Log.d(TAG, "Playback - Seeking to preset start "
|
||
+ "position=[" + presetStartPositionMillis + "]");
|
||
}
|
||
seekTo(presetStartPositionMillis);
|
||
}
|
||
}
|
||
|
||
public void seekTo(final long positionMillis) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "seekBy() called with: position = [" + positionMillis + "]");
|
||
}
|
||
if (!exoPlayerIsNull()) {
|
||
// prevent invalid positions when fast-forwarding/-rewinding
|
||
long normalizedPositionMillis = positionMillis;
|
||
if (normalizedPositionMillis < 0) {
|
||
normalizedPositionMillis = 0;
|
||
} else if (normalizedPositionMillis > simpleExoPlayer.getDuration()) {
|
||
normalizedPositionMillis = simpleExoPlayer.getDuration();
|
||
}
|
||
|
||
simpleExoPlayer.seekTo(normalizedPositionMillis);
|
||
}
|
||
}
|
||
|
||
private void seekBy(final long offsetMillis) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "seekBy() called with: offsetMillis = [" + offsetMillis + "]");
|
||
}
|
||
seekTo(simpleExoPlayer.getCurrentPosition() + offsetMillis);
|
||
}
|
||
|
||
public void seekToDefault() {
|
||
if (!exoPlayerIsNull()) {
|
||
simpleExoPlayer.seekToDefaultPosition();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sets the video duration time into all control components (e.g. seekbar).
|
||
* @param duration
|
||
*/
|
||
private void setVideoDurationToControls(final int duration) {
|
||
binding.playbackEndTime.setText(getTimeString(duration));
|
||
|
||
binding.playbackSeekBar.setMax(duration);
|
||
// This is important for Android TVs otherwise it would apply the default from
|
||
// setMax/Min methods which is (max - min) / 20
|
||
binding.playbackSeekBar.setKeyProgressIncrement(
|
||
PlayerHelper.retrieveSeekDurationFromPreferences(this));
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Player actions (play, pause, previous, fast-forward, ...)
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Player actions (play, pause, previous, fast-forward, ...)
|
||
|
||
public void play() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "play() called");
|
||
}
|
||
if (audioReactor == null || playQueue == null || exoPlayerIsNull()) {
|
||
return;
|
||
}
|
||
|
||
audioReactor.requestAudioFocus();
|
||
|
||
if (currentState == STATE_COMPLETED) {
|
||
if (playQueue.getIndex() == 0) {
|
||
seekToDefault();
|
||
} else {
|
||
playQueue.setIndex(0);
|
||
}
|
||
}
|
||
|
||
simpleExoPlayer.play();
|
||
saveStreamProgressState();
|
||
}
|
||
|
||
public void pause() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "pause() called");
|
||
}
|
||
if (audioReactor == null || exoPlayerIsNull()) {
|
||
return;
|
||
}
|
||
|
||
audioReactor.abandonAudioFocus();
|
||
simpleExoPlayer.pause();
|
||
saveStreamProgressState();
|
||
}
|
||
|
||
public void playPause() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onPlayPause() called");
|
||
}
|
||
|
||
if (getPlayWhenReady()
|
||
// When state is completed (replay button is shown) then (re)play and do not pause
|
||
&& currentState != STATE_COMPLETED) {
|
||
pause();
|
||
} else {
|
||
play();
|
||
}
|
||
}
|
||
|
||
public void playPrevious() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onPlayPrevious() called");
|
||
}
|
||
if (exoPlayerIsNull() || playQueue == null) {
|
||
return;
|
||
}
|
||
|
||
/* If current playback has run for PLAY_PREV_ACTIVATION_LIMIT_MILLIS milliseconds,
|
||
* restart current track. Also restart the track if the current track
|
||
* is the first in a queue.*/
|
||
if (simpleExoPlayer.getCurrentPosition() > PLAY_PREV_ACTIVATION_LIMIT_MILLIS
|
||
|| playQueue.getIndex() == 0) {
|
||
seekToDefault();
|
||
playQueue.offsetIndex(0);
|
||
} else {
|
||
saveStreamProgressState();
|
||
playQueue.offsetIndex(-1);
|
||
}
|
||
triggerProgressUpdate();
|
||
}
|
||
|
||
public void playNext() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onPlayNext() called");
|
||
}
|
||
if (playQueue == null) {
|
||
return;
|
||
}
|
||
|
||
saveStreamProgressState();
|
||
playQueue.offsetIndex(+1);
|
||
triggerProgressUpdate();
|
||
}
|
||
|
||
public void fastForward() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "fastRewind() called");
|
||
}
|
||
seekBy(retrieveSeekDurationFromPreferences(this));
|
||
triggerProgressUpdate();
|
||
}
|
||
|
||
public void fastRewind() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "fastRewind() called");
|
||
}
|
||
seekBy(-retrieveSeekDurationFromPreferences(this));
|
||
triggerProgressUpdate();
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// StreamInfo history: views and progress
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region StreamInfo history: views and progress
|
||
|
||
private void registerStreamViewed() {
|
||
if (currentMetadata != null) {
|
||
databaseUpdateDisposable.add(recordManager.onViewed(currentMetadata.getMetadata())
|
||
.onErrorComplete().subscribe());
|
||
}
|
||
}
|
||
|
||
private void saveStreamProgressState(final long progressMillis) {
|
||
if (currentMetadata == null
|
||
|| !prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) {
|
||
return;
|
||
}
|
||
if (DEBUG) {
|
||
Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis
|
||
+ ", currentMetadata=[" + currentMetadata.getMetadata().getName() + "]");
|
||
}
|
||
|
||
databaseUpdateDisposable
|
||
.add(recordManager.saveStreamState(currentMetadata.getMetadata(), progressMillis)
|
||
.observeOn(AndroidSchedulers.mainThread())
|
||
.doOnError(e -> {
|
||
if (DEBUG) {
|
||
e.printStackTrace();
|
||
}
|
||
})
|
||
.onErrorComplete()
|
||
.subscribe());
|
||
}
|
||
|
||
public void saveStreamProgressState() {
|
||
if (exoPlayerIsNull() || currentMetadata == null || playQueue == null
|
||
|| playQueue.getIndex() != simpleExoPlayer.getCurrentWindowIndex()) {
|
||
// Make sure play queue and current window index are equal, to prevent saving state for
|
||
// the wrong stream on discontinuity (e.g. when the stream just changed but the
|
||
// playQueue index and currentMetadata still haven't updated)
|
||
return;
|
||
}
|
||
// Save current position. It will help to restore this position once a user
|
||
// wants to play prev or next stream from the queue
|
||
playQueue.setRecovery(playQueue.getIndex(), simpleExoPlayer.getContentPosition());
|
||
saveStreamProgressState(simpleExoPlayer.getCurrentPosition());
|
||
}
|
||
|
||
public void saveStreamProgressStateCompleted() {
|
||
if (currentMetadata != null) {
|
||
// current stream has ended, so the progress is its duration (+1 to overcome rounding)
|
||
saveStreamProgressState((currentMetadata.getMetadata().getDuration() + 1) * 1000);
|
||
}
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Metadata
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Metadata
|
||
|
||
private void onMetadataChanged(@NonNull final MediaSourceTag tag) {
|
||
final StreamInfo info = tag.getMetadata();
|
||
if (DEBUG) {
|
||
Log.d(TAG, "Playback - onMetadataChanged() called, playing: " + info.getName());
|
||
}
|
||
|
||
initThumbnail(info.getThumbnailUrl());
|
||
registerStreamViewed();
|
||
updateStreamRelatedViews();
|
||
showHideKodiButton();
|
||
|
||
binding.titleTextView.setText(tag.getMetadata().getName());
|
||
binding.channelTextView.setText(tag.getMetadata().getUploaderName());
|
||
|
||
this.seekbarPreviewThumbnailHolder.resetFrom(
|
||
this.getContext(),
|
||
tag.getMetadata().getPreviewFrames());
|
||
|
||
NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false);
|
||
|
||
final boolean showThumbnail = prefs.getBoolean(
|
||
context.getString(R.string.show_thumbnail_key), true);
|
||
mediaSessionManager.setMetadata(
|
||
getVideoTitle(),
|
||
getUploaderName(),
|
||
showThumbnail ? Optional.ofNullable(getThumbnail()) : Optional.empty(),
|
||
StreamTypeUtil.isLiveStream(tag.getMetadata().getStreamType())
|
||
? -1
|
||
: tag.getMetadata().getDuration()
|
||
);
|
||
|
||
notifyMetadataUpdateToListeners();
|
||
|
||
if (areSegmentsVisible) {
|
||
if (segmentAdapter.setItems(info)) {
|
||
final int adapterPosition = getNearestStreamSegmentPosition(
|
||
simpleExoPlayer.getCurrentPosition());
|
||
segmentAdapter.selectSegmentAt(adapterPosition);
|
||
binding.itemsList.scrollToPosition(adapterPosition);
|
||
} else {
|
||
closeItemsList();
|
||
}
|
||
}
|
||
}
|
||
|
||
private void maybeUpdateCurrentMetadata() {
|
||
if (exoPlayerIsNull()) {
|
||
return;
|
||
}
|
||
|
||
final MediaSourceTag metadata;
|
||
try {
|
||
final MediaItem currentMediaItem = simpleExoPlayer.getCurrentMediaItem();
|
||
if (currentMediaItem == null || currentMediaItem.playbackProperties == null
|
||
|| currentMediaItem.playbackProperties.tag == null) {
|
||
return;
|
||
}
|
||
metadata = (MediaSourceTag) currentMediaItem.playbackProperties.tag;
|
||
} catch (final IndexOutOfBoundsException | ClassCastException ex) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "Could not update metadata", ex);
|
||
}
|
||
return;
|
||
}
|
||
|
||
maybeAutoQueueNextStream(metadata);
|
||
|
||
if (currentMetadata == metadata) {
|
||
return;
|
||
}
|
||
currentMetadata = metadata;
|
||
onMetadataChanged(metadata);
|
||
}
|
||
|
||
@NonNull
|
||
private String getVideoUrl() {
|
||
return currentMetadata == null
|
||
? context.getString(R.string.unknown_content)
|
||
: currentMetadata.getMetadata().getUrl();
|
||
}
|
||
|
||
@NonNull
|
||
private String getVideoUrlAtCurrentTime() {
|
||
final int timeSeconds = binding.playbackSeekBar.getProgress() / 1000;
|
||
String videoUrl = getVideoUrl();
|
||
if (!isLive() && timeSeconds >= 0 && currentMetadata != null
|
||
&& currentMetadata.getMetadata().getServiceId() == YouTube.getServiceId()) {
|
||
// Timestamp doesn't make sense in a live stream so drop it
|
||
videoUrl += ("&t=" + timeSeconds);
|
||
}
|
||
return videoUrl;
|
||
}
|
||
|
||
@NonNull
|
||
public String getVideoTitle() {
|
||
return currentMetadata == null
|
||
? context.getString(R.string.unknown_content)
|
||
: currentMetadata.getMetadata().getName();
|
||
}
|
||
|
||
@NonNull
|
||
public String getUploaderName() {
|
||
return currentMetadata == null
|
||
? context.getString(R.string.unknown_content)
|
||
: currentMetadata.getMetadata().getUploaderName();
|
||
}
|
||
|
||
@Nullable
|
||
public Bitmap getThumbnail() {
|
||
if (currentThumbnail == null) {
|
||
currentThumbnail = BitmapFactory.decodeResource(
|
||
context.getResources(), R.drawable.dummy_thumbnail);
|
||
}
|
||
return currentThumbnail;
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Play queue, segments and streams
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Play queue, segments and streams
|
||
|
||
private void maybeAutoQueueNextStream(@NonNull final MediaSourceTag metadata) {
|
||
if (playQueue == null || playQueue.getIndex() != playQueue.size() - 1
|
||
|| getRepeatMode() != REPEAT_MODE_OFF
|
||
|| !PlayerHelper.isAutoQueueEnabled(context)) {
|
||
return;
|
||
}
|
||
// auto queue when starting playback on the last item when not repeating
|
||
final PlayQueue autoQueue = PlayerHelper.autoQueueOf(metadata.getMetadata(),
|
||
playQueue.getStreams());
|
||
if (autoQueue != null) {
|
||
playQueue.append(autoQueue.getStreams());
|
||
}
|
||
}
|
||
|
||
public void selectQueueItem(final PlayQueueItem item) {
|
||
if (playQueue == null || exoPlayerIsNull()) {
|
||
return;
|
||
}
|
||
|
||
final int index = playQueue.indexOf(item);
|
||
if (index == -1) {
|
||
return;
|
||
}
|
||
|
||
if (playQueue.getIndex() == index && simpleExoPlayer.getCurrentWindowIndex() == index) {
|
||
seekToDefault();
|
||
} else {
|
||
saveStreamProgressState();
|
||
}
|
||
playQueue.setIndex(index);
|
||
}
|
||
|
||
@Override
|
||
public void onPlayQueueEdited() {
|
||
notifyPlaybackUpdateToListeners();
|
||
showOrHideButtons();
|
||
NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false);
|
||
}
|
||
|
||
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);
|
||
|
||
binding.itemsList.scrollToPosition(playQueue.getIndex());
|
||
|
||
updateQueueTime((int) simpleExoPlayer.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(simpleExoPlayer
|
||
.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);
|
||
}
|
||
|
||
if (currentMetadata != null) {
|
||
segmentAdapter.setItems(currentMetadata.getMetadata());
|
||
}
|
||
|
||
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);
|
||
});
|
||
|
||
// 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) {
|
||
if (playQueue != null && !playQueue.isComplete()) {
|
||
playQueue.fetch();
|
||
} else if (binding != null) {
|
||
binding.itemsList.clearOnScrollListeners();
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
private StreamSegmentAdapter.StreamSegmentListener getStreamSegmentListener() {
|
||
return (item, seconds) -> {
|
||
segmentAdapter.selectSegment(item);
|
||
seekTo(seconds * 1000L);
|
||
triggerProgressUpdate();
|
||
};
|
||
}
|
||
|
||
private int getNearestStreamSegmentPosition(final long playbackPosition) {
|
||
int nearestPosition = 0;
|
||
final List<StreamSegment> segments = currentMetadata.getMetadata().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) {
|
||
if (playQueue != null) {
|
||
playQueue.move(sourceIndex, targetIndex);
|
||
}
|
||
}
|
||
|
||
@Override
|
||
public void onSwiped(final int index) {
|
||
if (index != -1) {
|
||
playQueue.remove(index);
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
private PlayQueueItemBuilder.OnSelectedListener getOnSelectedListener() {
|
||
return new PlayQueueItemBuilder.OnSelectedListener() {
|
||
@Override
|
||
public void selected(final PlayQueueItem item, final View view) {
|
||
selectQueueItem(item);
|
||
}
|
||
|
||
@Override
|
||
public void held(final PlayQueueItem item, final View view) {
|
||
if (playQueue.indexOf(item) != -1) {
|
||
openPopupMenu(playQueue, item, view, true,
|
||
getParentActivity().getSupportFragmentManager(), context);
|
||
}
|
||
}
|
||
|
||
@Override
|
||
public void onStartDrag(final PlayQueueItemHolder viewHolder) {
|
||
if (itemTouchHelper != null) {
|
||
itemTouchHelper.startDrag(viewHolder);
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
@Override // own playback listener
|
||
@Nullable
|
||
public MediaSource sourceOf(final PlayQueueItem item, final StreamInfo info) {
|
||
if (audioPlayerSelected()) {
|
||
return audioResolver.resolve(info);
|
||
}
|
||
|
||
if (isAudioOnly && videoResolver.getStreamSourceType().orElse(
|
||
SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY)
|
||
== SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY) {
|
||
// If the current info has only video streams with audio and if the stream is played as
|
||
// audio, we need to use the audio resolver, otherwise the video stream will be played
|
||
// in background.
|
||
return audioResolver.resolve(info);
|
||
}
|
||
|
||
// Even if the stream is played in background, we need to use the video resolver if the
|
||
// info played is separated video-only and audio-only streams; otherwise, if the audio
|
||
// resolver was called when the app was in background, the app will only stream audio when
|
||
// the user come back to the app and will never fetch the video stream.
|
||
// Note that the video is not fetched when the app is in background because the video
|
||
// renderer is fully disabled (see useVideoSource method), except for HLS streams
|
||
// (see https://github.com/google/ExoPlayer/issues/9282).
|
||
return videoResolver.resolve(info);
|
||
}
|
||
|
||
public void disablePreloadingOfCurrentTrack() {
|
||
loadController.disablePreloadingOfCurrentTrack();
|
||
}
|
||
|
||
@Nullable
|
||
public VideoStream getSelectedVideoStream() {
|
||
return (selectedStreamIndex >= 0 && availableStreams != null
|
||
&& availableStreams.size() > selectedStreamIndex)
|
||
? availableStreams.get(selectedStreamIndex) : null;
|
||
}
|
||
|
||
private void updateStreamRelatedViews() {
|
||
if (currentMetadata == null) {
|
||
return;
|
||
}
|
||
final StreamInfo info = currentMetadata.getMetadata();
|
||
|
||
binding.qualityTextView.setVisibility(View.GONE);
|
||
binding.playbackSpeed.setVisibility(View.GONE);
|
||
|
||
binding.playbackEndTime.setVisibility(View.GONE);
|
||
binding.playbackLiveSync.setVisibility(View.GONE);
|
||
|
||
switch (info.getStreamType()) {
|
||
case AUDIO_STREAM:
|
||
binding.surfaceView.setVisibility(View.GONE);
|
||
binding.endScreen.setVisibility(View.VISIBLE);
|
||
binding.playbackEndTime.setVisibility(View.VISIBLE);
|
||
break;
|
||
|
||
case AUDIO_LIVE_STREAM:
|
||
binding.surfaceView.setVisibility(View.GONE);
|
||
binding.endScreen.setVisibility(View.VISIBLE);
|
||
binding.playbackLiveSync.setVisibility(View.VISIBLE);
|
||
break;
|
||
|
||
case LIVE_STREAM:
|
||
binding.surfaceView.setVisibility(View.VISIBLE);
|
||
binding.endScreen.setVisibility(View.GONE);
|
||
binding.playbackLiveSync.setVisibility(View.VISIBLE);
|
||
break;
|
||
|
||
case VIDEO_STREAM:
|
||
if (info.getVideoStreams().size() + info.getVideoOnlyStreams().size() == 0) {
|
||
break;
|
||
}
|
||
|
||
availableStreams = currentMetadata.getSortedAvailableVideoStreams();
|
||
selectedStreamIndex = currentMetadata.getSelectedVideoStreamIndex();
|
||
buildQualityMenu();
|
||
|
||
binding.qualityTextView.setVisibility(View.VISIBLE);
|
||
binding.surfaceView.setVisibility(View.VISIBLE);
|
||
default:
|
||
binding.endScreen.setVisibility(View.GONE);
|
||
binding.playbackEndTime.setVisibility(View.VISIBLE);
|
||
break;
|
||
}
|
||
|
||
buildPlaybackSpeedMenu();
|
||
binding.playbackSpeed.setVisibility(View.VISIBLE);
|
||
}
|
||
|
||
private void updateQueueTime(final int currentTime) {
|
||
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)
|
||
));
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Popup menus ("popup" means that they pop up, not that they belong to the popup player)
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Popup menus ("popup" means that they pop up, not that they belong to the popup player)
|
||
|
||
private void buildQualityMenu() {
|
||
if (qualityPopupMenu == null) {
|
||
return;
|
||
}
|
||
qualityPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_QUALITY);
|
||
|
||
for (int i = 0; i < availableStreams.size(); i++) {
|
||
final VideoStream videoStream = availableStreams.get(i);
|
||
qualityPopupMenu.getMenu().add(POPUP_MENU_ID_QUALITY, i, Menu.NONE, MediaFormat
|
||
.getNameById(videoStream.getFormatId()) + " " + videoStream.resolution);
|
||
}
|
||
if (getSelectedVideoStream() != null) {
|
||
binding.qualityTextView.setText(getSelectedVideoStream().resolution);
|
||
}
|
||
qualityPopupMenu.setOnMenuItemClickListener(this);
|
||
qualityPopupMenu.setOnDismissListener(this);
|
||
}
|
||
|
||
private void buildPlaybackSpeedMenu() {
|
||
if (playbackSpeedPopupMenu == null) {
|
||
return;
|
||
}
|
||
playbackSpeedPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_PLAYBACK_SPEED);
|
||
|
||
for (int i = 0; i < PLAYBACK_SPEEDS.length; i++) {
|
||
playbackSpeedPopupMenu.getMenu().add(POPUP_MENU_ID_PLAYBACK_SPEED, i, Menu.NONE,
|
||
formatSpeed(PLAYBACK_SPEEDS[i]));
|
||
}
|
||
binding.playbackSpeed.setText(formatSpeed(getPlaybackSpeed()));
|
||
playbackSpeedPopupMenu.setOnMenuItemClickListener(this);
|
||
playbackSpeedPopupMenu.setOnDismissListener(this);
|
||
}
|
||
|
||
private void buildCaptionMenu(@NonNull final List<String> availableLanguages) {
|
||
if (captionPopupMenu == null) {
|
||
return;
|
||
}
|
||
captionPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_CAPTION);
|
||
|
||
final String userPreferredLanguage =
|
||
prefs.getString(context.getString(R.string.caption_user_set_key), null);
|
||
/*
|
||
* only search for autogenerated cc as fallback
|
||
* if "(auto-generated)" was not already selected
|
||
* we are only looking for "(" instead of "(auto-generated)" to hopefully get all
|
||
* internationalized variants such as "(automatisch-erzeugt)" and so on
|
||
*/
|
||
boolean searchForAutogenerated = userPreferredLanguage != null
|
||
&& !userPreferredLanguage.contains("(");
|
||
|
||
// Add option for turning off caption
|
||
final MenuItem captionOffItem = captionPopupMenu.getMenu().add(POPUP_MENU_ID_CAPTION,
|
||
0, Menu.NONE, R.string.caption_none);
|
||
captionOffItem.setOnMenuItemClickListener(menuItem -> {
|
||
final int textRendererIndex = getCaptionRendererIndex();
|
||
if (textRendererIndex != RENDERER_UNAVAILABLE) {
|
||
trackSelector.setParameters(trackSelector.buildUponParameters()
|
||
.setRendererDisabled(textRendererIndex, true));
|
||
}
|
||
prefs.edit().remove(context.getString(R.string.caption_user_set_key)).apply();
|
||
return true;
|
||
});
|
||
|
||
// Add all available captions
|
||
for (int i = 0; i < availableLanguages.size(); i++) {
|
||
final String captionLanguage = availableLanguages.get(i);
|
||
final MenuItem captionItem = captionPopupMenu.getMenu().add(POPUP_MENU_ID_CAPTION,
|
||
i + 1, Menu.NONE, captionLanguage);
|
||
captionItem.setOnMenuItemClickListener(menuItem -> {
|
||
final int textRendererIndex = getCaptionRendererIndex();
|
||
if (textRendererIndex != RENDERER_UNAVAILABLE) {
|
||
trackSelector.setPreferredTextLanguage(captionLanguage);
|
||
trackSelector.setParameters(trackSelector.buildUponParameters()
|
||
.setRendererDisabled(textRendererIndex, false));
|
||
prefs.edit().putString(context.getString(R.string.caption_user_set_key),
|
||
captionLanguage).apply();
|
||
}
|
||
return true;
|
||
});
|
||
// apply caption language from previous user preference
|
||
if (userPreferredLanguage != null
|
||
&& (captionLanguage.equals(userPreferredLanguage)
|
||
|| (searchForAutogenerated && captionLanguage.startsWith(userPreferredLanguage))
|
||
|| (userPreferredLanguage.contains("(") && captionLanguage.startsWith(
|
||
userPreferredLanguage.substring(0, userPreferredLanguage.indexOf('(')))))) {
|
||
final int textRendererIndex = getCaptionRendererIndex();
|
||
if (textRendererIndex != RENDERER_UNAVAILABLE) {
|
||
trackSelector.setPreferredTextLanguage(captionLanguage);
|
||
trackSelector.setParameters(trackSelector.buildUponParameters()
|
||
.setRendererDisabled(textRendererIndex, false));
|
||
}
|
||
searchForAutogenerated = false;
|
||
}
|
||
}
|
||
captionPopupMenu.setOnDismissListener(this);
|
||
}
|
||
|
||
/**
|
||
* Called when an item of the quality selector or the playback speed selector is selected.
|
||
*/
|
||
@Override
|
||
public boolean onMenuItemClick(@NonNull final MenuItem menuItem) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onMenuItemClick() called with: "
|
||
+ "menuItem = [" + menuItem + "], "
|
||
+ "menuItem.getItemId = [" + menuItem.getItemId() + "]");
|
||
}
|
||
|
||
if (menuItem.getGroupId() == POPUP_MENU_ID_QUALITY) {
|
||
final int menuItemIndex = menuItem.getItemId();
|
||
if (selectedStreamIndex == menuItemIndex || availableStreams == null
|
||
|| availableStreams.size() <= menuItemIndex) {
|
||
return true;
|
||
}
|
||
|
||
saveStreamProgressState(); //TODO added, check if good
|
||
final String newResolution = availableStreams.get(menuItemIndex).resolution;
|
||
setRecovery();
|
||
setPlaybackQuality(newResolution);
|
||
reloadPlayQueueManager();
|
||
|
||
binding.qualityTextView.setText(menuItem.getTitle());
|
||
return true;
|
||
} else if (menuItem.getGroupId() == POPUP_MENU_ID_PLAYBACK_SPEED) {
|
||
final int speedIndex = menuItem.getItemId();
|
||
final float speed = PLAYBACK_SPEEDS[speedIndex];
|
||
|
||
setPlaybackSpeed(speed);
|
||
binding.playbackSpeed.setText(formatSpeed(speed));
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Called when some popup menu is dismissed.
|
||
*/
|
||
@Override
|
||
public void onDismiss(@Nullable final PopupMenu menu) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onDismiss() called with: menu = [" + menu + "]");
|
||
}
|
||
isSomePopupMenuVisible = false; //TODO check if this works
|
||
if (getSelectedVideoStream() != null) {
|
||
binding.qualityTextView.setText(getSelectedVideoStream().resolution);
|
||
}
|
||
if (isPlaying()) {
|
||
hideControls(DEFAULT_CONTROLS_DURATION, 0);
|
||
hideSystemUIIfNeeded();
|
||
}
|
||
}
|
||
|
||
private void onCaptionClicked() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onCaptionClicked() called");
|
||
}
|
||
captionPopupMenu.show();
|
||
isSomePopupMenuVisible = true;
|
||
}
|
||
|
||
private void setPlaybackQuality(@Nullable final String quality) {
|
||
videoResolver.setPlaybackQuality(quality);
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Captions (text tracks)
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Captions (text tracks)
|
||
|
||
private void setupSubtitleView() {
|
||
final float captionScale = PlayerHelper.getCaptionScale(context);
|
||
final CaptionStyleCompat captionStyle = PlayerHelper.getCaptionStyle(context);
|
||
if (popupPlayerSelected()) {
|
||
final float captionRatio = (captionScale - 1.0f) / 5.0f + 1.0f;
|
||
binding.subtitleView.setFractionalTextSize(
|
||
SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionRatio);
|
||
} else {
|
||
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);
|
||
}
|
||
binding.subtitleView.setApplyEmbeddedStyles(captionStyle == CaptionStyleCompat.DEFAULT);
|
||
binding.subtitleView.setStyle(captionStyle);
|
||
}
|
||
|
||
private void onTextTracksChanged() {
|
||
final int textRenderer = getCaptionRendererIndex();
|
||
|
||
if (binding == null) {
|
||
return;
|
||
}
|
||
if (trackSelector.getCurrentMappedTrackInfo() == null
|
||
|| textRenderer == RENDERER_UNAVAILABLE) {
|
||
binding.captionTextView.setVisibility(View.GONE);
|
||
return;
|
||
}
|
||
|
||
final TrackGroupArray textTracks = trackSelector.getCurrentMappedTrackInfo()
|
||
.getTrackGroups(textRenderer);
|
||
|
||
// Extract all loaded languages
|
||
final List<String> availableLanguages = new ArrayList<>(textTracks.length);
|
||
for (int i = 0; i < textTracks.length; i++) {
|
||
final TrackGroup textTrack = textTracks.get(i);
|
||
if (textTrack.length > 0) {
|
||
availableLanguages.add(textTrack.getFormat(0).language);
|
||
}
|
||
}
|
||
|
||
// Normalize mismatching language strings
|
||
final String preferredLanguage = trackSelector.getPreferredTextLanguage();
|
||
// Build UI
|
||
buildCaptionMenu(availableLanguages);
|
||
if (trackSelector.getParameters().getRendererDisabled(textRenderer)
|
||
|| preferredLanguage == null || (!availableLanguages.contains(preferredLanguage)
|
||
&& !containsCaseInsensitive(availableLanguages, preferredLanguage))) {
|
||
binding.captionTextView.setText(R.string.caption_none);
|
||
} else {
|
||
binding.captionTextView.setText(preferredLanguage);
|
||
}
|
||
binding.captionTextView.setVisibility(
|
||
availableLanguages.isEmpty() ? View.GONE : View.VISIBLE);
|
||
}
|
||
|
||
private int getCaptionRendererIndex() {
|
||
if (exoPlayerIsNull()) {
|
||
return RENDERER_UNAVAILABLE;
|
||
}
|
||
|
||
for (int t = 0; t < simpleExoPlayer.getRendererCount(); t++) {
|
||
if (simpleExoPlayer.getRendererType(t) == C.TRACK_TYPE_TEXT) {
|
||
return t;
|
||
}
|
||
}
|
||
|
||
return RENDERER_UNAVAILABLE;
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Click listeners
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Click listeners
|
||
|
||
@Override
|
||
public void onClick(final View v) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onClick() called with: v = [" + v + "]");
|
||
}
|
||
if (v.getId() == binding.resizeTextView.getId()) {
|
||
onResizeClicked();
|
||
} else if (v.getId() == binding.captionTextView.getId()) {
|
||
onCaptionClicked();
|
||
} else if (v.getId() == binding.playbackLiveSync.getId()) {
|
||
seekToDefault();
|
||
} else if (v.getId() == binding.playPauseButton.getId()) {
|
||
playPause();
|
||
} else if (v.getId() == binding.playPreviousButton.getId()) {
|
||
playPrevious();
|
||
} else if (v.getId() == binding.playNextButton.getId()) {
|
||
playNext();
|
||
} else if (v.getId() == binding.moreOptionsButton.getId()) {
|
||
onMoreOptionsClicked();
|
||
} else if (v.getId() == binding.share.getId()) {
|
||
ShareUtils.shareText(context, getVideoTitle(), getVideoUrlAtCurrentTime(),
|
||
currentItem.getThumbnailUrl());
|
||
} else if (v.getId() == binding.playWithKodi.getId()) {
|
||
onPlayWithKodiClicked();
|
||
} else if (v.getId() == binding.openInBrowser.getId()) {
|
||
onOpenInBrowserClicked();
|
||
} else if (v.getId() == binding.fullScreenButton.getId()) {
|
||
setRecovery();
|
||
NavigationHelper.playOnMainPlayer(context, playQueue, true);
|
||
return;
|
||
} else 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
|
||
|| (service.isLandscape() && globalScreenOrientationLocked(context))) {
|
||
fragmentListener.onScreenRotationButtonClicked();
|
||
} else {
|
||
toggleFullscreen();
|
||
}
|
||
} else if (v.getId() == binding.switchMute.getId()) {
|
||
onMuteUnmuteButtonClicked();
|
||
} else if (v.getId() == binding.playerCloseButton.getId()) {
|
||
context.sendBroadcast(new Intent(VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER));
|
||
}
|
||
|
||
manageControlsAfterOnClick(v);
|
||
}
|
||
|
||
/**
|
||
* Manages the controls after a click occurred on the player UI.
|
||
* @param v – The view that was clicked
|
||
*/
|
||
public void manageControlsAfterOnClick(@NonNull final View v) {
|
||
if (currentState == STATE_COMPLETED) {
|
||
return;
|
||
}
|
||
|
||
controlsVisibilityHandler.removeCallbacksAndMessages(null);
|
||
showHideShadow(true, DEFAULT_CONTROLS_DURATION);
|
||
animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION,
|
||
AnimationType.ALPHA, 0, () -> {
|
||
if (currentState == STATE_PLAYING && !isSomePopupMenuVisible) {
|
||
if (v.getId() == binding.playPauseButton.getId()
|
||
// Hide controls in fullscreen immediately
|
||
|| (v.getId() == binding.screenRotationButton.getId()
|
||
&& isFullscreen)) {
|
||
hideControls(0, 0);
|
||
} else {
|
||
hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
@Override
|
||
public boolean onLongClick(final View v) {
|
||
if (v.getId() == binding.moreOptionsButton.getId() && isFullscreen) {
|
||
fragmentListener.onMoreOptionsLongClicked();
|
||
hideControls(0, 0);
|
||
hideSystemUIIfNeeded();
|
||
} else if (v.getId() == binding.share.getId()) {
|
||
ShareUtils.copyToClipboard(context, getVideoUrlAtCurrentTime());
|
||
}
|
||
return true;
|
||
}
|
||
|
||
public boolean onKeyDown(final int keyCode) {
|
||
switch (keyCode) {
|
||
default:
|
||
break;
|
||
case KeyEvent.KEYCODE_SPACE:
|
||
if (isFullscreen) {
|
||
playPause();
|
||
if (isPlaying()) {
|
||
hideControls(0, 0);
|
||
}
|
||
return true;
|
||
}
|
||
break;
|
||
case KeyEvent.KEYCODE_BACK:
|
||
if (DeviceUtils.isTv(context) && isControlsVisible()) {
|
||
hideControls(0, 0);
|
||
return true;
|
||
}
|
||
break;
|
||
case KeyEvent.KEYCODE_DPAD_UP:
|
||
case KeyEvent.KEYCODE_DPAD_LEFT:
|
||
case KeyEvent.KEYCODE_DPAD_DOWN:
|
||
case KeyEvent.KEYCODE_DPAD_RIGHT:
|
||
case KeyEvent.KEYCODE_DPAD_CENTER:
|
||
if ((binding.getRoot().hasFocus() && !binding.playbackControlRoot.hasFocus())
|
||
|| isQueueVisible) {
|
||
// do not interfere with focus in playlist and play queue etc.
|
||
return false;
|
||
}
|
||
|
||
if (currentState == Player.STATE_BLOCKED) {
|
||
return true;
|
||
}
|
||
|
||
if (isControlsVisible()) {
|
||
hideControls(DEFAULT_CONTROLS_DURATION, DPAD_CONTROLS_HIDE_TIME);
|
||
} else {
|
||
binding.playPauseButton.requestFocus();
|
||
showControlsThenHide();
|
||
showSystemUIPartially();
|
||
return true;
|
||
}
|
||
break;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private void onMoreOptionsClicked() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onMoreOptionsClicked() called");
|
||
}
|
||
|
||
final boolean isMoreControlsVisible =
|
||
binding.secondaryControls.getVisibility() == View.VISIBLE;
|
||
|
||
animateRotation(binding.moreOptionsButton, DEFAULT_CONTROLS_DURATION,
|
||
isMoreControlsVisible ? 0 : 180);
|
||
animate(binding.secondaryControls, !isMoreControlsVisible, DEFAULT_CONTROLS_DURATION,
|
||
AnimationType.SLIDE_AND_ALPHA, 0, () -> {
|
||
// Fix for a ripple effect on background drawable.
|
||
// When view returns from GONE state it takes more milliseconds than returning
|
||
// from INVISIBLE state. And the delay makes ripple background end to fast
|
||
if (isMoreControlsVisible) {
|
||
binding.secondaryControls.setVisibility(View.INVISIBLE);
|
||
}
|
||
});
|
||
showControls(DEFAULT_CONTROLS_DURATION);
|
||
}
|
||
|
||
private void onPlayWithKodiClicked() {
|
||
if (currentMetadata != null) {
|
||
pause();
|
||
try {
|
||
NavigationHelper.playWithKore(context, Uri.parse(getVideoUrl()));
|
||
} catch (final Exception e) {
|
||
if (DEBUG) {
|
||
Log.i(TAG, "Failed to start kore", e);
|
||
}
|
||
KoreUtils.showInstallKoreDialog(getParentActivity());
|
||
}
|
||
}
|
||
}
|
||
|
||
private void onOpenInBrowserClicked() {
|
||
if (currentMetadata != null) {
|
||
ShareUtils.openUrlInBrowser(getParentActivity(),
|
||
currentMetadata.getMetadata().getOriginalUrl());
|
||
}
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Video size, resize, orientation, fullscreen
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Video size, resize, orientation, fullscreen
|
||
|
||
private void setupScreenRotationButton() {
|
||
binding.screenRotationButton.setVisibility(videoPlayerSelected()
|
||
&& (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));
|
||
}
|
||
|
||
private void setResizeMode(@AspectRatioFrameLayout.ResizeMode final int resizeMode) {
|
||
binding.surfaceView.setResizeMode(resizeMode);
|
||
binding.resizeTextView.setText(PlayerHelper.resizeTypeOf(context, resizeMode));
|
||
}
|
||
|
||
void onResizeClicked() {
|
||
if (binding != null) {
|
||
setResizeMode(nextResizeModeAndSaveToPrefs(this, binding.surfaceView.getResizeMode()));
|
||
}
|
||
}
|
||
|
||
@Override // exoplayer listener
|
||
public void onVideoSizeChanged(@NonNull final VideoSize videoSize) {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "onVideoSizeChanged() called with: "
|
||
+ "width / height = [" + videoSize.width + " / " + videoSize.height
|
||
+ " = " + (((float) videoSize.width) / videoSize.height) + "], "
|
||
+ "unappliedRotationDegrees = [" + videoSize.unappliedRotationDegrees + "], "
|
||
+ "pixelWidthHeightRatio = [" + videoSize.pixelWidthHeightRatio + "]");
|
||
}
|
||
|
||
binding.surfaceView.setAspectRatio(((float) videoSize.width) / videoSize.height);
|
||
isVerticalVideo = videoSize.width < videoSize.height;
|
||
|
||
if (globalScreenOrientationLocked(context)
|
||
&& isFullscreen
|
||
&& service.isLandscape() == isVerticalVideo
|
||
&& !DeviceUtils.isTv(context)
|
||
&& !DeviceUtils.isTablet(context)
|
||
&& fragmentListener != null) {
|
||
// set correct orientation
|
||
fragmentListener.onScreenRotationButtonClicked();
|
||
}
|
||
|
||
setupScreenRotationButton();
|
||
}
|
||
|
||
public void toggleFullscreen() {
|
||
if (DEBUG) {
|
||
Log.d(TAG, "toggleFullscreen() called");
|
||
}
|
||
if (popupPlayerSelected() || exoPlayerIsNull() || fragmentListener == null) {
|
||
return;
|
||
}
|
||
|
||
isFullscreen = !isFullscreen;
|
||
if (!isFullscreen) {
|
||
// 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);
|
||
} else {
|
||
// 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);
|
||
}
|
||
fragmentListener.onFullscreenStateChanged(isFullscreen);
|
||
|
||
if (isFullscreen) {
|
||
binding.titleTextView.setVisibility(View.VISIBLE);
|
||
binding.channelTextView.setVisibility(View.VISIBLE);
|
||
binding.playerCloseButton.setVisibility(View.GONE);
|
||
} else {
|
||
binding.titleTextView.setVisibility(View.GONE);
|
||
binding.channelTextView.setVisibility(View.GONE);
|
||
binding.playerCloseButton.setVisibility(
|
||
videoPlayerSelected() ? View.VISIBLE : View.GONE);
|
||
}
|
||
setupScreenRotationButton();
|
||
}
|
||
|
||
public void checkLandscape() {
|
||
final AppCompatActivity parent = getParentActivity();
|
||
final boolean videoInLandscapeButNotInFullscreen =
|
||
service.isLandscape() && !isFullscreen && videoPlayerSelected() && !isAudioOnly;
|
||
|
||
final boolean notPaused = currentState != STATE_COMPLETED && currentState != STATE_PAUSED;
|
||
if (parent != null
|
||
&& videoInLandscapeButNotInFullscreen
|
||
&& notPaused
|
||
&& !DeviceUtils.isTablet(context)) {
|
||
toggleFullscreen();
|
||
}
|
||
}
|
||
//endregion
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Gestures
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Gestures
|
||
|
||
@SuppressWarnings("checkstyle:ParameterNumber")
|
||
private 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 smaller value to be consistent between screen orientations
|
||
// (and to make usage easier)
|
||
final int width = r - l;
|
||
final int height = b - t;
|
||
final int min = Math.min(width, height);
|
||
maxGestureLength = (int) (min * MAX_GESTURE_LENGTH);
|
||
|
||
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 (audioReactor != null) {
|
||
final float currentVolumeNormalized =
|
||
(float) audioReactor.getVolume() / audioReactor.getMaxVolume();
|
||
binding.volumeProgressBar.setProgress(
|
||
(int) (binding.volumeProgressBar.getMax() * currentVolumeNormalized));
|
||
}
|
||
}
|
||
|
||
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
|
||
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Activity / fragment binding
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Activity / fragment binding
|
||
|
||
public void setFragmentListener(final PlayerServiceEventListener listener) {
|
||
fragmentListener = listener;
|
||
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);
|
||
notifyQueueUpdateToListeners();
|
||
notifyMetadataUpdateToListeners();
|
||
notifyPlaybackUpdateToListeners();
|
||
triggerProgressUpdate();
|
||
}
|
||
|
||
public void removeFragmentListener(final PlayerServiceEventListener listener) {
|
||
if (fragmentListener == listener) {
|
||
fragmentListener = null;
|
||
}
|
||
}
|
||
|
||
void setActivityListener(final PlayerEventListener listener) {
|
||
activityListener = listener;
|
||
// TODO why not queue update?
|
||
notifyMetadataUpdateToListeners();
|
||
notifyPlaybackUpdateToListeners();
|
||
triggerProgressUpdate();
|
||
}
|
||
|
||
void removeActivityListener(final PlayerEventListener listener) {
|
||
if (activityListener == listener) {
|
||
activityListener = null;
|
||
}
|
||
}
|
||
|
||
void stopActivityBinding() {
|
||
if (fragmentListener != null) {
|
||
fragmentListener.onServiceStopped();
|
||
fragmentListener = null;
|
||
}
|
||
if (activityListener != null) {
|
||
activityListener.onServiceStopped();
|
||
activityListener = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 (videoPlayerSelected() && (isPlaying() || isLoading())) {
|
||
switch (getMinimizeOnExitAction(context)) {
|
||
case MINIMIZE_ON_EXIT_MODE_BACKGROUND:
|
||
useVideoSource(false);
|
||
break;
|
||
case MINIMIZE_ON_EXIT_MODE_POPUP:
|
||
setRecovery();
|
||
NavigationHelper.playOnPopupPlayer(getParentActivity(), playQueue, true);
|
||
break;
|
||
case MINIMIZE_ON_EXIT_MODE_NONE: default:
|
||
pause();
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
private void notifyQueueUpdateToListeners() {
|
||
if (fragmentListener != null && playQueue != null) {
|
||
fragmentListener.onQueueUpdate(playQueue);
|
||
}
|
||
if (activityListener != null && playQueue != null) {
|
||
activityListener.onQueueUpdate(playQueue);
|
||
}
|
||
}
|
||
|
||
private void notifyMetadataUpdateToListeners() {
|
||
if (fragmentListener != null && currentMetadata != null) {
|
||
fragmentListener.onMetadataUpdate(currentMetadata.getMetadata(), playQueue);
|
||
}
|
||
if (activityListener != null && currentMetadata != null) {
|
||
activityListener.onMetadataUpdate(currentMetadata.getMetadata(), playQueue);
|
||
}
|
||
}
|
||
|
||
private void notifyPlaybackUpdateToListeners() {
|
||
if (fragmentListener != null && !exoPlayerIsNull() && playQueue != null) {
|
||
fragmentListener.onPlaybackUpdate(currentState, getRepeatMode(),
|
||
playQueue.isShuffled(), simpleExoPlayer.getPlaybackParameters());
|
||
}
|
||
if (activityListener != null && !exoPlayerIsNull() && playQueue != null) {
|
||
activityListener.onPlaybackUpdate(currentState, getRepeatMode(),
|
||
playQueue.isShuffled(), getPlaybackParameters());
|
||
}
|
||
}
|
||
|
||
private void notifyProgressUpdateToListeners(final int currentProgress,
|
||
final int duration,
|
||
final int bufferPercent) {
|
||
if (fragmentListener != null) {
|
||
fragmentListener.onProgressUpdate(currentProgress, duration, bufferPercent);
|
||
}
|
||
if (activityListener != null) {
|
||
activityListener.onProgressUpdate(currentProgress, duration, bufferPercent);
|
||
}
|
||
}
|
||
|
||
@Nullable
|
||
public AppCompatActivity getParentActivity() {
|
||
// ! instanceof ViewGroup means that view was added via windowManager for Popup
|
||
if (binding == null || !(binding.getRoot().getParent() instanceof ViewGroup)) {
|
||
return null;
|
||
}
|
||
|
||
return (AppCompatActivity) ((ViewGroup) binding.getRoot().getParent()).getContext();
|
||
}
|
||
|
||
private void useVideoSource(final boolean videoEnabled) {
|
||
if (playQueue == null || isAudioOnly == !videoEnabled || audioPlayerSelected()) {
|
||
return;
|
||
}
|
||
|
||
isAudioOnly = !videoEnabled;
|
||
// When a user returns from background, controls could be hidden but SystemUI will be shown
|
||
// 100%. Hide it.
|
||
if (!isAudioOnly && !isControlsVisible()) {
|
||
hideSystemUIIfNeeded();
|
||
}
|
||
|
||
// The current metadata may be null sometimes (for e.g. when using an unstable connection
|
||
// in livestreams) so we will be not able to execute the block below.
|
||
// Reload the play queue manager in this case, which is the behavior when we don't know the
|
||
// index of the video renderer or playQueueManagerReloadingNeeded returns true.
|
||
if (currentMetadata == null) {
|
||
reloadPlayQueueManager();
|
||
setRecovery();
|
||
return;
|
||
}
|
||
|
||
final int videoRenderIndex = getVideoRendererIndex();
|
||
final StreamInfo info = currentMetadata.getMetadata();
|
||
|
||
// In the case we don't know the source type, fallback to the one with video with audio or
|
||
// audio-only source.
|
||
final SourceType sourceType = videoResolver.getStreamSourceType().orElse(
|
||
SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY);
|
||
|
||
if (playQueueManagerReloadingNeeded(sourceType, info, videoRenderIndex)) {
|
||
reloadPlayQueueManager();
|
||
} else {
|
||
final StreamType streamType = info.getStreamType();
|
||
if (streamType == StreamType.AUDIO_STREAM
|
||
|| streamType == StreamType.AUDIO_LIVE_STREAM) {
|
||
// Nothing to do more than setting the recovery position
|
||
setRecovery();
|
||
return;
|
||
}
|
||
|
||
final TrackGroupArray videoTrackGroupArray = Objects.requireNonNull(
|
||
trackSelector.getCurrentMappedTrackInfo()).getTrackGroups(videoRenderIndex);
|
||
if (videoEnabled) {
|
||
// Clearing the null selection override enable again the video stream (and its
|
||
// fetching).
|
||
trackSelector.setParameters(trackSelector.buildUponParameters()
|
||
.clearSelectionOverride(videoRenderIndex, videoTrackGroupArray));
|
||
} else {
|
||
// Using setRendererDisabled still fetch the video stream in background, contrary
|
||
// to setSelectionOverride with a null override.
|
||
trackSelector.setParameters(trackSelector.buildUponParameters()
|
||
.setSelectionOverride(videoRenderIndex, videoTrackGroupArray, null));
|
||
}
|
||
}
|
||
|
||
setRecovery();
|
||
}
|
||
|
||
/**
|
||
* Return whether the play queue manager needs to be reloaded when switching player type.
|
||
*
|
||
* <p>
|
||
* The play queue manager needs to be reloaded if the video renderer index is not known and if
|
||
* the content is not an audio content, but also if none of the following cases is met:
|
||
*
|
||
* <ul>
|
||
* <li>the content is an {@link StreamType#AUDIO_STREAM audio stream} or an
|
||
* {@link StreamType#AUDIO_LIVE_STREAM audio live stream};</li>
|
||
* <li>the content is a {@link StreamType#LIVE_STREAM live stream} and the source type is a
|
||
* {@link SourceType#LIVE_STREAM live source};</li>
|
||
* <li>the content's source is {@link SourceType#VIDEO_WITH_SEPARATED_AUDIO a video stream
|
||
* with a separated audio source} or has no audio-only streams available <b>and</b> is a
|
||
* {@link StreamType#LIVE_STREAM live stream} or a
|
||
* {@link StreamType#LIVE_STREAM live stream}.
|
||
* </li>
|
||
* </ul>
|
||
* </p>
|
||
*
|
||
* @param sourceType the {@link SourceType} of the stream
|
||
* @param streamInfo the {@link StreamInfo} of the stream
|
||
* @param videoRendererIndex the video renderer index of the video source, if that's a video
|
||
* source (or {@link #RENDERER_UNAVAILABLE})
|
||
* @return whether the play queue manager needs to be reloaded
|
||
*/
|
||
private boolean playQueueManagerReloadingNeeded(final SourceType sourceType,
|
||
@NonNull final StreamInfo streamInfo,
|
||
final int videoRendererIndex) {
|
||
final StreamType streamType = streamInfo.getStreamType();
|
||
|
||
if (videoRendererIndex == RENDERER_UNAVAILABLE && streamType != StreamType.AUDIO_STREAM
|
||
&& streamType != StreamType.AUDIO_LIVE_STREAM) {
|
||
return true;
|
||
}
|
||
|
||
// The content is an audio stream, an audio live stream, or a live stream with a live
|
||
// source: it's not needed to reload the play queue manager because the stream source will
|
||
// be the same
|
||
if ((streamType == StreamType.AUDIO_STREAM || streamType == StreamType.AUDIO_LIVE_STREAM)
|
||
|| (streamType == StreamType.LIVE_STREAM
|
||
&& sourceType == SourceType.LIVE_STREAM)) {
|
||
return false;
|
||
}
|
||
|
||
// The content's source is a video with separated audio or a video with audio -> the video
|
||
// and its fetch may be disabled
|
||
// The content's source is a video with embedded audio and the content has no separated
|
||
// audio stream available: it's probably not needed to reload the play queue manager
|
||
// because the stream source will be probably the same as the current played
|
||
if (sourceType == SourceType.VIDEO_WITH_SEPARATED_AUDIO
|
||
|| (sourceType == SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY
|
||
&& isNullOrEmpty(streamInfo.getAudioStreams()))) {
|
||
// It's not needed to reload the play queue manager only if the content's stream type
|
||
// is a video stream or a live stream
|
||
return streamType != StreamType.VIDEO_STREAM && streamType != StreamType.LIVE_STREAM;
|
||
}
|
||
|
||
// Other cases: the play queue manager reload is needed
|
||
return true;
|
||
}
|
||
//endregion
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// Getters
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region Getters
|
||
|
||
public int getCurrentState() {
|
||
return currentState;
|
||
}
|
||
|
||
public boolean exoPlayerIsNull() {
|
||
return simpleExoPlayer == null;
|
||
}
|
||
|
||
public boolean isStopped() {
|
||
return exoPlayerIsNull()
|
||
|| simpleExoPlayer.getPlaybackState() == SimpleExoPlayer.STATE_IDLE;
|
||
}
|
||
|
||
public boolean isPlaying() {
|
||
return !exoPlayerIsNull() && simpleExoPlayer.isPlaying();
|
||
}
|
||
|
||
public boolean getPlayWhenReady() {
|
||
return !exoPlayerIsNull() && simpleExoPlayer.getPlayWhenReady();
|
||
}
|
||
|
||
private boolean isLoading() {
|
||
return !exoPlayerIsNull() && simpleExoPlayer.isLoading();
|
||
}
|
||
|
||
private boolean isLive() {
|
||
try {
|
||
return !exoPlayerIsNull() && simpleExoPlayer.isCurrentWindowDynamic();
|
||
} catch (final IndexOutOfBoundsException e) {
|
||
// Why would this even happen =(... but lets log it anyway, better safe than sorry
|
||
if (DEBUG) {
|
||
Log.d(TAG, "player.isCurrentWindowDynamic() failed: ", e);
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
|
||
@NonNull
|
||
public Context getContext() {
|
||
return context;
|
||
}
|
||
|
||
@NonNull
|
||
public SharedPreferences getPrefs() {
|
||
return prefs;
|
||
}
|
||
|
||
public MediaSessionManager getMediaSessionManager() {
|
||
return mediaSessionManager;
|
||
}
|
||
|
||
|
||
public PlayerType getPlayerType() {
|
||
return playerType;
|
||
}
|
||
|
||
public boolean audioPlayerSelected() {
|
||
return playerType == PlayerType.AUDIO;
|
||
}
|
||
|
||
public boolean videoPlayerSelected() {
|
||
return playerType == PlayerType.VIDEO;
|
||
}
|
||
|
||
public boolean popupPlayerSelected() {
|
||
return playerType == PlayerType.POPUP;
|
||
}
|
||
|
||
|
||
@Nullable
|
||
public PlayQueue getPlayQueue() {
|
||
return playQueue;
|
||
}
|
||
|
||
public AudioReactor getAudioReactor() {
|
||
return audioReactor;
|
||
}
|
||
|
||
public GestureDetectorCompat getGestureDetector() {
|
||
return gestureDetector;
|
||
}
|
||
|
||
public boolean isFullscreen() {
|
||
return isFullscreen;
|
||
}
|
||
|
||
public boolean isVerticalVideo() {
|
||
return isVerticalVideo;
|
||
}
|
||
|
||
public boolean isPopupClosing() {
|
||
return isPopupClosing;
|
||
}
|
||
|
||
|
||
public boolean isSomePopupMenuVisible() {
|
||
return isSomePopupMenuVisible;
|
||
}
|
||
|
||
public void setSomePopupMenuVisible(final boolean somePopupMenuVisible) {
|
||
isSomePopupMenuVisible = somePopupMenuVisible;
|
||
}
|
||
|
||
public ImageButton getPlayPauseButton() {
|
||
return binding.playPauseButton;
|
||
}
|
||
|
||
public View getClosingOverlayView() {
|
||
return binding.closingOverlay;
|
||
}
|
||
|
||
public ProgressBar getVolumeProgressBar() {
|
||
return binding.volumeProgressBar;
|
||
}
|
||
|
||
public ProgressBar getBrightnessProgressBar() {
|
||
return binding.brightnessProgressBar;
|
||
}
|
||
|
||
public int getMaxGestureLength() {
|
||
return maxGestureLength;
|
||
}
|
||
|
||
public ImageView getVolumeImageView() {
|
||
return binding.volumeImageView;
|
||
}
|
||
|
||
public RelativeLayout getVolumeRelativeLayout() {
|
||
return binding.volumeRelativeLayout;
|
||
}
|
||
|
||
public ImageView getBrightnessImageView() {
|
||
return binding.brightnessImageView;
|
||
}
|
||
|
||
public RelativeLayout getBrightnessRelativeLayout() {
|
||
return binding.brightnessRelativeLayout;
|
||
}
|
||
|
||
public FloatingActionButton getCloseOverlayButton() {
|
||
return closeOverlayBinding.closeButton;
|
||
}
|
||
|
||
public View getLoadingPanel() {
|
||
return binding.loadingPanel;
|
||
}
|
||
|
||
public TextView getCurrentDisplaySeek() {
|
||
return binding.currentDisplaySeek;
|
||
}
|
||
|
||
public PlayerFastSeekOverlay getFastSeekOverlay() {
|
||
return binding.fastSeekOverlay;
|
||
}
|
||
|
||
@Nullable
|
||
public WindowManager.LayoutParams getPopupLayoutParams() {
|
||
return popupLayoutParams;
|
||
}
|
||
|
||
@Nullable
|
||
public WindowManager getWindowManager() {
|
||
return windowManager;
|
||
}
|
||
|
||
public float getScreenWidth() {
|
||
return screenWidth;
|
||
}
|
||
|
||
public float getScreenHeight() {
|
||
return screenHeight;
|
||
}
|
||
|
||
public View getRootView() {
|
||
return binding.getRoot();
|
||
}
|
||
|
||
public ExpandableSurfaceView getSurfaceView() {
|
||
return binding.surfaceView;
|
||
}
|
||
|
||
public PlayQueueAdapter getPlayQueueAdapter() {
|
||
return playQueueAdapter;
|
||
}
|
||
|
||
public PlayerBinding getBinding() {
|
||
return binding;
|
||
}
|
||
|
||
//endregion
|
||
|
||
|
||
/*//////////////////////////////////////////////////////////////////////////
|
||
// SurfaceHolderCallback helpers
|
||
//////////////////////////////////////////////////////////////////////////*/
|
||
//region SurfaceHolderCallback helpers
|
||
|
||
private void setupVideoSurface() {
|
||
// make sure there is nothing left over from previous calls
|
||
cleanupVideoSurface();
|
||
|
||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // >=API23
|
||
surfaceHolderCallback = new SurfaceHolderCallback(context, simpleExoPlayer);
|
||
binding.surfaceView.getHolder().addCallback(surfaceHolderCallback);
|
||
final Surface surface = binding.surfaceView.getHolder().getSurface();
|
||
// initially set the surface manually otherwise
|
||
// onRenderedFirstFrame() will not be called
|
||
simpleExoPlayer.setVideoSurface(surface);
|
||
} else {
|
||
simpleExoPlayer.setVideoSurfaceView(binding.surfaceView);
|
||
}
|
||
}
|
||
|
||
private void cleanupVideoSurface() {
|
||
// Only for API >= 23
|
||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && surfaceHolderCallback != null) {
|
||
if (binding != null) {
|
||
binding.surfaceView.getHolder().removeCallback(surfaceHolderCallback);
|
||
}
|
||
surfaceHolderCallback.release();
|
||
surfaceHolderCallback = null;
|
||
}
|
||
}
|
||
//endregion
|
||
|
||
/**
|
||
* Get the video renderer index of the current playing stream.
|
||
*
|
||
* This method returns the video renderer index of the current
|
||
* {@link MappingTrackSelector.MappedTrackInfo} or {@link #RENDERER_UNAVAILABLE} if the current
|
||
* {@link MappingTrackSelector.MappedTrackInfo} is null or if there is no video renderer index.
|
||
*
|
||
* @return the video renderer index or {@link #RENDERER_UNAVAILABLE} if it cannot be get
|
||
*/
|
||
private int getVideoRendererIndex() {
|
||
final MappingTrackSelector.MappedTrackInfo mappedTrackInfo = trackSelector
|
||
.getCurrentMappedTrackInfo();
|
||
|
||
if (mappedTrackInfo == null) {
|
||
return RENDERER_UNAVAILABLE;
|
||
}
|
||
|
||
// Check every renderer
|
||
return IntStream.range(0, mappedTrackInfo.getRendererCount())
|
||
// Check the renderer is a video renderer and has at least one track
|
||
.filter(i -> !mappedTrackInfo.getTrackGroups(i).isEmpty()
|
||
&& simpleExoPlayer.getRendererType(i) == C.TRACK_TYPE_VIDEO)
|
||
// Return the first index found (there is at most one renderer per renderer type)
|
||
.findFirst()
|
||
// No video renderer index with at least one track found: return unavailable index
|
||
.orElse(RENDERER_UNAVAILABLE);
|
||
}
|
||
}
|