15 Commits

Author SHA1 Message Date
52a22aa10e Fix typescript issues 2025-10-10 14:09:16 +02:00
771196dd0f Some cleanup 2025-10-10 14:05:32 +02:00
82ff34fa9c Fix package lock 2025-10-10 14:05:32 +02:00
b2cf9d63f1 Map source 2025-10-10 14:05:32 +02:00
937d34d73e Implement event handlers for web 2025-10-10 14:05:32 +02:00
a092578cab Implement html video properties using headless video 2025-10-10 14:05:32 +02:00
d6803b2b4c Implement VideoView for web 2025-10-10 14:05:32 +02:00
6703499e60 Scaffold web 2025-10-10 14:05:32 +02:00
ecf5849f2c fixup! Add shaka as an optional dependency and fix their type 2025-10-10 14:05:32 +02:00
8ad923750b wip: Biome config (TO DELETE LATER) 2025-10-10 14:05:32 +02:00
796c0edfa0 Add shaka as an optional dependency and fix their type 2025-10-10 14:05:32 +02:00
57039bb564 Add shell.nix & editorconfig 2025-10-10 14:05:32 +02:00
Krzysztof Moch
9b74665fb4 chore: update release script (#4727) 2025-10-08 13:39:19 +02:00
Krzysztof Moch
1671c63dab feat(android): support flexible page sizes (#4726) 2025-10-08 13:14:53 +02:00
Krzysztof Moch
02044de0e9 feat: add notification controls (#4721) 2025-10-06 16:36:52 +02:00
69 changed files with 2622 additions and 1264 deletions

9
.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = space
indent_size = 2

5
biome.json Normal file
View File

@@ -0,0 +1,5 @@
{
"formatter": {
"useEditorconfig": true
}
}

1461
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@
"allowUnusedLabels": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ESNext"],
"lib": ["ESNext", "dom"],
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
@@ -33,4 +33,4 @@
"target": "ESNext",
"verbatimModuleSyntax": true
}
}
}

View File

@@ -7,7 +7,9 @@
"ios": "react-native run-ios",
"lint": "eslint .",
"typecheck": "tsc",
"start": "react-native start --client-logs"
"start": "react-native start --client-logs",
"bundle-install": "bundle install",
"pods": "cd ios && pod install && cd .."
},
"dependencies": {
"@react-native-community/slider": "^4.5.6",

View File

@@ -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)}
/>
<SwitchControl
label="Notification Controls"
value={settings.showNotificationControls}
onValueChange={(value) =>
updateSetting('showNotificationControls', value)
}
/>
</View>
</View>

View File

@@ -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,
};

View File

@@ -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;
};

View File

@@ -64,7 +64,8 @@
"release": true
},
"hooks": {
"before:release": "bun run --cwd packages/react-native-video build"
"before:release": "bun run --cwd packages/react-native-video build && bun run --cwd packages/drm-plugin build",
"before:git": "bun install && bun example bundle-install && bun example pods && git add bun.lock && git add example/ios/Podfile.lock"
},
"plugins": {
"@release-it/bumper": {
@@ -73,6 +74,10 @@
"file": "packages/react-native-video/package.json",
"path": "version"
},
{
"file": "packages/drm-plugin/package.json",
"path": "version"
},
{
"file": "example/package.json",
"path": "version"
@@ -91,6 +96,10 @@
"type": "fix",
"section": "Bug Fixes 🐛"
},
{
"type": "refactor",
"section": "Code Refactoring 🛠"
},
{
"type": "chore(deps)",
"section": "Dependency Upgrades 📦"

View File

@@ -1,6 +1,6 @@
{
"name": "@twg/react-native-video-drm",
"version": "0.1.0",
"version": "7.0.0-alpha.5",
"description": "DRM plugin for react-native-video",
"main": "./lib/module/index.js",
"types": "./lib/typescript/src/index.d.ts",
@@ -41,7 +41,7 @@
"prepare": "bun run build",
"build": "bob build",
"specs": "nitrogen",
"release": "release-it --only-version"
"release": "release-it --preRelease alpha --npm.tag=next"
},
"keywords": [
"react-native",

View File

@@ -120,7 +120,7 @@ android {
externalNativeBuild {
cmake {
cppFlags "-O2 -frtti -fexceptions -Wall -fstack-protector-all"
arguments "-DANDROID_STL=c++_shared"
arguments "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
abiFilters (*reactNativeArchitectures())
}
}

View File

@@ -1,9 +1,9 @@
RNVideo_kotlinVersion=1.9.24
RNVideo_minKotlinVersion=1.8.0
RNVideo_minSdkVersion=23
RNVideo_targetSdkVersion=34
RNVideo_compileSdkVersion=34
RNVideo_ndkversion=26.1.10909125
RNVideo_minSdkVersion=24
RNVideo_targetSdkVersion=35
RNVideo_compileSdkVersion=35
RNVideo_ndkversion=27.1.12297006
RNVideo_useExoplayerDash=true
RNVideo_useExoplayerHls=true

View File

@@ -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) {}
}

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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<CommandButton>,
showPauseButton: Boolean
): ImmutableList<CommandButton> {
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<CommandButton>,
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
}
}

View File

@@ -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<CommandButton> {
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<SessionResult> {
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)
}
}

View File

@@ -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<HybridVideoPlayer, MediaSession>()
private var binder = VideoPlaybackServiceBinder(this)
private var sourceActivity: Class<Activity>? = null
private var placeholderCanceled = false
private var sourceActivity: Class<Activity>? = 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<Activity>) {
@@ -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?")
}
}
}
}

View File

@@ -25,7 +25,10 @@ class VideoPlaybackServiceConnection (private val player: WeakReference<HybridVi
}
serviceBinder = binder as? VideoPlaybackServiceBinder
serviceBinder?.service?.registerPlayer(player, activity.javaClass)
// Only register when the player actually needs background service/notification
if (player.playInBackground || player.showNotificationControls) {
serviceBinder?.service?.registerPlayer(player, activity.javaClass)
}
} catch (err: Exception) {
Log.e("VideoPlaybackServiceConnection", "Could not bind to playback service", err)
}

View File

@@ -102,6 +102,23 @@ class HybridVideoPlayer() : HybridVideoPlayerSpec() {
field = value
}
override var showNotificationControls: Boolean = false
set(value) {
val wasRunning = (field || playInBackground)
val shouldRun = (value || playInBackground)
if (shouldRun && !wasRunning) {
VideoPlaybackService.startService(context, videoPlaybackServiceConnection)
}
if (!shouldRun && wasRunning) {
VideoPlaybackService.stopService(this, videoPlaybackServiceConnection)
}
field = value
// Inform service to refresh notification/session layout
try { videoPlaybackServiceConnection.serviceBinder?.service?.updatePlayerPreferences(this) } catch (_: Exception) {}
}
// Player Properties
override var currentTime: Double by mainThreadProperty(
get = { player.currentPosition.toDouble() / 1000.0 },
@@ -172,16 +189,18 @@ class HybridVideoPlayer() : HybridVideoPlayerSpec() {
override var playInBackground: Boolean = false
set(value) {
// playback in background was disabled and is now enabled
if (value == true && field == false) {
val shouldRun = (value || showNotificationControls)
val wasRunning = (field || showNotificationControls)
if (shouldRun && !wasRunning) {
VideoPlaybackService.startService(context, videoPlaybackServiceConnection)
field = true
}
// playback in background was enabled and is now disabled
else if (field == true) {
if (!shouldRun && wasRunning) {
VideoPlaybackService.stopService(this, videoPlaybackServiceConnection)
field = false
}
field = value
// Update preferences to refresh notifications/registration
try { videoPlaybackServiceConnection.serviceBinder?.service?.updatePlayerPreferences(this) } catch (_: Exception) {}
}
override var playWhenInactive: Boolean = false
@@ -323,7 +342,7 @@ class HybridVideoPlayer() : HybridVideoPlayerSpec() {
}
private fun release() {
if (playInBackground) {
if (playInBackground || showNotificationControls) {
VideoPlaybackService.stopService(this, videoPlaybackServiceConnection)
}

View File

@@ -5,7 +5,16 @@ import com.facebook.proguard.annotations.DoNotStrip
@DoNotStrip
class HybridVideoPlayerSourceFactory: HybridVideoPlayerSourceFactorySpec() {
override fun fromUri(uri: String): HybridVideoPlayerSourceSpec {
val config = NativeVideoConfig(uri, null, null, null, null, true)
val config = NativeVideoConfig(
uri = uri,
externalSubtitles = null,
drm = null,
headers = null,
bufferConfig = null,
metadata = null,
initializeOnCreation = true
)
return HybridVideoPlayerSource(config)
}

View File

@@ -0,0 +1,323 @@
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 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
}
}

View File

@@ -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()

View File

@@ -22,6 +22,7 @@ extension HybridVideoPlayer: VideoPlayerObserverDelegate {
func onRateChanged(rate: Float) {
eventEmitter.onPlaybackRateChange(Double(rate))
NowPlayingInfoCenterManager.shared.updateNowPlayingInfo()
updateAndEmitPlaybackState()
}

View File

@@ -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<Void> {
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: ())
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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 <fbjni/fbjni.h>
#include "CustomVideoMetadata.hpp"
#include <optional>
#include <string>
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<JCustomVideoMetadata> {
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<jni::JString>("title");
jni::local_ref<jni::JString> title = this->getFieldValue(fieldTitle);
static const auto fieldSubtitle = clazz->getField<jni::JString>("subtitle");
jni::local_ref<jni::JString> subtitle = this->getFieldValue(fieldSubtitle);
static const auto fieldDescription = clazz->getField<jni::JString>("description");
jni::local_ref<jni::JString> description = this->getFieldValue(fieldDescription);
static const auto fieldArtist = clazz->getField<jni::JString>("artist");
jni::local_ref<jni::JString> artist = this->getFieldValue(fieldArtist);
static const auto fieldImageUri = clazz->getField<jni::JString>("imageUri");
jni::local_ref<jni::JString> 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<JCustomVideoMetadata::javaobject> 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

View File

@@ -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 <memory>
#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 {

View File

@@ -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"

View File

@@ -72,6 +72,15 @@ namespace margelo::nitro::video {
auto __result = method(_javaPart);
return __result->cthis()->shared_cast<JHybridVideoPlayerEventEmitterSpec>();
}
bool JHybridVideoPlayerSpec::getShowNotificationControls() {
static const auto method = javaClassStatic()->getMethod<jboolean()>("getShowNotificationControls");
auto __result = method(_javaPart);
return static_cast<bool>(__result);
}
void JHybridVideoPlayerSpec::setShowNotificationControls(bool showNotificationControls) {
static const auto method = javaClassStatic()->getMethod<void(jboolean /* showNotificationControls */)>("setShowNotificationControls");
method(_javaPart, showNotificationControls);
}
VideoPlayerStatus JHybridVideoPlayerSpec::getStatus() {
static const auto method = javaClassStatic()->getMethod<jni::local_ref<JVideoPlayerStatus>()>("getStatus");
auto __result = method(_javaPart);

View File

@@ -51,6 +51,8 @@ namespace margelo::nitro::video {
// Properties
std::shared_ptr<HybridVideoPlayerSourceSpec> getSource() override;
std::shared_ptr<HybridVideoPlayerEventEmitterSpec> getEventEmitter() override;
bool getShowNotificationControls() override;
void setShowNotificationControls(bool showNotificationControls) override;
VideoPlayerStatus getStatus() override;
double getDuration() override;
double getVolume() override;

View File

@@ -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<jni::JMap<jni::JString, jni::JString>> headers = this->getFieldValue(fieldHeaders);
static const auto fieldBufferConfig = clazz->getField<JBufferConfig>("bufferConfig");
jni::local_ref<JBufferConfig> bufferConfig = this->getFieldValue(fieldBufferConfig);
static const auto fieldMetadata = clazz->getField<JCustomVideoMetadata>("metadata");
jni::local_ref<JCustomVideoMetadata> metadata = this->getFieldValue(fieldMetadata);
static const auto fieldInitializeOnCreation = clazz->getField<jni::JBoolean>("initializeOnCreation");
jni::local_ref<jni::JBoolean> 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<bool>(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
);
}

View File

@@ -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 */
}

View File

@@ -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

View File

@@ -38,6 +38,9 @@ data class NativeVideoConfig
val bufferConfig: BufferConfig?,
@DoNotStrip
@Keep
val metadata: CustomVideoMetadata?,
@DoNotStrip
@Keep
val initializeOnCreation: Boolean?
) {
/* main constructor */

View File

@@ -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<CustomVideoMetadata>
/**
* Specialized version of `std::optional<CustomVideoMetadata>`.
*/
using std__optional_CustomVideoMetadata_ = std::optional<CustomVideoMetadata>;
inline std::optional<CustomVideoMetadata> create_std__optional_CustomVideoMetadata_(const CustomVideoMetadata& value) {
return std::optional<CustomVideoMetadata>(value);
}
// pragma MARK: std::shared_ptr<Promise<VideoInformation>>
/**
* Specialized version of `std::shared_ptr<Promise<VideoInformation>>`.

View File

@@ -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"

View File

@@ -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 <memory>
#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"

View File

@@ -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"

View File

@@ -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<decltype(showNotificationControls)>(showNotificationControls));
}
inline VideoPlayerStatus getStatus() noexcept override {
auto __result = _swiftPart.getStatus();
return static_cast<VideoPlayerStatus>(__result);

View File

@@ -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()
}
}()
}
}
}

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -18,7 +18,7 @@ public extension NativeVideoConfig {
/**
* Create a new instance of `NativeVideoConfig`.
*/
init(uri: String, externalSubtitles: [NativeExternalSubtitle]?, drm: NativeDrmParams?, headers: Dictionary<String, String>?, bufferConfig: BufferConfig?, initializeOnCreation: Bool?) {
init(uri: String, externalSubtitles: [NativeExternalSubtitle]?, drm: NativeDrmParams?, headers: Dictionary<String, String>?, 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 {

View File

@@ -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(<NitroModules/JSIConverter.hpp>)
#include <NitroModules/JSIConverter.hpp>
#else
#error NitroModules cannot be found! Are you sure you installed NitroModules properly?
#endif
#if __has_include(<NitroModules/NitroDefines.hpp>)
#include <NitroModules/NitroDefines.hpp>
#else
#error NitroModules cannot be found! Are you sure you installed NitroModules properly?
#endif
#include <string>
#include <optional>
namespace margelo::nitro::video {
/**
* A struct which can be represented as a JavaScript object (CustomVideoMetadata).
*/
struct CustomVideoMetadata {
public:
std::optional<std::string> title SWIFT_PRIVATE;
std::optional<std::string> subtitle SWIFT_PRIVATE;
std::optional<std::string> description SWIFT_PRIVATE;
std::optional<std::string> artist SWIFT_PRIVATE;
std::optional<std::string> imageUri SWIFT_PRIVATE;
public:
CustomVideoMetadata() = default;
explicit CustomVideoMetadata(std::optional<std::string> title, std::optional<std::string> subtitle, std::optional<std::string> description, std::optional<std::string> artist, std::optional<std::string> 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<margelo::nitro::video::CustomVideoMetadata> 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<std::optional<std::string>>::fromJSI(runtime, obj.getProperty(runtime, "title")),
JSIConverter<std::optional<std::string>>::fromJSI(runtime, obj.getProperty(runtime, "subtitle")),
JSIConverter<std::optional<std::string>>::fromJSI(runtime, obj.getProperty(runtime, "description")),
JSIConverter<std::optional<std::string>>::fromJSI(runtime, obj.getProperty(runtime, "artist")),
JSIConverter<std::optional<std::string>>::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<std::optional<std::string>>::toJSI(runtime, arg.title));
obj.setProperty(runtime, "subtitle", JSIConverter<std::optional<std::string>>::toJSI(runtime, arg.subtitle));
obj.setProperty(runtime, "description", JSIConverter<std::optional<std::string>>::toJSI(runtime, arg.description));
obj.setProperty(runtime, "artist", JSIConverter<std::optional<std::string>>::toJSI(runtime, arg.artist));
obj.setProperty(runtime, "imageUri", JSIConverter<std::optional<std::string>>::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<std::optional<std::string>>::canConvert(runtime, obj.getProperty(runtime, "title"))) return false;
if (!JSIConverter<std::optional<std::string>>::canConvert(runtime, obj.getProperty(runtime, "subtitle"))) return false;
if (!JSIConverter<std::optional<std::string>>::canConvert(runtime, obj.getProperty(runtime, "description"))) return false;
if (!JSIConverter<std::optional<std::string>>::canConvert(runtime, obj.getProperty(runtime, "artist"))) return false;
if (!JSIConverter<std::optional<std::string>>::canConvert(runtime, obj.getProperty(runtime, "imageUri"))) return false;
return true;
}
};
} // namespace margelo::nitro

View File

@@ -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);

View File

@@ -66,6 +66,8 @@ namespace margelo::nitro::video {
// Properties
virtual std::shared_ptr<HybridVideoPlayerSourceSpec> getSource() = 0;
virtual std::shared_ptr<HybridVideoPlayerEventEmitterSpec> getEventEmitter() = 0;
virtual bool getShowNotificationControls() = 0;
virtual void setShowNotificationControls(bool showNotificationControls) = 0;
virtual VideoPlayerStatus getStatus() = 0;
virtual double getDuration() = 0;
virtual double getVolume() = 0;

View File

@@ -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 <string>
#include "NativeExternalSubtitle.hpp"
@@ -32,6 +34,7 @@ namespace margelo::nitro::video { struct BufferConfig; }
#include "NativeDrmParams.hpp"
#include <unordered_map>
#include "BufferConfig.hpp"
#include "CustomVideoMetadata.hpp"
namespace margelo::nitro::video {
@@ -45,11 +48,12 @@ namespace margelo::nitro::video {
std::optional<NativeDrmParams> drm SWIFT_PRIVATE;
std::optional<std::unordered_map<std::string, std::string>> headers SWIFT_PRIVATE;
std::optional<BufferConfig> bufferConfig SWIFT_PRIVATE;
std::optional<CustomVideoMetadata> metadata SWIFT_PRIVATE;
std::optional<bool> initializeOnCreation SWIFT_PRIVATE;
public:
NativeVideoConfig() = default;
explicit NativeVideoConfig(std::string uri, std::optional<std::vector<NativeExternalSubtitle>> externalSubtitles, std::optional<NativeDrmParams> drm, std::optional<std::unordered_map<std::string, std::string>> headers, std::optional<BufferConfig> bufferConfig, std::optional<bool> initializeOnCreation): uri(uri), externalSubtitles(externalSubtitles), drm(drm), headers(headers), bufferConfig(bufferConfig), initializeOnCreation(initializeOnCreation) {}
explicit NativeVideoConfig(std::string uri, std::optional<std::vector<NativeExternalSubtitle>> externalSubtitles, std::optional<NativeDrmParams> drm, std::optional<std::unordered_map<std::string, std::string>> headers, std::optional<BufferConfig> bufferConfig, std::optional<CustomVideoMetadata> metadata, std::optional<bool> 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<std::optional<margelo::nitro::video::NativeDrmParams>>::fromJSI(runtime, obj.getProperty(runtime, "drm")),
JSIConverter<std::optional<std::unordered_map<std::string, std::string>>>::fromJSI(runtime, obj.getProperty(runtime, "headers")),
JSIConverter<std::optional<margelo::nitro::video::BufferConfig>>::fromJSI(runtime, obj.getProperty(runtime, "bufferConfig")),
JSIConverter<std::optional<margelo::nitro::video::CustomVideoMetadata>>::fromJSI(runtime, obj.getProperty(runtime, "metadata")),
JSIConverter<std::optional<bool>>::fromJSI(runtime, obj.getProperty(runtime, "initializeOnCreation"))
);
}
@@ -77,6 +82,7 @@ namespace margelo::nitro {
obj.setProperty(runtime, "drm", JSIConverter<std::optional<margelo::nitro::video::NativeDrmParams>>::toJSI(runtime, arg.drm));
obj.setProperty(runtime, "headers", JSIConverter<std::optional<std::unordered_map<std::string, std::string>>>::toJSI(runtime, arg.headers));
obj.setProperty(runtime, "bufferConfig", JSIConverter<std::optional<margelo::nitro::video::BufferConfig>>::toJSI(runtime, arg.bufferConfig));
obj.setProperty(runtime, "metadata", JSIConverter<std::optional<margelo::nitro::video::CustomVideoMetadata>>::toJSI(runtime, arg.metadata));
obj.setProperty(runtime, "initializeOnCreation", JSIConverter<std::optional<bool>>::toJSI(runtime, arg.initializeOnCreation));
return obj;
}
@@ -90,6 +96,7 @@ namespace margelo::nitro {
if (!JSIConverter<std::optional<margelo::nitro::video::NativeDrmParams>>::canConvert(runtime, obj.getProperty(runtime, "drm"))) return false;
if (!JSIConverter<std::optional<std::unordered_map<std::string, std::string>>>::canConvert(runtime, obj.getProperty(runtime, "headers"))) return false;
if (!JSIConverter<std::optional<margelo::nitro::video::BufferConfig>>::canConvert(runtime, obj.getProperty(runtime, "bufferConfig"))) return false;
if (!JSIConverter<std::optional<margelo::nitro::video::CustomVideoMetadata>>::canConvert(runtime, obj.getProperty(runtime, "metadata"))) return false;
if (!JSIConverter<std::optional<bool>>::canConvert(runtime, obj.getProperty(runtime, "initializeOnCreation"))) return false;
return true;
}

View File

@@ -75,6 +75,7 @@
},
"devDependencies": {
"@expo/config-plugins": "^10.0.2",
"@react-native/eslint-config": "^0.77.0",
"@types/react": "^18.2.44",
"del-cli": "^5.1.0",
"eslint": "^8.51.0",
@@ -84,7 +85,7 @@
"prettier": "^3.0.3",
"react": "18.3.1",
"react-native": "^0.77.0",
"@react-native/eslint-config": "^0.77.0",
"shaka-player": "^4.15.9",
"react-native-builder-bob": "^0.40.0",
"react-native-nitro-modules": "^0.29.0",
"typescript": "^5.2.2"
@@ -149,5 +150,11 @@
"type": "view-legacy",
"languages": "kotlin-swift",
"version": "0.41.2"
},
"optionalDependencies": {
"shaka-player": "^4.15.9"
},
"dependencies": {
"@types/react-native-web": "^0.19.2"
}
}

View File

@@ -1,6 +1,6 @@
import { Platform } from 'react-native';
import { NitroModules } from 'react-native-nitro-modules';
import { type VideoPlayer as VideoPlayerImpl } from '../spec/nitro/VideoPlayer.nitro';
import type { VideoPlayer as VideoPlayerImpl } from '../spec/nitro/VideoPlayer.nitro';
import type { VideoPlayerSource } from '../spec/nitro/VideoPlayerSource.nitro';
import type { IgnoreSilentSwitchMode } from './types/IgnoreSilentSwitchMode';
import type { MixAudioMode } from './types/MixAudioMode';
@@ -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<void> {
await this.wrapPromise(this.player.initialize());

View File

@@ -0,0 +1,295 @@
import shaka from "shaka-player";
import type { VideoPlayerSource } from "../spec/nitro/VideoPlayerSource.nitro";
import type { IgnoreSilentSwitchMode } from "./types/IgnoreSilentSwitchMode";
import type { MixAudioMode } from "./types/MixAudioMode";
import type { TextTrack } from "./types/TextTrack";
import type { NoAutocomplete } from "./types/Utils";
import type { VideoConfig, VideoSource } from "./types/VideoConfig";
import {
tryParseNativeVideoError,
VideoRuntimeError,
} from "./types/VideoError";
import type { VideoPlayerBase } from "./types/VideoPlayerBase";
import type { VideoPlayerStatus } from "./types/VideoPlayerStatus";
import { VideoPlayerEvents } from "./VideoPlayerEvents";
import { WebEventEmiter } from "./WebEventEmiter";
class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
protected player = new shaka.Player();
protected video: HTMLVideoElement;
protected headers: Record<string, string> = {};
constructor(source: VideoSource | VideoConfig | VideoPlayerSource) {
const video = document.createElement("video");
super(new WebEventEmiter(video));
this.video = video;
this.player.attach(this.video);
this.player.getNetworkingEngine()!.registerRequestFilter((_type, request) => {
request.headers = this.headers;
});
this.replaceSourceAsync(source);
}
/**
* Cleans up player's native resources and releases native state.
* After calling this method, the player is no longer usable.
* @internal
*/
__destroy() {
this.player.destroy();
}
__getNativeRef() {
return this.video;
}
/**
* Handles parsing native errors to VideoRuntimeError and calling onError if provided
* @internal
*/
private throwError(error: unknown) {
const parsedError = tryParseNativeVideoError(error);
if (
parsedError instanceof VideoRuntimeError &&
this.triggerEvent("onError", parsedError)
) {
// We don't throw errors if onError is provided
return;
}
throw parsedError;
}
// Source
get source(): VideoPlayerSource {
// TODO: properly implement this
return {
uri: this.player.getAssetUri()!,
config: {},
} as any;
}
// Status
get status(): VideoPlayerStatus {
if (this.video.error) return "error";
if (this.video.readyState === HTMLMediaElement.HAVE_NOTHING) return "idle";
if (
this.video.readyState === HTMLMediaElement.HAVE_ENOUGH_DATA ||
this.video.readyState === HTMLMediaElement.HAVE_FUTURE_DATA
)
return "readyToPlay";
return "loading";
}
// Duration
get duration(): number {
return this.video.duration;
}
// Volume
get volume(): number {
return this.video.volume;
}
set volume(value: number) {
this.video.volume = value;
}
// Current Time
get currentTime(): number {
return this.video.currentTime;
}
set currentTime(value: number) {
this.video.currentTime = value;
}
// Muted
get muted(): boolean {
return this.video.muted;
}
set muted(value: boolean) {
this.video.muted = value;
}
// Loop
get loop(): boolean {
return this.video.loop;
}
set loop(value: boolean) {
this.video.loop = value;
}
// Rate
get rate(): number {
return this.video.playbackRate;
}
set rate(value: number) {
this.video.playbackRate = value;
}
// Mix Audio Mode
get mixAudioMode(): MixAudioMode {
return "auto";
}
set mixAudioMode(_: MixAudioMode) {
if (__DEV__) {
console.warn(
"mixAudioMode is not supported on this platform, it wont have any effect",
);
}
}
// Ignore Silent Switch Mode
get ignoreSilentSwitchMode(): IgnoreSilentSwitchMode {
return "auto";
}
set ignoreSilentSwitchMode(_: IgnoreSilentSwitchMode) {
if (__DEV__) {
console.warn(
"ignoreSilentSwitchMode is not supported on this platform, it wont have any effect",
);
}
}
// Play In Background
get playInBackground(): boolean {
return true;
}
set playInBackground(_: boolean) {
if (__DEV__) {
console.warn(
"playInBackground is not supported on this platform, it wont have any effect",
);
}
}
// Play When Inactive
get playWhenInactive(): boolean {
return true;
}
set playWhenInactive(_: boolean) {
if (__DEV__) {
console.warn(
"playWhenInactive is not supported on this platform, it wont have any effect",
);
}
}
// Is Playing
get isPlaying(): boolean {
return this.status === "readyToPlay" && !this.video.paused;
}
async initialize(): Promise<void> {
// noop on web
}
async preload(): Promise<void> {
// we start loading when initializing the source.
}
/**
* Releases the player's native resources and releases native state.
* After calling this method, the player is no longer usable.
* Accessing any properties or methods of the player after calling this method will throw an error.
* If you want to clean player resource use `replaceSourceAsync` with `null` instead.
*/
release(): void {
this.__destroy();
}
play(): void {
try {
this.video.play();
} catch (error) {
this.throwError(error);
}
}
pause(): void {
try {
this.video.pause();
} catch (error) {
this.throwError(error);
}
}
seekBy(time: number): void {
try {
this.video.currentTime += time;
} catch (error) {
this.throwError(error);
}
}
seekTo(time: number): void {
try {
this.video.currentTime = time;
} catch (error) {
this.throwError(error);
}
}
async replaceSourceAsync(
source:
| VideoSource
| VideoConfig
| NoAutocomplete<VideoPlayerSource>
| null,
): Promise<void> {
const src =
typeof source === "object" && source && "uri" in source
? source.uri
: source;
if (typeof src === "number") {
console.error("A source uri must be a string. Numbers are only supported on native.");
return;
}
// TODO: handle start time
this.player.load(src)
if (typeof source !== "object") return;
this.headers = source?.headers ?? {};
// this.player.configure({
// drm: undefined,
// streaming: {
// bufferingGoal: source?.bufferConfig?.maxBufferMs,
// },
// } satisfies Partial<shaka.extern.PlayerConfiguration>);
}
// Text Track Management
getAvailableTextTracks(): TextTrack[] {
return this.player.getTextTracks().map(x => ({
id: x.id.toString(),
label: x.label ?? "",
language: x.language,
selected: x.active,
}));
}
selectTextTrack(textTrack: TextTrack | null): void {
this.player.setTextTrackVisibility(textTrack !== null)
if (!textTrack) return;
const track = this.player
.getTextTracks()
.find((x) => x.id === Number(textTrack.id));
if (track) this.player.selectTextTrack(track);
}
// Selected Text Track
get selectedTrack(): TextTrack | undefined {
return this.getAvailableTextTracks().find(x => x.selected);
}
}
export { VideoPlayer };

View File

@@ -1,11 +1,11 @@
import type { VideoPlayerEventEmitter } from '../spec/nitro/VideoPlayerEventEmitter.nitro';
import {
ALL_PLAYER_EVENTS,
type VideoPlayerEvents as NativePlayerEvents,
type AllPlayerEvents as PlayerEvents,
} from './types/Events';
} from "./types/Events";
export class VideoPlayerEvents {
protected eventEmitter: VideoPlayerEventEmitter;
protected eventEmitter: NativePlayerEvents;
protected eventListeners: Partial<
Record<keyof PlayerEvents, Set<(...params: any[]) => void>>
> = {};
@@ -13,9 +13,9 @@ export class VideoPlayerEvents {
protected readonly supportedEvents: (keyof PlayerEvents)[] =
ALL_PLAYER_EVENTS;
constructor(eventEmitter: VideoPlayerEventEmitter) {
constructor(eventEmitter: NativePlayerEvents) {
this.eventEmitter = eventEmitter;
for (let event of this.supportedEvents) {
for (const event of this.supportedEvents) {
// @ts-expect-error we narrow the type of the event
this.eventEmitter[event] = this.triggerEvent.bind(this, event);
}
@@ -26,7 +26,7 @@ export class VideoPlayerEvents {
...params: Parameters<PlayerEvents[Event]>
): boolean {
if (!this.eventListeners[event]?.size) return false;
for (let fn of this.eventListeners[event]) {
for (const fn of this.eventListeners[event]) {
fn(...params);
}
return true;
@@ -34,7 +34,7 @@ export class VideoPlayerEvents {
addEventListener<Event extends keyof PlayerEvents>(
event: Event,
callback: PlayerEvents[Event]
callback: PlayerEvents[Event],
) {
this.eventListeners[event] ??= new Set<PlayerEvents[Event]>();
this.eventListeners[event].add(callback);
@@ -42,7 +42,7 @@ export class VideoPlayerEvents {
removeEventListener<Event extends keyof PlayerEvents>(
event: Event,
callback: PlayerEvents[Event]
callback: PlayerEvents[Event],
) {
this.eventListeners[event]?.delete(callback);
}

View File

@@ -0,0 +1,198 @@
import type {
BandwidthData,
onLoadData,
onLoadStartData,
onPlaybackStateChangeData,
onProgressData,
onVolumeChangeData,
AllPlayerEvents as PlayerEvents,
TimedMetadata,
} from "./types/Events";
import type { TextTrack } from "./types/TextTrack";
import type { VideoRuntimeError } from "./types/VideoError";
import type { VideoPlayerStatus } from "./types/VideoPlayerStatus";
export class WebEventEmiter implements PlayerEvents {
private _isBuferring = false;
constructor(private video: HTMLVideoElement) {
// TODO: add `onBandwithUpdate`
// on buffer
this.video.addEventListener("canplay", this._onCanPlay);
this.video.addEventListener("waiting", this._onWaiting);
// on end
this.video.addEventListener("ended", this._onEnded);
// on load
this.video.addEventListener("durationchange", this._onDurationChange);
// on load start
this.video.addEventListener("loadstart", this._onLoadStart);
// on playback state change
this.video.addEventListener("play", this._onPlay);
this.video.addEventListener("pause", this._onPause);
// on playback rate change
this.video.addEventListener("ratechange", this._onRateChange);
// on progress
this.video.addEventListener("timeupdate", this._onTimeUpdate);
// on ready to play
this.video.addEventListener("loadeddata", this._onLoadedData);
// on seek
this.video.addEventListener("seeked", this._onSeeked);
// on volume change
this.video.addEventListener("volumechange", this._onVolumeChange);
// on status change
this.video.addEventListener("error", this._onError);
}
destroy() {
this.video.removeEventListener("canplay", this._onCanPlay);
this.video.removeEventListener("waiting", this._onWaiting);
this.video.removeEventListener("ended", this._onEnded);
this.video.removeEventListener("durationchange", this._onDurationChange);
this.video.removeEventListener("play", this._onPlay);
this.video.removeEventListener("pause", this._onPause);
this.video.removeEventListener("ratechange", this._onRateChange);
this.video.removeEventListener("timeupdate", this._onTimeUpdate);
this.video.removeEventListener("loadeddata", this._onLoadedData);
this.video.removeEventListener("seeked", this._onSeeked);
this.video.removeEventListener("volumechange", this._onVolumeChange);
this.video.removeEventListener("error", this._onError);
}
_onTimeUpdate() {
this.onProgress({
currentTime: this.video.currentTime,
bufferDuration: this.video.buffered.length
? this.video.buffered.end(this.video.buffered.length - 1)
: 0,
});
}
_onCanPlay() {
this._isBuferring = false;
this.onBuffer(false);
this.onStatusChange("readyToPlay");
}
_onWaiting() {
this._isBuferring = true;
this.onBuffer(true);
this.onStatusChange("loading");
}
_onDurationChange() {
this.onLoad({
currentTime: this.video.currentTime,
duration: this.video.duration,
width: this.video.width,
height: this.video.height,
orientation: "unknown",
});
}
_onEnded() {
this.onEnd();
this.onStatusChange("idle");
}
_onLoadStart() {
this.onLoadStart({
sourceType: "network",
source: {
uri: this.video.currentSrc,
config: {
uri: this.video.currentSrc,
externalSubtitles: [],
},
getAssetInformationAsync: async () => {
return {
duration: BigInt(this.video.duration),
height: this.video.height,
width: this.video.width,
orientation: "unknown",
bitrate: NaN,
fileSize: BigInt(NaN),
isHDR: false,
isLive: false,
};
},
},
});
}
_onPlay() {
this.onPlaybackStateChange({
isPlaying: true,
isBuffering: this._isBuferring,
});
}
_onPause() {
this.onPlaybackStateChange({
isPlaying: false,
isBuffering: this._isBuferring,
});
}
_onRateChange() {
this.onPlaybackRateChange(this.video.playbackRate);
}
_onLoadedData() {
this.onReadyToDisplay();
}
_onSeeked() {
this.onSeek(this.video.currentTime);
}
_onVolumeChange() {
this.onVolumeChange({ muted: this.video.muted, volume: this.video.volume });
}
_onError() {
this.onStatusChange("error");
}
NOOP = () => {};
onError: (error: VideoRuntimeError) => void = this.NOOP;
onAudioBecomingNoisy: () => void = this.NOOP;
onAudioFocusChange: (hasAudioFocus: boolean) => void = this.NOOP;
onBandwidthUpdate: (data: BandwidthData) => void = this.NOOP;
onBuffer: (buffering: boolean) => void = this.NOOP;
onControlsVisibleChange: (visible: boolean) => void = this.NOOP;
onEnd: () => void = this.NOOP;
onExternalPlaybackChange: (externalPlaybackActive: boolean) => void =
this.NOOP;
onLoad: (data: onLoadData) => void = this.NOOP;
onLoadStart: (data: onLoadStartData) => void = this.NOOP;
onPlaybackStateChange: (data: onPlaybackStateChangeData) => void = this.NOOP;
onPlaybackRateChange: (rate: number) => void = this.NOOP;
onProgress: (data: onProgressData) => void = this.NOOP;
onReadyToDisplay: () => void = this.NOOP;
onSeek: (seekTime: number) => void = this.NOOP;
onTimedMetadata: (metadata: TimedMetadata) => void = this.NOOP;
onTextTrackDataChanged: (texts: string[]) => void = this.NOOP;
onTrackChange: (track: TextTrack | null) => void = this.NOOP;
onVolumeChange: (data: onVolumeChangeData) => void = this.NOOP;
onStatusChange: (status: VideoPlayerStatus) => void = this.NOOP;
}

View File

@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { VideoPlayer } from '../VideoPlayer';
import { type AllPlayerEvents } from '../types/Events';
import type { AllPlayerEvents } from '../types/Events';
import type { VideoPlayer } from '../VideoPlayer';
/**
* Attaches an event listener to a `VideoPlayer` instance for a specified event.

View File

@@ -1,7 +1,7 @@
import type { VideoPlayerSource } from '../../spec/nitro/VideoPlayerSource.nitro';
import type { NoAutocomplete } from '../types/Utils';
import type { VideoConfig, VideoSource } from '../types/VideoConfig';
import { isVideoPlayerSource } from '../utils/sourceFactory';
import { isVideoPlayerSource } from '../utils/sourceUtils';
import { VideoPlayer } from '../VideoPlayer';
import { useManagedInstance } from './useManagedInstance';

View File

@@ -0,0 +1,7 @@
declare module 'shaka-player' {
export = shaka;
}
declare module 'shaka-player/dist/shaka-player.compiled' {
export = shaka;
}

View File

@@ -1,7 +1,7 @@
import type { VideoPlayerSource } from '../../spec/nitro/VideoPlayerSource.nitro';
import type { TextTrack } from './TextTrack';
import type { VideoRuntimeError } from './VideoError';
import type { VideoOrientation } from './VideoOrientation';
import type { VideoPlayerSourceBase } from './VideoPlayerSourceBase';
import type { VideoPlayerStatus } from './VideoPlayerStatus';
export interface VideoPlayerEvents {
@@ -28,6 +28,7 @@ export interface VideoPlayerEvents {
/**
* Called when the video view's controls visibility changes.
* @param visible Whether the video view's controls are visible.
* @platform Android, Ios
*/
onControlsVisibleChange: (visible: boolean) => void;
/**
@@ -72,15 +73,18 @@ export interface VideoPlayerEvents {
onSeek: (seekTime: number) => void;
/**
* Called when player receives timed metadata.
* @platform Android, Ios
*/
onTimedMetadata: (metadata: TimedMetadata) => void;
/**
* Called when the text track (currently displayed subtitle) data changes.
* @platform Android, Ios
*/
onTextTrackDataChanged: (texts: string[]) => void;
/**
* Called when the selected text track changes.
* @param track - The newly selected text track, or null if no track is selected
* @platform Android, Ios
*/
onTrackChange: (track: TextTrack | null) => void;
/**
@@ -178,7 +182,7 @@ export interface onLoadStartData {
/**
* The source of the video.
*/
source: VideoPlayerSource;
source: VideoPlayerSourceBase;
}
export interface onPlaybackStateChangeData {

View File

@@ -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;
}

View File

@@ -5,8 +5,9 @@ import type {
} from '../../spec/nitro/VideoPlayer.nitro';
import type { VideoPlayerSource } from '../../spec/nitro/VideoPlayerSource.nitro';
import type { VideoConfig, VideoSource } from '../types/VideoConfig';
import { createSource, isVideoPlayerSource } from './sourceFactory';
import { createSource } from './sourceFactory';
import { tryParseNativeVideoError } from '../types/VideoError';
import { isVideoPlayerSource } from './sourceUtils';
const VideoPlayerFactory =
NitroModules.createHybridObject<VideoPlayerFactory>('VideoPlayerFactory');

View File

@@ -12,21 +12,13 @@ import type {
VideoSource,
} from '../types/VideoConfig';
import { tryParseNativeVideoError } from '../types/VideoError';
import { isVideoPlayerSource } from './sourceUtils';
const VideoPlayerSourceFactory =
NitroModules.createHybridObject<VideoPlayerSourceFactory>(
'VideoPlayerSourceFactory'
);
export const isVideoPlayerSource = (obj: any): obj is VideoPlayerSource => {
return (
obj && // obj is not null
typeof obj === 'object' && // obj is an object
'name' in obj && // obj has a name property
obj.name === 'VideoPlayerSource' // obj.name is 'VideoPlayerSource'
);
};
/**
* Creates a `VideoPlayerSource` instance from a URI (string).
*

View File

@@ -0,0 +1,11 @@
import type { VideoPlayerSource } from "../../spec/nitro/VideoPlayerSource.nitro";
export const isVideoPlayerSource = (obj: any): obj is VideoPlayerSource => {
return (
obj && // obj is not null
typeof obj === 'object' && // obj is an object
'name' in obj && // obj has a name property
obj.name === 'VideoPlayerSource' // obj.name is 'VideoPlayerSource'
);
};

View File

@@ -1,87 +1,14 @@
import * as React from 'react';
import type { ViewProps, ViewStyle } from 'react-native';
import type { ViewStyle } from 'react-native';
import { NitroModules } from 'react-native-nitro-modules';
import type {
SurfaceType,
VideoViewViewManager,
VideoViewViewManagerFactory,
} from '../../spec/nitro/VideoViewViewManager.nitro';
import type { VideoViewEvents } from '../types/Events';
import type { ResizeMode } from '../types/ResizeMode';
import { tryParseNativeVideoError, VideoError } from '../types/VideoError';
import type { VideoPlayer } from '../VideoPlayer';
import { NativeVideoView } from './NativeVideoView';
export interface VideoViewProps extends Partial<VideoViewEvents>, ViewProps {
/**
* The player to play the video - {@link VideoPlayer}
*/
player: VideoPlayer;
/**
* The style of the video view - {@link ViewStyle}
*/
style?: ViewStyle;
/**
* Whether to show the controls. Defaults to false.
*/
controls?: boolean;
/**
* Whether to enable & show the picture in picture button in native controls. Defaults to false.
*/
pictureInPicture?: boolean;
/**
* Whether to automatically enter picture in picture mode when the video is playing. Defaults to false.
*/
autoEnterPictureInPicture?: boolean;
/**
* How the video should be resized to fit the view. Defaults to 'none'.
* - 'contain': Scale the video uniformly (maintain aspect ratio) so that it fits entirely within the view
* - 'cover': Scale the video uniformly (maintain aspect ratio) so that it fills the entire view (may crop)
* - 'stretch': Scale the video to fill the entire view without maintaining aspect ratio
* - 'none': Do not resize the video
*/
resizeMode?: ResizeMode;
/**
* Whether to keep the screen awake while the video view is mounted. Defaults to true.
*/
keepScreenAwake?: boolean;
/**
* The type of underlying native view. Defaults to 'surface'.
* - 'surface': Uses a SurfaceView on Android. More performant, but cannot be animated or transformed.
* - 'texture': Uses a TextureView on Android. Less performant, but can be animated and transformed.
*
* Only applicable on Android
*
* @default 'surface'
* @platform android
*/
surfaceType?: SurfaceType;
}
export interface VideoViewRef {
/**
* Enter fullscreen mode
*/
enterFullscreen: () => void;
/**
* Exit fullscreen mode
*/
exitFullscreen: () => void;
/**
* Enter picture in picture mode
*/
enterPictureInPicture: () => void;
/**
* Exit picture in picture mode
*/
exitPictureInPicture: () => void;
/**
* Check if picture in picture mode is supported
* @returns true if picture in picture mode is supported, false otherwise
*/
canEnterPictureInPicture: () => boolean;
}
import type { VideoViewProps, VideoViewRef } from './ViewViewProps';
let nitroIdCounter = 1;
const VideoViewViewManagerFactory =

View File

@@ -0,0 +1,84 @@
import {
forwardRef,
memo,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import { View, type ViewStyle } from "react-native";
import type { VideoPlayer } from "../VideoPlayer.web";
import type { VideoViewProps, VideoViewRef } from "./ViewViewProps";
/**
* VideoView is a component that allows you to display a video from a {@link VideoPlayer}.
*
* @param player - The player to play the video - {@link VideoPlayer}
* @param controls - Whether to show the controls. Defaults to false.
* @param style - The style of the video view - {@link ViewStyle}
* @param pictureInPicture - Whether to show the picture in picture button. Defaults to false.
* @param autoEnterPictureInPicture - Whether to automatically enter picture in picture mode
* when the video is playing. Defaults to false.
* @param resizeMode - How the video should be resized to fit the view. Defaults to 'none'.
*/
const VideoView = forwardRef<VideoViewRef, VideoViewProps>(
(
{
player: nPlayer,
controls = false,
resizeMode = "none",
// auto pip is unsupported
pictureInPicture = false,
autoEnterPictureInPicture = false,
keepScreenAwake = true,
...props
},
ref,
) => {
const player = nPlayer as unknown as VideoPlayer;
const vRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const videoElement = player.__getNativeRef();
vRef.current?.appendChild(videoElement);
return () => {
vRef.current?.removeChild(videoElement);
};
}, [player]);
useImperativeHandle(
ref,
() => ({
enterFullscreen: () => {
player.__getNativeRef().requestFullscreen({ navigationUI: "hide" });
},
exitFullscreen: () => {
document.exitFullscreen();
},
enterPictureInPicture: () => {
player.__getNativeRef().requestPictureInPicture();
},
exitPictureInPicture: () => {
document.exitPictureInPicture();
},
canEnterPictureInPicture: () => document.pictureInPictureEnabled,
}),
[player],
);
useEffect(() => {
player.__getNativeRef().controls = controls;
}, [player, controls]);
return (
<View {...props}>
<div
ref={vRef}
style={{ objectFit: resizeMode === "stretch" ? "fill" : resizeMode }}
/>
</View>
);
},
);
VideoView.displayName = "VideoView";
export default memo(VideoView);

View File

@@ -0,0 +1,76 @@
import type { ViewProps, ViewStyle } from "react-native";
import type { SurfaceType } from "../../spec/nitro/VideoViewViewManager.nitro";
import type { VideoViewEvents } from "../types/Events";
import type { ResizeMode } from "../types/ResizeMode";
import type { VideoPlayer } from "../VideoPlayer";
export interface VideoViewProps extends Partial<VideoViewEvents>, ViewProps {
/**
* The player to play the video - {@link VideoPlayer}
*/
player: VideoPlayer;
/**
* The style of the video view - {@link ViewStyle}
*/
style?: ViewStyle;
/**
* Whether to show the controls. Defaults to false.
*/
controls?: boolean;
/**
* Whether to enable & show the picture in picture button in native controls. Defaults to false.
*/
pictureInPicture?: boolean;
/**
* Whether to automatically enter picture in picture mode when the video is playing. Defaults to false.
*/
autoEnterPictureInPicture?: boolean;
/**
* How the video should be resized to fit the view. Defaults to 'none'.
* - 'contain': Scale the video uniformly (maintain aspect ratio) so that it fits entirely within the view
* - 'cover': Scale the video uniformly (maintain aspect ratio) so that it fills the entire view (may crop)
* - 'stretch': Scale the video to fill the entire view without maintaining aspect ratio
* - 'none': Do not resize the video
*/
resizeMode?: ResizeMode;
/**
* Whether to keep the screen awake while the video view is mounted. Defaults to true.
*/
keepScreenAwake?: boolean;
/**
* The type of underlying native view. Defaults to 'surface'.
* - 'surface': Uses a SurfaceView on Android. More performant, but cannot be animated or transformed.
* - 'texture': Uses a TextureView on Android. Less performant, but can be animated and transformed.
*
* Only applicable on Android
*
* @default 'surface'
* @platform android
*/
surfaceType?: SurfaceType;
}
export interface VideoViewRef {
/**
* Enter fullscreen mode
*/
enterFullscreen: () => void;
/**
* Exit fullscreen mode
*/
exitFullscreen: () => void;
/**
* Enter picture in picture mode
*/
enterPictureInPicture: () => void;
/**
* Exit picture in picture mode
*/
exitPictureInPicture: () => void;
/**
* Check if picture in picture mode is supported
* @returns true if picture in picture mode is supported, false otherwise
*/
canEnterPictureInPicture: () => boolean;
}

View File

@@ -1,26 +1,27 @@
export { useEvent } from './core/hooks/useEvent';
export { useVideoPlayer } from './core/hooks/useVideoPlayer';
export * from './core/types/Events';
export type { IgnoreSilentSwitchMode } from './core/types/IgnoreSilentSwitchMode';
export type { MixAudioMode } from './core/types/MixAudioMode';
export type { ResizeMode } from './core/types/ResizeMode';
export type { TextTrack } from './core/types/TextTrack';
export type { VideoConfig, VideoSource } from './core/types/VideoConfig';
export {
type LibraryError,
type PlayerError,
type SourceError,
type UnknownError,
type VideoComponentError,
type VideoError,
type VideoErrorCode,
type VideoRuntimeError,
type VideoViewError,
} from './core/types/VideoError';
export type { VideoPlayerStatus } from './core/types/VideoPlayerStatus';
export {
default as VideoView,
type VideoViewProps,
type VideoViewRef,
} from './core/video-view/VideoView';
export { VideoPlayer } from './core/VideoPlayer';
export { useEvent } from "./core/hooks/useEvent";
export { useVideoPlayer } from "./core/hooks/useVideoPlayer";
export type * from "./core/types/Events";
export type { IgnoreSilentSwitchMode } from "./core/types/IgnoreSilentSwitchMode";
export type { MixAudioMode } from "./core/types/MixAudioMode";
export type { ResizeMode } from "./core/types/ResizeMode";
export type { TextTrack } from "./core/types/TextTrack";
export type { VideoConfig, VideoSource } from "./core/types/VideoConfig";
export type {
LibraryError,
PlayerError,
SourceError,
UnknownError,
VideoComponentError,
VideoError,
VideoErrorCode,
VideoRuntimeError,
VideoViewError,
} from "./core/types/VideoError";
export type { VideoPlayerStatus } from "./core/types/VideoPlayerStatus";
export { VideoPlayer } from "./core/VideoPlayer";
export { default as VideoView } from "./core/video-view/VideoView";
export type {
VideoViewProps,
VideoViewRef,
} from "./core/video-view/ViewViewProps";

View File

@@ -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<void>;
/**

View File

@@ -18,14 +18,9 @@ cd ../..
echo "[DRM Plugin] Publishing drm plugin"
read -p "[DRM Plugin] Do you want to release the DRM plugin? (y/n): " confirm
if [[ $confirm == "y" || $confirm == "Y" ]]; then
cd packages/drm-plugin
bun run release $@
cd ../..
else
echo "[DRM Plugin] Skipping DRM plugin release."
fi
cd packages/drm-plugin
bun run release $@
cd ../..
echo "[React Native Video] Making Github Release"
bun run release:github $@

6
shell.nix Normal file
View File

@@ -0,0 +1,6 @@
{pkgs ? import <nixpkgs> {}}:
pkgs.mkShell {
packages = with pkgs; [
bun
];
}