diff --git a/README.md b/README.md
index dc8980da2b..23ae5e188a 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,6 @@ We have an discord server where you can ask questions and get help. [Join the di
\ No newline at end of file
diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
index 546c43a7b6..5ddc2bd8d4 100644
--- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
+++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
@@ -12,10 +12,15 @@
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 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.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.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 void onHostDestroy() {
cleanUpResources();
}
+ @Override
+ protected void onDetachedFromWindow() {
+ cleanupPlaybackService();
+ super.onDetachedFromWindow();
+ }
+
public void cleanUpResources() {
stopPlayback();
themedReactContext.removeLifecycleEventListener(this);
@@ -656,6 +676,10 @@ private void initializePlayerCore(ReactExoplayerView self) {
PlaybackParameters params = new PlaybackParameters(rate, 1f);
player.setPlaybackParameters(params);
changeAudioOutput(this.audioOutput);
+
+ if(showNotificationControls) {
+ setupPlaybackService();
+ }
}
private DrmSessionManager initializePlayerDrm(ReactExoplayerView self) {
@@ -741,6 +765,69 @@ private void finishPlayerInitialization() {
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 @@ private MediaSource buildMediaSource(Uri uri, String overrideExtension, DrmSessi
}
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 @@ private void releasePlayer() {
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 void onCues(CueGroup cueGroup) {
// ReactExoplayerViewManager public api
- public void setSrc(final Uri uri, final long startPositionMs, final long cropStartMs, final long cropEndMs, final String extension, Map headers) {
+ public void setSrc(final Uri uri, final long startPositionMs, final long cropStartMs, final long cropEndMs, final String extension, Map 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 void setSrc(final Uri uri, final long startPositionMs, final long cropSta
this.mediaDataSourceFactory =
DataSourceUtil.getDefaultDataSourceFactory(this.themedReactContext, bandwidthMeter,
this.requestHeaders);
+ this.customMetadata = customMetadata;
if (!isSourceEqual) {
reloadSource();
@@ -1573,6 +1688,7 @@ public void clearSrc() {
this.extension = null;
this.requestHeaders = null;
this.mediaDataSourceFactory = null;
+ customMetadata = null;
clearResumePosition();
}
}
@@ -1956,6 +2072,16 @@ public void setContentStartTime(int contentStartTime) {
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;
}
diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java
index 1f50faa1bd..6b5b0d3070 100644
--- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java
+++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java
@@ -7,6 +7,7 @@
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 {
+ 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)
+ }
+}
diff --git a/android/src/main/java/com/brentvatne/exoplayer/VideoPlaybackService.kt b/android/src/main/java/com/brentvatne/exoplayer/VideoPlaybackService.kt
new file mode 100644
index 0000000000..80bf34ff3d
--- /dev/null
+++ b/android/src/main/java/com/brentvatne/exoplayer/VideoPlaybackService.kt
@@ -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()
+ 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
+ }
+}
diff --git a/docs/pages/component/props.mdx b/docs/pages/component/props.mdx
index 945d10e2dc..b7c44c2348 100644
--- a/docs/pages/component/props.mdx
+++ b/docs/pages/component/props.mdx
@@ -706,20 +706,23 @@ source={{ uri: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8'
#### Overriding the metadata of a source
-
+
-Provide an optional `title`, `subtitle`, `customImageUri` and/or `description` properties for the video.
-Useful when to adapt the tvOS playback experience.
+Provide an optional `title`, `subtitle`, `artist`, `imageUri` and/or `description` properties for the video.
+Useful when using notification controls on Android or iOS or to adapt the tvOS playback experience.
Example:
```javascript
source={{
uri: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8',
- title: 'Custom Title',
- subtitle: 'Custom Subtitle',
- description: 'Custom Description',
- customImageUri: 'https://pbs.twimg.com/profile_images/1498641868397191170/6qW2XkuI_400x400.png'
+ metadata: {
+ title: 'Custom Title',
+ subtitle: 'Custom Subtitle',
+ artist: 'Custom Artist',
+ description: 'Custom Description',
+ imageUri: 'https://pbs.twimg.com/profile_images/1498641868397191170/6qW2XkuI_400x400.png'
+ }
}}
```
@@ -780,6 +783,45 @@ textTracks={[
]}
```
+### `showNotificationContorols`
+
+
+
+Controls whether to show media controls in the notification area.
+For Android each Video component will have its own notification controls and for iOS only one notification control will be shown for the last Active Video component.
+
+You propably want also set `playInBackground` to `true` to keep the video playing when the app is in the background or `playWhenInactive` to `true` to keep the video playing when notifications or the Control Center are in front of the video.
+
+To customize the notification controls you can use `metadata` property in the `source` prop.
+
+- **false (default)** - Don't show media controls in the notification area
+- **true** - Show media controls in the notification area
+
+**To test notification controls on iOS you need to run the app on a real device, as the simulator does not support it.**
+
+**For Android you have to add the following code in your `AndroidManifest.xml` file:**
+
+```xml
+
+ ...
+
+
+ ...
+
+
+ ...
+
+
+
+
+
+
+
+```
+
### `useSecureView`
diff --git a/examples/basic/android/app/src/main/AndroidManifest.xml b/examples/basic/android/app/src/main/AndroidManifest.xml
index 5b9cb5c5e8..0dfc19b3fa 100644
--- a/examples/basic/android/app/src/main/AndroidManifest.xml
+++ b/examples/basic/android/app/src/main/AndroidManifest.xml
@@ -10,6 +10,9 @@
+
+
+
+
+
+
+
+
diff --git a/examples/basic/src/VideoPlayer.tsx b/examples/basic/src/VideoPlayer.tsx
index 6baefccb55..d94baba5b6 100644
--- a/examples/basic/src/VideoPlayer.tsx
+++ b/examples/basic/src/VideoPlayer.tsx
@@ -71,6 +71,7 @@ interface StateType {
showRNVControls: boolean;
useCache: boolean;
poster?: string;
+ showNotificationControls: boolean;
}
class VideoPlayer extends Component {
@@ -100,6 +101,7 @@ class VideoPlayer extends Component {
showRNVControls: false,
useCache: false,
poster: undefined,
+ showNotificationControls: false,
};
seekerWidth = 0;
@@ -115,10 +117,24 @@ class VideoPlayer extends Component {
{
description: 'local file portrait',
uri: require('./portrait.mp4'),
+ metadata: {
+ title: 'Test Title',
+ subtitle: 'Test Subtitle',
+ artist: 'Test Artist',
+ description: 'Test Description',
+ imageUri: 'https://pbs.twimg.com/profile_images/1498641868397191170/6qW2XkuI_400x400.png'
+ }
},
{
description: '(hls|live) red bull tv',
uri: 'https://rbmn-live.akamaized.net/hls/live/590964/BoRB-AT/master_928.m3u8',
+ metadata: {
+ title: 'Custom Title',
+ subtitle: 'Custom Subtitle',
+ artist: 'Custom Artist',
+ description: 'Custom Description',
+ imageUri: 'https://pbs.twimg.com/profile_images/1498641868397191170/6qW2XkuI_400x400.png'
+ }
},
{
description: 'invalid URL',
@@ -410,6 +426,12 @@ class VideoPlayer extends Component {
}
}
+ toggleShowNotificationControls() {
+ this.setState({
+ showNotificationControls: !this.state.showNotificationControls,
+ });
+ }
+
goToChannel(channel: number) {
this.setState({
srcListId: channel,
@@ -729,6 +751,14 @@ class VideoPlayer extends Component {
selectedText="poster"
unselectedText="no poster"
/>
+ {
+ this.toggleShowNotificationControls();
+ }}
+ selectedText="hide notification controls"
+ unselectedText="show notification controls"
+ />
{/* shall be replaced by slider */}
@@ -858,6 +888,7 @@ class VideoPlayer extends Component {
return (