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:
		| @@ -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> | ||||||
| @@ -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; | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -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) { | ||||||
|   | |||||||
| @@ -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) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -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 | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -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']} /> | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
| @@ -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; | ||||||
|           }} |           }} | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
							
								
								
									
										28
									
								
								ios/Video/DataStructures/CustomMetadata.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								ios/Video/DataStructures/CustomMetadata.swift
									
									
									
									
									
										Normal 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 ?? "" | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -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 |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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? { | ||||||
|   | |||||||
							
								
								
									
										281
									
								
								ios/Video/NowPlayingInfoCenterManager.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										281
									
								
								ios/Video/NowPlayingInfoCenterManager.swift
									
									
									
									
									
										Normal 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() | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -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?( | ||||||
|             [ |             [ | ||||||
|   | |||||||
| @@ -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); | ||||||
|   | |||||||
| @@ -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]); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user