mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2025-06-26 07:02:55 +00:00
Merge pull request #11965 from Profpatsch/player-classes-kotlin-conversion
Some simple refactors & beginning of kotlin conversions of the player classes
This commit is contained in:
commit
006b4c9ae1
@ -188,21 +188,21 @@ public final class VideoDetailFragment
|
||||
};
|
||||
|
||||
@State
|
||||
protected int serviceId = Constants.NO_SERVICE_ID;
|
||||
int serviceId = Constants.NO_SERVICE_ID;
|
||||
@State
|
||||
@NonNull
|
||||
protected String title = "";
|
||||
String title = "";
|
||||
@State
|
||||
@Nullable
|
||||
protected String url = null;
|
||||
String url = null;
|
||||
@Nullable
|
||||
protected PlayQueue playQueue = null;
|
||||
private PlayQueue playQueue = null;
|
||||
@State
|
||||
int bottomSheetState = BottomSheetBehavior.STATE_EXPANDED;
|
||||
@State
|
||||
int lastStableBottomSheetState = BottomSheetBehavior.STATE_EXPANDED;
|
||||
@State
|
||||
protected boolean autoPlayEnabled = true;
|
||||
boolean autoPlayEnabled = true;
|
||||
|
||||
@Nullable
|
||||
private StreamInfo currentInfo = null;
|
||||
@ -438,18 +438,15 @@ public final class VideoDetailFragment
|
||||
@Override
|
||||
public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
switch (requestCode) {
|
||||
case ReCaptchaActivity.RECAPTCHA_REQUEST:
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
NavigationHelper.openVideoDetailFragment(requireContext(), getFM(),
|
||||
serviceId, url, title, null, false);
|
||||
} else {
|
||||
Log.e(TAG, "ReCaptcha failed");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
Log.e(TAG, "Request code from activity not supported [" + requestCode + "]");
|
||||
break;
|
||||
if (requestCode == ReCaptchaActivity.RECAPTCHA_REQUEST) {
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
NavigationHelper.openVideoDetailFragment(requireContext(), getFM(),
|
||||
serviceId, url, title, null, false);
|
||||
} else {
|
||||
Log.e(TAG, "ReCaptcha failed");
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Request code from activity not supported [" + requestCode + "]");
|
||||
}
|
||||
}
|
||||
|
||||
@ -815,25 +812,17 @@ public final class VideoDetailFragment
|
||||
|
||||
}
|
||||
|
||||
protected void prepareAndLoadInfo() {
|
||||
private void prepareAndLoadInfo() {
|
||||
scrollToTop();
|
||||
startLoading(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLoading(final boolean forceLoad) {
|
||||
super.startLoading(forceLoad);
|
||||
|
||||
initTabs();
|
||||
currentInfo = null;
|
||||
if (currentWorker != null) {
|
||||
currentWorker.dispose();
|
||||
}
|
||||
|
||||
runWorker(forceLoad, stack.isEmpty());
|
||||
startLoading(forceLoad, null);
|
||||
}
|
||||
|
||||
private void startLoading(final boolean forceLoad, final boolean addToBackStack) {
|
||||
private void startLoading(final boolean forceLoad, final @Nullable Boolean addToBackStack) {
|
||||
super.startLoading(forceLoad);
|
||||
|
||||
initTabs();
|
||||
@ -842,7 +831,7 @@ public final class VideoDetailFragment
|
||||
currentWorker.dispose();
|
||||
}
|
||||
|
||||
runWorker(forceLoad, addToBackStack);
|
||||
runWorker(forceLoad, addToBackStack != null ? addToBackStack : stack.isEmpty());
|
||||
}
|
||||
|
||||
private void runWorker(final boolean forceLoad, final boolean addToBackStack) {
|
||||
@ -1138,7 +1127,7 @@ public final class VideoDetailFragment
|
||||
}
|
||||
|
||||
private void openMainPlayer() {
|
||||
if (!isPlayerServiceAvailable()) {
|
||||
if (noPlayerServiceAvailable()) {
|
||||
playerHolder.startService(autoPlayEnabled, this);
|
||||
return;
|
||||
}
|
||||
@ -1163,7 +1152,7 @@ public final class VideoDetailFragment
|
||||
*/
|
||||
private void hideMainPlayerOnLoadingNewStream() {
|
||||
final var root = getRoot();
|
||||
if (!isPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) {
|
||||
if (noPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1337,23 +1326,23 @@ public final class VideoDetailFragment
|
||||
binding.detailContentRootHiding.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
protected void setInitialData(final int newServiceId,
|
||||
@Nullable final String newUrl,
|
||||
@NonNull final String newTitle,
|
||||
@Nullable final PlayQueue newPlayQueue) {
|
||||
private void setInitialData(final int newServiceId,
|
||||
@Nullable final String newUrl,
|
||||
@NonNull final String newTitle,
|
||||
@Nullable final PlayQueue newPlayQueue) {
|
||||
this.serviceId = newServiceId;
|
||||
this.url = newUrl;
|
||||
this.title = newTitle;
|
||||
this.playQueue = newPlayQueue;
|
||||
}
|
||||
|
||||
private void setErrorImage(final int imageResource) {
|
||||
private void setErrorImage() {
|
||||
if (binding == null || activity == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
binding.detailThumbnailImageView.setImageDrawable(
|
||||
AppCompatResources.getDrawable(requireContext(), imageResource));
|
||||
AppCompatResources.getDrawable(requireContext(), R.drawable.not_available_monkey));
|
||||
animate(binding.detailThumbnailImageView, false, 0, AnimationType.ALPHA,
|
||||
0, () -> animate(binding.detailThumbnailImageView, true, 500));
|
||||
}
|
||||
@ -1361,7 +1350,7 @@ public final class VideoDetailFragment
|
||||
@Override
|
||||
public void handleError() {
|
||||
super.handleError();
|
||||
setErrorImage(R.drawable.not_available_monkey);
|
||||
setErrorImage();
|
||||
|
||||
if (binding.relatedItemsLayout != null) { // hide related streams for tablets
|
||||
binding.relatedItemsLayout.setVisibility(View.INVISIBLE);
|
||||
@ -1776,16 +1765,14 @@ public final class VideoDetailFragment
|
||||
final PlaybackParameters parameters) {
|
||||
setOverlayPlayPauseImage(player != null && player.isPlaying());
|
||||
|
||||
switch (state) {
|
||||
case Player.STATE_PLAYING:
|
||||
if (binding.positionView.getAlpha() != 1.0f
|
||||
&& player.getPlayQueue() != null
|
||||
&& player.getPlayQueue().getItem() != null
|
||||
&& player.getPlayQueue().getItem().getUrl().equals(url)) {
|
||||
animate(binding.positionView, true, 100);
|
||||
animate(binding.detailPositionView, true, 100);
|
||||
}
|
||||
break;
|
||||
if (state == Player.STATE_PLAYING) {
|
||||
if (binding.positionView.getAlpha() != 1.0f
|
||||
&& player.getPlayQueue() != null
|
||||
&& player.getPlayQueue().getItem() != null
|
||||
&& player.getPlayQueue().getItem().getUrl().equals(url)) {
|
||||
animate(binding.positionView, true, 100);
|
||||
animate(binding.detailPositionView, true, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2444,8 +2431,8 @@ public final class VideoDetailFragment
|
||||
return player != null;
|
||||
}
|
||||
|
||||
boolean isPlayerServiceAvailable() {
|
||||
return playerService != null;
|
||||
boolean noPlayerServiceAvailable() {
|
||||
return playerService == null;
|
||||
}
|
||||
|
||||
boolean isPlayerAndPlayerServiceAvailable() {
|
||||
|
@ -220,11 +220,18 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceConnected(final ComponentName name, final IBinder service) {
|
||||
public void onServiceConnected(final ComponentName name, final IBinder binder) {
|
||||
Log.d(TAG, "Player service is connected");
|
||||
|
||||
if (service instanceof PlayerService.LocalBinder) {
|
||||
player = ((PlayerService.LocalBinder) service).getService().getPlayer();
|
||||
if (binder instanceof PlayerService.LocalBinder) {
|
||||
@Nullable final PlayerService s =
|
||||
((PlayerService.LocalBinder) binder).getService();
|
||||
if (s == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"PlayerService.LocalBinder.getService() must never be"
|
||||
+ "null after the service connects");
|
||||
}
|
||||
player = s.getPlayer();
|
||||
}
|
||||
|
||||
if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {
|
||||
|
@ -492,15 +492,15 @@ public final class Player implements PlaybackListener, Listener {
|
||||
|
||||
switch (playerType) {
|
||||
case MAIN:
|
||||
UIs.destroyAll(PopupPlayerUi.class);
|
||||
UIs.destroyAllOfType(PopupPlayerUi.class);
|
||||
UIs.addAndPrepare(new MainPlayerUi(this, binding));
|
||||
break;
|
||||
case POPUP:
|
||||
UIs.destroyAll(MainPlayerUi.class);
|
||||
UIs.destroyAllOfType(MainPlayerUi.class);
|
||||
UIs.addAndPrepare(new PopupPlayerUi(this, binding));
|
||||
break;
|
||||
case AUDIO:
|
||||
UIs.destroyAll(VideoPlayerUi.class);
|
||||
UIs.destroyAllOfType(VideoPlayerUi.class);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -591,9 +591,15 @@ public final class Player implements PlaybackListener, Listener {
|
||||
}
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
|
||||
/**
|
||||
* Shut down this player.
|
||||
* Saves the stream progress, sets recovery.
|
||||
* Then destroys the player in all UIs and destroys the UIs as well.
|
||||
*/
|
||||
public void saveAndShutdown() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "destroy() called");
|
||||
Log.d(TAG, "saveAndShutdown() called");
|
||||
}
|
||||
|
||||
saveStreamProgressState();
|
||||
@ -606,7 +612,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
databaseUpdateDisposable.clear();
|
||||
progressUpdateDisposable.set(null);
|
||||
|
||||
UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object
|
||||
UIs.destroyAllOfType(null);
|
||||
}
|
||||
|
||||
public void setRecovery() {
|
||||
@ -1995,6 +2001,10 @@ public final class Player implements PlaybackListener, Listener {
|
||||
triggerProgressUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the listener, if it was set.
|
||||
* @param listener listener to remove
|
||||
* */
|
||||
public void removeFragmentListener(final PlayerServiceEventListener listener) {
|
||||
if (fragmentListener == listener) {
|
||||
fragmentListener = null;
|
||||
@ -2009,6 +2019,10 @@ public final class Player implements PlaybackListener, Listener {
|
||||
triggerProgressUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the listener, if it was set.
|
||||
* @param listener listener to remove
|
||||
* */
|
||||
void removeActivityListener(final PlayerEventListener listener) {
|
||||
if (activityListener == listener) {
|
||||
activityListener = null;
|
||||
|
@ -1,350 +0,0 @@
|
||||
/*
|
||||
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* Part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.player;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Binder;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.support.v4.media.MediaBrowserCompat;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.ServiceCompat;
|
||||
import androidx.media.MediaBrowserServiceCompat;
|
||||
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||
|
||||
import org.schabi.newpipe.ktx.BundleKt;
|
||||
import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl;
|
||||
import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer;
|
||||
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
|
||||
import org.schabi.newpipe.player.notification.NotificationPlayerUi;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
|
||||
/**
|
||||
* One service for all players.
|
||||
*/
|
||||
public final class PlayerService extends MediaBrowserServiceCompat {
|
||||
private static final String TAG = PlayerService.class.getSimpleName();
|
||||
private static final boolean DEBUG = Player.DEBUG;
|
||||
|
||||
public static final String SHOULD_START_FOREGROUND_EXTRA = "should_start_foreground_extra";
|
||||
public static final String BIND_PLAYER_HOLDER_ACTION = "bind_player_holder_action";
|
||||
|
||||
// These objects are used to cleanly separate the Service implementation (in this file) and the
|
||||
// media browser and playback preparer implementations. At the moment the playback preparer is
|
||||
// only used in conjunction with the media browser.
|
||||
private MediaBrowserImpl mediaBrowserImpl;
|
||||
private MediaBrowserPlaybackPreparer mediaBrowserPlaybackPreparer;
|
||||
|
||||
// these are instantiated in onCreate() as per
|
||||
// https://developer.android.com/training/cars/media#browser_workflow
|
||||
private MediaSessionCompat mediaSession;
|
||||
private MediaSessionConnector sessionConnector;
|
||||
|
||||
@Nullable
|
||||
private Player player;
|
||||
|
||||
private final IBinder mBinder = new PlayerService.LocalBinder(this);
|
||||
|
||||
/**
|
||||
* The parameter taken by this {@link Consumer} can be null to indicate the player is being
|
||||
* stopped.
|
||||
*/
|
||||
@Nullable
|
||||
private Consumer<Player> onPlayerStartedOrStopped = null;
|
||||
|
||||
|
||||
//region Service lifecycle
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreate() called");
|
||||
}
|
||||
assureCorrectAppLanguage(this);
|
||||
ThemeHelper.setTheme(this);
|
||||
|
||||
mediaBrowserImpl = new MediaBrowserImpl(this, this::notifyChildrenChanged);
|
||||
|
||||
// see https://developer.android.com/training/cars/media#browser_workflow
|
||||
mediaSession = new MediaSessionCompat(this, "MediaSessionPlayerServ");
|
||||
setSessionToken(mediaSession.getSessionToken());
|
||||
sessionConnector = new MediaSessionConnector(mediaSession);
|
||||
sessionConnector.setMetadataDeduplicationEnabled(true);
|
||||
|
||||
mediaBrowserPlaybackPreparer = new MediaBrowserPlaybackPreparer(
|
||||
this,
|
||||
sessionConnector::setCustomErrorMessage,
|
||||
() -> sessionConnector.setCustomErrorMessage(null),
|
||||
(playWhenReady) -> {
|
||||
if (player != null) {
|
||||
player.onPrepare();
|
||||
}
|
||||
}
|
||||
);
|
||||
sessionConnector.setPlaybackPreparer(mediaBrowserPlaybackPreparer);
|
||||
|
||||
// Note: you might be tempted to create the player instance and call startForeground here,
|
||||
// but be aware that the Android system might start the service just to perform media
|
||||
// queries. In those cases creating a player instance is a waste of resources, and calling
|
||||
// startForeground means creating a useless empty notification. In case it's really needed
|
||||
// the player instance can be created here, but startForeground() should definitely not be
|
||||
// called here unless the service is actually starting in the foreground, to avoid the
|
||||
// useless notification.
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(final Intent intent, final int flags, final int startId) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onStartCommand() called with: intent = [" + intent
|
||||
+ "], extras = [" + BundleKt.toDebugString(intent.getExtras())
|
||||
+ "], flags = [" + flags + "], startId = [" + startId + "]");
|
||||
}
|
||||
|
||||
// All internal NewPipe intents used to interact with the player, that are sent to the
|
||||
// PlayerService using startForegroundService(), will have SHOULD_START_FOREGROUND_EXTRA,
|
||||
// to ensure startForeground() is called (otherwise Android will force-crash the app).
|
||||
if (intent.getBooleanExtra(SHOULD_START_FOREGROUND_EXTRA, false)) {
|
||||
final boolean playerWasNull = (player == null);
|
||||
if (playerWasNull) {
|
||||
// make sure the player exists, in case the service was resumed
|
||||
player = new Player(this, mediaSession, sessionConnector);
|
||||
}
|
||||
|
||||
// Be sure that the player notification is set and the service is started in foreground,
|
||||
// otherwise, the app may crash on Android 8+ as the service would never be put in the
|
||||
// foreground while we said to the system we would do so. The service is always
|
||||
// requested to be started in foreground, so always creating a notification if there is
|
||||
// no one already and starting the service in foreground should not create any issues.
|
||||
// If the service is already started in foreground, requesting it to be started
|
||||
// shouldn't do anything.
|
||||
player.UIs().getOpt(NotificationPlayerUi.class)
|
||||
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
|
||||
|
||||
if (playerWasNull && onPlayerStartedOrStopped != null) {
|
||||
// notify that a new player was created (but do it after creating the foreground
|
||||
// notification just to make sure we don't incur, due to slowness, in
|
||||
// "Context.startForegroundService() did not then call Service.startForeground()")
|
||||
onPlayerStartedOrStopped.accept(player);
|
||||
}
|
||||
}
|
||||
|
||||
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
|
||||
&& (player == null || player.getPlayQueue() == null)) {
|
||||
/*
|
||||
No need to process media button's actions if the player is not working, otherwise
|
||||
the player service would strangely start with nothing to play
|
||||
Stop the service in this case, which will be removed from the foreground and its
|
||||
notification cancelled in its destruction
|
||||
*/
|
||||
destroyPlayerAndStopService();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
if (player != null) {
|
||||
player.handleIntent(intent);
|
||||
player.UIs().getOpt(MediaSessionPlayerUi.class)
|
||||
.ifPresent(ui -> ui.handleMediaButtonIntent(intent));
|
||||
}
|
||||
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
public void stopForImmediateReusing() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "stopForImmediateReusing() called");
|
||||
}
|
||||
|
||||
if (player != null && !player.exoPlayerIsNull()) {
|
||||
// Releases wifi & cpu, disables keepScreenOn, etc.
|
||||
// We can't just pause the player here because it will make transition
|
||||
// from one stream to a new stream not smooth
|
||||
player.smoothStopForImmediateReusing();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTaskRemoved(final Intent rootIntent) {
|
||||
super.onTaskRemoved(rootIntent);
|
||||
if (player != null && !player.videoPlayerSelected()) {
|
||||
return;
|
||||
}
|
||||
onDestroy();
|
||||
// Unload from memory completely
|
||||
Runtime.getRuntime().halt(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "destroy() called");
|
||||
}
|
||||
super.onDestroy();
|
||||
|
||||
cleanup();
|
||||
|
||||
mediaBrowserPlaybackPreparer.dispose();
|
||||
mediaSession.release();
|
||||
mediaBrowserImpl.dispose();
|
||||
}
|
||||
|
||||
private void cleanup() {
|
||||
if (player != null) {
|
||||
if (onPlayerStartedOrStopped != null) {
|
||||
// notify that the player is being destroyed
|
||||
onPlayerStartedOrStopped.accept(null);
|
||||
}
|
||||
player.destroy();
|
||||
player = null;
|
||||
}
|
||||
|
||||
// Should already be handled by MediaSessionPlayerUi, but just to be sure.
|
||||
mediaSession.setActive(false);
|
||||
|
||||
// Should already be handled by NotificationUtil.cancelNotificationAndStopForeground() in
|
||||
// NotificationPlayerUi, but let's make sure that the foreground service is stopped.
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the player and allows the player instance to be garbage collected. Sets the media
|
||||
* session to inactive. Stops the foreground service and removes the player notification
|
||||
* associated with it. Tries to stop the {@link PlayerService} completely, but this step will
|
||||
* have no effect in case some service connection still uses the service (e.g. the Android Auto
|
||||
* system accesses the media browser even when no player is running).
|
||||
*/
|
||||
public void destroyPlayerAndStopService() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "destroyPlayerAndStopService() called");
|
||||
}
|
||||
|
||||
cleanup();
|
||||
|
||||
// This only really stops the service if there are no other service connections (see docs):
|
||||
// for example the (Android Auto) media browser binder will block stopService().
|
||||
// This is why we also stopForeground() above, to make sure the notification is removed.
|
||||
// If we were to call stopSelf(), then the service would be surely stopped (regardless of
|
||||
// other service connections), but this would be a waste of resources since the service
|
||||
// would be immediately restarted by those same connections to perform the queries.
|
||||
stopService(new Intent(this, PlayerService.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(final Context base) {
|
||||
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base));
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Bind
|
||||
@Override
|
||||
public IBinder onBind(final Intent intent) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onBind() called with: intent = [" + intent
|
||||
+ "], extras = [" + BundleKt.toDebugString(intent.getExtras()) + "]");
|
||||
}
|
||||
|
||||
if (BIND_PLAYER_HOLDER_ACTION.equals(intent.getAction())) {
|
||||
// Note that this binder might be reused multiple times while the service is alive, even
|
||||
// after unbind() has been called: https://stackoverflow.com/a/8794930 .
|
||||
return mBinder;
|
||||
|
||||
} else if (MediaBrowserServiceCompat.SERVICE_INTERFACE.equals(intent.getAction())) {
|
||||
// MediaBrowserService also uses its own binder, so for actions related to the media
|
||||
// browser service, pass the onBind to the superclass.
|
||||
return super.onBind(intent);
|
||||
|
||||
} else {
|
||||
// This is an unknown request, avoid returning any binder to not leak objects.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static class LocalBinder extends Binder {
|
||||
private final WeakReference<PlayerService> playerService;
|
||||
|
||||
LocalBinder(final PlayerService playerService) {
|
||||
this.playerService = new WeakReference<>(playerService);
|
||||
}
|
||||
|
||||
public PlayerService getService() {
|
||||
return playerService.get();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the current active player instance. May be null, since the player service can outlive
|
||||
* the player e.g. to respond to Android Auto media browser queries.
|
||||
*/
|
||||
@Nullable
|
||||
public Player getPlayer() {
|
||||
return player;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the listener that will be called when the player is started or stopped. If a
|
||||
* {@code null} listener is passed, then the current listener will be unset. The parameter taken
|
||||
* by the {@link Consumer} can be null to indicate that the player is stopping.
|
||||
* @param listener the listener to set or unset
|
||||
*/
|
||||
public void setPlayerListener(@Nullable final Consumer<Player> listener) {
|
||||
this.onPlayerStartedOrStopped = listener;
|
||||
if (listener != null) {
|
||||
// if there is no player, then `null` will be sent here, to ensure the state is synced
|
||||
listener.accept(player);
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Media browser
|
||||
@Override
|
||||
public BrowserRoot onGetRoot(@NonNull final String clientPackageName,
|
||||
final int clientUid,
|
||||
@Nullable final Bundle rootHints) {
|
||||
// TODO check if the accessing package has permission to view data
|
||||
return mediaBrowserImpl.onGetRoot(clientPackageName, clientUid, rootHints);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadChildren(@NonNull final String parentId,
|
||||
@NonNull final Result<List<MediaBrowserCompat.MediaItem>> result) {
|
||||
mediaBrowserImpl.onLoadChildren(parentId, result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSearch(@NonNull final String query,
|
||||
final Bundle extras,
|
||||
@NonNull final Result<List<MediaBrowserCompat.MediaItem>> result) {
|
||||
mediaBrowserImpl.onSearch(query, result);
|
||||
}
|
||||
//endregion
|
||||
}
|
348
app/src/main/java/org/schabi/newpipe/player/PlayerService.kt
Normal file
348
app/src/main/java/org/schabi/newpipe/player/PlayerService.kt
Normal file
@ -0,0 +1,348 @@
|
||||
/*
|
||||
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* Part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.schabi.newpipe.player
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.util.Log
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
import org.schabi.newpipe.ktx.toDebugString
|
||||
import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl
|
||||
import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer
|
||||
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi
|
||||
import org.schabi.newpipe.player.notification.NotificationPlayerUi
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.ThemeHelper
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.function.BiConsumer
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* One service for all players.
|
||||
*/
|
||||
class PlayerService : MediaBrowserServiceCompat() {
|
||||
// These objects are used to cleanly separate the Service implementation (in this file) and the
|
||||
// media browser and playback preparer implementations. At the moment the playback preparer is
|
||||
// only used in conjunction with the media browser.
|
||||
private var mediaBrowserImpl: MediaBrowserImpl? = null
|
||||
private var mediaBrowserPlaybackPreparer: MediaBrowserPlaybackPreparer? = null
|
||||
|
||||
// these are instantiated in onCreate() as per
|
||||
// https://developer.android.com/training/cars/media#browser_workflow
|
||||
private var mediaSession: MediaSessionCompat? = null
|
||||
private var sessionConnector: MediaSessionConnector? = null
|
||||
|
||||
/**
|
||||
* @return the current active player instance. May be null, since the player service can outlive
|
||||
* the player e.g. to respond to Android Auto media browser queries.
|
||||
*/
|
||||
var player: Player? = null
|
||||
private set
|
||||
|
||||
private val mBinder: IBinder = LocalBinder(this)
|
||||
|
||||
/**
|
||||
* The parameter taken by this [Consumer] can be null to indicate the player is being
|
||||
* stopped.
|
||||
*/
|
||||
private var onPlayerStartedOrStopped: Consumer<Player?>? = null
|
||||
|
||||
//region Service lifecycle
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreate() called")
|
||||
}
|
||||
Localization.assureCorrectAppLanguage(this)
|
||||
ThemeHelper.setTheme(this)
|
||||
|
||||
mediaBrowserImpl = MediaBrowserImpl(
|
||||
this,
|
||||
Consumer { parentId: String ->
|
||||
this.notifyChildrenChanged(
|
||||
parentId
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// see https://developer.android.com/training/cars/media#browser_workflow
|
||||
val session = MediaSessionCompat(this, "MediaSessionPlayerServ")
|
||||
mediaSession = session
|
||||
setSessionToken(session.sessionToken)
|
||||
val connector = MediaSessionConnector(session)
|
||||
sessionConnector = connector
|
||||
connector.setMetadataDeduplicationEnabled(true)
|
||||
|
||||
mediaBrowserPlaybackPreparer = MediaBrowserPlaybackPreparer(
|
||||
this,
|
||||
BiConsumer { message: String, code: Int ->
|
||||
connector.setCustomErrorMessage(
|
||||
message,
|
||||
code
|
||||
)
|
||||
},
|
||||
Runnable { connector.setCustomErrorMessage(null) },
|
||||
Consumer { playWhenReady: Boolean? ->
|
||||
player?.onPrepare()
|
||||
}
|
||||
)
|
||||
connector.setPlaybackPreparer(mediaBrowserPlaybackPreparer)
|
||||
|
||||
// Note: you might be tempted to create the player instance and call startForeground here,
|
||||
// but be aware that the Android system might start the service just to perform media
|
||||
// queries. In those cases creating a player instance is a waste of resources, and calling
|
||||
// startForeground means creating a useless empty notification. In case it's really needed
|
||||
// the player instance can be created here, but startForeground() should definitely not be
|
||||
// called here unless the service is actually starting in the foreground, to avoid the
|
||||
// useless notification.
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
if (DEBUG) {
|
||||
Log.d(
|
||||
TAG,
|
||||
(
|
||||
"onStartCommand() called with: intent = [" + intent +
|
||||
"], extras = [" + intent.extras.toDebugString() +
|
||||
"], flags = [" + flags + "], startId = [" + startId + "]"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// All internal NewPipe intents used to interact with the player, that are sent to the
|
||||
// PlayerService using startForegroundService(), will have SHOULD_START_FOREGROUND_EXTRA,
|
||||
// to ensure startForeground() is called (otherwise Android will force-crash the app).
|
||||
if (intent.getBooleanExtra(SHOULD_START_FOREGROUND_EXTRA, false)) {
|
||||
val playerWasNull = (player == null)
|
||||
if (playerWasNull) {
|
||||
// make sure the player exists, in case the service was resumed
|
||||
player = Player(this, mediaSession!!, sessionConnector!!)
|
||||
}
|
||||
|
||||
// Be sure that the player notification is set and the service is started in foreground,
|
||||
// otherwise, the app may crash on Android 8+ as the service would never be put in the
|
||||
// foreground while we said to the system we would do so. The service is always
|
||||
// requested to be started in foreground, so always creating a notification if there is
|
||||
// no one already and starting the service in foreground should not create any issues.
|
||||
// If the service is already started in foreground, requesting it to be started
|
||||
// shouldn't do anything.
|
||||
player!!.UIs().get(NotificationPlayerUi::class.java)
|
||||
?.createNotificationAndStartForeground()
|
||||
|
||||
val startedOrStopped = onPlayerStartedOrStopped
|
||||
if (playerWasNull && startedOrStopped != null) {
|
||||
// notify that a new player was created (but do it after creating the foreground
|
||||
// notification just to make sure we don't incur, due to slowness, in
|
||||
// "Context.startForegroundService() did not then call Service.startForeground()")
|
||||
startedOrStopped.accept(player)
|
||||
}
|
||||
}
|
||||
|
||||
val p = player
|
||||
if (Intent.ACTION_MEDIA_BUTTON == intent.action &&
|
||||
(p == null || p.playQueue == null)
|
||||
) {
|
||||
/*
|
||||
No need to process media button's actions if the player is not working, otherwise
|
||||
the player service would strangely start with nothing to play
|
||||
Stop the service in this case, which will be removed from the foreground and its
|
||||
notification cancelled in its destruction
|
||||
*/
|
||||
destroyPlayerAndStopService()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (p != null) {
|
||||
p.handleIntent(intent)
|
||||
p.UIs().get(MediaSessionPlayerUi::class.java)
|
||||
?.handleMediaButtonIntent(intent)
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
fun stopForImmediateReusing() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "stopForImmediateReusing() called")
|
||||
}
|
||||
|
||||
val p = player
|
||||
if (p != null && !p.exoPlayerIsNull()) {
|
||||
// Releases wifi & cpu, disables keepScreenOn, etc.
|
||||
// We can't just pause the player here because it will make transition
|
||||
// from one stream to a new stream not smooth
|
||||
p.smoothStopForImmediateReusing()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
val p = player
|
||||
if (p != null && !p.videoPlayerSelected()) {
|
||||
return
|
||||
}
|
||||
onDestroy()
|
||||
// Unload from memory completely
|
||||
Runtime.getRuntime().halt(0)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "destroy() called")
|
||||
}
|
||||
super.onDestroy()
|
||||
|
||||
cleanup()
|
||||
|
||||
mediaBrowserPlaybackPreparer?.dispose()
|
||||
mediaSession?.release()
|
||||
mediaBrowserImpl?.dispose()
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
val p = player
|
||||
if (p != null) {
|
||||
// notify that the player is being destroyed
|
||||
onPlayerStartedOrStopped?.accept(null)
|
||||
p.saveAndShutdown()
|
||||
player = null
|
||||
}
|
||||
|
||||
// Should already be handled by MediaSessionPlayerUi, but just to be sure.
|
||||
mediaSession?.setActive(false)
|
||||
|
||||
// Should already be handled by NotificationUtil.cancelNotificationAndStopForeground() in
|
||||
// NotificationPlayerUi, but let's make sure that the foreground service is stopped.
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the player and allows the player instance to be garbage collected. Sets the media
|
||||
* session to inactive. Stops the foreground service and removes the player notification
|
||||
* associated with it. Tries to stop the [PlayerService] completely, but this step will
|
||||
* have no effect in case some service connection still uses the service (e.g. the Android Auto
|
||||
* system accesses the media browser even when no player is running).
|
||||
*/
|
||||
fun destroyPlayerAndStopService() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "destroyPlayerAndStopService() called")
|
||||
}
|
||||
|
||||
cleanup()
|
||||
|
||||
// This only really stops the service if there are no other service connections (see docs):
|
||||
// for example the (Android Auto) media browser binder will block stopService().
|
||||
// This is why we also stopForeground() above, to make sure the notification is removed.
|
||||
// If we were to call stopSelf(), then the service would be surely stopped (regardless of
|
||||
// other service connections), but this would be a waste of resources since the service
|
||||
// would be immediately restarted by those same connections to perform the queries.
|
||||
stopService(Intent(this, PlayerService::class.java))
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base))
|
||||
}
|
||||
|
||||
//endregion
|
||||
//region Bind
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
if (DEBUG) {
|
||||
Log.d(
|
||||
TAG,
|
||||
(
|
||||
"onBind() called with: intent = [" + intent +
|
||||
"], extras = [" + intent.extras.toDebugString() + "]"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (BIND_PLAYER_HOLDER_ACTION == intent.action) {
|
||||
// Note that this binder might be reused multiple times while the service is alive, even
|
||||
// after unbind() has been called: https://stackoverflow.com/a/8794930 .
|
||||
return mBinder
|
||||
} else if (SERVICE_INTERFACE == intent.action) {
|
||||
// MediaBrowserService also uses its own binder, so for actions related to the media
|
||||
// browser service, pass the onBind to the superclass.
|
||||
return super.onBind(intent)
|
||||
} else {
|
||||
// This is an unknown request, avoid returning any binder to not leak objects.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
class LocalBinder internal constructor(playerService: PlayerService) : Binder() {
|
||||
private val playerService = WeakReference<PlayerService?>(playerService)
|
||||
|
||||
val service: PlayerService?
|
||||
get() = playerService.get()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the listener that will be called when the player is started or stopped. If a
|
||||
* `null` listener is passed, then the current listener will be unset. The parameter taken
|
||||
* by the [Consumer] can be null to indicate that the player is stopping.
|
||||
* @param listener the listener to set or unset
|
||||
*/
|
||||
fun setPlayerListener(listener: Consumer<Player?>?) {
|
||||
this.onPlayerStartedOrStopped = listener
|
||||
listener?.accept(player)
|
||||
}
|
||||
|
||||
//endregion
|
||||
//region Media browser
|
||||
override fun onGetRoot(
|
||||
clientPackageName: String,
|
||||
clientUid: Int,
|
||||
rootHints: Bundle?
|
||||
): BrowserRoot? {
|
||||
// TODO check if the accessing package has permission to view data
|
||||
return mediaBrowserImpl?.onGetRoot(clientPackageName, clientUid, rootHints)
|
||||
}
|
||||
|
||||
override fun onLoadChildren(
|
||||
parentId: String,
|
||||
result: Result<List<MediaBrowserCompat.MediaItem>>
|
||||
) {
|
||||
mediaBrowserImpl?.onLoadChildren(parentId, result)
|
||||
}
|
||||
|
||||
override fun onSearch(
|
||||
query: String,
|
||||
extras: Bundle?,
|
||||
result: Result<List<MediaBrowserCompat.MediaItem>>
|
||||
) {
|
||||
mediaBrowserImpl?.onSearch(query, result)
|
||||
} //endregion
|
||||
|
||||
companion object {
|
||||
private val TAG: String = PlayerService::class.java.getSimpleName()
|
||||
private val DEBUG = Player.DEBUG
|
||||
|
||||
const val SHOULD_START_FOREGROUND_EXTRA: String = "should_start_foreground_extra"
|
||||
const val BIND_PLAYER_HOLDER_ACTION: String = "bind_player_holder_action"
|
||||
}
|
||||
}
|
@ -120,6 +120,14 @@ public final class PlayerHolder {
|
||||
return App.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to (and if needed start) the {@link PlayerService}
|
||||
* and bind {@link PlayerServiceConnection} to it.
|
||||
* If the service is already started, only set the listener.
|
||||
* @param playAfterConnect If this holder’s service was already started,
|
||||
* start playing immediately
|
||||
* @param newListener set this listener
|
||||
* */
|
||||
public void startService(final boolean playAfterConnect,
|
||||
final PlayerServiceExtendedEventListener newListener) {
|
||||
if (DEBUG) {
|
||||
@ -180,9 +188,15 @@ public final class PlayerHolder {
|
||||
}
|
||||
final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service;
|
||||
|
||||
playerService = localBinder.getService();
|
||||
@Nullable final PlayerService s = localBinder.getService();
|
||||
if (s == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"PlayerService.LocalBinder.getService() must never be"
|
||||
+ "null after the service connects");
|
||||
}
|
||||
playerService = s;
|
||||
if (listener != null) {
|
||||
listener.onServiceConnected(playerService);
|
||||
listener.onServiceConnected(s);
|
||||
getPlayer().ifPresent(p -> listener.onPlayerConnected(p, playAfterConnect));
|
||||
}
|
||||
startPlayerListener();
|
||||
@ -190,7 +204,7 @@ public final class PlayerHolder {
|
||||
|
||||
// notify the main activity that binding the service has completed, so that it can
|
||||
// open the bottom mini-player
|
||||
NavigationHelper.sendPlayerStartedEvent(localBinder.getService());
|
||||
NavigationHelper.sendPlayerStartedEvent(s);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -4,7 +4,7 @@ import org.schabi.newpipe.util.GuardedByMutex
|
||||
import java.util.Optional
|
||||
|
||||
class PlayerUiList(vararg initialPlayerUis: PlayerUi) {
|
||||
var playerUis = GuardedByMutex(mutableListOf<PlayerUi>())
|
||||
private val playerUis = GuardedByMutex(mutableListOf<PlayerUi>())
|
||||
|
||||
/**
|
||||
* Creates a [PlayerUiList] starting with the provided player uis. The provided player uis
|
||||
@ -50,19 +50,19 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) {
|
||||
|
||||
/**
|
||||
* Destroys all matching player UIs and removes them from the list.
|
||||
* @param playerUiType the class of the player UI to destroy;
|
||||
* the [Class.isInstance] method will be used, so even subclasses will be
|
||||
* @param playerUiType the class of the player UI to destroy, everything if `null`.
|
||||
* The [Class.isInstance] method will be used, so even subclasses will be
|
||||
* destroyed and removed
|
||||
* @param T the class type parameter </T>
|
||||
* */
|
||||
fun <T> destroyAll(playerUiType: Class<T?>) {
|
||||
fun <T : PlayerUi> destroyAllOfType(playerUiType: Class<T>? = null) {
|
||||
val toDestroy = mutableListOf<PlayerUi>()
|
||||
|
||||
// short blocking removal from class to prevent interfering from other threads
|
||||
playerUis.runWithLockSync {
|
||||
val new = mutableListOf<PlayerUi>()
|
||||
for (ui in lockData) {
|
||||
if (playerUiType.isInstance(ui)) {
|
||||
if (playerUiType == null || playerUiType.isInstance(ui)) {
|
||||
toDestroy.add(ui)
|
||||
} else {
|
||||
new.add(ui)
|
||||
@ -83,7 +83,7 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) {
|
||||
* @param T the class type parameter
|
||||
* @return the first player UI of the required type found in the list, or null
|
||||
</T> */
|
||||
fun <T> get(playerUiType: Class<T>): T? =
|
||||
fun <T : PlayerUi> get(playerUiType: Class<T>): T? =
|
||||
playerUis.runWithLockSync {
|
||||
for (ui in lockData) {
|
||||
if (playerUiType.isInstance(ui)) {
|
||||
@ -105,7 +105,7 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) {
|
||||
* [Optional] otherwise
|
||||
</T> */
|
||||
@Deprecated("use get", ReplaceWith("get(playerUiType)"))
|
||||
fun <T> getOpt(playerUiType: Class<T>): Optional<T & Any> =
|
||||
fun <T : PlayerUi> getOpt(playerUiType: Class<T>): Optional<T> =
|
||||
Optional.ofNullable(get(playerUiType))
|
||||
|
||||
/**
|
||||
|
@ -16,6 +16,7 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.nextResizeModeAndSaveToPrefs;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Resources;
|
||||
import android.graphics.Bitmap;
|
||||
@ -761,7 +762,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the play/pause button ({@link R.id.playPauseButton}) to reflect the action
|
||||
* Update the play/pause button (`R.id.playPauseButton`) to reflect the action
|
||||
* that will be performed when the button is clicked..
|
||||
* @param action the action that is performed when the play/pause button is clicked
|
||||
*/
|
||||
@ -947,6 +948,8 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
||||
player.toggleShuffleModeEnabled();
|
||||
}
|
||||
|
||||
// TODO: don’t reference internal exoplayer2 resources
|
||||
@SuppressLint("PrivateResource")
|
||||
@Override
|
||||
public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
|
||||
super.onRepeatModeChanged(repeatMode);
|
||||
|
Loading…
x
Reference in New Issue
Block a user