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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 908 additions and 105 deletions

View File

@ -61,6 +61,6 @@ We have an discord server where you can ask questions and get help. [Join the di
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="./docs/assets/baners/twg-dark.png" /> <source media="(prefers-color-scheme: dark)" srcset="./docs/assets/baners/twg-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./docs/assets/baners/twg-light.png" /> <source media="(prefers-color-scheme: light)" srcset="./docs/assets/baners/twg-light.png" />
<img alt="TheWidlarzGroup" src="./docs/assets/baners/twg-light-1.png" /> <img alt="TheWidlarzGroup" src="./docs/assets/baners/twg-light.png" />
</picture> </picture>
</a> </a>

View File

@ -12,10 +12,15 @@ import static com.brentvatne.exoplayer.DataSourceUtil.buildAssetDataSourceFactor
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.Activity; import android.app.Activity;
import android.app.ActivityManager; import android.app.ActivityManager;
import android.content.ComponentName;
import android.content.Context; import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.media.AudioManager; import android.media.AudioManager;
import android.net.Uri; import android.net.Uri;
import android.os.Build;
import android.os.Handler; import android.os.Handler;
import android.os.IBinder;
import android.os.Looper; import android.os.Looper;
import android.os.Message; import android.os.Message;
import android.text.TextUtils; import android.text.TextUtils;
@ -27,6 +32,7 @@ import android.widget.ImageButton;
import androidx.activity.OnBackPressedCallback; import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread; import androidx.annotation.WorkerThread;
import androidx.core.view.WindowCompat; import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsCompat;
@ -35,6 +41,7 @@ import androidx.media3.common.AudioAttributes;
import androidx.media3.common.C; import androidx.media3.common.C;
import androidx.media3.common.Format; import androidx.media3.common.Format;
import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Metadata; import androidx.media3.common.Metadata;
import androidx.media3.common.PlaybackException; import androidx.media3.common.PlaybackException;
import androidx.media3.common.PlaybackParameters; 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.emsg.EventMessage;
import androidx.media3.extractor.metadata.id3.Id3Frame; import androidx.media3.extractor.metadata.id3.Id3Frame;
import androidx.media3.extractor.metadata.id3.TextInformationFrame; import androidx.media3.extractor.metadata.id3.TextInformationFrame;
import androidx.media3.session.MediaSessionService;
import androidx.media3.ui.LegacyPlayerControlView; import androidx.media3.ui.LegacyPlayerControlView;
import com.brentvatne.common.api.BufferConfig; import com.brentvatne.common.api.BufferConfig;
@ -172,6 +180,10 @@ public class ReactExoplayerView extends FrameLayout implements
private ExoPlayer player; private ExoPlayer player;
private DefaultTrackSelector trackSelector; private DefaultTrackSelector trackSelector;
private boolean playerNeedsSource; private boolean playerNeedsSource;
private MediaMetadata customMetadata;
private ServiceConnection playbackServiceConnection;
private PlaybackServiceBinder playbackServiceBinder;
private int resumeWindow; private int resumeWindow;
private long resumePosition; private long resumePosition;
@ -224,6 +236,8 @@ public class ReactExoplayerView extends FrameLayout implements
private String[] drmLicenseHeader = null; private String[] drmLicenseHeader = null;
private boolean controls; private boolean controls;
private Uri adTagUrl; private Uri adTagUrl;
private boolean showNotificationControls = false;
// \ End props // \ End props
// React // React
@ -342,6 +356,12 @@ public class ReactExoplayerView extends FrameLayout implements
cleanUpResources(); cleanUpResources();
} }
@Override
protected void onDetachedFromWindow() {
cleanupPlaybackService();
super.onDetachedFromWindow();
}
public void cleanUpResources() { public void cleanUpResources() {
stopPlayback(); stopPlayback();
themedReactContext.removeLifecycleEventListener(this); themedReactContext.removeLifecycleEventListener(this);
@ -656,6 +676,10 @@ public class ReactExoplayerView extends FrameLayout implements
PlaybackParameters params = new PlaybackParameters(rate, 1f); PlaybackParameters params = new PlaybackParameters(rate, 1f);
player.setPlaybackParameters(params); player.setPlaybackParameters(params);
changeAudioOutput(this.audioOutput); changeAudioOutput(this.audioOutput);
if(showNotificationControls) {
setupPlaybackService();
}
} }
private DrmSessionManager initializePlayerDrm(ReactExoplayerView self) { private DrmSessionManager initializePlayerDrm(ReactExoplayerView self) {
@ -741,6 +765,69 @@ public class ReactExoplayerView extends FrameLayout implements
applyModifiers(); 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 { private DrmSessionManager buildDrmSessionManager(UUID uuid, String licenseUrl, String[] keyRequestPropertiesArray) throws UnsupportedDrmException {
return buildDrmSessionManager(uuid, licenseUrl, keyRequestPropertiesArray, 0); return buildDrmSessionManager(uuid, licenseUrl, keyRequestPropertiesArray, 0);
} }
@ -795,7 +882,12 @@ public class ReactExoplayerView extends FrameLayout implements
} }
config.setDisableDisconnectError(this.disableDisconnectError); 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) { if (adTagUrl != null) {
mediaItemBuilder.setAdsConfiguration( mediaItemBuilder.setAdsConfiguration(
@ -935,12 +1027,20 @@ public class ReactExoplayerView extends FrameLayout implements
if (adsLoader != null) { if (adsLoader != null) {
adsLoader.setPlayer(null); adsLoader.setPlayer(null);
} }
if(playbackServiceBinder != null) {
playbackServiceBinder.getService().unregisterPlayer(player);
themedReactContext.unbindService(playbackServiceConnection);
}
updateResumePosition(); updateResumePosition();
player.release(); player.release();
player.removeListener(this); player.removeListener(this);
trackSelector = null; trackSelector = null;
player = null; player = null;
} }
if (adsLoader != null) { if (adsLoader != null) {
adsLoader.release(); adsLoader.release();
} }
@ -1542,7 +1642,21 @@ public class ReactExoplayerView extends FrameLayout implements
// ReactExoplayerViewManager public api // 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) { if (uri != null) {
boolean isSourceEqual = uri.equals(srcUri) && cropStartMs == this.cropStartMs && cropEndMs == this.cropEndMs; boolean isSourceEqual = uri.equals(srcUri) && cropStartMs == this.cropStartMs && cropEndMs == this.cropEndMs;
hasDrmFailed = false; hasDrmFailed = false;
@ -1555,6 +1669,7 @@ public class ReactExoplayerView extends FrameLayout implements
this.mediaDataSourceFactory = this.mediaDataSourceFactory =
DataSourceUtil.getDefaultDataSourceFactory(this.themedReactContext, bandwidthMeter, DataSourceUtil.getDefaultDataSourceFactory(this.themedReactContext, bandwidthMeter,
this.requestHeaders); this.requestHeaders);
this.customMetadata = customMetadata;
if (!isSourceEqual) { if (!isSourceEqual) {
reloadSource(); reloadSource();
@ -1573,6 +1688,7 @@ public class ReactExoplayerView extends FrameLayout implements
this.extension = null; this.extension = null;
this.requestHeaders = null; this.requestHeaders = null;
this.mediaDataSourceFactory = null; this.mediaDataSourceFactory = null;
customMetadata = null;
clearResumePosition(); clearResumePosition();
} }
} }
@ -1956,6 +2072,16 @@ public class ReactExoplayerView extends FrameLayout implements
this.contentStartTime = 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) { public void setDisableBuffering(boolean disableBuffering) {
this.disableBuffering = disableBuffering; this.disableBuffering = disableBuffering;
} }

View File

@ -7,6 +7,7 @@ import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import androidx.media3.datasource.RawResourceDataSource; import androidx.media3.datasource.RawResourceDataSource;
import androidx.media3.exoplayer.DefaultLoadControl; 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_START_POSITION = "startPosition";
private static final String PROP_SRC_CROP_START = "cropStart"; private static final String PROP_SRC_CROP_START = "cropStart";
private static final String PROP_SRC_CROP_END = "cropEnd"; 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_AD_TAG_URL = "adTagUrl";
private static final String PROP_SRC_TYPE = "type"; private static final String PROP_SRC_TYPE = "type";
private static final String PROP_DRM = "drm"; 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_CONTROLS = "controls";
private static final String PROP_SUBTITLE_STYLE = "subtitleStyle"; private static final String PROP_SUBTITLE_STYLE = "subtitleStyle";
private static final String PROP_SHUTTER_COLOR = "shutterColor"; 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 static final String PROP_DEBUG = "debug";
private final ReactExoplayerConfig config; 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)) { if (TextUtils.isEmpty(uriString)) {
videoView.clearSrc(); videoView.clearSrc();
return; return;
@ -175,7 +204,7 @@ public class ReactExoplayerViewManager extends ViewGroupManager<ReactExoplayerVi
Uri srcUri = Uri.parse(uriString); Uri srcUri = Uri.parse(uriString);
if (srcUri != null) { if (srcUri != null) {
videoView.setSrc(srcUri, startPositionMs, cropStartMs, cropEndMs, extension, headers); videoView.setSrc(srcUri, startPositionMs, cropStartMs, cropEndMs, extension, headers, customMetadata);
} }
} else { } else {
int identifier = context.getResources().getIdentifier( int identifier = context.getResources().getIdentifier(
@ -400,6 +429,11 @@ public class ReactExoplayerViewManager extends ViewGroupManager<ReactExoplayerVi
videoView.setBufferConfig(config); 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) @ReactProp(name = PROP_DEBUG, defaultBoolean = false)
public void setDebug(final ReactExoplayerView videoView, public void setDebug(final ReactExoplayerView videoView,
@Nullable final ReadableMap debugConfig) { @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
}
}

View File

@ -706,20 +706,23 @@ source={{ uri: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8'
#### Overriding the metadata of a source #### Overriding the metadata of a source
<PlatformsList types={['tvOS']} /> <PlatformsList types={['Android, iOS, tvOS']} />
Provide an optional `title`, `subtitle`, `customImageUri` and/or `description` properties for the video. Provide an optional `title`, `subtitle`, `artist`, `imageUri` and/or `description` properties for the video.
Useful when to adapt the tvOS playback experience. Useful when using notification controls on Android or iOS or to adapt the tvOS playback experience.
Example: Example:
```javascript ```javascript
source={{ source={{
uri: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8', uri: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8',
title: 'Custom Title', metadata: {
subtitle: 'Custom Subtitle', title: 'Custom Title',
description: 'Custom Description', subtitle: 'Custom Subtitle',
customImageUri: 'https://pbs.twimg.com/profile_images/1498641868397191170/6qW2XkuI_400x400.png' artist: 'Custom Artist',
description: 'Custom Description',
imageUri: 'https://pbs.twimg.com/profile_images/1498641868397191170/6qW2XkuI_400x400.png'
}
}} }}
``` ```
@ -780,6 +783,45 @@ textTracks={[
]} ]}
``` ```
### `showNotificationContorols`
<PlatformsList types={['Android', 'iOS']}/>
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
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
...
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
...
<application>
...
<service
android:name="com.brentvatne.exoplayer.VideoPlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
</application>
</manifest>
```
### `useSecureView` ### `useSecureView`
<PlatformsList types={['Android']} /> <PlatformsList types={['Android']} />

View File

@ -10,6 +10,9 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<application <application
android:name=".MainApplication" android:name=".MainApplication"
android:allowBackup="true" android:allowBackup="true"
@ -32,5 +35,13 @@
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name="com.brentvatne.exoplayer.VideoPlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>

View File

@ -71,6 +71,7 @@ interface StateType {
showRNVControls: boolean; showRNVControls: boolean;
useCache: boolean; useCache: boolean;
poster?: string; poster?: string;
showNotificationControls: boolean;
} }
class VideoPlayer extends Component { class VideoPlayer extends Component {
@ -100,6 +101,7 @@ class VideoPlayer extends Component {
showRNVControls: false, showRNVControls: false,
useCache: false, useCache: false,
poster: undefined, poster: undefined,
showNotificationControls: false,
}; };
seekerWidth = 0; seekerWidth = 0;
@ -115,10 +117,24 @@ class VideoPlayer extends Component {
{ {
description: 'local file portrait', description: 'local file portrait',
uri: require('./portrait.mp4'), 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', description: '(hls|live) red bull tv',
uri: 'https://rbmn-live.akamaized.net/hls/live/590964/BoRB-AT/master_928.m3u8', 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', description: 'invalid URL',
@ -410,6 +426,12 @@ class VideoPlayer extends Component {
} }
} }
toggleShowNotificationControls() {
this.setState({
showNotificationControls: !this.state.showNotificationControls,
});
}
goToChannel(channel: number) { goToChannel(channel: number) {
this.setState({ this.setState({
srcListId: channel, srcListId: channel,
@ -729,6 +751,14 @@ class VideoPlayer extends Component {
selectedText="poster" selectedText="poster"
unselectedText="no poster" unselectedText="no poster"
/> />
<ToggleControl
isSelected={this.state.showNotificationControls}
onPress={() => {
this.toggleShowNotificationControls();
}}
selectedText="hide notification controls"
unselectedText="show notification controls"
/>
</View> </View>
<View style={styles.generalControls}> <View style={styles.generalControls}>
{/* shall be replaced by slider */} {/* shall be replaced by slider */}
@ -858,6 +888,7 @@ class VideoPlayer extends Component {
return ( return (
<TouchableOpacity style={viewStyle}> <TouchableOpacity style={viewStyle}>
<Video <Video
showNotificationControls={this.state.showNotificationControls}
ref={(ref: VideoRef) => { ref={(ref: VideoRef) => {
this.video = ref; this.video = ref;
}} }}

View File

@ -13,11 +13,13 @@ export default function App() {
uri: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', uri: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
// uri: 'https://sample.vodobox.net/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8', // uri: 'https://sample.vodobox.net/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8',
// type: 'm3u8', // type: 'm3u8',
title: 'Custom Title', metadata: {
subtitle: 'Custom Subtitle', title: 'Custom Title',
description: 'Custom Description', subtitle: 'Custom Subtitle',
customImageUri: description: 'Custom Description',
'https://pbs.twimg.com/profile_images/1498641868397191170/6qW2XkuI_400x400.png', imageUri:
'https://pbs.twimg.com/profile_images/1498641868397191170/6qW2XkuI_400x400.png',
},
}} }}
style={[styles.fullScreen, StyleSheet.absoluteFillObject]} style={[styles.fullScreen, StyleSheet.absoluteFillObject]}
controls controls

View File

@ -0,0 +1,28 @@
struct CustomMetadata {
let title: String?
let subtitle: String?
let artist: String?
let description: String?
let imageUri: String?
let json: NSDictionary?
init(_ json: NSDictionary?) {
guard let json else {
self.json = nil
title = nil
subtitle = nil
artist = nil
description = nil
imageUri = nil
return
}
self.json = json
title = json["title"] as? String ?? ""
subtitle = json["subtitle"] as? String ?? ""
artist = json["artist"] as? String ?? ""
description = json["description"] as? String ?? ""
imageUri = json["imageUri"] as? String ?? ""
}
}

View File

@ -8,11 +8,7 @@ struct VideoSource {
let startPosition: Float64? let startPosition: Float64?
let cropStart: Int64? let cropStart: Int64?
let cropEnd: Int64? let cropEnd: Int64?
// Custom Metadata let customMetadata: CustomMetadata?
let title: String?
let subtitle: String?
let description: String?
let customImageUri: String?
let json: NSDictionary? let json: NSDictionary?
@ -28,10 +24,7 @@ struct VideoSource {
self.startPosition = nil self.startPosition = nil
self.cropStart = nil self.cropStart = nil
self.cropEnd = nil self.cropEnd = nil
self.title = nil self.customMetadata = nil
self.subtitle = nil
self.description = nil
self.customImageUri = nil
return return
} }
self.json = json self.json = json
@ -54,9 +47,6 @@ struct VideoSource {
self.startPosition = json["startPosition"] as? Float64 self.startPosition = json["startPosition"] as? Float64
self.cropStart = (json["cropStart"] as? Float64).flatMap { Int64(round($0)) } self.cropStart = (json["cropStart"] as? Float64).flatMap { Int64(round($0)) }
self.cropEnd = (json["cropEnd"] as? Float64).flatMap { Int64(round($0)) } self.cropEnd = (json["cropEnd"] as? Float64).flatMap { Int64(round($0)) }
self.title = json["title"] as? String self.customMetadata = CustomMetadata(json["metadata"] as? NSDictionary)
self.subtitle = json["subtitle"] as? String
self.description = json["description"] as? String
self.customImageUri = json["customImageUri"] as? String
} }
} }

View File

@ -389,15 +389,21 @@ enum RCTVideoUtils {
return item.copy() as! AVMetadataItem return item.copy() as! AVMetadataItem
} }
static func createImageMetadataItem(imageUri: String) -> Data? { static func createImageMetadataItem(imageUri: String) async -> Data? {
if let uri = URL(string: imageUri), guard let url = URL(string: imageUri) else {
let imgData = try? Data(contentsOf: uri), return nil
let image = UIImage(data: imgData),
let pngData = image.pngData() {
return pngData
} }
return nil do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data), let pngData = image.pngData() else {
return nil
}
return pngData
} catch {
print("Error fetching image data: \(error.localizedDescription)")
return nil
}
} }
static func getCurrentWindow() -> UIWindow? { static func getCurrentWindow() -> UIWindow? {

View File

@ -0,0 +1,281 @@
import Foundation
import MediaPlayer
class NowPlayingInfoCenterManager {
static let shared = NowPlayingInfoCenterManager()
private let SEEK_INTERVAL_SECONDS: Double = 10
private weak var currentPlayer: AVPlayer?
private var players = NSHashTable<AVPlayer>.weakObjects()
private var observers: [Int: NSKeyValueObservation] = [:]
private var playbackObserver: Any?
private var playTarget: Any?
private var pauseTarget: Any?
private var skipForwardTarget: Any?
private var skipBackwardTarget: Any?
private var playbackPositionTarget: Any?
private var seekTarget: Any?
private var receivingRemoveControlEvents = false {
didSet {
if receivingRemoveControlEvents {
try? AVAudioSession.sharedInstance().setCategory(.playback)
try? AVAudioSession.sharedInstance().setActive(true)
UIApplication.shared.beginReceivingRemoteControlEvents()
} else {
UIApplication.shared.endReceivingRemoteControlEvents()
}
}
}
deinit {
cleanup()
}
func registerPlayer(player: AVPlayer) {
if players.contains(player) {
return
}
if receivingRemoveControlEvents == false {
receivingRemoveControlEvents = true
}
if let oldObserver = observers[player.hashValue] {
oldObserver.invalidate()
}
observers[player.hashValue] = observePlayers(player: player)
players.add(player)
if currentPlayer == nil {
setCurrentPlayer(player: player)
}
}
func removePlayer(player: AVPlayer) {
if !players.contains(player) {
return
}
if let observer = observers[players.hashValue] {
observer.invalidate()
}
observers.removeValue(forKey: players.hashValue)
players.remove(player)
if currentPlayer == player {
currentPlayer = nil
updateMetadata()
}
if players.allObjects.isEmpty {
cleanup()
}
}
public func cleanup() {
observers.removeAll()
players.removeAllObjects()
if let playbackObserver {
currentPlayer?.removeTimeObserver(playbackObserver)
}
let commandCenter = MPRemoteCommandCenter.shared()
invalidateTargets(commandCenter)
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
receivingRemoveControlEvents = false
}
private func setCurrentPlayer(player: AVPlayer) {
if player == currentPlayer {
return
}
if let playbackObserver {
currentPlayer?.removeTimeObserver(playbackObserver)
}
currentPlayer = player
registerTargets()
updateMetadata()
// one second observer
playbackObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(value: 1, timescale: 4),
queue: .global(),
using: { [weak self] _ in
self?.updatePlaybackInfo()
}
)
}
private func registerTargets() {
let commandCenter = MPRemoteCommandCenter.shared()
invalidateTargets(commandCenter)
playTarget = commandCenter.playCommand.addTarget { [weak self] _ in
guard let self, let player = self.currentPlayer else {
return .commandFailed
}
if player.rate == 0 {
player.play()
}
return .success
}
pauseTarget = commandCenter.pauseCommand.addTarget { [weak self] _ in
guard let self, let player = self.currentPlayer else {
return .commandFailed
}
if player.rate != 0 {
player.pause()
}
return .success
}
skipBackwardTarget = commandCenter.skipBackwardCommand.addTarget { [weak self] _ in
guard let self, let player = self.currentPlayer else {
return .commandFailed
}
let newTime = player.currentTime() - CMTime(seconds: SEEK_INTERVAL_SECONDS, preferredTimescale: .max)
player.seek(to: newTime)
return .success
}
skipForwardTarget = commandCenter.skipForwardCommand.addTarget { [weak self] _ in
guard let self, let player = self.currentPlayer else {
return .commandFailed
}
let newTime = player.currentTime() + CMTime(seconds: SEEK_INTERVAL_SECONDS, preferredTimescale: .max)
player.seek(to: newTime)
return .success
}
playbackPositionTarget = commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let self, let player = self.currentPlayer else {
return .commandFailed
}
if let event = event as? MPChangePlaybackPositionCommandEvent {
player.seek(to: CMTime(seconds: event.positionTime, preferredTimescale: .max)) { _ in
player.play()
}
return .success
}
return .commandFailed
}
}
private func invalidateTargets(_ commandCenter: MPRemoteCommandCenter) {
commandCenter.playCommand.removeTarget(playTarget)
commandCenter.pauseCommand.removeTarget(pauseTarget)
commandCenter.skipForwardCommand.removeTarget(skipForwardTarget)
commandCenter.skipBackwardCommand.removeTarget(skipBackwardTarget)
commandCenter.changePlaybackPositionCommand.removeTarget(playbackPositionTarget)
}
public func updateMetadata() {
guard let player = currentPlayer, let currentItem = player.currentItem else {
let commandCenter = MPRemoteCommandCenter.shared()
invalidateTargets(commandCenter)
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
return
}
// commonMetadata is metadata from asset, externalMetadata is custom metadata set by user
let metadata = currentItem.asset.commonMetadata + currentItem.externalMetadata
var nowPlayingInfo: [String: Any] = [:]
if let titleItem = AVMetadataItem.metadataItems(from: metadata, filteredByIdentifier: .commonIdentifierTitle).first?.value {
nowPlayingInfo[MPMediaItemPropertyTitle] = titleItem
} else {
nowPlayingInfo[MPMediaItemPropertyTitle] = ""
}
if let artistItem = AVMetadataItem.metadataItems(from: metadata, filteredByIdentifier: .commonIdentifierArtist).first?.value {
nowPlayingInfo[MPMediaItemPropertyArtist] = artistItem
} else {
nowPlayingInfo[MPMediaItemPropertyArtist] = ""
}
// I have some issue with this - setting artworkItem when it not set dont return nil but also is crashing application
// this is very hacky workaround for it
if let artworkItem = AVMetadataItem.metadataItems(from: metadata, filteredByIdentifier: .commonIdentifierArtwork).first?.value as? Data {
if let image = UIImage(data: artworkItem) {
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size, requestHandler: { _ in
return image
})
}
} else {
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: UIImage().size, requestHandler: { _ in
UIImage()
})
}
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = currentItem.duration.seconds
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentItem.currentTime().seconds
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate
nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
private func updatePlaybackInfo() {
guard let player = currentPlayer, let currentItem = player.currentItem else {
return
}
// We dont want to update playback if we did not set metadata yet
if var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo {
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = currentItem.duration.seconds
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentItem.currentTime().seconds.rounded()
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
}
private func findNewCurrentPlayer() {
if let newPlayer = players.allObjects.first(where: {
$0.rate != 0
}) {
setCurrentPlayer(player: newPlayer)
}
}
// We will observe players rate to find last active player that info will be displayed
private func observePlayers(player: AVPlayer) -> NSKeyValueObservation {
return player.observe(\.rate) { [weak self] player, change in
guard let self else { return }
let rate = change.newValue
// case where there is new player that is not paused
// In this case event is triggered by non currentPlayer
if rate != 0 && currentPlayer != player {
setCurrentPlayer(player: player)
return
}
// case where currentPlayer was paused
// In this case event is triggeret by currentPlayer
if rate == 0 && currentPlayer == player {
findNewCurrentPlayer()
}
}
}
}

View File

@ -66,6 +66,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
private var _filterEnabled = false private var _filterEnabled = false
private var _presentingViewController: UIViewController? private var _presentingViewController: UIViewController?
private var _startPosition: Float64 = -1 private var _startPosition: Float64 = -1
private var _showNotificationControls = false
private var _pictureInPictureEnabled = false { private var _pictureInPictureEnabled = false {
didSet { didSet {
#if os(iOS) #if os(iOS)
@ -244,6 +245,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
self.removePlayerLayer() self.removePlayerLayer()
_playerObserver.clearPlayer() _playerObserver.clearPlayer()
if let player = _player {
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
}
#if os(iOS) #if os(iOS)
_pip = nil _pip = nil
#endif #endif
@ -358,54 +363,54 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
// MARK: - Player and source // MARK: - Player and source
func preparePlayerItem() async throws -> AVPlayerItem { func preparePlayerItem() async throws -> AVPlayerItem {
guard let source = self._source else { guard let source = _source else {
DebugLog("The source not exist") DebugLog("The source not exist")
self.isSetSourceOngoing = false isSetSourceOngoing = false
self.applyNextSource() applyNextSource()
throw NSError(domain: "", code: 0, userInfo: nil) throw NSError(domain: "", code: 0, userInfo: nil)
} }
if let uri = source.uri, uri.starts(with: "ph://") { if let uri = source.uri, uri.starts(with: "ph://") {
let photoAsset = await RCTVideoUtils.preparePHAsset(uri: uri) let photoAsset = await RCTVideoUtils.preparePHAsset(uri: uri)
return await self.playerItemPrepareText(asset: photoAsset, assetOptions: nil, uri: source.uri ?? "") return await playerItemPrepareText(asset: photoAsset, assetOptions: nil, uri: source.uri ?? "")
} }
guard let assetResult = RCTVideoUtils.prepareAsset(source: source), guard let assetResult = RCTVideoUtils.prepareAsset(source: source),
let asset = assetResult.asset, let asset = assetResult.asset,
let assetOptions = assetResult.assetOptions else { let assetOptions = assetResult.assetOptions else {
DebugLog("Could not find video URL in source '\(String(describing: self._source))'") DebugLog("Could not find video URL in source '\(String(describing: _source))'")
self.isSetSourceOngoing = false isSetSourceOngoing = false
self.applyNextSource() applyNextSource()
throw NSError(domain: "", code: 0, userInfo: nil) throw NSError(domain: "", code: 0, userInfo: nil)
} }
guard let assetResult = RCTVideoUtils.prepareAsset(source: source), guard let assetResult = RCTVideoUtils.prepareAsset(source: source),
let asset = assetResult.asset, let asset = assetResult.asset,
let assetOptions = assetResult.assetOptions else { let assetOptions = assetResult.assetOptions else {
DebugLog("Could not find video URL in source '\(String(describing: self._source))'") DebugLog("Could not find video URL in source '\(String(describing: _source))'")
self.isSetSourceOngoing = false isSetSourceOngoing = false
self.applyNextSource() applyNextSource()
throw NSError(domain: "", code: 0, userInfo: nil) throw NSError(domain: "", code: 0, userInfo: nil)
} }
if let startPosition = self._source?.startPosition { if let startPosition = _source?.startPosition {
self._startPosition = startPosition / 1000 _startPosition = startPosition / 1000
} }
#if USE_VIDEO_CACHING #if USE_VIDEO_CACHING
if self._videoCache.shouldCache(source: source, textTracks: self._textTracks) { if _videoCache.shouldCache(source: source, textTracks: _textTracks) {
return try await self._videoCache.playerItemForSourceUsingCache(uri: source.uri, assetOptions: assetOptions) return try await _videoCache.playerItemForSourceUsingCache(uri: source.uri, assetOptions: assetOptions)
} }
#endif #endif
if self._drm != nil || self._localSourceEncryptionKeyScheme != nil { if _drm != nil || _localSourceEncryptionKeyScheme != nil {
self._resouceLoaderDelegate = RCTResourceLoaderDelegate( _resouceLoaderDelegate = RCTResourceLoaderDelegate(
asset: asset, asset: asset,
drm: self._drm, drm: _drm,
localSourceEncryptionKeyScheme: self._localSourceEncryptionKeyScheme, localSourceEncryptionKeyScheme: _localSourceEncryptionKeyScheme,
onVideoError: self.onVideoError, onVideoError: onVideoError,
onGetLicense: self.onGetLicense, onGetLicense: onGetLicense,
reactTag: self.reactTag reactTag: reactTag
) )
} }
@ -413,53 +418,62 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
func setupPlayer(playerItem: AVPlayerItem) async throws { func setupPlayer(playerItem: AVPlayerItem) async throws {
if !self.isSetSourceOngoing { if !isSetSourceOngoing {
DebugLog("setSrc has been canceled last step") DebugLog("setSrc has been canceled last step")
return return
} }
self._player?.pause() _player?.pause()
self._playerItem = playerItem _playerItem = playerItem
self._playerObserver.playerItem = self._playerItem _playerObserver.playerItem = _playerItem
self.setPreferredForwardBufferDuration(self._preferredForwardBufferDuration) setPreferredForwardBufferDuration(_preferredForwardBufferDuration)
self.setPlaybackRange(playerItem, withCropStart: self._source?.cropStart, withCropEnd: self._source?.cropEnd) setPlaybackRange(playerItem, withCropStart: _source?.cropStart, withCropEnd: _source?.cropEnd)
self.setFilter(self._filterName) setFilter(_filterName)
if let maxBitRate = self._maxBitRate { if let maxBitRate = _maxBitRate {
self._playerItem?.preferredPeakBitRate = Double(maxBitRate) _playerItem?.preferredPeakBitRate = Double(maxBitRate)
} }
self._player = self._player ?? AVPlayer() if _player == nil {
_player = AVPlayer()
_player!.replaceCurrentItem(with: playerItem)
self._player?.replaceCurrentItem(with: playerItem) // We need to register player after we set current item and only for init
NowPlayingInfoCenterManager.shared.registerPlayer(player: _player!)
} else {
_player?.replaceCurrentItem(with: playerItem)
self._playerObserver.player = self._player // later we can just call "updateMetadata:
self.applyModifiers() NowPlayingInfoCenterManager.shared.updateMetadata()
self._player?.actionAtItemEnd = .none }
_playerObserver.player = _player
applyModifiers()
_player?.actionAtItemEnd = .none
if #available(iOS 10.0, *) { if #available(iOS 10.0, *) {
self.setAutomaticallyWaitsToMinimizeStalling(self._automaticallyWaitsToMinimizeStalling) setAutomaticallyWaitsToMinimizeStalling(_automaticallyWaitsToMinimizeStalling)
} }
#if USE_GOOGLE_IMA #if USE_GOOGLE_IMA
if self._adTagUrl != nil { if _adTagUrl != nil {
// Set up your content playhead and contentComplete callback. // Set up your content playhead and contentComplete callback.
self._contentPlayhead = IMAAVPlayerContentPlayhead(avPlayer: self._player!) _contentPlayhead = IMAAVPlayerContentPlayhead(avPlayer: _player!)
self._imaAdsManager.setUpAdsLoader() _imaAdsManager.setUpAdsLoader()
} }
#endif #endif
// Perform on next run loop, otherwise onVideoLoadStart is nil // Perform on next run loop, otherwise onVideoLoadStart is nil
self.onVideoLoadStart?([ onVideoLoadStart?([
"src": [ "src": [
"uri": self._source?.uri ?? NSNull(), "uri": _source?.uri ?? NSNull(),
"type": self._source?.type ?? NSNull(), "type": _source?.type ?? NSNull(),
"isNetwork": NSNumber(value: self._source?.isNetwork ?? false), "isNetwork": NSNumber(value: _source?.isNetwork ?? false),
], ],
"drm": self._drm?.json ?? NSNull(), "drm": _drm?.json ?? NSNull(),
"target": self.reactTag, "target": reactTag,
]) ])
self.isSetSourceOngoing = false isSetSourceOngoing = false
self.applyNextSource() applyNextSource()
} }
@objc @objc
@ -478,6 +492,11 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
self._player?.replaceCurrentItem(with: nil) self._player?.replaceCurrentItem(with: nil)
self.isSetSourceOngoing = false self.isSetSourceOngoing = false
self.applyNextSource() self.applyNextSource()
if let player = self._player {
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
}
DebugLog("setSrc Stopping playback") DebugLog("setSrc Stopping playback")
return return
} }
@ -500,6 +519,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
self.onVideoError?(["error": error.localizedDescription]) self.onVideoError?(["error": error.localizedDescription])
self.isSetSourceOngoing = false self.isSetSourceOngoing = false
self.applyNextSource() self.applyNextSource()
if let player = self._player {
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
}
} }
} }
} }
@ -523,7 +546,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
func playerItemPrepareText(asset: AVAsset!, assetOptions: NSDictionary?, uri: String) async -> AVPlayerItem { func playerItemPrepareText(asset: AVAsset!, assetOptions: NSDictionary?, uri: String) async -> AVPlayerItem {
if (self._textTracks == nil) || self._textTracks?.isEmpty == true || (uri.hasSuffix(".m3u8")) { if (self._textTracks == nil) || self._textTracks?.isEmpty == true || (uri.hasSuffix(".m3u8")) {
return self.playerItemPropegateMetadata(AVPlayerItem(asset: asset)) return await self.playerItemPropegateMetadata(AVPlayerItem(asset: asset))
} }
// AVPlayer can't airplay AVMutableCompositions // AVPlayer can't airplay AVMutableCompositions
@ -540,26 +563,30 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
self.setTextTracks(validTextTracks) self.setTextTracks(validTextTracks)
} }
return self.playerItemPropegateMetadata(AVPlayerItem(asset: mixComposition)) return await self.playerItemPropegateMetadata(AVPlayerItem(asset: mixComposition))
} }
func playerItemPropegateMetadata(_ playerItem: AVPlayerItem!) -> AVPlayerItem { func playerItemPropegateMetadata(_ playerItem: AVPlayerItem!) async -> AVPlayerItem {
var mapping: [AVMetadataIdentifier: Any] = [:] var mapping: [AVMetadataIdentifier: Any] = [:]
if let title = _source?.title { if let title = _source?.customMetadata?.title {
mapping[.commonIdentifierTitle] = title mapping[.commonIdentifierTitle] = title
} }
if let subtitle = _source?.subtitle { if let artist = _source?.customMetadata?.artist {
mapping[.commonIdentifierArtist] = artist
}
if let subtitle = _source?.customMetadata?.subtitle {
mapping[.iTunesMetadataTrackSubTitle] = subtitle mapping[.iTunesMetadataTrackSubTitle] = subtitle
} }
if let description = _source?.description { if let description = _source?.customMetadata?.description {
mapping[.commonIdentifierDescription] = description mapping[.commonIdentifierDescription] = description
} }
if let customImageUri = _source?.customImageUri, if let imageUri = _source?.customMetadata?.imageUri,
let imageData = RCTVideoUtils.createImageMetadataItem(imageUri: customImageUri) { let imageData = await RCTVideoUtils.createImageMetadataItem(imageUri: imageUri) {
mapping[.commonIdentifierArtwork] = imageData mapping[.commonIdentifierArtwork] = imageData
} }
@ -1006,6 +1033,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
func createPlayerViewController(player: AVPlayer, withPlayerItem _: AVPlayerItem) -> RCTVideoPlayerViewController { func createPlayerViewController(player: AVPlayer, withPlayerItem _: AVPlayerItem) -> RCTVideoPlayerViewController {
let viewController = RCTVideoPlayerViewController() let viewController = RCTVideoPlayerViewController()
viewController.showsPlaybackControls = self._controls viewController.showsPlaybackControls = self._controls
viewController.updatesNowPlayingInfoCenter = false
viewController.rctDelegate = self viewController.rctDelegate = self
viewController.preferredOrientation = _fullscreenOrientation viewController.preferredOrientation = _fullscreenOrientation
@ -1057,6 +1085,21 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
} }
@objc
func setShowNotificationControls(_ showNotificationControls: Bool) {
guard let player = _player else {
return
}
if showNotificationControls {
NowPlayingInfoCenterManager.shared.registerPlayer(player: player)
} else {
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
}
_showNotificationControls = showNotificationControls
}
@objc @objc
func setProgressUpdateInterval(_ progressUpdateInterval: Float) { func setProgressUpdateInterval(_ progressUpdateInterval: Float) {
_playerObserver.replaceTimeObserverIfSet(Float64(progressUpdateInterval)) _playerObserver.replaceTimeObserverIfSet(Float64(progressUpdateInterval))
@ -1182,7 +1225,11 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
// MARK: - Lifecycle // MARK: - Lifecycle
override func removeFromSuperview() { override func removeFromSuperview() {
_player?.pause() if let player = _player {
player.pause()
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
}
_player = nil _player = nil
_resouceLoaderDelegate = nil _resouceLoaderDelegate = nil
_playerObserver.clearPlayer() _playerObserver.clearPlayer()
@ -1354,6 +1401,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
func handlePlaybackFailed() { func handlePlaybackFailed() {
if let player = _player {
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
}
guard let _playerItem else { return } guard let _playerItem else { return }
onVideoError?( onVideoError?(
[ [

View File

@ -37,6 +37,7 @@ RCT_EXPORT_VIEW_PROPERTY(progressUpdateInterval, float);
RCT_EXPORT_VIEW_PROPERTY(restoreUserInterfaceForPIPStopCompletionHandler, BOOL); RCT_EXPORT_VIEW_PROPERTY(restoreUserInterfaceForPIPStopCompletionHandler, BOOL);
RCT_EXPORT_VIEW_PROPERTY(localSourceEncryptionKeyScheme, NSString); RCT_EXPORT_VIEW_PROPERTY(localSourceEncryptionKeyScheme, NSString);
RCT_EXPORT_VIEW_PROPERTY(subtitleStyle, NSDictionary); RCT_EXPORT_VIEW_PROPERTY(subtitleStyle, NSDictionary);
RCT_EXPORT_VIEW_PROPERTY(showNotificationControls, BOOL)
/* Should support: onLoadStart, onLoad, and onError to stay consistent with Image */ /* Should support: onLoadStart, onLoad, and onError to stay consistent with Image */
RCT_EXPORT_VIEW_PROPERTY(onVideoLoadStart, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onVideoLoadStart, RCTDirectEventBlock);
RCT_EXPORT_VIEW_PROPERTY(onVideoLoad, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onVideoLoad, RCTDirectEventBlock);

View File

@ -166,10 +166,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
startPosition: resolvedSource.startPosition ?? -1, startPosition: resolvedSource.startPosition ?? -1,
cropStart: resolvedSource.cropStart || 0, cropStart: resolvedSource.cropStart || 0,
cropEnd: resolvedSource.cropEnd, cropEnd: resolvedSource.cropEnd,
title: resolvedSource.title, metadata: resolvedSource.metadata,
subtitle: resolvedSource.subtitle,
description: resolvedSource.description,
customImageUri: resolvedSource.customImageUri,
}; };
}, [source]); }, [source]);

View File

@ -19,6 +19,13 @@ type Headers = ReadonlyArray<
}> }>
>; >;
type VideoMetadata = Readonly<{
title?: string;
subtitle?: string;
description?: string;
imageUri?: string;
}>;
export type VideoSrc = Readonly<{ export type VideoSrc = Readonly<{
uri?: string; uri?: string;
isNetwork?: boolean; isNetwork?: boolean;
@ -31,10 +38,7 @@ export type VideoSrc = Readonly<{
startPosition?: Float; startPosition?: Float;
cropStart?: Float; cropStart?: Float;
cropEnd?: Float; cropEnd?: Float;
title?: string; metadata?: VideoMetadata;
subtitle?: string;
description?: string;
customImageUri?: string;
}>; }>;
type DRMType = WithDefault<string, 'widevine'>; type DRMType = WithDefault<string, 'widevine'>;
@ -300,7 +304,7 @@ export interface VideoNativeProps extends ViewProps {
restoreUserInterfaceForPIPStopCompletionHandler?: boolean; restoreUserInterfaceForPIPStopCompletionHandler?: boolean;
localSourceEncryptionKeyScheme?: string; localSourceEncryptionKeyScheme?: string;
debug?: DebugConfig; debug?: DebugConfig;
showNotificationControls?: WithDefault<boolean, false>; // Android, iOS
bufferConfig?: BufferConfig; // Android bufferConfig?: BufferConfig; // Android
contentStartTime?: Int32; // Android contentStartTime?: Int32; // Android
currentPlaybackTime?: Double; // Android currentPlaybackTime?: Double; // Android

View File

@ -22,10 +22,7 @@ export type ReactVideoSourceProperties = {
startPosition?: number; startPosition?: number;
cropStart?: number; cropStart?: number;
cropEnd?: number; cropEnd?: number;
title?: string; metadata?: VideoMetadata;
subtitle?: string;
description?: string;
customImageUri?: string;
}; };
export type ReactVideoSource = Readonly< export type ReactVideoSource = Readonly<
@ -34,6 +31,14 @@ export type ReactVideoSource = Readonly<
} }
>; >;
export type VideoMetadata = Readonly<{
title?: string;
subtitle?: string;
description?: string;
artist?: string;
imageUri?: string;
}>;
export type DebugConfig = Readonly<{ export type DebugConfig = Readonly<{
enable?: boolean; enable?: boolean;
thread?: boolean; thread?: boolean;
@ -221,6 +226,7 @@ export interface ReactVideoProps extends ReactVideoEvents, ViewProps {
repeat?: boolean; repeat?: boolean;
reportBandwidth?: boolean; //Android reportBandwidth?: boolean; //Android
resizeMode?: EnumValues<VideoResizeMode>; resizeMode?: EnumValues<VideoResizeMode>;
showNotificationControls?: boolean; // Android, iOS
selectedAudioTrack?: SelectedTrack; selectedAudioTrack?: SelectedTrack;
selectedTextTrack?: SelectedTrack; selectedTextTrack?: SelectedTrack;
selectedVideoTrack?: SelectedVideoTrack; // android selectedVideoTrack?: SelectedVideoTrack; // android