NewPipe/app/src/main/java/org/schabi/newpipe/player/Player.java

4570 lines
179 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}