feat: add notification controls (#3723)

* feat(ios): add `showNotificationControls` prop

* feat(android): add `showNotificationControls` prop

* add docs

* feat!: add `metadata` property to srouce

This is breaking change for iOS/tvOS as we are moving some properties, but I believe that this will more readable and more user friendly

* chore(ios): remove UI blocking function

* code review changes for android

* update example

* fix readme

* fix typos

* update docs

* fix typo

* chore: improve sample metadata notification

* update codegen types

* rename properties

* update tvOS example

* reset metadata on source change

* update docs

---------

Co-authored-by: Olivier Bouillet <freeboub@gmail.com>
This commit is contained in:
Krzysztof Moch
2024-05-07 12:30:57 +02:00
committed by GitHub
parent c59d00a0f0
commit 8ad4be459b
18 changed files with 908 additions and 105 deletions

View File

@@ -12,10 +12,15 @@ import static com.brentvatne.exoplayer.DataSourceUtil.buildAssetDataSourceFactor
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.ActivityManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.text.TextUtils;
@@ -27,6 +32,7 @@ import android.widget.ImageButton;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
@@ -35,6 +41,7 @@ import androidx.media3.common.AudioAttributes;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Metadata;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.PlaybackParameters;
@@ -94,6 +101,7 @@ import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter;
import androidx.media3.extractor.metadata.emsg.EventMessage;
import androidx.media3.extractor.metadata.id3.Id3Frame;
import androidx.media3.extractor.metadata.id3.TextInformationFrame;
import androidx.media3.session.MediaSessionService;
import androidx.media3.ui.LegacyPlayerControlView;
import com.brentvatne.common.api.BufferConfig;
@@ -172,6 +180,10 @@ public class ReactExoplayerView extends FrameLayout implements
private ExoPlayer player;
private DefaultTrackSelector trackSelector;
private boolean playerNeedsSource;
private MediaMetadata customMetadata;
private ServiceConnection playbackServiceConnection;
private PlaybackServiceBinder playbackServiceBinder;
private int resumeWindow;
private long resumePosition;
@@ -224,6 +236,8 @@ public class ReactExoplayerView extends FrameLayout implements
private String[] drmLicenseHeader = null;
private boolean controls;
private Uri adTagUrl;
private boolean showNotificationControls = false;
// \ End props
// React
@@ -342,6 +356,12 @@ public class ReactExoplayerView extends FrameLayout implements
cleanUpResources();
}
@Override
protected void onDetachedFromWindow() {
cleanupPlaybackService();
super.onDetachedFromWindow();
}
public void cleanUpResources() {
stopPlayback();
themedReactContext.removeLifecycleEventListener(this);
@@ -656,6 +676,10 @@ public class ReactExoplayerView extends FrameLayout implements
PlaybackParameters params = new PlaybackParameters(rate, 1f);
player.setPlaybackParameters(params);
changeAudioOutput(this.audioOutput);
if(showNotificationControls) {
setupPlaybackService();
}
}
private DrmSessionManager initializePlayerDrm(ReactExoplayerView self) {
@@ -741,6 +765,69 @@ public class ReactExoplayerView extends FrameLayout implements
applyModifiers();
}
private void setupPlaybackService() {
if (!showNotificationControls || player == null) {
return;
}
playbackServiceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
playbackServiceBinder = (PlaybackServiceBinder) service;
try {
playbackServiceBinder.getService().registerPlayer(player);
} catch (Exception e) {
DebugLog.e(TAG, "Cloud not register ExoPlayer");
}
}
@Override
public void onServiceDisconnected(ComponentName name) {
try {
playbackServiceBinder.getService().unregisterPlayer(player);
} catch (Exception ignored) {}
playbackServiceBinder = null;
}
@Override
public void onNullBinding(ComponentName name) {
DebugLog.e(TAG, "Cloud not register ExoPlayer");
}
};
Intent intent = new Intent(themedReactContext, VideoPlaybackService.class);
intent.setAction(MediaSessionService.SERVICE_INTERFACE);
themedReactContext.startService(intent);
int flags;
if (Build.VERSION.SDK_INT >= 29) {
flags = Context.BIND_AUTO_CREATE | Context.BIND_INCLUDE_CAPABILITIES;
} else {
flags = Context.BIND_AUTO_CREATE;
}
themedReactContext.bindService(intent, playbackServiceConnection, flags);
}
private void cleanupPlaybackService() {
try {
if(player != null && playbackServiceBinder != null) {
playbackServiceBinder.getService().unregisterPlayer(player);
}
playbackServiceBinder = null;
if(playbackServiceConnection != null) {
themedReactContext.unbindService(playbackServiceConnection);
}
} catch(Exception e) {
DebugLog.w(TAG, "Cloud not cleanup playback service");
}
}
private DrmSessionManager buildDrmSessionManager(UUID uuid, String licenseUrl, String[] keyRequestPropertiesArray) throws UnsupportedDrmException {
return buildDrmSessionManager(uuid, licenseUrl, keyRequestPropertiesArray, 0);
}
@@ -795,7 +882,12 @@ public class ReactExoplayerView extends FrameLayout implements
}
config.setDisableDisconnectError(this.disableDisconnectError);
MediaItem.Builder mediaItemBuilder = new MediaItem.Builder().setUri(uri);
MediaItem.Builder mediaItemBuilder = new MediaItem.Builder()
.setUri(uri);
if (customMetadata != null) {
mediaItemBuilder.setMediaMetadata(customMetadata);
}
if (adTagUrl != null) {
mediaItemBuilder.setAdsConfiguration(
@@ -935,12 +1027,20 @@ public class ReactExoplayerView extends FrameLayout implements
if (adsLoader != null) {
adsLoader.setPlayer(null);
}
if(playbackServiceBinder != null) {
playbackServiceBinder.getService().unregisterPlayer(player);
themedReactContext.unbindService(playbackServiceConnection);
}
updateResumePosition();
player.release();
player.removeListener(this);
trackSelector = null;
player = null;
}
if (adsLoader != null) {
adsLoader.release();
}
@@ -1542,7 +1642,21 @@ public class ReactExoplayerView extends FrameLayout implements
// ReactExoplayerViewManager public api
public void setSrc(final Uri uri, final long startPositionMs, final long cropStartMs, final long cropEndMs, final String extension, Map<String, String> headers) {
public void setSrc(final Uri uri, final long startPositionMs, final long cropStartMs, final long cropEndMs, final String extension, Map<String, String> headers, MediaMetadata customMetadata) {
if (this.customMetadata != customMetadata && player != null) {
MediaItem currentMediaItem = player.getCurrentMediaItem();
if (currentMediaItem == null) {
return;
}
MediaItem newMediaItem = currentMediaItem.buildUpon().setMediaMetadata(customMetadata).build();
// This will cause video blink/reload but won't louse progress
player.setMediaItem(newMediaItem, false);
}
if (uri != null) {
boolean isSourceEqual = uri.equals(srcUri) && cropStartMs == this.cropStartMs && cropEndMs == this.cropEndMs;
hasDrmFailed = false;
@@ -1555,6 +1669,7 @@ public class ReactExoplayerView extends FrameLayout implements
this.mediaDataSourceFactory =
DataSourceUtil.getDefaultDataSourceFactory(this.themedReactContext, bandwidthMeter,
this.requestHeaders);
this.customMetadata = customMetadata;
if (!isSourceEqual) {
reloadSource();
@@ -1573,6 +1688,7 @@ public class ReactExoplayerView extends FrameLayout implements
this.extension = null;
this.requestHeaders = null;
this.mediaDataSourceFactory = null;
customMetadata = null;
clearResumePosition();
}
}
@@ -1956,6 +2072,16 @@ public class ReactExoplayerView extends FrameLayout implements
this.contentStartTime = contentStartTime;
}
public void setShowNotificationControls(boolean showNotificationControls) {
this.showNotificationControls = showNotificationControls;
if (playbackServiceConnection == null && showNotificationControls) {
setupPlaybackService();
} else if(!showNotificationControls && playbackServiceConnection != null) {
cleanupPlaybackService();
}
}
public void setDisableBuffering(boolean disableBuffering) {
this.disableBuffering = disableBuffering;
}

View File

@@ -7,6 +7,7 @@ import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.RawResourceDataSource;
import androidx.media3.exoplayer.DefaultLoadControl;
@@ -39,6 +40,7 @@ public class ReactExoplayerViewManager extends ViewGroupManager<ReactExoplayerVi
private static final String PROP_SRC_START_POSITION = "startPosition";
private static final String PROP_SRC_CROP_START = "cropStart";
private static final String PROP_SRC_CROP_END = "cropEnd";
private static final String PROP_SRC_METADATA = "metadata";
private static final String PROP_AD_TAG_URL = "adTagUrl";
private static final String PROP_SRC_TYPE = "type";
private static final String PROP_DRM = "drm";
@@ -82,6 +84,7 @@ public class ReactExoplayerViewManager extends ViewGroupManager<ReactExoplayerVi
private static final String PROP_CONTROLS = "controls";
private static final String PROP_SUBTITLE_STYLE = "subtitleStyle";
private static final String PROP_SHUTTER_COLOR = "shutterColor";
private static final String PROP_SHOW_NOTIFICATION_CONTROLS = "showNotificationControls";
private static final String PROP_DEBUG = "debug";
private final ReactExoplayerConfig config;
@@ -166,6 +169,32 @@ public class ReactExoplayerViewManager extends ViewGroupManager<ReactExoplayerVi
}
}
ReadableMap propMetadata = ReactBridgeUtils.safeGetMap(src, PROP_SRC_METADATA);
MediaMetadata customMetadata = null;
if (propMetadata != null) {
String title = ReactBridgeUtils.safeGetString(propMetadata, "title");
String subtitle = ReactBridgeUtils.safeGetString(propMetadata, "subtitle");
String description = ReactBridgeUtils.safeGetString(propMetadata, "description");
String artist = ReactBridgeUtils.safeGetString(propMetadata, "artist");
String imageUriString = ReactBridgeUtils.safeGetString(propMetadata, "imageUri");
Uri imageUri = null;
try {
imageUri = Uri.parse(imageUriString);
} catch (Exception e) {
DebugLog.e("ExoPlayer Warning", "Could not parse imageUri in metadata");
}
customMetadata = new MediaMetadata.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setDescription(description)
.setArtist(artist)
.setArtworkUri(imageUri)
.build();
}
if (TextUtils.isEmpty(uriString)) {
videoView.clearSrc();
return;
@@ -175,7 +204,7 @@ public class ReactExoplayerViewManager extends ViewGroupManager<ReactExoplayerVi
Uri srcUri = Uri.parse(uriString);
if (srcUri != null) {
videoView.setSrc(srcUri, startPositionMs, cropStartMs, cropEndMs, extension, headers);
videoView.setSrc(srcUri, startPositionMs, cropStartMs, cropEndMs, extension, headers, customMetadata);
}
} else {
int identifier = context.getResources().getIdentifier(
@@ -400,6 +429,11 @@ public class ReactExoplayerViewManager extends ViewGroupManager<ReactExoplayerVi
videoView.setBufferConfig(config);
}
@ReactProp(name = PROP_SHOW_NOTIFICATION_CONTROLS)
public void setShowNotificationControls(final ReactExoplayerView videoView, final boolean showNotificationControls) {
videoView.setShowNotificationControls(showNotificationControls);
}
@ReactProp(name = PROP_DEBUG, defaultBoolean = false)
public void setDebug(final ReactExoplayerView videoView,
@Nullable final ReadableMap debugConfig) {

View File

@@ -0,0 +1,43 @@
package com.brentvatne.exoplayer
import android.os.Bundle
import androidx.media3.common.Player
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import com.google.common.util.concurrent.ListenableFuture
class VideoPlaybackCallback(private val seekIntervalMS: Long) : MediaSession.Callback {
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
try {
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailablePlayerCommands(
MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
.add(Player.COMMAND_SEEK_FORWARD)
.add(Player.COMMAND_SEEK_BACK)
.build()
).setAvailableSessionCommands(
MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
.add(SessionCommand(VideoPlaybackService.COMMAND_SEEK_FORWARD, Bundle.EMPTY))
.add(SessionCommand(VideoPlaybackService.COMMAND_SEEK_BACKWARD, Bundle.EMPTY))
.build()
)
.build()
} catch (e: Exception) {
return MediaSession.ConnectionResult.reject()
}
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
when (customCommand.customAction) {
VideoPlaybackService.COMMAND_SEEK_FORWARD -> session.player.seekTo(session.player.contentPosition + seekIntervalMS)
VideoPlaybackService.COMMAND_SEEK_BACKWARD -> session.player.seekTo(session.player.contentPosition + seekIntervalMS)
}
return super.onCustomCommand(session, controller, customCommand, args)
}
}

View File

@@ -0,0 +1,150 @@
package com.brentvatne.exoplayer
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Binder
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.CommandButton
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import androidx.media3.session.MediaStyleNotificationHelper
import androidx.media3.session.SessionCommand
import okhttp3.internal.immutableListOf
class PlaybackServiceBinder(val service: VideoPlaybackService) : Binder()
class VideoPlaybackService : MediaSessionService() {
private var mediaSessionsList = mutableMapOf<ExoPlayer, MediaSession>()
private var binder = PlaybackServiceBinder(this)
// Controls
private val commandSeekForward = SessionCommand(COMMAND_SEEK_FORWARD, Bundle.EMPTY)
private val commandSeekBackward = SessionCommand(COMMAND_SEEK_BACKWARD, Bundle.EMPTY)
@SuppressLint("PrivateResource")
private val seekForwardBtn = CommandButton.Builder()
.setDisplayName("forward")
.setSessionCommand(commandSeekForward)
.setIconResId(androidx.media3.ui.R.drawable.exo_notification_fastforward)
.build()
@SuppressLint("PrivateResource")
private val seekBackwardBtn = CommandButton.Builder()
.setDisplayName("backward")
.setSessionCommand(commandSeekBackward)
.setIconResId(androidx.media3.ui.R.drawable.exo_notification_rewind)
.build()
// Player Registry
fun registerPlayer(player: ExoPlayer) {
if (mediaSessionsList.containsKey(player)) {
return
}
val mediaSession = MediaSession.Builder(this, player)
.setId("RNVideoPlaybackService_" + player.hashCode())
.setCallback(VideoPlaybackCallback(SEEK_INTERVAL_MS))
.setCustomLayout(immutableListOf(seekBackwardBtn, seekForwardBtn))
.build()
mediaSessionsList[player] = mediaSession
addSession(mediaSession)
}
fun unregisterPlayer(player: ExoPlayer) {
hidePlayerNotification(player)
val session = mediaSessionsList.remove(player)
session?.release()
if (mediaSessionsList.isEmpty()) {
cleanup()
stopSelf()
}
}
// Callbacks
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
return null
}
override fun onBind(intent: Intent?): IBinder {
super.onBind(intent)
return binder
}
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
createSessionNotification(session)
}
override fun onTaskRemoved(rootIntent: Intent?) {
cleanup()
stopSelf()
}
override fun onDestroy() {
cleanup()
super.onDestroy()
}
private fun createSessionNotification(session: MediaSession) {
val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel(
NotificationChannel(
NOTIFICATION_CHANEL_ID,
NOTIFICATION_CHANEL_ID,
NotificationManager.IMPORTANCE_LOW
)
)
}
if (session.player.currentMediaItem == null) {
notificationManager.cancel(session.player.hashCode())
return
}
val notificationCompact = NotificationCompat.Builder(this, NOTIFICATION_CHANEL_ID)
.setSmallIcon(androidx.media3.session.R.drawable.media3_icon_circular_play)
.setStyle(MediaStyleNotificationHelper.MediaStyle(session))
.build()
notificationManager.notify(session.player.hashCode(), notificationCompact)
}
private fun hidePlayerNotification(player: ExoPlayer) {
val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(player.hashCode())
}
private fun hideAllNotifications() {
val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancelAll()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.deleteNotificationChannel(NOTIFICATION_CHANEL_ID)
}
}
private fun cleanup() {
hideAllNotifications()
mediaSessionsList.forEach { (_, session) ->
session.release()
}
mediaSessionsList.clear()
}
companion object {
const val COMMAND_SEEK_FORWARD = "SEEK_FORWARD"
const val COMMAND_SEEK_BACKWARD = "SEEK_BACKWARD"
const val NOTIFICATION_CHANEL_ID = "RNVIDEO_SESSION_NOTIFICATION"
const val SEEK_INTERVAL_MS = 10000L
}
}