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

4347 lines
167 KiB
Java

package org.schabi.newpipe.player;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.animation.PropertyValuesHolder;
import android.animation.ValueAnimator;
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.ContextThemeWrapper;
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.PopupMenu;
import android.widget.ProgressBar;
import android.widget.RelativeLayout;
import android.widget.SeekBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.AppCompatImageButton;
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.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.PlaybackParameters;
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.CaptionStyleCompat;
import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
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.VideoListener;
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.databinding.PlayerBinding;
import org.schabi.newpipe.databinding.PlayerPopupCloseOverlayBinding;
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.history.HistoryRecordManager;
import org.schabi.newpipe.player.MainPlayer.PlayerType;
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.PlaybackParameterDialog;
import org.schabi.newpipe.player.helper.PlayerDataSource;
import org.schabi.newpipe.player.helper.PlayerHelper;
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.seekbarpreview.SeekbarPreviewThumbnailHelper;
import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHolder;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.external_communication.KoreUtils;
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.external_communication.ShareUtils;
import org.schabi.newpipe.views.ExpandableSurfaceView;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
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;
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_AD_INSERTION;
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_INTERNAL;
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_PERIOD_TRANSITION;
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.DiscontinuityReason;
import static com.google.android.exoplayer2.Player.EventListener;
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 java.util.concurrent.TimeUnit.MILLISECONDS;
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;
public final class Player implements
EventListener,
PlaybackListener,
VideoListener,
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 APPEND_ONLY = "append_only";
public static final String RESUME_PLAYBACK = "resume_playback";
public static final String PLAY_WHEN_READY = "play_when_ready";
public static final String SELECT_ON_APPEND = "select_on_append";
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 = 500; // 500 millis
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
/*//////////////////////////////////////////////////////////////////////////
// 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
//////////////////////////////////////////////////////////////////////////*/
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;
@Nullable private Toast errorToast;
/*//////////////////////////////////////////////////////////////////////////
// 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 ValueAnimator controlViewAnimator;
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;
/*//////////////////////////////////////////////////////////////////////////
// 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();
}
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();
simpleExoPlayer.addVideoListener(this);
// Setup subtitle view
simpleExoPlayer.addTextOutput(binding.subtitleView);
// 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()
.setTunnelingAudioSessionId(C.generateAudioSessionIdV21(context)));
} else if (DEBUG) {
Log.d(TAG, "[" + Util.DEVICE_DEBUG_INFO + "] does not support media tunneling");
}
}
private void initListeners() {
binding.playbackSeekBar.setOnSeekBarChangeListener(this);
binding.playbackSpeed.setOnClickListener(this);
binding.qualityTextView.setOnClickListener(this);
binding.captionTextView.setOnClickListener(this);
binding.resizeTextView.setOnClickListener(this);
binding.playbackLiveSync.setOnClickListener(this);
final PlayerGestureListener listener = new PlayerGestureListener(this, service);
gestureDetector = new GestureDetectorCompat(context, listener);
binding.getRoot().setOnTouchListener(listener);
binding.queueButton.setOnClickListener(this);
binding.segmentsButton.setOnClickListener(this);
binding.repeatButton.setOnClickListener(this);
binding.shuffleButton.setOnClickListener(this);
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 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()));
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Playback initialization via intent
//////////////////////////////////////////////////////////////////////////*/
//region Playback initialization via intent
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 append intents
if (intent.getBooleanExtra(APPEND_ONLY, false) && playQueue != null) {
final int sizeBeforeAppend = playQueue.size();
playQueue.append(newQueue.getStreams());
if ((intent.getBooleanExtra(SELECT_ON_APPEND, false)
|| currentState == STATE_COMPLETED) && newQueue.getStreams().size() > 0) {
playQueue.setIndex(sizeBeforeAppend);
}
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());
/*
* 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) {
error.printStackTrace();
}
// 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);
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.removeVideoListener(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();
if (windowPos > 0 && windowPos <= simpleExoPlayer.getDuration()) {
setRecovery(queuePos, windowPos);
}
}
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(false);
}
//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) {
if (currentState != STATE_PAUSED_SEEK) {
binding.playbackSeekBar.setProgress(currentProgress);
}
binding.playbackCurrentTime.setText(getTimeString(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);
}
final boolean showThumbnail = prefs.getBoolean(
context.getString(R.string.show_thumbnail_key), true);
// setMetadata only updates the metadata when any of the metadata keys are null
mediaSessionManager.setMetadata(getVideoTitle(), getUploaderName(),
showThumbnail ? getThumbnail() : null, duration);
}
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
&& currentItem.getStreamType() != StreamType.AUDIO_LIVE_STREAM
&& currentItem.getStreamType() != StreamType.LIVE_STREAM) {
// 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;
}
/**
* Show a animation, and depending on goneOnEnd, will stay on the screen or be gone.
*
* @param drawableId the drawable that will be used to animate,
* pass -1 to clear any animation that is visible
* @param goneOnEnd will set the animation view to GONE on the end of the animation
*/
public void showAndAnimateControl(final int drawableId, final boolean goneOnEnd) {
if (DEBUG) {
Log.d(TAG, "showAndAnimateControl() called with: "
+ "drawableId = [" + drawableId + "], goneOnEnd = [" + goneOnEnd + "]");
}
if (controlViewAnimator != null && controlViewAnimator.isRunning()) {
if (DEBUG) {
Log.d(TAG, "showAndAnimateControl: controlViewAnimator.isRunning");
}
controlViewAnimator.end();
}
if (drawableId == -1) {
if (binding.controlAnimationView.getVisibility() == View.VISIBLE) {
controlViewAnimator = ObjectAnimator.ofPropertyValuesHolder(
binding.controlAnimationView,
PropertyValuesHolder.ofFloat(View.ALPHA, 1.0f, 0.0f),
PropertyValuesHolder.ofFloat(View.SCALE_X, 1.4f, 1.0f),
PropertyValuesHolder.ofFloat(View.SCALE_Y, 1.4f, 1.0f)
).setDuration(DEFAULT_CONTROLS_DURATION);
controlViewAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(final Animator animation) {
binding.controlAnimationView.setVisibility(View.GONE);
}
});
controlViewAnimator.start();
}
return;
}
final float scaleFrom = goneOnEnd ? 1f : 1f;
final float scaleTo = goneOnEnd ? 1.8f : 1.4f;
final float alphaFrom = goneOnEnd ? 1f : 0f;
final float alphaTo = goneOnEnd ? 0f : 1f;
controlViewAnimator = ObjectAnimator.ofPropertyValuesHolder(
binding.controlAnimationView,
PropertyValuesHolder.ofFloat(View.ALPHA, alphaFrom, alphaTo),
PropertyValuesHolder.ofFloat(View.SCALE_X, scaleFrom, scaleTo),
PropertyValuesHolder.ofFloat(View.SCALE_Y, scaleFrom, scaleTo)
);
controlViewAnimator.setDuration(goneOnEnd ? 1000 : 500);
controlViewAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(final Animator animation) {
binding.controlAnimationView.setVisibility(goneOnEnd ? View.GONE : View.VISIBLE);
}
});
binding.controlAnimationView.setVisibility(View.VISIBLE);
binding.controlAnimationView.setImageDrawable(
AppCompatResources.getDrawable(context, drawableId));
controlViewAnimator.start();
}
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);
}
private void showHideShadow(final boolean show, final long duration) {
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);
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();
}
controlsVisibilityHandler.removeCallbacksAndMessages(null);
animate(binding.playbackControlRoot, false, DEFAULT_CONTROLS_DURATION);
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();
showAndAnimateControl(-1, true);
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();
}
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");
}
showAndAnimateControl(-1, true);
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();
}
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(((AppCompatImageButton) 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, 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(final ImageButton button, final boolean shuffled) {
button.setImageAlpha(shuffled ? 255 : 77);
}
//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(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(@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_PERIOD_TRANSITION:
// 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_AD_INSERTION:
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);
}
//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.EventListener#onPlayerError(ExoPlaybackException)
*/
@Override
public void onPlayerError(@NonNull final ExoPlaybackException error) {
if (DEBUG) {
Log.d(TAG, "ExoPlayer - onPlayerError() called with: " + "error = [" + error + "]");
}
if (errorToast != null) {
errorToast.cancel();
errorToast = null;
}
saveStreamProgressState();
switch (error.type) {
case ExoPlaybackException.TYPE_SOURCE:
processSourceError(error.getSourceException());
showStreamError(error);
break;
case ExoPlaybackException.TYPE_UNEXPECTED:
showRecoverableError(error);
setRecovery();
reloadPlayQueueManager();
break;
case ExoPlaybackException.TYPE_REMOTE:
case ExoPlaybackException.TYPE_RENDERER:
default:
showUnrecoverableError(error);
onPlaybackShutdown();
break;
}
if (fragmentListener != null) {
fragmentListener.onPlayerError(error);
}
}
private void processSourceError(final IOException error) {
if (exoPlayerIsNull() || playQueue == null) {
return;
}
setRecovery();
if (error instanceof BehindLiveWindowException) {
reloadPlayQueueManager();
} else {
playQueue.error();
}
}
private void showStreamError(final Exception exception) {
exception.printStackTrace();
if (errorToast == null) {
errorToast = Toast
.makeText(context, R.string.player_stream_failure, Toast.LENGTH_SHORT);
errorToast.show();
}
}
private void showRecoverableError(final Exception exception) {
exception.printStackTrace();
if (errorToast == null) {
errorToast = Toast
.makeText(context, R.string.player_recoverable_failure, Toast.LENGTH_SHORT);
errorToast.show();
}
}
private void showUnrecoverableError(final Exception exception) {
exception.printStackTrace();
if (errorToast != null) {
errorToast.cancel();
}
errorToast = Toast
.makeText(context, R.string.player_unrecoverable_failure, Toast.LENGTH_SHORT);
errorToast.show();
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Playback position and seek
//////////////////////////////////////////////////////////////////////////*/
//region Playback position and seek
@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();
showAndAnimateControl(R.drawable.ic_fast_forward, true);
}
public void fastRewind() {
if (DEBUG) {
Log.d(TAG, "fastRewind() called");
}
seekBy(-retrieveSeekDurationFromPreferences(this));
triggerProgressUpdate();
showAndAnimateControl(R.drawable.ic_fast_rewind, true);
}
//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);
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 {
metadata = (MediaSourceTag) simpleExoPlayer.getCurrentTag();
} catch (IndexOutOfBoundsException | ClassCastException error) {
if (DEBUG) {
Log.d(TAG, "Could not update metadata: " + error.getMessage());
error.printStackTrace();
}
return;
}
if (metadata == null) {
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() {
return currentThumbnail == null
? BitmapFactory.decodeResource(context.getResources(), R.drawable.dummy_thumbnail)
: 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);
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);
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.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);
});
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 * 1000);
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() * 1000 > 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) {
return (isAudioOnly ? audioResolver : 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(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(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(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 onQualitySelectorClicked() {
if (DEBUG) {
Log.d(TAG, "onQualitySelectorClicked() called");
}
qualityPopupMenu.show();
isSomePopupMenuVisible = true;
final VideoStream videoStream = getSelectedVideoStream();
if (videoStream != null) {
final String qualityText = MediaFormat.getNameById(videoStream.getFormatId()) + " "
+ videoStream.resolution;
binding.qualityTextView.setText(qualityText);
}
saveWasPlaying();
}
private void onPlaybackSpeedClicked() {
if (DEBUG) {
Log.d(TAG, "onPlaybackSpeedClicked() called");
}
if (videoPlayerSelected()) {
PlaybackParameterDialog.newInstance(getPlaybackSpeed(), getPlaybackPitch(),
getPlaybackSkipSilence(), this::setPlaybackParameters)
.show(getParentActivity().getSupportFragmentManager(), null);
} else {
playbackSpeedPopupMenu.show();
isSomePopupMenuVisible = true;
}
}
private void onCaptionClicked() {
if (DEBUG) {
Log.d(TAG, "onCaptionClicked() called");
}
captionPopupMenu.show();
isSomePopupMenuVisible = true;
}
private void setPlaybackQuality(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, (float) 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.qualityTextView.getId()) {
onQualitySelectorClicked();
} else if (v.getId() == binding.playbackSpeed.getId()) {
onPlaybackSpeedClicked();
} else 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.queueButton.getId()) {
onQueueClicked();
return;
} else if (v.getId() == binding.segmentsButton.getId()) {
onSegmentsClicked();
return;
} else if (v.getId() == binding.repeatButton.getId()) {
onRepeatClicked();
return;
} else if (v.getId() == binding.shuffleButton.getId()) {
onShuffleClicked();
return;
} 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));
}
if (currentState != STATE_COMPLETED) {
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();
}
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()) {
// do not interfere with focus in playlist etc.
return false;
}
if (currentState == Player.STATE_BLOCKED) {
return true;
}
if (!isControlsVisible()) {
if (!isQueueVisible) {
binding.playPauseButton.requestFocus();
}
showControlsThenHide();
showSystemUIPartially();
return true;
} else {
hideControls(DEFAULT_CONTROLS_DURATION, DPAD_CONTROLS_HIDE_TIME);
}
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(final int width, final int height,
final int unappliedRotationDegrees,
final float pixelWidthHeightRatio) {
if (DEBUG) {
Log.d(TAG, "onVideoSizeChanged() called with: "
+ "width / height = [" + width + " / " + height
+ " = " + (((float) width) / height) + "], "
+ "unappliedRotationDegrees = [" + unappliedRotationDegrees + "], "
+ "pixelWidthHeightRatio = [" + pixelWidthHeightRatio + "]");
}
binding.surfaceView.setAspectRatio(((float) width) / height);
isVerticalVideo = width < 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(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(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);
}
}
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 video) {
if (playQueue == null || isAudioOnly == !video || audioPlayerSelected()) {
return;
}
isAudioOnly = !video;
// When a user returns from background controls could be hidden
// but systemUI will be shown 100%. Hide it
if (!isAudioOnly && !isControlsVisible()) {
hideSystemUIIfNeeded();
}
setRecovery();
reloadPlayQueueManager();
}
//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 (@NonNull 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.getMessage());
e.printStackTrace();
}
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;
}
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 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;
}
@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;
}
//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() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // >=API23
if (surfaceHolderCallback != null) {
if (binding != null) {
binding.surfaceView.getHolder().removeCallback(surfaceHolderCallback);
}
surfaceHolderCallback.release();
surfaceHolderCallback = null;
}
}
}
//endregion
}