diff --git a/example/src/App.tsx b/example/src/App.tsx index 819bf68b..00220134 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -146,6 +146,7 @@ const VideoDemo = () => { player.playWhenInactive = settings.playWhenInactive; player.mixAudioMode = settings.mixAudioMode; player.ignoreSilentSwitchMode = settings.ignoreSilentSwitchMode; + player.showNotificationControls = settings.showNotificationControls; }, [settings, player]); const handleSeek = (val: number) => { @@ -315,6 +316,13 @@ const VideoDemo = () => { value={settings.playWhenInactive} onValueChange={(value) => updateSetting('playWhenInactive', value)} /> + + updateSetting('showNotificationControls', value) + } + /> diff --git a/example/src/types/videoSettings.ts b/example/src/types/videoSettings.ts index 5112f2ee..0c1f731e 100644 --- a/example/src/types/videoSettings.ts +++ b/example/src/types/videoSettings.ts @@ -17,6 +17,7 @@ export interface VideoSettings { ignoreSilentSwitchMode: IgnoreSilentSwitchMode; playInBackground: boolean; playWhenInactive: boolean; + showNotificationControls: boolean; } export const defaultSettings: VideoSettings = { @@ -32,4 +33,5 @@ export const defaultSettings: VideoSettings = { ignoreSilentSwitchMode: 'auto', playInBackground: true, playWhenInactive: false, + showNotificationControls: true, }; diff --git a/example/src/utils/videoSource.ts b/example/src/utils/videoSource.ts index 7c03ad4b..20c5997c 100644 --- a/example/src/utils/videoSource.ts +++ b/example/src/utils/videoSource.ts @@ -103,5 +103,14 @@ export const getVideoSource = (type: VideoType): VideoConfig => { type: 'vtt', }, ], + metadata: { + title: 'Big Buck Bunny', + artist: 'Blender Foundation', + imageUri: + 'https://peach.blender.org/wp-content/uploads/title_anouncement.jpg', + subtitle: 'By the Blender Institute', + description: + 'Big Buck Bunny is a short computer-animated comedy film by the Blender Institute, part of the Blender Foundation. It was made using Blender, a free and open-source 3D creation suite.', + }, } as VideoConfig; }; diff --git a/packages/react-native-video/android/src/main/java/com/twg/video/core/extensions/VideoPlaybackService+ServiceManagment.kt b/packages/react-native-video/android/src/main/java/com/twg/video/core/extensions/VideoPlaybackService+ServiceManagment.kt index 1fbb08e5..52e29ac7 100644 --- a/packages/react-native-video/android/src/main/java/com/twg/video/core/extensions/VideoPlaybackService+ServiceManagment.kt +++ b/packages/react-native-video/android/src/main/java/com/twg/video/core/extensions/VideoPlaybackService+ServiceManagment.kt @@ -42,7 +42,11 @@ fun VideoPlaybackService.Companion.stopService( serviceConnection: VideoPlaybackServiceConnection ) { try { - NitroModules.applicationContext?.currentActivity?.unbindService(serviceConnection) + // Unregister the player first; this might stop the service if no players remain serviceConnection.unregisterPlayer(player) + // Ask service (if still connected) to stop when idle + try { serviceConnection.serviceBinder?.service?.stopIfNoPlayers() } catch (_: Exception) {} + // Then unbind + NitroModules.applicationContext?.currentActivity?.unbindService(serviceConnection) } catch (_: Exception) {} } diff --git a/packages/react-native-video/android/src/main/java/com/twg/video/core/player/MediaItemUtils.kt b/packages/react-native-video/android/src/main/java/com/twg/video/core/player/MediaItemUtils.kt index 64eb5733..329ada25 100644 --- a/packages/react-native-video/android/src/main/java/com/twg/video/core/player/MediaItemUtils.kt +++ b/packages/react-native-video/android/src/main/java/com/twg/video/core/player/MediaItemUtils.kt @@ -6,9 +6,11 @@ import androidx.annotation.OptIn import androidx.core.net.toUri import androidx.media3.common.MediaItem import androidx.media3.common.C +import androidx.media3.common.MediaMetadata import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi import com.margelo.nitro.video.BufferConfig +import com.margelo.nitro.video.CustomVideoMetadata import com.margelo.nitro.video.HybridVideoPlayerSource import com.margelo.nitro.video.LivePlaybackParams import com.margelo.nitro.video.NativeDrmParams @@ -39,6 +41,10 @@ fun createMediaItemFromVideoConfig( mediaItemBuilder.setLiveConfiguration(getLiveConfiguration(livePlaybackParams)) } + source.config.metadata?.let { metadata -> + mediaItemBuilder.setMediaMetadata(getCustomMetadata(metadata)) + } + return PluginsRegistry.shared.overrideMediaItemBuilder( source, mediaItemBuilder @@ -122,3 +128,14 @@ fun getLiveConfiguration( return liveConfiguration.build() } + +fun getCustomMetadata(metadata: CustomVideoMetadata): MediaMetadata { + return MediaMetadata.Builder() + .setDisplayTitle(metadata.title) + .setTitle(metadata.title) + .setSubtitle(metadata.subtitle) + .setDescription(metadata.description) + .setArtist(metadata.artist) + .setArtworkUri(metadata.imageUri?.toUri()) + .build() +} diff --git a/packages/react-native-video/android/src/main/java/com/twg/video/core/player/MediaSourceUtils.kt b/packages/react-native-video/android/src/main/java/com/twg/video/core/player/MediaSourceUtils.kt index cb5f3da7..0d422099 100644 --- a/packages/react-native-video/android/src/main/java/com/twg/video/core/player/MediaSourceUtils.kt +++ b/packages/react-native-video/android/src/main/java/com/twg/video/core/player/MediaSourceUtils.kt @@ -77,6 +77,10 @@ fun buildExternalSubtitlesMediaSource(context: Context, source: HybridVideoPlaye .setUri(source.uri.toUri()) .setSubtitleConfigurations(getSubtitlesConfiguration(source.config)) + source.config.metadata?.let { metadata -> + mediaItemBuilderWithSubtitles.setMediaMetadata(getCustomMetadata(metadata)) + } + val mediaItemBuilder = PluginsRegistry.shared.overrideMediaItemBuilder( source, mediaItemBuilderWithSubtitles diff --git a/packages/react-native-video/android/src/main/java/com/twg/video/core/services/playback/CustomMediaNotificationProvider.kt b/packages/react-native-video/android/src/main/java/com/twg/video/core/services/playback/CustomMediaNotificationProvider.kt new file mode 100644 index 00000000..294fc2c6 --- /dev/null +++ b/packages/react-native-video/android/src/main/java/com/twg/video/core/services/playback/CustomMediaNotificationProvider.kt @@ -0,0 +1,147 @@ +package com.twg.video.core.services.playback + +import android.content.Context +import android.app.PendingIntent +import android.content.Intent +import android.util.Log +import androidx.annotation.OptIn +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.MediaSession +import com.google.common.collect.ImmutableList +import androidx.core.os.bundleOf +import android.os.Bundle +import com.margelo.nitro.NitroModules +import com.twg.video.core.LibraryError + +@OptIn(UnstableApi::class) +class CustomMediaNotificationProvider(context: Context) : DefaultMediaNotificationProvider(context) { + + init { + setSmallIcon(androidx.media3.session.R.drawable.media3_notification_small_icon) + } + + fun getContext(): Context { + return NitroModules.applicationContext ?: run { + throw LibraryError.ApplicationContextNotFound + } + } + + override fun getNotificationContentTitle(metadata: MediaMetadata): CharSequence? { + return metadata.title + ?: metadata.displayTitle + ?: metadata.subtitle + ?: metadata.description + ?: "${getAppName()} is playing" + } + + override fun getNotificationContentText(metadata: MediaMetadata): CharSequence? { + return metadata.artist + ?: metadata.subtitle + ?: metadata.description + } + + companion object { + private const val SEEK_INTERVAL_MS = 10000L + private const val TAG = "CustomMediaNotificationProvider" + + enum class COMMAND(val stringValue: String) { + NONE("NONE"), + SEEK_FORWARD("COMMAND_SEEK_FORWARD"), + SEEK_BACKWARD("COMMAND_SEEK_BACKWARD"), + TOGGLE_PLAY("COMMAND_TOGGLE_PLAY"), + PLAY("COMMAND_PLAY"), + PAUSE("COMMAND_PAUSE") + } + + fun commandFromString(value: String): COMMAND = + when (value) { + COMMAND.SEEK_FORWARD.stringValue -> COMMAND.SEEK_FORWARD + COMMAND.SEEK_BACKWARD.stringValue -> COMMAND.SEEK_BACKWARD + COMMAND.TOGGLE_PLAY.stringValue -> COMMAND.TOGGLE_PLAY + COMMAND.PLAY.stringValue -> COMMAND.PLAY + COMMAND.PAUSE.stringValue -> COMMAND.PAUSE + else -> COMMAND.NONE + } + + fun handleCommand(command: COMMAND, session: MediaSession) { + // TODO: get somehow ControlsConfig here - for now hardcoded 10000ms + + when (command) { + COMMAND.SEEK_BACKWARD -> session.player.seekTo(session.player.contentPosition - SEEK_INTERVAL_MS) + COMMAND.SEEK_FORWARD -> session.player.seekTo(session.player.contentPosition + SEEK_INTERVAL_MS) + COMMAND.TOGGLE_PLAY -> handleCommand(if (session.player.isPlaying) COMMAND.PAUSE else COMMAND.PLAY, session) + COMMAND.PLAY -> session.player.play() + COMMAND.PAUSE -> session.player.pause() + else -> Log.w(TAG, "Received COMMAND.NONE - was there an error?") + } + } + } + + private fun getAppName(): String { + return try { + val context = getContext() + val pm = context.packageManager + val label = pm.getApplicationLabel(context.applicationInfo) + label.toString() + } catch (e: Exception) { + return "Unknown" + } + } + + override fun getMediaButtons( + session: MediaSession, + playerCommands: Player.Commands, + mediaButtonPreferences: ImmutableList, + showPauseButton: Boolean + ): ImmutableList { + val rewind = CommandButton.Builder() + .setDisplayName("Rewind") + .setSessionCommand(androidx.media3.session.SessionCommand( + COMMAND.SEEK_BACKWARD.stringValue, + Bundle.EMPTY + )) + .setIconResId(androidx.media3.session.R.drawable.media3_icon_skip_back_10) + .setExtras(bundleOf(COMMAND_KEY_COMPACT_VIEW_INDEX to 0)) + .build() + + val toggle = CommandButton.Builder() + .setDisplayName(if (showPauseButton) "Pause" else "Play") + .setSessionCommand(androidx.media3.session.SessionCommand( + COMMAND.TOGGLE_PLAY.stringValue, + Bundle.EMPTY + )) + .setIconResId( + if (showPauseButton) androidx.media3.session.R.drawable.media3_icon_pause + else androidx.media3.session.R.drawable.media3_icon_play + ) + .setExtras(bundleOf(COMMAND_KEY_COMPACT_VIEW_INDEX to 1)) + .build() + + val forward = CommandButton.Builder() + .setDisplayName("Forward") + .setSessionCommand(androidx.media3.session.SessionCommand( + COMMAND.SEEK_FORWARD.stringValue, + Bundle.EMPTY + )) + .setIconResId(androidx.media3.session.R.drawable.media3_icon_skip_forward_10) + .setExtras(bundleOf(COMMAND_KEY_COMPACT_VIEW_INDEX to 2)) + .build() + + return ImmutableList.of(rewind, toggle, forward) + } + + override fun addNotificationActions( + mediaSession: MediaSession, + mediaButtons: ImmutableList, + builder: androidx.core.app.NotificationCompat.Builder, + actionFactory: androidx.media3.session.MediaNotification.ActionFactory + ): IntArray { + // Use default behavior to add actions from our custom buttons and return compact indices + val compact = super.addNotificationActions(mediaSession, mediaButtons, builder, actionFactory) + return if (compact.isEmpty()) intArrayOf(0, 1, 2) else compact + } +} diff --git a/packages/react-native-video/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackCallback.kt b/packages/react-native-video/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackCallback.kt index 419aded1..25349a5d 100644 --- a/packages/react-native-video/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackCallback.kt +++ b/packages/react-native-video/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackCallback.kt @@ -4,13 +4,41 @@ import android.os.Bundle import androidx.annotation.OptIn import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.session.CommandButton import androidx.media3.session.MediaSession import androidx.media3.session.SessionCommand import androidx.media3.session.SessionResult import com.google.common.util.concurrent.ListenableFuture +import com.google.common.collect.ImmutableList @OptIn(UnstableApi::class) class VideoPlaybackCallback : MediaSession.Callback { + // For Android 13+ + private fun buildCustomButtons(): ImmutableList { + val rewind = CommandButton.Builder() + .setDisplayName("Rewind") + .setSessionCommand( + SessionCommand( + CustomMediaNotificationProvider.Companion.COMMAND.SEEK_BACKWARD.stringValue, + Bundle.EMPTY + ) + ) + .setIconResId(androidx.media3.session.R.drawable.media3_icon_skip_back_10) + .build() + + val forward = CommandButton.Builder() + .setDisplayName("Forward") + .setSessionCommand( + SessionCommand( + CustomMediaNotificationProvider.Companion.COMMAND.SEEK_FORWARD.stringValue, + Bundle.EMPTY + ) + ) + .setIconResId(androidx.media3.session.R.drawable.media3_icon_skip_forward_10) + .build() + + return ImmutableList.of(rewind, forward) + } override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { try { @@ -23,36 +51,46 @@ class VideoPlaybackCallback : MediaSession.Callback { ).setAvailableSessionCommands( MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() .add( - SessionCommand( - VideoPlaybackService.Companion.COMMAND.SEEK_FORWARD.stringValue, - Bundle.EMPTY - ) + SessionCommand( + CustomMediaNotificationProvider.Companion.COMMAND.SEEK_FORWARD.stringValue, + Bundle.EMPTY + ) ) .add( SessionCommand( - VideoPlaybackService.Companion.COMMAND.SEEK_BACKWARD.stringValue, - Bundle.EMPTY + CustomMediaNotificationProvider.Companion.COMMAND.SEEK_BACKWARD.stringValue, + Bundle.EMPTY ) ) - .build() - ) - .build() + .add( + SessionCommand( + CustomMediaNotificationProvider.Companion.COMMAND.TOGGLE_PLAY.stringValue, + Bundle.EMPTY + ) + ).build() + ).build() } catch (e: Exception) { return MediaSession.ConnectionResult.reject() } } + override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { + session.setCustomLayout(buildCustomButtons()) + super.onPostConnect(session, controller) + } + override fun onCustomCommand( session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle ): ListenableFuture { - VideoPlaybackService.Companion.handleCommand( - VideoPlaybackService.Companion.commandFromString( - customCommand.customAction - ), session + CustomMediaNotificationProvider.Companion.handleCommand( + CustomMediaNotificationProvider.Companion.commandFromString( + customCommand.customAction + ), session ) + return super.onCustomCommand(session, controller, customCommand, args) } } diff --git a/packages/react-native-video/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackService.kt b/packages/react-native-video/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackService.kt index 2f7f3f8c..bd10a6f2 100644 --- a/packages/react-native-video/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackService.kt +++ b/packages/react-native-video/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackService.kt @@ -1,30 +1,22 @@ package com.twg.video.core.services.playback -import android.annotation.SuppressLint import android.app.Activity -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager import android.app.PendingIntent import android.content.Intent import android.os.Binder -import android.os.Build -import android.os.Bundle import android.os.IBinder import android.util.Log import androidx.annotation.OptIn -import androidx.core.app.NotificationCompat -import androidx.lifecycle.OnLifecycleEvent +import androidx.media3.common.util.BitmapLoader import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.SimpleBitmapLoader import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService -import androidx.media3.session.MediaStyleNotificationHelper -import androidx.media3.session.SessionCommand -import androidx.media3.ui.R +import com.margelo.nitro.NitroModules import com.margelo.nitro.video.HybridVideoPlayer -import okhttp3.internal.immutableListOf +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors class VideoPlaybackServiceBinder(val service: VideoPlaybackService): Binder() @@ -32,26 +24,12 @@ class VideoPlaybackServiceBinder(val service: VideoPlaybackService): Binder() class VideoPlaybackService : MediaSessionService() { private var mediaSessionsList = mutableMapOf() private var binder = VideoPlaybackServiceBinder(this) - private var sourceActivity: Class? = null - private var placeholderCanceled = false + private var sourceActivity: Class? = null // retained for future deep-links; currently unused - // Controls for Android 13+ - see buildNotification function - private val commandSeekForward = SessionCommand(COMMAND.SEEK_FORWARD.stringValue, Bundle.EMPTY) - private val commandSeekBackward = SessionCommand(COMMAND.SEEK_BACKWARD.stringValue, Bundle.EMPTY) - - @SuppressLint("PrivateResource") - private val seekForwardBtn = CommandButton.Builder() - .setDisplayName("forward") - .setSessionCommand(commandSeekForward) - .setIconResId(R.drawable.exo_notification_fastforward) - .build() - - @SuppressLint("PrivateResource") - private val seekBackwardBtn = CommandButton.Builder() - .setDisplayName("backward") - .setSessionCommand(commandSeekBackward) - .setIconResId(R.drawable.exo_notification_rewind) - .build() + override fun onCreate() { + super.onCreate() + setMediaNotificationProvider(CustomMediaNotificationProvider(this)) + } // Player Registry fun registerPlayer(player: HybridVideoPlayer, from: Class) { @@ -60,27 +38,54 @@ class VideoPlaybackService : MediaSessionService() { } sourceActivity = from - val mediaSession = MediaSession.Builder(this, player.player) + val builder = MediaSession.Builder(this, player.player) .setId("RNVideoPlaybackService_" + player.hashCode()) .setCallback(VideoPlaybackCallback()) - .setCustomLayout(immutableListOf(seekBackwardBtn, seekForwardBtn)) - .build() + + // Ensure tapping the notification opens the app via sessionActivity + try { + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + if (launchIntent != null) { + launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + val contentIntent = PendingIntent.getActivity( + this, + 0, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + builder.setSessionActivity(contentIntent) + } + } catch (_: Exception) {} + + + val mediaSession = builder.build() mediaSessionsList[player] = mediaSession addSession(mediaSession) - - // Manually trigger initial notification creation for the registered player - // This ensures the player notification appears immediately, even if not playing - onUpdateNotification(mediaSession, true) } fun unregisterPlayer(player: HybridVideoPlayer) { - hidePlayerNotification(player.player) val session = mediaSessionsList.remove(player) session?.release() - if (mediaSessionsList.isEmpty()) { - cleanup() - stopSelf() + stopIfNoPlayers() + } + + fun updatePlayerPreferences(player: HybridVideoPlayer) { + val session = mediaSessionsList[player] + if (session == null) { + // If not registered but now needs it, register + if (player.playInBackground || player.showNotificationControls) { + val activity = try { NitroModules.applicationContext?.currentActivity } catch (_: Exception) { null } + if (activity != null) registerPlayer(player, activity.javaClass) + } + return + } + + // If no longer needs registration, unregister and possibly stop service + if (!player.playInBackground && !player.showNotificationControls) { + unregisterPlayer(player) + stopIfNoPlayers() + return } } @@ -93,235 +98,41 @@ class VideoPlaybackService : MediaSessionService() { return binder } - override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { - val notification = buildNotification(session) - val notificationId = session.player.hashCode() - val notificationManager: NotificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - - // Always cancel the placeholder notification once we have a real player notification - if (!placeholderCanceled) { - notificationManager.cancel(PLACEHOLDER_NOTIFICATION_ID) - placeholderCanceled = true - } - - if (startInForegroundRequired) { - startForeground(notificationId, notification) - } else { - 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(notificationId) - return - } - - notificationManager.notify(notificationId, notification) - } - } - override fun onTaskRemoved(rootIntent: Intent?) { + stopForegroundSafely() cleanup() stopSelf() } override fun onDestroy() { + stopForegroundSafely() cleanup() - val notificationManager: NotificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - notificationManager.deleteNotificationChannel(NOTIFICATION_CHANEL_ID) - } super.onDestroy() } - private fun buildNotification(session: MediaSession): Notification { - val returnToPlayer = Intent(this, sourceActivity ?: this.javaClass).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - - /* - * On Android 13+ controls are automatically handled via media session - * On Android 12 and bellow we need to add controls manually - */ - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - NotificationCompat.Builder(this, NOTIFICATION_CHANEL_ID) - .setSmallIcon(androidx.media3.session.R.drawable.media3_icon_circular_play) - .setStyle(MediaStyleNotificationHelper.MediaStyle(session)) - .setContentIntent(PendingIntent.getActivity(this, 0, returnToPlayer, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) - .build() - } else { - val playerId = session.player.hashCode() - - // Action for COMMAND.SEEK_BACKWARD - val seekBackwardIntent = Intent(this, VideoPlaybackService::class.java).apply { - putExtra("PLAYER_ID", playerId) - putExtra("ACTION", COMMAND.SEEK_BACKWARD.stringValue) - } - val seekBackwardPendingIntent = PendingIntent.getService( - this, - playerId * 10, - seekBackwardIntent, - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - // ACTION FOR COMMAND.TOGGLE_PLAY - val togglePlayIntent = Intent(this, VideoPlaybackService::class.java).apply { - putExtra("PLAYER_ID", playerId) - putExtra("ACTION", COMMAND.TOGGLE_PLAY.stringValue) - } - val togglePlayPendingIntent = PendingIntent.getService( - this, - playerId * 10 + 1, - togglePlayIntent, - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - // ACTION FOR COMMAND.SEEK_FORWARD - val seekForwardIntent = Intent(this, VideoPlaybackService::class.java).apply { - putExtra("PLAYER_ID", playerId) - putExtra("ACTION", COMMAND.SEEK_FORWARD.stringValue) - } - val seekForwardPendingIntent = PendingIntent.getService( - this, - playerId * 10 + 2, - seekForwardIntent, - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - NotificationCompat.Builder(this, NOTIFICATION_CHANEL_ID) - // Show controls on lock screen even when user hides sensitive content. - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setSmallIcon(androidx.media3.session.R.drawable.media3_icon_circular_play) - // Add media control buttons that invoke intents in your media service - .addAction(androidx.media3.session.R.drawable.media3_icon_rewind, "Seek Backward", seekBackwardPendingIntent) // #0 - .addAction( - if (session.player.isPlaying) { - androidx.media3.session.R.drawable.media3_icon_pause - } else { - androidx.media3.session.R.drawable.media3_icon_play - }, - "Toggle Play", - togglePlayPendingIntent - ) // #1 - .addAction(androidx.media3.session.R.drawable.media3_icon_fast_forward, "Seek Forward", seekForwardPendingIntent) // #2 - // Apply the media style template - .setStyle(MediaStyleNotificationHelper.MediaStyle(session).setShowActionsInCompactView(0, 1, 2)) - .setContentTitle(session.player.mediaMetadata.title) - .setContentText(session.player.mediaMetadata.description) - .setContentIntent(PendingIntent.getActivity(this, 0, returnToPlayer, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) - .setLargeIcon(session.player.mediaMetadata.artworkUri?.let { session.bitmapLoader.loadBitmap(it).get() }) - .setOngoing(true) - .build() - } - } - - private fun hidePlayerNotification(player: ExoPlayer) { - val notificationManager: NotificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancel(player.hashCode()) - } - - private fun hideAllNotifications() { - val notificationManager: NotificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancelAll() + private fun stopForegroundSafely() { + try { + stopForeground(STOP_FOREGROUND_REMOVE) + } catch (_: Exception) {} } private fun cleanup() { - hideAllNotifications() + stopForegroundSafely() + stopSelf() mediaSessionsList.forEach { (_, session) -> session.release() } mediaSessionsList.clear() - placeholderCanceled = false } - private fun createPlaceholderNotification(): Notification { - val notificationManager = getSystemService(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 - ) - ) + // Stop the service if there are no active media sessions (no players need it) + fun stopIfNoPlayers() { + if (mediaSessionsList.isEmpty()) { + cleanup() } - - return NotificationCompat.Builder(this, NOTIFICATION_CHANEL_ID) - .setSmallIcon(androidx.media3.session.R.drawable.media3_icon_circular_play) - .setContentTitle("Media playback") - .setContentText("Preparing playback") - .build() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !placeholderCanceled) { - startForeground(PLACEHOLDER_NOTIFICATION_ID, createPlaceholderNotification()) - } - - intent?.let { - val playerId = it.getIntExtra("PLAYER_ID", -1) - val actionCommand = it.getStringExtra("ACTION") - - if (playerId < 0) { - Log.w(TAG, "Received Command without playerId") - return super.onStartCommand(intent, flags, startId) - } - - if (actionCommand == null) { - Log.w(TAG, "Received Command without action command") - return super.onStartCommand(intent, flags, startId) - } - - val session = mediaSessionsList.values.find { s -> s.player.hashCode() == playerId } ?: return super.onStartCommand(intent, flags, startId) - - handleCommand(commandFromString(actionCommand), session) - } - return super.onStartCommand(intent, flags, startId) } companion object { - private const val SEEK_INTERVAL_MS = 10000L - private const val TAG = "VideoPlaybackService" - private const val PLACEHOLDER_NOTIFICATION_ID = 9999 - - const val NOTIFICATION_CHANEL_ID = "RNVIDEO_SESSION_NOTIFICATION" const val VIDEO_PLAYBACK_SERVICE_INTERFACE = SERVICE_INTERFACE - - enum class COMMAND(val stringValue: String) { - NONE("NONE"), - SEEK_FORWARD("COMMAND_SEEK_FORWARD"), - SEEK_BACKWARD("COMMAND_SEEK_BACKWARD"), - TOGGLE_PLAY("COMMAND_TOGGLE_PLAY"), - PLAY("COMMAND_PLAY"), - PAUSE("COMMAND_PAUSE") - } - - fun commandFromString(value: String): COMMAND = - when (value) { - COMMAND.SEEK_FORWARD.stringValue -> COMMAND.SEEK_FORWARD - COMMAND.SEEK_BACKWARD.stringValue -> COMMAND.SEEK_BACKWARD - COMMAND.TOGGLE_PLAY.stringValue -> COMMAND.TOGGLE_PLAY - COMMAND.PLAY.stringValue -> COMMAND.PLAY - COMMAND.PAUSE.stringValue -> COMMAND.PAUSE - else -> COMMAND.NONE - } - fun handleCommand(command: COMMAND, session: MediaSession) { - // TODO: get somehow ControlsConfig here - for now hardcoded 10000ms - - when (command) { - COMMAND.SEEK_BACKWARD -> session.player.seekTo(session.player.contentPosition - SEEK_INTERVAL_MS) - COMMAND.SEEK_FORWARD -> session.player.seekTo(session.player.contentPosition + SEEK_INTERVAL_MS) - COMMAND.TOGGLE_PLAY -> handleCommand(if (session.player.isPlaying) COMMAND.PAUSE else COMMAND.PLAY, session) - COMMAND.PLAY -> session.player.play() - COMMAND.PAUSE -> session.player.pause() - else -> Log.w(TAG, "Received COMMAND.NONE - was there an error?") - } - } } } diff --git a/packages/react-native-video/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackServiceConnection.kt b/packages/react-native-video/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackServiceConnection.kt index 383e9bce..ecc621f0 100644 --- a/packages/react-native-video/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackServiceConnection.kt +++ b/packages/react-native-video/android/src/main/java/com/twg/video/core/services/playback/VideoPlaybackServiceConnection.kt @@ -25,7 +25,10 @@ class VideoPlaybackServiceConnection (private val player: WeakReference.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 togglePlayPauseTarget: Any? + + private let remoteCommandCenter = MPRemoteCommandCenter.shared() + + var receivingRemoteControlEvents = false { + didSet { + if receivingRemoteControlEvents { + DispatchQueue.main.async { + VideoManager.shared.setRemoteControlEventsActive(true) + UIApplication.shared.beginReceivingRemoteControlEvents() + } + } else { + DispatchQueue.main.async { + UIApplication.shared.endReceivingRemoteControlEvents() + VideoManager.shared.setRemoteControlEventsActive(false) + } + } + } + } + + deinit { + cleanup() + } + + func registerPlayer(player: AVPlayer) { + if players.contains(player) { + return + } + + if receivingRemoteControlEvents == false { + receivingRemoteControlEvents = 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[player.hashValue] { + observer.invalidate() + } + + observers.removeValue(forKey: player.hashValue) + players.remove(player) + + if currentPlayer == player { + currentPlayer = nil + updateNowPlayingInfo() + } + + if players.allObjects.isEmpty { + cleanup() + } + } + + public func cleanup() { + observers.removeAll() + players.removeAllObjects() + + if let playbackObserver { + currentPlayer?.removeTimeObserver(playbackObserver) + } + + invalidateCommandTargets() + + MPNowPlayingInfoCenter.default().nowPlayingInfo = [:] + receivingRemoteControlEvents = false + } + + private func setCurrentPlayer(player: AVPlayer) { + if player == currentPlayer { + return + } + + if let playbackObserver { + currentPlayer?.removeTimeObserver(playbackObserver) + } + + currentPlayer = player + registerCommandTargets() + + updateNowPlayingInfo() + playbackObserver = player.addPeriodicTimeObserver( + forInterval: CMTime(value: 1, timescale: 4), + queue: .global(), + using: { [weak self] _ in + self?.updateNowPlayingInfo() + } + ) + } + + private func registerCommandTargets() { + invalidateCommandTargets() + + playTarget = remoteCommandCenter.playCommand.addTarget { [weak self] _ in + guard let self, let player = self.currentPlayer else { + return .commandFailed + } + + if player.rate == 0 { + player.play() + } + + return .success + } + + pauseTarget = remoteCommandCenter.pauseCommand.addTarget { [weak self] _ in + guard let self, let player = self.currentPlayer else { + return .commandFailed + } + + if player.rate != 0 { + player.pause() + } + + return .success + } + + skipBackwardTarget = remoteCommandCenter.skipBackwardCommand.addTarget { + [weak self] _ in + guard let self, let player = self.currentPlayer else { + return .commandFailed + } + let newTime = + player.currentTime() + - CMTime(seconds: self.SEEK_INTERVAL_SECONDS, preferredTimescale: .max) + player.seek(to: newTime) + return .success + } + + skipForwardTarget = remoteCommandCenter.skipForwardCommand.addTarget { + [weak self] _ in + guard let self, let player = self.currentPlayer else { + return .commandFailed + } + + let newTime = + player.currentTime() + + CMTime(seconds: self.SEEK_INTERVAL_SECONDS, preferredTimescale: .max) + player.seek(to: newTime) + return .success + } + + playbackPositionTarget = remoteCommandCenter.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) + ) + return .success + } + return .commandFailed + } + + // Handler for togglePlayPauseCommand, sent by Apple's Earpods wired headphones + togglePlayPauseTarget = remoteCommandCenter.togglePlayPauseCommand.addTarget + { [weak self] _ in + guard let self, let player = self.currentPlayer else { + return .commandFailed + } + + if player.rate == 0 { + player.play() + } else { + player.pause() + } + + return .success + } + } + + private func invalidateCommandTargets() { + remoteCommandCenter.playCommand.removeTarget(playTarget) + remoteCommandCenter.pauseCommand.removeTarget(pauseTarget) + remoteCommandCenter.skipForwardCommand.removeTarget(skipForwardTarget) + remoteCommandCenter.skipBackwardCommand.removeTarget(skipBackwardTarget) + remoteCommandCenter.changePlaybackPositionCommand.removeTarget( + playbackPositionTarget + ) + remoteCommandCenter.togglePlayPauseCommand.removeTarget( + togglePlayPauseTarget + ) + } + + public func updateNowPlayingInfo() { + guard let player = currentPlayer, let currentItem = player.currentItem + else { + invalidateCommandTargets() + MPNowPlayingInfoCenter.default().nowPlayingInfo = [:] + return + } + + // commonMetadata is metadata from asset, externalMetadata is custom metadata set by user + // externalMetadata should override commonMetadata to allow override metadata from source + // When the metadata has the tag "iTunSMPB" or "iTunNORM" then the metadata is not converted correctly and comes [nil, nil, ...] + // This leads to a crash of the app + let metadata: [AVMetadataItem] = { + + let common = processMetadataItems(currentItem.asset.commonMetadata) + let external = processMetadataItems(currentItem.externalMetadata) + + return Array(common.merging(external) { _, new in new }.values) + }() + + let titleItem = + AVMetadataItem.metadataItems( + from: metadata, + filteredByIdentifier: .commonIdentifierTitle + ).first?.stringValue ?? "" + + let artistItem = + AVMetadataItem.metadataItems( + from: metadata, + filteredByIdentifier: .commonIdentifierArtist + ).first?.stringValue ?? "" + + // 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 + let imgData = AVMetadataItem.metadataItems( + from: metadata, + filteredByIdentifier: .commonIdentifierArtwork + ).first?.dataValue + let image = imgData.flatMap { UIImage(data: $0) } ?? UIImage() + let artworkItem = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + + let newNowPlayingInfo: [String: Any] = [ + MPMediaItemPropertyTitle: titleItem, + MPMediaItemPropertyArtist: artistItem, + MPMediaItemPropertyArtwork: artworkItem, + MPMediaItemPropertyPlaybackDuration: currentItem.duration.seconds, + MPNowPlayingInfoPropertyElapsedPlaybackTime: currentItem.currentTime() + .seconds.rounded(), + MPNowPlayingInfoPropertyPlaybackRate: player.rate, + MPNowPlayingInfoPropertyIsLiveStream: CMTIME_IS_INDEFINITE( + currentItem.asset.duration + ), + ] + let currentNowPlayingInfo = + MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] + + MPNowPlayingInfoCenter.default().nowPlayingInfo = + currentNowPlayingInfo.merging(newNowPlayingInfo) { _, new in new } + } + + 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 && self.currentPlayer != player { + self.setCurrentPlayer(player: player) + return + } + + // case where currentPlayer was paused + // In this case event is triggered by currentPlayer + if rate == 0 && self.currentPlayer == player { + self.findNewCurrentPlayer() + } + } + } + + private func processMetadataItems(_ items: [AVMetadataItem]) -> [String: + AVMetadataItem] + { + var result = [String: AVMetadataItem]() + + for item in items { + if let id = item.identifier?.rawValue, !id.isEmpty, result[id] == nil { + result[id] = item + } + } + + return result + } +} diff --git a/packages/react-native-video/ios/core/VideoManager.swift b/packages/react-native-video/ios/core/VideoManager.swift index af3baac2..c89b52a3 100644 --- a/packages/react-native-video/ios/core/VideoManager.swift +++ b/packages/react-native-video/ios/core/VideoManager.swift @@ -95,6 +95,16 @@ class VideoManager { updateAudioSessionConfiguration() } + // MARK: - Remote Control Events + func setRemoteControlEventsActive(_ active: Bool) { + if isAudioSessionManagementDisabled || remoteControlEventsActive == active { + return + } + + remoteControlEventsActive = active + requestAudioSessionUpdate() + } + // MARK: - Audio Session Management private func activateAudioSession() { if isAudioSessionActive { @@ -133,7 +143,11 @@ class VideoManager { player.mixAudioMode == .donotmix } - if isAnyPlayerPlaying || anyPlayerNeedsNotMixWithOthers { + let anyPlayerNeedsNotificationControls = players.allObjects.contains { player in + player.showNotificationControls + } + + if isAnyPlayerPlaying || anyPlayerNeedsNotMixWithOthers || anyPlayerNeedsNotificationControls || remoteControlEventsActive { activateAudioSession() } else { deactivateAudioSession() @@ -162,6 +176,10 @@ class VideoManager { player.playInBackground } + let anyPlayerNeedsNotificationControls = players.allObjects.contains { player in + player.showNotificationControls + } + if isAudioSessionManagementDisabled { return } @@ -172,7 +190,7 @@ class VideoManager { earpiece: false, // TODO: Pass actual value after we add prop pip: anyViewNeedsPictureInPicture, backgroundPlayback: anyPlayerNeedsBackgroundPlayback, - notificationControls: false // TODO: Pass actual value after we add prop + notificationControls: anyPlayerNeedsNotificationControls ) let audioMixingMode = determineAudioMixingMode() diff --git a/packages/react-native-video/ios/hybrids/VideoPlayer/HybridVideoPlayer+Events.swift b/packages/react-native-video/ios/hybrids/VideoPlayer/HybridVideoPlayer+Events.swift index 8aabaab1..3db4bc7b 100644 --- a/packages/react-native-video/ios/hybrids/VideoPlayer/HybridVideoPlayer+Events.swift +++ b/packages/react-native-video/ios/hybrids/VideoPlayer/HybridVideoPlayer+Events.swift @@ -22,6 +22,7 @@ extension HybridVideoPlayer: VideoPlayerObserverDelegate { func onRateChanged(rate: Float) { eventEmitter.onPlaybackRateChange(Double(rate)) + NowPlayingInfoCenterManager.shared.updateNowPlayingInfo() updateAndEmitPlaybackState() } diff --git a/packages/react-native-video/ios/hybrids/VideoPlayer/HybridVideoPlayer.swift b/packages/react-native-video/ios/hybrids/VideoPlayer/HybridVideoPlayer.swift index ee0eaa21..6bd918da 100644 --- a/packages/react-native-video/ios/hybrids/VideoPlayer/HybridVideoPlayer.swift +++ b/packages/react-native-video/ios/hybrids/VideoPlayer/HybridVideoPlayer.swift @@ -158,6 +158,16 @@ class HybridVideoPlayer: HybridVideoPlayerSpec, NativeVideoPlayerSpec { return player.rate != 0 } + var showNotificationControls: Bool = false { + didSet { + if showNotificationControls { + NowPlayingInfoCenterManager.shared.registerPlayer(player: player) + } else { + NowPlayingInfoCenterManager.shared.removePlayer(player: player) + } + } + } + func initialize() throws -> Promise { return Promise.async { [weak self] in guard let self else { @@ -174,6 +184,7 @@ class HybridVideoPlayer: HybridVideoPlayerSpec, NativeVideoPlayerSpec { } func release() { + NowPlayingInfoCenterManager.shared.removePlayer(player: player) self.player.replaceCurrentItem(with: nil) self.playerItem = nil @@ -270,6 +281,7 @@ class HybridVideoPlayer: HybridVideoPlayerSpec, NativeVideoPlayerSpec { self.source = source self.playerItem = try await self.initializePlayerItem() self.player.replaceCurrentItem(with: self.playerItem) + NowPlayingInfoCenterManager.shared.updateNowPlayingInfo() promise.resolve(withResult: ()) } diff --git a/packages/react-native-video/ios/hybrids/VideoPlayerSource/HybridVideoPlayerSourceFactory.swift b/packages/react-native-video/ios/hybrids/VideoPlayerSource/HybridVideoPlayerSourceFactory.swift index e9c4c47d..8b6caa2a 100644 --- a/packages/react-native-video/ios/hybrids/VideoPlayerSource/HybridVideoPlayerSourceFactory.swift +++ b/packages/react-native-video/ios/hybrids/VideoPlayerSource/HybridVideoPlayerSourceFactory.swift @@ -8,12 +8,22 @@ import Foundation class HybridVideoPlayerSourceFactory: HybridVideoPlayerSourceFactorySpec { - func fromVideoConfig(config: NativeVideoConfig) throws -> any HybridVideoPlayerSourceSpec { + func fromVideoConfig(config: NativeVideoConfig) throws + -> any HybridVideoPlayerSourceSpec + { return try HybridVideoPlayerSource(config: config) } - + func fromUri(uri: String) throws -> HybridVideoPlayerSourceSpec { - let config = NativeVideoConfig(uri: uri, externalSubtitles: nil, drm: nil, headers: nil, bufferConfig: nil, initializeOnCreation: true) + let config = NativeVideoConfig( + uri: uri, + externalSubtitles: nil, + drm: nil, + headers: nil, + bufferConfig: nil, + metadata: nil, + initializeOnCreation: true + ) return try HybridVideoPlayerSource(config: config) } } diff --git a/packages/react-native-video/ios/view/VideoComponentView.swift b/packages/react-native-video/ios/view/VideoComponentView.swift index 120f64bb..1cf2423a 100644 --- a/packages/react-native-video/ios/view/VideoComponentView.swift +++ b/packages/react-native-video/ios/view/VideoComponentView.swift @@ -148,6 +148,9 @@ import AVKit controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] controller.view.backgroundColor = .clear + // We manage this manually in NowPlayingInfoCenterManager + controller.updatesNowPlayingInfoCenter = false + if #available(iOS 16.0, *) { if let initialSpeed = controller.speeds.first(where: { $0.rate == player.rate }) { controller.selectSpeed(initialSpeed) diff --git a/packages/react-native-video/nitrogen/generated/android/c++/JCustomVideoMetadata.hpp b/packages/react-native-video/nitrogen/generated/android/c++/JCustomVideoMetadata.hpp new file mode 100644 index 00000000..91a8b384 --- /dev/null +++ b/packages/react-native-video/nitrogen/generated/android/c++/JCustomVideoMetadata.hpp @@ -0,0 +1,70 @@ +/// +/// JCustomVideoMetadata.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2025 Marc Rousavy @ Margelo +/// + +#pragma once + +#include +#include "CustomVideoMetadata.hpp" + +#include +#include + +namespace margelo::nitro::video { + + using namespace facebook; + + /** + * The C++ JNI bridge between the C++ struct "CustomVideoMetadata" and the the Kotlin data class "CustomVideoMetadata". + */ + struct JCustomVideoMetadata final: public jni::JavaClass { + public: + static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/video/CustomVideoMetadata;"; + + public: + /** + * Convert this Java/Kotlin-based struct to the C++ struct CustomVideoMetadata by copying all values to C++. + */ + [[maybe_unused]] + [[nodiscard]] + CustomVideoMetadata toCpp() const { + static const auto clazz = javaClassStatic(); + static const auto fieldTitle = clazz->getField("title"); + jni::local_ref title = this->getFieldValue(fieldTitle); + static const auto fieldSubtitle = clazz->getField("subtitle"); + jni::local_ref subtitle = this->getFieldValue(fieldSubtitle); + static const auto fieldDescription = clazz->getField("description"); + jni::local_ref description = this->getFieldValue(fieldDescription); + static const auto fieldArtist = clazz->getField("artist"); + jni::local_ref artist = this->getFieldValue(fieldArtist); + static const auto fieldImageUri = clazz->getField("imageUri"); + jni::local_ref imageUri = this->getFieldValue(fieldImageUri); + return CustomVideoMetadata( + title != nullptr ? std::make_optional(title->toStdString()) : std::nullopt, + subtitle != nullptr ? std::make_optional(subtitle->toStdString()) : std::nullopt, + description != nullptr ? std::make_optional(description->toStdString()) : std::nullopt, + artist != nullptr ? std::make_optional(artist->toStdString()) : std::nullopt, + imageUri != nullptr ? std::make_optional(imageUri->toStdString()) : std::nullopt + ); + } + + public: + /** + * Create a Java/Kotlin-based struct by copying all values from the given C++ struct to Java. + */ + [[maybe_unused]] + static jni::local_ref fromCpp(const CustomVideoMetadata& value) { + return newInstance( + value.title.has_value() ? jni::make_jstring(value.title.value()) : nullptr, + value.subtitle.has_value() ? jni::make_jstring(value.subtitle.value()) : nullptr, + value.description.has_value() ? jni::make_jstring(value.description.value()) : nullptr, + value.artist.has_value() ? jni::make_jstring(value.artist.value()) : nullptr, + value.imageUri.has_value() ? jni::make_jstring(value.imageUri.value()) : nullptr + ); + } + }; + +} // namespace margelo::nitro::video diff --git a/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerSourceFactorySpec.cpp b/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerSourceFactorySpec.cpp index 429c5cff..62f47e87 100644 --- a/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerSourceFactorySpec.cpp +++ b/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerSourceFactorySpec.cpp @@ -25,6 +25,8 @@ namespace margelo::nitro::video { struct BufferConfig; } namespace margelo::nitro::video { struct LivePlaybackParams; } // Forward declaration of `Resolution` to properly resolve imports. namespace margelo::nitro::video { struct Resolution; } +// Forward declaration of `CustomVideoMetadata` to properly resolve imports. +namespace margelo::nitro::video { struct CustomVideoMetadata; } #include #include "HybridVideoPlayerSourceSpec.hpp" @@ -53,6 +55,8 @@ namespace margelo::nitro::video { struct Resolution; } #include "JLivePlaybackParams.hpp" #include "Resolution.hpp" #include "JResolution.hpp" +#include "CustomVideoMetadata.hpp" +#include "JCustomVideoMetadata.hpp" namespace margelo::nitro::video { diff --git a/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerSourceSpec.cpp b/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerSourceSpec.cpp index 35c50a0b..625752a4 100644 --- a/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerSourceSpec.cpp +++ b/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerSourceSpec.cpp @@ -23,6 +23,8 @@ namespace margelo::nitro::video { struct BufferConfig; } namespace margelo::nitro::video { struct LivePlaybackParams; } // Forward declaration of `Resolution` to properly resolve imports. namespace margelo::nitro::video { struct Resolution; } +// Forward declaration of `CustomVideoMetadata` to properly resolve imports. +namespace margelo::nitro::video { struct CustomVideoMetadata; } // Forward declaration of `VideoInformation` to properly resolve imports. namespace margelo::nitro::video { struct VideoInformation; } // Forward declaration of `VideoOrientation` to properly resolve imports. @@ -52,6 +54,8 @@ namespace margelo::nitro::video { enum class VideoOrientation; } #include "JLivePlaybackParams.hpp" #include "Resolution.hpp" #include "JResolution.hpp" +#include "CustomVideoMetadata.hpp" +#include "JCustomVideoMetadata.hpp" #include "VideoInformation.hpp" #include "JVideoInformation.hpp" #include "VideoOrientation.hpp" diff --git a/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerSpec.cpp b/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerSpec.cpp index abcf94e6..0afead62 100644 --- a/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerSpec.cpp +++ b/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerSpec.cpp @@ -72,6 +72,15 @@ namespace margelo::nitro::video { auto __result = method(_javaPart); return __result->cthis()->shared_cast(); } + bool JHybridVideoPlayerSpec::getShowNotificationControls() { + static const auto method = javaClassStatic()->getMethod("getShowNotificationControls"); + auto __result = method(_javaPart); + return static_cast(__result); + } + void JHybridVideoPlayerSpec::setShowNotificationControls(bool showNotificationControls) { + static const auto method = javaClassStatic()->getMethod("setShowNotificationControls"); + method(_javaPart, showNotificationControls); + } VideoPlayerStatus JHybridVideoPlayerSpec::getStatus() { static const auto method = javaClassStatic()->getMethod()>("getStatus"); auto __result = method(_javaPart); diff --git a/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerSpec.hpp b/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerSpec.hpp index 5b6717ab..292b07fd 100644 --- a/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerSpec.hpp +++ b/packages/react-native-video/nitrogen/generated/android/c++/JHybridVideoPlayerSpec.hpp @@ -51,6 +51,8 @@ namespace margelo::nitro::video { // Properties std::shared_ptr getSource() override; std::shared_ptr getEventEmitter() override; + bool getShowNotificationControls() override; + void setShowNotificationControls(bool showNotificationControls) override; VideoPlayerStatus getStatus() override; double getDuration() override; double getVolume() override; diff --git a/packages/react-native-video/nitrogen/generated/android/c++/JNativeVideoConfig.hpp b/packages/react-native-video/nitrogen/generated/android/c++/JNativeVideoConfig.hpp index dda2d39e..6bae0ffe 100644 --- a/packages/react-native-video/nitrogen/generated/android/c++/JNativeVideoConfig.hpp +++ b/packages/react-native-video/nitrogen/generated/android/c++/JNativeVideoConfig.hpp @@ -11,7 +11,9 @@ #include "NativeVideoConfig.hpp" #include "BufferConfig.hpp" +#include "CustomVideoMetadata.hpp" #include "JBufferConfig.hpp" +#include "JCustomVideoMetadata.hpp" #include "JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__string_____OnGetLicensePayload.hpp" #include "JLivePlaybackParams.hpp" #include "JNativeDrmParams.hpp" @@ -62,6 +64,8 @@ namespace margelo::nitro::video { jni::local_ref> headers = this->getFieldValue(fieldHeaders); static const auto fieldBufferConfig = clazz->getField("bufferConfig"); jni::local_ref bufferConfig = this->getFieldValue(fieldBufferConfig); + static const auto fieldMetadata = clazz->getField("metadata"); + jni::local_ref metadata = this->getFieldValue(fieldMetadata); static const auto fieldInitializeOnCreation = clazz->getField("initializeOnCreation"); jni::local_ref initializeOnCreation = this->getFieldValue(fieldInitializeOnCreation); return NativeVideoConfig( @@ -86,6 +90,7 @@ namespace margelo::nitro::video { return __map; }()) : std::nullopt, bufferConfig != nullptr ? std::make_optional(bufferConfig->toCpp()) : std::nullopt, + metadata != nullptr ? std::make_optional(metadata->toCpp()) : std::nullopt, initializeOnCreation != nullptr ? std::make_optional(static_cast(initializeOnCreation->value())) : std::nullopt ); } @@ -116,6 +121,7 @@ namespace margelo::nitro::video { return __map; }() : nullptr, value.bufferConfig.has_value() ? JBufferConfig::fromCpp(value.bufferConfig.value()) : nullptr, + value.metadata.has_value() ? JCustomVideoMetadata::fromCpp(value.metadata.value()) : nullptr, value.initializeOnCreation.has_value() ? jni::JBoolean::valueOf(value.initializeOnCreation.value()) : nullptr ); } diff --git a/packages/react-native-video/nitrogen/generated/android/kotlin/com/margelo/nitro/video/CustomVideoMetadata.kt b/packages/react-native-video/nitrogen/generated/android/kotlin/com/margelo/nitro/video/CustomVideoMetadata.kt new file mode 100644 index 00000000..0073c60c --- /dev/null +++ b/packages/react-native-video/nitrogen/generated/android/kotlin/com/margelo/nitro/video/CustomVideoMetadata.kt @@ -0,0 +1,41 @@ +/// +/// CustomVideoMetadata.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2025 Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.video + +import androidx.annotation.Keep +import com.facebook.proguard.annotations.DoNotStrip +import com.margelo.nitro.core.* + + +/** + * Represents the JavaScript object/struct "CustomVideoMetadata". + */ +@DoNotStrip +@Keep +data class CustomVideoMetadata + @DoNotStrip + @Keep + constructor( + @DoNotStrip + @Keep + val title: String?, + @DoNotStrip + @Keep + val subtitle: String?, + @DoNotStrip + @Keep + val description: String?, + @DoNotStrip + @Keep + val artist: String?, + @DoNotStrip + @Keep + val imageUri: String? + ) { + /* main constructor */ +} diff --git a/packages/react-native-video/nitrogen/generated/android/kotlin/com/margelo/nitro/video/HybridVideoPlayerSpec.kt b/packages/react-native-video/nitrogen/generated/android/kotlin/com/margelo/nitro/video/HybridVideoPlayerSpec.kt index 16fd90f6..e5565b58 100644 --- a/packages/react-native-video/nitrogen/generated/android/kotlin/com/margelo/nitro/video/HybridVideoPlayerSpec.kt +++ b/packages/react-native-video/nitrogen/generated/android/kotlin/com/margelo/nitro/video/HybridVideoPlayerSpec.kt @@ -45,6 +45,12 @@ abstract class HybridVideoPlayerSpec: HybridObject() { @get:Keep abstract val eventEmitter: HybridVideoPlayerEventEmitterSpec + @get:DoNotStrip + @get:Keep + @set:DoNotStrip + @set:Keep + abstract var showNotificationControls: Boolean + @get:DoNotStrip @get:Keep abstract val status: VideoPlayerStatus diff --git a/packages/react-native-video/nitrogen/generated/android/kotlin/com/margelo/nitro/video/NativeVideoConfig.kt b/packages/react-native-video/nitrogen/generated/android/kotlin/com/margelo/nitro/video/NativeVideoConfig.kt index 1d7fdba8..c3452ca0 100644 --- a/packages/react-native-video/nitrogen/generated/android/kotlin/com/margelo/nitro/video/NativeVideoConfig.kt +++ b/packages/react-native-video/nitrogen/generated/android/kotlin/com/margelo/nitro/video/NativeVideoConfig.kt @@ -38,6 +38,9 @@ data class NativeVideoConfig val bufferConfig: BufferConfig?, @DoNotStrip @Keep + val metadata: CustomVideoMetadata?, + @DoNotStrip + @Keep val initializeOnCreation: Boolean? ) { /* main constructor */ diff --git a/packages/react-native-video/nitrogen/generated/ios/ReactNativeVideo-Swift-Cxx-Bridge.hpp b/packages/react-native-video/nitrogen/generated/ios/ReactNativeVideo-Swift-Cxx-Bridge.hpp index a68e82c1..531b4de2 100644 --- a/packages/react-native-video/nitrogen/generated/ios/ReactNativeVideo-Swift-Cxx-Bridge.hpp +++ b/packages/react-native-video/nitrogen/generated/ios/ReactNativeVideo-Swift-Cxx-Bridge.hpp @@ -12,6 +12,8 @@ namespace margelo::nitro::video { struct BandwidthData; } // Forward declaration of `BufferConfig` to properly resolve imports. namespace margelo::nitro::video { struct BufferConfig; } +// Forward declaration of `CustomVideoMetadata` to properly resolve imports. +namespace margelo::nitro::video { struct CustomVideoMetadata; } // Forward declaration of `HybridVideoPlayerEventEmitterSpec` to properly resolve imports. namespace margelo::nitro::video { class HybridVideoPlayerEventEmitterSpec; } // Forward declaration of `HybridVideoPlayerFactorySpec` to properly resolve imports. @@ -82,6 +84,7 @@ namespace ReactNativeVideo { class HybridVideoViewViewManagerSpec_cxx; } // Include C++ defined types #include "BandwidthData.hpp" #include "BufferConfig.hpp" +#include "CustomVideoMetadata.hpp" #include "HybridVideoPlayerEventEmitterSpec.hpp" #include "HybridVideoPlayerFactorySpec.hpp" #include "HybridVideoPlayerSourceFactorySpec.hpp" @@ -872,6 +875,15 @@ namespace margelo::nitro::video::bridge::swift { return *optional; } + // pragma MARK: std::optional + /** + * Specialized version of `std::optional`. + */ + using std__optional_CustomVideoMetadata_ = std::optional; + inline std::optional create_std__optional_CustomVideoMetadata_(const CustomVideoMetadata& value) { + return std::optional(value); + } + // pragma MARK: std::shared_ptr> /** * Specialized version of `std::shared_ptr>`. diff --git a/packages/react-native-video/nitrogen/generated/ios/ReactNativeVideo-Swift-Cxx-Umbrella.hpp b/packages/react-native-video/nitrogen/generated/ios/ReactNativeVideo-Swift-Cxx-Umbrella.hpp index c4fd9a77..2d88026b 100644 --- a/packages/react-native-video/nitrogen/generated/ios/ReactNativeVideo-Swift-Cxx-Umbrella.hpp +++ b/packages/react-native-video/nitrogen/generated/ios/ReactNativeVideo-Swift-Cxx-Umbrella.hpp @@ -12,6 +12,8 @@ namespace margelo::nitro::video { struct BandwidthData; } // Forward declaration of `BufferConfig` to properly resolve imports. namespace margelo::nitro::video { struct BufferConfig; } +// Forward declaration of `CustomVideoMetadata` to properly resolve imports. +namespace margelo::nitro::video { struct CustomVideoMetadata; } // Forward declaration of `HybridVideoPlayerEventEmitterSpec` to properly resolve imports. namespace margelo::nitro::video { class HybridVideoPlayerEventEmitterSpec; } // Forward declaration of `HybridVideoPlayerFactorySpec` to properly resolve imports. @@ -76,6 +78,7 @@ namespace margelo::nitro::video { struct onVolumeChangeData; } // Include C++ defined types #include "BandwidthData.hpp" #include "BufferConfig.hpp" +#include "CustomVideoMetadata.hpp" #include "HybridVideoPlayerEventEmitterSpec.hpp" #include "HybridVideoPlayerFactorySpec.hpp" #include "HybridVideoPlayerSourceFactorySpec.hpp" diff --git a/packages/react-native-video/nitrogen/generated/ios/c++/HybridVideoPlayerSourceFactorySpecSwift.hpp b/packages/react-native-video/nitrogen/generated/ios/c++/HybridVideoPlayerSourceFactorySpecSwift.hpp index c32eac83..71d90811 100644 --- a/packages/react-native-video/nitrogen/generated/ios/c++/HybridVideoPlayerSourceFactorySpecSwift.hpp +++ b/packages/react-native-video/nitrogen/generated/ios/c++/HybridVideoPlayerSourceFactorySpecSwift.hpp @@ -30,6 +30,8 @@ namespace margelo::nitro::video { struct BufferConfig; } namespace margelo::nitro::video { struct LivePlaybackParams; } // Forward declaration of `Resolution` to properly resolve imports. namespace margelo::nitro::video { struct Resolution; } +// Forward declaration of `CustomVideoMetadata` to properly resolve imports. +namespace margelo::nitro::video { struct CustomVideoMetadata; } #include #include "HybridVideoPlayerSourceSpec.hpp" @@ -47,6 +49,7 @@ namespace margelo::nitro::video { struct Resolution; } #include "BufferConfig.hpp" #include "LivePlaybackParams.hpp" #include "Resolution.hpp" +#include "CustomVideoMetadata.hpp" #include "ReactNativeVideo-Swift-Cxx-Umbrella.hpp" diff --git a/packages/react-native-video/nitrogen/generated/ios/c++/HybridVideoPlayerSourceSpecSwift.hpp b/packages/react-native-video/nitrogen/generated/ios/c++/HybridVideoPlayerSourceSpecSwift.hpp index ec3bbf3c..3a3564fc 100644 --- a/packages/react-native-video/nitrogen/generated/ios/c++/HybridVideoPlayerSourceSpecSwift.hpp +++ b/packages/react-native-video/nitrogen/generated/ios/c++/HybridVideoPlayerSourceSpecSwift.hpp @@ -28,6 +28,8 @@ namespace margelo::nitro::video { struct BufferConfig; } namespace margelo::nitro::video { struct LivePlaybackParams; } // Forward declaration of `Resolution` to properly resolve imports. namespace margelo::nitro::video { struct Resolution; } +// Forward declaration of `CustomVideoMetadata` to properly resolve imports. +namespace margelo::nitro::video { struct CustomVideoMetadata; } // Forward declaration of `VideoInformation` to properly resolve imports. namespace margelo::nitro::video { struct VideoInformation; } // Forward declaration of `VideoOrientation` to properly resolve imports. @@ -47,6 +49,7 @@ namespace margelo::nitro::video { enum class VideoOrientation; } #include "BufferConfig.hpp" #include "LivePlaybackParams.hpp" #include "Resolution.hpp" +#include "CustomVideoMetadata.hpp" #include "VideoInformation.hpp" #include "VideoOrientation.hpp" diff --git a/packages/react-native-video/nitrogen/generated/ios/c++/HybridVideoPlayerSpecSwift.hpp b/packages/react-native-video/nitrogen/generated/ios/c++/HybridVideoPlayerSpecSwift.hpp index 697dea35..6ea8e857 100644 --- a/packages/react-native-video/nitrogen/generated/ios/c++/HybridVideoPlayerSpecSwift.hpp +++ b/packages/react-native-video/nitrogen/generated/ios/c++/HybridVideoPlayerSpecSwift.hpp @@ -82,6 +82,12 @@ namespace margelo::nitro::video { auto __result = _swiftPart.getEventEmitter(); return __result; } + inline bool getShowNotificationControls() noexcept override { + return _swiftPart.getShowNotificationControls(); + } + inline void setShowNotificationControls(bool showNotificationControls) noexcept override { + _swiftPart.setShowNotificationControls(std::forward(showNotificationControls)); + } inline VideoPlayerStatus getStatus() noexcept override { auto __result = _swiftPart.getStatus(); return static_cast(__result); diff --git a/packages/react-native-video/nitrogen/generated/ios/swift/CustomVideoMetadata.swift b/packages/react-native-video/nitrogen/generated/ios/swift/CustomVideoMetadata.swift new file mode 100644 index 00000000..a9a0e616 --- /dev/null +++ b/packages/react-native-video/nitrogen/generated/ios/swift/CustomVideoMetadata.swift @@ -0,0 +1,169 @@ +/// +/// CustomVideoMetadata.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2025 Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Represents an instance of `CustomVideoMetadata`, backed by a C++ struct. + */ +public typealias CustomVideoMetadata = margelo.nitro.video.CustomVideoMetadata + +public extension CustomVideoMetadata { + private typealias bridge = margelo.nitro.video.bridge.swift + + /** + * Create a new instance of `CustomVideoMetadata`. + */ + init(title: String?, subtitle: String?, description: String?, artist: String?, imageUri: String?) { + self.init({ () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = title { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }(), { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = subtitle { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }(), { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = description { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }(), { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = artist { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }(), { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = imageUri { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }()) + } + + var title: String? { + @inline(__always) + get { + return { () -> String? in + if let __unwrapped = self.__title.value { + return String(__unwrapped) + } else { + return nil + } + }() + } + @inline(__always) + set { + self.__title = { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = newValue { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }() + } + } + + var subtitle: String? { + @inline(__always) + get { + return { () -> String? in + if let __unwrapped = self.__subtitle.value { + return String(__unwrapped) + } else { + return nil + } + }() + } + @inline(__always) + set { + self.__subtitle = { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = newValue { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }() + } + } + + var description: String? { + @inline(__always) + get { + return { () -> String? in + if let __unwrapped = self.__description.value { + return String(__unwrapped) + } else { + return nil + } + }() + } + @inline(__always) + set { + self.__description = { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = newValue { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }() + } + } + + var artist: String? { + @inline(__always) + get { + return { () -> String? in + if let __unwrapped = self.__artist.value { + return String(__unwrapped) + } else { + return nil + } + }() + } + @inline(__always) + set { + self.__artist = { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = newValue { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }() + } + } + + var imageUri: String? { + @inline(__always) + get { + return { () -> String? in + if let __unwrapped = self.__imageUri.value { + return String(__unwrapped) + } else { + return nil + } + }() + } + @inline(__always) + set { + self.__imageUri = { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = newValue { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }() + } + } +} diff --git a/packages/react-native-video/nitrogen/generated/ios/swift/HybridVideoPlayerSpec.swift b/packages/react-native-video/nitrogen/generated/ios/swift/HybridVideoPlayerSpec.swift index 6cc90962..a72adeb6 100644 --- a/packages/react-native-video/nitrogen/generated/ios/swift/HybridVideoPlayerSpec.swift +++ b/packages/react-native-video/nitrogen/generated/ios/swift/HybridVideoPlayerSpec.swift @@ -13,6 +13,7 @@ public protocol HybridVideoPlayerSpec_protocol: HybridObject { // Properties var source: (any HybridVideoPlayerSourceSpec) { get } var eventEmitter: (any HybridVideoPlayerEventEmitterSpec) { get } + var showNotificationControls: Bool { get set } var status: VideoPlayerStatus { get } var duration: Double { get } var volume: Double { get set } diff --git a/packages/react-native-video/nitrogen/generated/ios/swift/HybridVideoPlayerSpec_cxx.swift b/packages/react-native-video/nitrogen/generated/ios/swift/HybridVideoPlayerSpec_cxx.swift index cf401a48..13e805cf 100644 --- a/packages/react-native-video/nitrogen/generated/ios/swift/HybridVideoPlayerSpec_cxx.swift +++ b/packages/react-native-video/nitrogen/generated/ios/swift/HybridVideoPlayerSpec_cxx.swift @@ -126,6 +126,17 @@ open class HybridVideoPlayerSpec_cxx { } } + public final var showNotificationControls: Bool { + @inline(__always) + get { + return self.__implementation.showNotificationControls + } + @inline(__always) + set { + self.__implementation.showNotificationControls = newValue + } + } + public final var status: Int32 { @inline(__always) get { diff --git a/packages/react-native-video/nitrogen/generated/ios/swift/NativeVideoConfig.swift b/packages/react-native-video/nitrogen/generated/ios/swift/NativeVideoConfig.swift index ea8f0a1a..0c9d6138 100644 --- a/packages/react-native-video/nitrogen/generated/ios/swift/NativeVideoConfig.swift +++ b/packages/react-native-video/nitrogen/generated/ios/swift/NativeVideoConfig.swift @@ -18,7 +18,7 @@ public extension NativeVideoConfig { /** * Create a new instance of `NativeVideoConfig`. */ - init(uri: String, externalSubtitles: [NativeExternalSubtitle]?, drm: NativeDrmParams?, headers: Dictionary?, bufferConfig: BufferConfig?, initializeOnCreation: Bool?) { + init(uri: String, externalSubtitles: [NativeExternalSubtitle]?, drm: NativeDrmParams?, headers: Dictionary?, bufferConfig: BufferConfig?, metadata: CustomVideoMetadata?, initializeOnCreation: Bool?) { self.init(std.string(uri), { () -> bridge.std__optional_std__vector_NativeExternalSubtitle__ in if let __unwrappedValue = externalSubtitles { return bridge.create_std__optional_std__vector_NativeExternalSubtitle__(__unwrappedValue.withUnsafeBufferPointer { __pointer -> bridge.std__vector_NativeExternalSubtitle_ in @@ -51,6 +51,12 @@ public extension NativeVideoConfig { } else { return .init() } + }(), { () -> bridge.std__optional_CustomVideoMetadata_ in + if let __unwrappedValue = metadata { + return bridge.create_std__optional_CustomVideoMetadata_(__unwrappedValue) + } else { + return .init() + } }(), { () -> bridge.std__optional_bool_ in if let __unwrappedValue = initializeOnCreation { return bridge.create_std__optional_bool_(__unwrappedValue) @@ -173,6 +179,29 @@ public extension NativeVideoConfig { } } + var metadata: CustomVideoMetadata? { + @inline(__always) + get { + return { () -> CustomVideoMetadata? in + if let __unwrapped = self.__metadata.value { + return __unwrapped + } else { + return nil + } + }() + } + @inline(__always) + set { + self.__metadata = { () -> bridge.std__optional_CustomVideoMetadata_ in + if let __unwrappedValue = newValue { + return bridge.create_std__optional_CustomVideoMetadata_(__unwrappedValue) + } else { + return .init() + } + }() + } + } + var initializeOnCreation: Bool? { @inline(__always) get { diff --git a/packages/react-native-video/nitrogen/generated/shared/c++/CustomVideoMetadata.hpp b/packages/react-native-video/nitrogen/generated/shared/c++/CustomVideoMetadata.hpp new file mode 100644 index 00000000..8f977006 --- /dev/null +++ b/packages/react-native-video/nitrogen/generated/shared/c++/CustomVideoMetadata.hpp @@ -0,0 +1,84 @@ +/// +/// CustomVideoMetadata.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2025 Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + +#include +#include + +namespace margelo::nitro::video { + + /** + * A struct which can be represented as a JavaScript object (CustomVideoMetadata). + */ + struct CustomVideoMetadata { + public: + std::optional title SWIFT_PRIVATE; + std::optional subtitle SWIFT_PRIVATE; + std::optional description SWIFT_PRIVATE; + std::optional artist SWIFT_PRIVATE; + std::optional imageUri SWIFT_PRIVATE; + + public: + CustomVideoMetadata() = default; + explicit CustomVideoMetadata(std::optional title, std::optional subtitle, std::optional description, std::optional artist, std::optional imageUri): title(title), subtitle(subtitle), description(description), artist(artist), imageUri(imageUri) {} + }; + +} // namespace margelo::nitro::video + +namespace margelo::nitro { + + // C++ CustomVideoMetadata <> JS CustomVideoMetadata (object) + template <> + struct JSIConverter final { + static inline margelo::nitro::video::CustomVideoMetadata fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return margelo::nitro::video::CustomVideoMetadata( + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, "title")), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, "subtitle")), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, "description")), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, "artist")), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, "imageUri")) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::video::CustomVideoMetadata& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, "title", JSIConverter>::toJSI(runtime, arg.title)); + obj.setProperty(runtime, "subtitle", JSIConverter>::toJSI(runtime, arg.subtitle)); + obj.setProperty(runtime, "description", JSIConverter>::toJSI(runtime, arg.description)); + obj.setProperty(runtime, "artist", JSIConverter>::toJSI(runtime, arg.artist)); + obj.setProperty(runtime, "imageUri", JSIConverter>::toJSI(runtime, arg.imageUri)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, "title"))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, "subtitle"))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, "description"))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, "artist"))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, "imageUri"))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-video/nitrogen/generated/shared/c++/HybridVideoPlayerSpec.cpp b/packages/react-native-video/nitrogen/generated/shared/c++/HybridVideoPlayerSpec.cpp index 95cd2edd..0c6e677d 100644 --- a/packages/react-native-video/nitrogen/generated/shared/c++/HybridVideoPlayerSpec.cpp +++ b/packages/react-native-video/nitrogen/generated/shared/c++/HybridVideoPlayerSpec.cpp @@ -16,6 +16,8 @@ namespace margelo::nitro::video { registerHybrids(this, [](Prototype& prototype) { prototype.registerHybridGetter("source", &HybridVideoPlayerSpec::getSource); prototype.registerHybridGetter("eventEmitter", &HybridVideoPlayerSpec::getEventEmitter); + prototype.registerHybridGetter("showNotificationControls", &HybridVideoPlayerSpec::getShowNotificationControls); + prototype.registerHybridSetter("showNotificationControls", &HybridVideoPlayerSpec::setShowNotificationControls); prototype.registerHybridGetter("status", &HybridVideoPlayerSpec::getStatus); prototype.registerHybridGetter("duration", &HybridVideoPlayerSpec::getDuration); prototype.registerHybridGetter("volume", &HybridVideoPlayerSpec::getVolume); diff --git a/packages/react-native-video/nitrogen/generated/shared/c++/HybridVideoPlayerSpec.hpp b/packages/react-native-video/nitrogen/generated/shared/c++/HybridVideoPlayerSpec.hpp index 41396d5e..c4ca014b 100644 --- a/packages/react-native-video/nitrogen/generated/shared/c++/HybridVideoPlayerSpec.hpp +++ b/packages/react-native-video/nitrogen/generated/shared/c++/HybridVideoPlayerSpec.hpp @@ -66,6 +66,8 @@ namespace margelo::nitro::video { // Properties virtual std::shared_ptr getSource() = 0; virtual std::shared_ptr getEventEmitter() = 0; + virtual bool getShowNotificationControls() = 0; + virtual void setShowNotificationControls(bool showNotificationControls) = 0; virtual VideoPlayerStatus getStatus() = 0; virtual double getDuration() = 0; virtual double getVolume() = 0; diff --git a/packages/react-native-video/nitrogen/generated/shared/c++/NativeVideoConfig.hpp b/packages/react-native-video/nitrogen/generated/shared/c++/NativeVideoConfig.hpp index 338b3a9d..a483726b 100644 --- a/packages/react-native-video/nitrogen/generated/shared/c++/NativeVideoConfig.hpp +++ b/packages/react-native-video/nitrogen/generated/shared/c++/NativeVideoConfig.hpp @@ -24,6 +24,8 @@ namespace margelo::nitro::video { struct NativeExternalSubtitle; } namespace margelo::nitro::video { struct NativeDrmParams; } // Forward declaration of `BufferConfig` to properly resolve imports. namespace margelo::nitro::video { struct BufferConfig; } +// Forward declaration of `CustomVideoMetadata` to properly resolve imports. +namespace margelo::nitro::video { struct CustomVideoMetadata; } #include #include "NativeExternalSubtitle.hpp" @@ -32,6 +34,7 @@ namespace margelo::nitro::video { struct BufferConfig; } #include "NativeDrmParams.hpp" #include #include "BufferConfig.hpp" +#include "CustomVideoMetadata.hpp" namespace margelo::nitro::video { @@ -45,11 +48,12 @@ namespace margelo::nitro::video { std::optional drm SWIFT_PRIVATE; std::optional> headers SWIFT_PRIVATE; std::optional bufferConfig SWIFT_PRIVATE; + std::optional metadata SWIFT_PRIVATE; std::optional initializeOnCreation SWIFT_PRIVATE; public: NativeVideoConfig() = default; - explicit NativeVideoConfig(std::string uri, std::optional> externalSubtitles, std::optional drm, std::optional> headers, std::optional bufferConfig, std::optional initializeOnCreation): uri(uri), externalSubtitles(externalSubtitles), drm(drm), headers(headers), bufferConfig(bufferConfig), initializeOnCreation(initializeOnCreation) {} + explicit NativeVideoConfig(std::string uri, std::optional> externalSubtitles, std::optional drm, std::optional> headers, std::optional bufferConfig, std::optional metadata, std::optional initializeOnCreation): uri(uri), externalSubtitles(externalSubtitles), drm(drm), headers(headers), bufferConfig(bufferConfig), metadata(metadata), initializeOnCreation(initializeOnCreation) {} }; } // namespace margelo::nitro::video @@ -67,6 +71,7 @@ namespace margelo::nitro { JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, "drm")), JSIConverter>>::fromJSI(runtime, obj.getProperty(runtime, "headers")), JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, "bufferConfig")), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, "metadata")), JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, "initializeOnCreation")) ); } @@ -77,6 +82,7 @@ namespace margelo::nitro { obj.setProperty(runtime, "drm", JSIConverter>::toJSI(runtime, arg.drm)); obj.setProperty(runtime, "headers", JSIConverter>>::toJSI(runtime, arg.headers)); obj.setProperty(runtime, "bufferConfig", JSIConverter>::toJSI(runtime, arg.bufferConfig)); + obj.setProperty(runtime, "metadata", JSIConverter>::toJSI(runtime, arg.metadata)); obj.setProperty(runtime, "initializeOnCreation", JSIConverter>::toJSI(runtime, arg.initializeOnCreation)); return obj; } @@ -90,6 +96,7 @@ namespace margelo::nitro { if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, "drm"))) return false; if (!JSIConverter>>::canConvert(runtime, obj.getProperty(runtime, "headers"))) return false; if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, "bufferConfig"))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, "metadata"))) return false; if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, "initializeOnCreation"))) return false; return true; } diff --git a/packages/react-native-video/src/core/VideoPlayer.ts b/packages/react-native-video/src/core/VideoPlayer.ts index ee972242..bfac4ad5 100644 --- a/packages/react-native-video/src/core/VideoPlayer.ts +++ b/packages/react-native-video/src/core/VideoPlayer.ts @@ -185,6 +185,14 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase { return this.player.isPlaying; } + get showNotificationControls(): boolean { + return this.player.showNotificationControls; + } + + set showNotificationControls(value: boolean) { + this.player.showNotificationControls = value; + } + async initialize(): Promise { await this.wrapPromise(this.player.initialize()); diff --git a/packages/react-native-video/src/core/types/VideoConfig.ts b/packages/react-native-video/src/core/types/VideoConfig.ts index 806bff8a..18c38c1a 100644 --- a/packages/react-native-video/src/core/types/VideoConfig.ts +++ b/packages/react-native-video/src/core/types/VideoConfig.ts @@ -26,6 +26,11 @@ export type VideoConfig = { * The player buffer configuration. */ bufferConfig?: BufferConfig; + /** + * The custom metadata to be associated with the video. + * This metadata can be used by the native player to show information about the video. + */ + metadata?: CustomVideoMetadata; /** * The external subtitles to be used. * @note on iOS, only WebVTT (.vtt) subtitles are supported (for HLS streams and MP4 files). @@ -135,3 +140,11 @@ interface NativeExternalSubtitle { interface NativeDrmParams extends DrmParams { type?: string; } + +interface CustomVideoMetadata { + title?: string; + subtitle?: string; + description?: string; + artist?: string; + imageUri?: string; +} diff --git a/packages/react-native-video/src/spec/nitro/VideoPlayer.nitro.ts b/packages/react-native-video/src/spec/nitro/VideoPlayer.nitro.ts index 62b0dfde..62ae54a4 100644 --- a/packages/react-native-video/src/spec/nitro/VideoPlayer.nitro.ts +++ b/packages/react-native-video/src/spec/nitro/VideoPlayer.nitro.ts @@ -13,6 +13,16 @@ export interface VideoPlayer // Holder of the video player events. readonly eventEmitter: VideoPlayerEventEmitter; + /** + * Show playback controls in the notifications area + * + * @note on Android, this can be overridden by {@linkcode VideoPlayer.playInBackground}, as Android requires + * a foreground service to show notifications while the app is in the background. + * + * @default false + */ + showNotificationControls: boolean; + replaceSourceAsync(source: VideoPlayerSource | null): Promise; /**