From 8ad4be459b935e30077e38cce696875ea9d6b21f Mon Sep 17 00:00:00 2001 From: Krzysztof Moch Date: Tue, 7 May 2024 12:30:57 +0200 Subject: [PATCH] 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 --- README.md | 2 +- .../exoplayer/ReactExoplayerView.java | 130 +++++++- .../exoplayer/ReactExoplayerViewManager.java | 36 ++- .../exoplayer/VideoPlaybackCallback.kt | 43 +++ .../exoplayer/VideoPlaybackService.kt | 150 ++++++++++ docs/pages/component/props.mdx | 56 +++- .../android/app/src/main/AndroidManifest.xml | 11 + examples/basic/src/VideoPlayer.tsx | 31 ++ examples/exampletvOS/App.tsx | 12 +- ios/Video/DataStructures/CustomMetadata.swift | 28 ++ ios/Video/DataStructures/VideoSource.swift | 16 +- ios/Video/Features/RCTVideoUtils.swift | 20 +- ios/Video/NowPlayingInfoCenterManager.swift | 281 ++++++++++++++++++ ios/Video/RCTVideo.swift | 163 ++++++---- ios/Video/RCTVideoManager.m | 1 + src/Video.tsx | 5 +- src/specs/VideoNativeComponent.ts | 14 +- src/types/video.ts | 14 +- 18 files changed, 908 insertions(+), 105 deletions(-) create mode 100644 android/src/main/java/com/brentvatne/exoplayer/VideoPlaybackCallback.kt create mode 100644 android/src/main/java/com/brentvatne/exoplayer/VideoPlaybackService.kt create mode 100644 ios/Video/DataStructures/CustomMetadata.swift create mode 100644 ios/Video/NowPlayingInfoCenterManager.swift 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 - TheWidlarzGroup + TheWidlarzGroup \ 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 (