Compare commits

...

117 Commits

Author SHA1 Message Date
47151c7119 Fix android build
Some checks failed
Build Android / Build Android Example App (pull_request) Has been cancelled
Build Android / Build Android Example App Without Ads (pull_request) Has been cancelled
Check Android / Kotlin-Lint (pull_request) Has been cancelled
2024-10-14 11:02:21 +02:00
56c129aa4f Don't patch package anymore 2024-10-13 22:45:06 -06:00
68cbe3c4b1 Don't require rate to be defined 2024-10-13 20:45:25 -06:00
81e864c0e1 Update shaka when nativeRef changes 2024-10-13 19:17:01 -06:00
0e9ac4d125 Shaka loggin 2024-10-13 15:14:48 -06:00
9191a06600 Simple shaka 2024-10-13 15:04:44 -06:00
dc61c3efea Fix tsc 2024-10-13 14:18:49 -06:00
11f480f206 Start muted 2024-10-13 14:15:28 -06:00
9619e7517b Try again 2024-10-13 02:06:12 -06:00
3ddc6e931a More hacks 2024-10-13 01:09:57 -06:00
6b5831dc1c Hacks 2024-10-13 01:07:48 -06:00
3fc002f3fd Fix ts issues 2024-10-13 00:44:54 -06:00
edb5c6bcfa Merge remote-tracking branch 'railbird/master' into feat/shaka 2024-10-12 23:52:26 -06:00
5bc975b2c9 Shaka? 2024-10-12 23:48:55 -06:00
d79b5c9a83 Merge remote-tracking branch 'origin/master' into feat/web 2024-10-12 23:40:00 -06:00
Olivier Bouillet
a8d5841c7c
fix: ensure aspect ratio from video is handled in a coherent way (#4219)
* fix: ensure aspect ratio from video is handled in a coherent way
2024-10-12 13:51:57 +02:00
Krzysztof Moch
352dfbbc9b
fix(android): sideloaded subtitles (#4232) 2024-10-11 22:50:22 +02:00
HyunWoo Lee (Nunu Lee)
78f4f0480d
feat(exoplayerview): Migrate ExoPlayerView to kotlin (#4038) 2024-10-11 09:08:13 +02:00
Olivier Bouillet
d86adc52f3
Chore: rework ad props (#4220)
* fix: move ad configuration in source
2024-10-10 23:53:39 +02:00
Olivier Bouillet
9a3fcda3b8
feat: add setSource API function fix ads playback (#4185)
* feat: add setSource API function fix ads playback
2024-10-10 22:59:41 +02:00
Olivier Bouillet
4c9db2845b
chore: Add react compiler workaround (#4227) 2024-10-10 07:24:11 +02:00
Wojciech Ogrodowczyk
2c19a4770d
fix(iOS): pause video on end reached & don't remove listeners (#4218)
This fixes an issue when seeking back to the beginning results in
no `onProgress` events being fired.
2024-10-07 14:32:35 +02:00
Seyed Mostafa Hasani
d1883a7e00
feat(android): add settings button to control video playback speed (#4211) 2024-10-05 18:34:25 +02:00
Kamil Moskała
d81e6ea31e
docs: highlight maintainer services & update twg site links (#4214)
* docs: add cta block

* chore: update twg urls
2024-10-05 11:21:50 +02:00
Seyed Mostafa Hasani
74fb44ddcf
docs: update the TextTrackType enum (#4216)
* chore: update the document

* chore: update the document
2024-10-05 11:20:54 +02:00
Krzysztof Moch
0820f8167f
chore: release v6.6.4 2024-10-03 08:32:35 +02:00
Paul Rinaldi
40872f5ea7
fix(android) Use startForegroundService and do not delete the notification channel until onDestroy (#4105)
See https://developer.android.com/develop/background-work/services/foreground-services\#fgs-prerequisites
    See rationale here https://stackoverflow.com/questions/45525214/are-there-any-benefits-to-using-context-startforegroundserviceintent-instead-o
    Deleting the notification channel while the foreground service is still running is not permitted.
2024-10-03 08:23:17 +02:00
Seyed Mostafa Hasani
149924ffcb
feat(android): add live video label configuration (#4190) 2024-10-02 23:37:18 +02:00
Krzysztof Moch
82dc4cf3a0
chore: release v6.6.3 2024-09-29 21:24:47 +02:00
Kamil Moskała
279cc0e5ed
feat(android): allow to hide specific controls (#4183)
* feat(android): enable to hide specific controls

* fix: ts

* fix: lint

* docs: update `controlsStyles` docs
2024-09-29 20:51:02 +02:00
Olivier Bouillet
3ecf324bb3
fix(android): bad rotation handling (#4205) 2024-09-29 20:48:44 +02:00
Olivier Bouillet
0c6b47f42c
docs: remove desugaring section as no more need on media3 1.4.1 (#4206) 2024-09-29 20:46:11 +02:00
Krzysztof Moch
b11f05f175
fix(tvos): typo (#4204)
* fix(tvos): typo

* lint
2024-09-28 16:39:09 +02:00
Krzysztof Moch
724b32b434
chore(infra): hide previous bot comments (#4191)
* chore(infra): hide prev comments from bot

* fix comment format
2024-09-28 14:52:23 +02:00
Lukas
c81eea54d8
fix(docs): invalid URLs in updating section (#4201) 2024-09-26 20:23:26 +02:00
Olivier Bouillet
ae82c83eef
fix(ios): Add safety checks and remove some of the ! in types declaration (#4182) 2024-09-22 18:41:25 +02:00
Krzysztof Moch
17dc2c064f
chore: release v6.6.2 2024-09-20 18:54:07 +02:00
Krzysztof Moch
0e4c95def9
feat(iOS): rewrite DRM Module (#4136)
* minimal api

* add suport for `getLicense`

* update logic for obtaining `assetId`

* add support for localSourceEncryptionKeyScheme

* fix typo

* fix pendingLicenses key bug

* lint code

* code clean

* code clean

* remove old files

* fix tvOS build

* fix errors loop

* move `localSourceEncryptionKeyScheme` into drm params

* add check for drm type

* use DebugLog

* lint

* update docs

* lint code

* fix bad rebase

* update docs

* fix crashes on simulators

* show error on simulator when using DRM

* fix typos

* code clean
2024-09-20 17:46:10 +02:00
Olivier Bouillet
c96f7d41f3
chore(sample): fix default track identification and add audio tracks selection option (#4184) 2024-09-20 16:26:20 +02:00
Olivier Bouillet
6fedca0df7
chore(sample): upgrade sample expo version (#4179) 2024-09-19 13:51:24 +02:00
Olivier Bouillet
892efdd3ab chore: release v6.6.1 2024-09-18 22:51:28 +02:00
Olivier Bouillet
7d43d5d3da
fix(ios): fix side loaded text track management (#4180) 2024-09-18 22:43:25 +02:00
Krzysztof Moch
41d3da9146
chore: release v6.6.0 2024-09-18 21:17:15 +02:00
Olivier Bouillet
835186a321
fix(JS): improve loader api to allow function call instead of component (#4171) 2024-09-17 15:58:47 +02:00
Olivier Bouillet
7f6b500c82
fix(android): ensure maxbitrate & selectedVideoTrack interact correctly (#4155) 2024-09-17 15:57:26 +02:00
Seyed Mostafa Hasani
1ef2b3a977
chore(android): add null check for id of videoFormat (#4174)
* chore(android): add null check for id of videoFormat

* chore: null check videoFormat.id on onBandwidthSample

* fix: PR feedback

* fix: linter error

* chore: update trackId fallback value
2024-09-17 14:11:02 +02:00
Amin Meshk
0538b3b468
fix(expo-plugin): add check for existing service in AndroidManifest for notification controls (#4172)
* fix: add check for existing VideoPlaybackService in AndroidManifest

* Update src/expo-plugins/withNotificationControls.ts

Co-authored-by: Seyed Mostafa Hasani <seyedmostafahassani@gmail.com>

* fix: comment spacing

---------

Co-authored-by: Olivier Bouillet <62574056+freeboub@users.noreply.github.com>
Co-authored-by: Seyed Mostafa Hasani <seyedmostafahassani@gmail.com>
2024-09-17 14:09:39 +02:00
Seyed Mostafa Hasani
e57c7bda5d
feat(android): upgrade dependencies / media3 1.4.1 / androidxCore to 1.13.1 / androidxActivity 1.8.2 (#4173) 2024-09-16 13:54:51 +02:00
Olivier Bouillet
24d90e9ec8
chore(android): move contentStartTime into source prop (#4160) 2024-09-14 19:53:54 +02:00
Krzysztof Moch
b74cb59602
chore(android): add null checks (#4168) 2024-09-14 15:20:50 +02:00
Olivier Bouillet
84a27f3d9f
fix: refactor side loaded text tracks management (#4158)
* fix: refactor side loaded text tracks management

More textTracks in source.
android/ios: ensure text tracks are not selected by default
android/ios make textTrack field not nullable
clean up doc
check compatibility with the old api
Add comments on deprecated JS apis
Apply API change on basic sample

* chore: fix linter

* fix(ios): fix build with caching & remove warnings
2024-09-13 10:50:33 +02:00
Olivier Bouillet
7118ba6819
chore(ios): remove some warnings (#4159) 2024-09-13 10:49:43 +02:00
Krzysztof Moch
2c1fc964bf
fix(visionOS): remove unsupported apis (#4154) 2024-09-09 15:46:53 +02:00
Olivier Bouillet
b2fd8d62a1
fix(android): ensure pause is well tken in account after onEnd (#4147)
Issue linked to: https://github.com/TheWidlarzGroup/react-native-video/issues/2690
This original issue is not reproduced
2024-09-06 15:11:33 +02:00
Olivier Bouillet
809a730198
fix(ios): ensure onBandwidthUpdate is reported only when value change (#4149)
* fix(ios): ensure onBandwidthUpdate is reported only when value change

* chore: fix PodFile.lock
2024-09-06 15:11:12 +02:00
Olivier Bouillet
e18769ab3a
fix(sample): remove warning on ios with NavigationBar (#4148)
* fix(sample): remove warning on ios with NavigationBar
2024-09-06 09:45:24 +02:00
Seyed Mostafa Hasani
4a2beaa147
chore(android): remove onBackPressed function in FullScreenPlayerView (#4049)
Co-authored-by: Olivier Bouillet <62574056+freeboub@users.noreply.github.com>
2024-09-06 00:12:22 +02:00
Gunnar Carlson
bee4123402
fix(ios): losing subtitle selection on foreground (#3707) 2024-09-05 10:41:27 +02:00
Olivier Bouillet
b871d937a3 chore: release v6.5.0 2024-09-04 11:47:49 +02:00
Kamil Moskała
b66d2fe146
docs: add ios platform for onBandwidthUpdate callback (#4145) 2024-09-04 11:08:49 +02:00
whdudtod1273
22c21ad249
feat: Correct isBehindLiveWindow Error Handling (#4143)
* feat: Correct isBehindLiveWindow Error Handling

---------

Co-authored-by: young <young@afreecatv.com>
2024-09-04 09:54:08 +02:00
Olivier Bouillet
9707081ab9
Chore/rework fullscreen configuration (#4142)
* feat(android): handle navigation bar status in full-screen mode
* chore: update default value of prop
* chore(android): rework fullscreen configuration

---------

Co-authored-by: mostafahasani <seyedmostafahassani@gmail.com>
2024-09-04 09:53:30 +02:00
Olivier Bouillet
d6bae3cd07
fix(ios): fix onBandwidth update event (old ios api is deprecated and doens't work) (#4140) 2024-09-03 15:33:43 +02:00
Seyed Mostafa Hasani
c51c061f43
chore(android): clean up ReactExoplayerView class (#4141) 2024-09-03 11:16:20 +02:00
Seyed Mostafa Hasani
8b8ebe9410
fix(android): show the status bar and navigation bar after exiting full-screen mode (#4112)
* fix(android):  show the status bar and navigation bar after exiting full-screen mode
2024-09-03 08:59:24 +02:00
Olivier Bouillet
308447a5ba
Fix/track selection by title (#4129)
* chore(sample): make track selection by title possible

* fix(android): fix test for track selection by title
2024-09-02 19:10:39 +02:00
Olivier Bouillet
89df9d69ff
fix(ios): ensure we don't disable tracks when not necessary (causes black screen) (#4130) 2024-09-02 19:08:27 +02:00
Olivier Bouillet
fbe570d62f
Fix/allow text track selection by index (#4124)
* fix(ios): ensure behavior is correct with empty text track list
* fix(ios): add index to text tracks reported
2024-09-02 17:01:39 +02:00
Olivier Bouillet
2fa6c43615
fix(android): add subtitleStyle.subtitlesFollowVideo prop to control subtitles positionning (#4133)
* fix(android): add subtitleStyle.subtitlesFollowVideo prop to control subtitles positionning
* docs: add new prop description
* docs: add supported platform for subtitleStyle
* chore: use constructor instead of parse
2024-09-02 16:13:06 +02:00
Olivier Bouillet
688d98d68f
fix(tvos): fix build (and update sample) (#4134)
* fix(tvos): fix build (and update sample)
2024-09-02 15:42:51 +02:00
Olivier Bouillet
3a32d67087
fix(ios): ensure behavior is correct with empty text track list (#4123)
* fix(ios): ensure behavior is correct with empty text track list
2024-09-02 15:40:38 +02:00
Olivier Bouillet
7a2b4014f4
fix(sample): update dependencies to fix local asset playback (#4121)
* fix(sample): align dependencies and fix local asset playback
2024-09-02 15:40:10 +02:00
Olivier Bouillet
fb3c0da6af
chore(sample): additionnal sample cleanup (#4122)
* chore: move MultiValueControl & toggleControl to component
* fix(sample): fix import / export to avoid circular deps
* chore(sample): fix warning
2024-08-31 18:32:32 +02:00
Błażej Lewandowski
451806c547
fix(expo-plugin): adding bg mode if none exist yet (#4126) 2024-08-31 15:10:52 +02:00
Guy Haguy
703ed43996
feat: add ads localize (#4113)
* add prop adLanguage; add docs

* add native code ios&android for adLanguage props

* add missing function to adsLoader and imafactory

* Update docs/pages/component/ads.md

Language correction

Co-authored-by: Olivier Bouillet <62574056+freeboub@users.noreply.github.com>

---------

Co-authored-by: Guy <guyha@reshet.tv>
Co-authored-by: Olivier Bouillet <62574056+freeboub@users.noreply.github.com>
2024-08-29 12:30:05 +02:00
Wojciech Ogrodowczyk
9c38d9f4ef
fix(ios): Add handler for Earpods play/pause command (#4116) 2024-08-29 12:28:27 +02:00
Olivier Bouillet
0576eacfdd
fix(VisionOS): do not access to isExternalPlaybackActive on VisionOS (#4109) 2024-08-29 12:27:07 +02:00
Kamil Moskała
24c99f03b9
chore: add space to validator text (#4111) 2024-08-26 17:25:15 +02:00
Krzysztof Moch
ffa5044e23
infra: add posibility to skip issue validation 2024-08-23 10:53:51 +02:00
Krzysztof Moch
7db7024cb3
fix: set does not have find method (#4110) 2024-08-22 11:22:11 +02:00
Subin Yang
ca795f298a
feat(android): Support Common Media Client Data (CMCD) (#4034)
* feat(VideoNativeComponent.ts): add support for cmcd configuration in VideoSrc type to enable cmcd feature on android
feat(video.ts): introduce CmcdMode enum and CmcdConfiguration type to define cmcd configuration options

* feat(Video.tsx): add support for CMCD configuration in Video component to handle Content Management and Delivery (CMCD) headers for Android platform.

* feat(CMCDProps.kt): add CMCDProps class to handle CMCD related properties and parsing logic for React Native module

* feat(CMCDConfig.kt): add CMCDConfig class to handle CMCD configuration for ExoPlayer with support for custom data and configuration options.

* feat(ReactExoplayerViewManager.java): add support for CMCD configuration in ReactExoplayerViewManager to enable Content Management and Control Data (CMCD) for better video playback optimization.

* feat(ReactExoplayerView.java): add support for setting CmcdConfiguration.Factory to customize CMCD configuration for media playback

* feat(Source.kt): add support for CMCD properties linked to the source to enhance functionality and data handling

* docs(props.mdx): add documentation for configuring CMCD parameters in the component, including usage examples and default values

* refactor(ReactExoplayerViewManager.java): remove unused PROP_CMCD and prevCmcdConfig variables to clean up code and improve readability

* refactor(Video.tsx): simplify cmcd configuration logic for better readability and maintainability

* docs(props.mdx): improve props documentation for clarity and consistency
feat(props.mdx): add definitions for CmcdMode enum and CmcdData type to enhance understanding of CMCD data structure and usage

* refactor(CMCDProps.kt): refactor CMCDProps class to data class for improved readability and immutability
-  update CMCDProps class to use List instead of Array for properties

* refactor(Video.tsx): refactor createCmcdHeader function to improve code readability and reduce duplication

* fix(CMCDProps.kt): remove import statement for CmcdConfiguration

* feat(ReactExoplayerView.java): add support for CMCD configuration in ReactExoplayerView component
feat(ReactExoplayerViewManager.java): remove redundant CMCD configuration logic from ReactExoplayerViewManager to simplify code and improve maintainability

* fix(Video.tsx): merge _cmcd memo into src memo for optimization
2024-08-22 10:47:51 +02:00
Kamil Moskała
65faba312d
fix(android): hide surfaceView for loading time when shutter is hidden (#4060)
* fix(android): hide surfaceView for loading time when shutter is hidden

* fix: hide/show surface view without casting
2024-08-22 10:37:58 +02:00
Błażej Lewandowski
b05201a9fa
fix crash on source change, if the app was put in bg beforehand (#4074) 2024-08-22 10:30:23 +02:00
Faustino Kialungila
0a1085ce03
fix(ios): build fail due to an unwrapped value (#4101)
* fix: ios build crash due to AVMediaSelectionGroup not being unwrapped

* fix: use shorthand optional binding

* fix: disable swiftlint shorthand_optional_binding for guard let

* fix(ios): use guard do catch

Co-authored-by: Krzysztof Moch <krzysmoch.programs@gmail.com>

---------

Co-authored-by: Krzysztof Moch <krzysmoch.programs@gmail.com>
2024-08-21 10:26:32 +02:00
ashlyWeiting
41e2bed6b3
feat(android): support hiding Exoplayer video duration on android (#4090)
* feat: support for hiding duration on Android

* docs: add hideDuration property to control styles documentation
2024-08-21 10:05:40 +02:00
Krzysztof Moch
4611284247
infra: add issue validator and stale action (#4061)
* infra: update stale action

* infra: add issue validator

* code clean

* add missing labels

* fix reproduction check

* code clean

* add version check

* fix version check

* add missing label

* add note to version message

* code clean

* update stale message
2024-08-21 09:55:45 +02:00
Olivier Bouillet
1b691f8e81 chore: release v6.4.5 2024-08-17 15:37:45 +02:00
Seyed Mostafa Hasani
7e222e8fc4
fix(android): resolve a release issue with DefaultDashChunkSource (#4097) 2024-08-17 15:33:37 +02:00
Krzysztof Moch
736594ed23
chore: release v6.4.4 2024-08-15 16:13:00 +02:00
Seyed Mostafa Hasani
b7d1cabf72
refactor(android): migrate DefaultDashChunkSource to Kotlin (#4078)
* refactor(android): migrate DefaultDashChunkSource to Kotlin
2024-08-13 09:58:31 +02:00
Paul
c6ae17e41d
fix(ios): remove resume logic in notification seek closure (#4068) 2024-08-12 13:58:40 +02:00
Seyed Mostafa Hasani
cd41a1b234
chore(doc): update document (props & method) (#4072)
* chore: update method document

* chore: update method document

* chore: update method document

* fix: PR feedback

* chore: update description for controls prop
2024-08-12 13:55:10 +02:00
Krzysztof Moch
899bb822a5
fix(android): build warnings (#4058) 2024-08-07 14:39:41 +02:00
Krzysztof Moch
6c03d0a700
infra: update feature request form (#4065) 2024-08-07 14:33:16 +02:00
Zoe Roux
6768c22139
Add nativeHtmlVideoRef doc 2024-07-16 12:31:32 +07:00
Zoe Roux
2b369df57d
Fix native import on web 2024-07-16 12:31:32 +07:00
Zoe Roux
8542c8f7d1
Add isSeeking on web 2024-07-16 12:31:32 +07:00
Zoe Roux
fc5b2d4563
Add web support for fullscreen 2024-07-16 11:33:50 +07:00
Zoe Roux
ffb4631854
Remove unused errorHandler ref 2024-07-16 11:33:50 +07:00
Zoe Roux
29cf7c97c3
Update doc for web 2024-07-16 11:33:50 +07:00
Zoe Roux
491ed77a32
Renamve nativeHtmlRef to nativeHtmlVideoRef 2024-07-16 11:33:50 +07:00
Zoe Roux
5b199b52b4
Move video style to const var 2024-07-16 11:33:50 +07:00
Zoe Roux
9d19157654
Add web command in basic example 2024-07-16 11:33:50 +07:00
Zoe Roux
3dabf5f16f
Fix and improve VideoNativeComponent (canPlay/isWidewine supportd) for web 2024-07-16 11:33:50 +07:00
Zoe Roux
e610a274d5
Prevent playback state change loop on web 2024-07-16 11:33:50 +07:00
Zoe Roux
27880f5212
Update the doc for web things 2024-07-16 11:33:50 +07:00
Zoe Roux
39dd30b762
Cleanup media session handling on the web 2024-07-16 11:33:50 +07:00
Zoe Roux
edf5d0c613
Test notifications on web 2024-07-16 11:33:50 +07:00
Zoe Roux
975fc2f303
Fix web bugs 2024-07-16 11:33:49 +07:00
Zoe Roux
aa85d71b87
Make the basic example app work on web 2024-07-16 11:33:18 +07:00
Zoe Roux
cce24cd829
Add media session support 2024-07-16 11:33:18 +07:00
Zoe Roux
cad63d465d
Add most properties 2024-07-16 11:33:18 +07:00
Zoe Roux
f5fa063bc0
Add most events 2024-07-16 11:33:18 +07:00
Zoe Roux
c6abcdeb2f
Create ref handling and basics stollen from Kyoo 2024-07-16 11:33:18 +07:00
Zoe Roux
a72ab331dc
Move video ref type to its own file 2024-07-16 11:33:15 +07:00
Zoe Roux
fa126de97f
Add VideoDecoderProperties for the web 2024-07-16 11:24:52 +07:00
Zoe Roux
ca2452edb6
Add shell.nix for nix users 2024-07-16 11:20:12 +07:00
102 changed files with 5751 additions and 3733 deletions

View File

@ -74,7 +74,7 @@ body:
- type: input - type: input
id: reproduction-repo id: reproduction-repo
attributes: attributes:
label: Reproduction label: Reproduction Link
description: Provide a link to a repository with a reproduction of the bug, this is optional but it will make us to fix the bug faster description: Provide a link to a repository with a reproduction of the bug, this is optional but it will make us to fix the bug faster
placeholder: Reproduction Repository placeholder: Reproduction Repository
value: "repository link" value: "repository link"

View File

@ -7,7 +7,7 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: Thanks for taking the time to fill out this feature report! value: Thanks for taking the time to fill out this feature report!
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
@ -17,7 +17,7 @@ body:
value: "Very cool idea!" value: "Very cool idea!"
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: why-it-is-needed id: why-it-is-needed
attributes: attributes:
@ -49,4 +49,11 @@ body:
validations: validations:
required: false required: false
- type: markdown
attributes:
value: |
## Support
If this functionality is important to you and you need it, contact [TheWidlarzGroup](https://www.thewidlarzgroup.com/?utm_source=rnv&utm_medium=feature-request#Contact) - [`hi@thewidlarzgroup.com`](mailto:hi@thewidlarzgroup.com)

293
.github/scripts/validate.js vendored Normal file
View File

@ -0,0 +1,293 @@
const FIELD_MAPPINGS = {
Platform: 'What platforms are you having the problem on?',
Version: 'Version',
SystemVersion: 'System Version',
DeviceType: 'On what device are you experiencing the issue?',
Architecture: 'Architecture',
Description: 'What happened?',
ReproductionLink: 'Reproduction Link',
Reproduction: 'Reproduction',
};
const PLATFORM_LABELS = {
iOS: 'Platform: iOS',
visionOS: 'Platform: iOS',
'Apple tvOS': 'Platform: iOS',
Android: 'Platform: Android',
'Android TV': 'Platform: Android',
Windows: 'Platform: Windows',
web: 'Platform: Web',
};
const BOT_LABELS = [
'Missing Info',
'Repro Provided',
'Missing Repro',
'Waiting for Review',
'Newer Version Available',
...Object.values(PLATFORM_LABELS),
];
const SKIP_LABEL = 'No Validation';
const MESSAGE = {
FEATURE_REQUEST: `Thank you for your feature request. We will review it and get back to you if we need more information.`,
BUG_REPORT: `Thank you for your bug report. We will review it and get back to you if we need more information.`,
MISSING_INFO: (missingFields) => {
return `Thank you for your issue report. Please note that the following information is missing or incomplete:\n\n${missingFields
.map((field) => `- ${field.replace('missing-', '')}`)
.join(
'\n',
)}\n\nPlease update your issue with this information to help us address it more effectively.
\n > Note: issues without complete information have a lower priority`;
},
OUTDATED_VERSION: (issueVersion, latestVersion) => {
return (
`There is a newer version of the library available. ` +
`You are using version ${issueVersion}, while the latest stable version is ${latestVersion}. ` +
`Please update to the latest version and check if the issue still exists.` +
`\n > Note: If the issue still exists, please update the issue report with the latest information.`
);
},
};
const checkLatestVersion = async () => {
try {
const response = await fetch(
'https://registry.npmjs.org/react-native-video/latest',
);
const data = await response.json();
return data.version;
} catch (error) {
console.error('Error checking latest version:', error);
return null;
}
};
const getFieldValue = (body, field) => {
if (!FIELD_MAPPINGS[field]) {
console.warn('Field not supported:', field);
return '';
}
const fieldValue = FIELD_MAPPINGS[field];
const sections = body.split('###');
const section = sections.find((section) => {
// Find the section that contains the field
// For Reproduction, we need to make sure that we don't match Reproduction Link
if (field === 'Reproduction') {
return (
section.includes(fieldValue) && !section.includes('Reproduction Link')
);
}
return section.includes(fieldValue);
});
return section ? section.replace(fieldValue, '').trim() : '';
};
const validateBugReport = async (body, labels) => {
const selectedPlatforms = getFieldValue(body, 'Platform')
.split(',')
.map((p) => p.trim());
if (selectedPlatforms.length === 0) {
labels.add('missing-platform');
} else {
selectedPlatforms.forEach((platform) => {
const label = PLATFORM_LABELS[platform];
if (label) {
labels.add(label);
} else {
console.warn('Platform not supported', platform);
}
});
}
const version = getFieldValue(body, 'Version');
if (version) {
const words = version.split(' ');
const versionPattern = /\d+\.\d+\.\d+/;
const isVersionValid = words.some((word) => versionPattern.test(word));
if (!isVersionValid) {
labels.add('missing-version');
}
const latestVersion = await checkLatestVersion();
if (latestVersion && latestVersion !== version) {
labels.add(`outdated-version-${version}-${latestVersion}`);
}
}
const fields = [
{
name: 'SystemVersion',
invalidValue:
'What version of the system is using device that you are experiencing the issue?',
},
{name: 'DeviceType'},
{name: 'Architecture'},
{name: 'Description', invalidValue: 'A bug happened!'},
{name: 'Reproduction', invalidValue: 'Step to reproduce this bug are:'},
{name: 'ReproductionLink', invalidValue: 'repository link'},
];
fields.forEach(({name, invalidValue}) => {
const value = getFieldValue(body, name);
if (!value || value === invalidValue) {
const fieldName = FIELD_MAPPINGS[name];
labels.add(`missing-${fieldName.toLowerCase()}`);
}
});
};
const validateFeatureRequest = (body, labels) => {
// Implement feature request validation logic here
};
const handleIssue = async ({github, context}) => {
const {issue} = context.payload;
const {body} = issue;
const labels = new Set(issue.labels.map((label) => label.name));
if (labels.has(SKIP_LABEL)) {
console.log('Skiping Issue Validation');
return;
}
// Clear out labels that are added by the bot
BOT_LABELS.forEach((label) => labels.delete(label));
const isBug = labels.has('bug');
const isFeature = labels.has('feature');
if (isFeature) {
await handleFeatureRequest({github, context, body, labels});
} else if (isBug) {
await handleBugReport({github, context, body, labels});
} else {
console.warn('Issue is not a bug or feature request');
}
await updateIssueLabels({github, context, labels});
};
const handleFeatureRequest = async ({github, context, body, labels}) => {
validateFeatureRequest(body, labels);
const comment = MESSAGE.FEATURE_REQUEST;
await createComment({github, context, body: comment});
};
const handleBugReport = async ({github, context, body, labels}) => {
await validateBugReport(body, labels);
if (Array.from(labels).some((label) => label.startsWith('missing-'))) {
await handleMissingInformation({github, context, labels});
} else {
await handleValidReport({github, context, labels});
}
};
const handleMissingInformation = async ({github, context, labels}) => {
const missingFields = Array.from(labels).filter((label) =>
label.startsWith('missing-'),
);
const outdatedVersionLabel = Array.from(labels).find((label) =>
label.startsWith('outdated-version'),
);
if (missingFields.length > 0) {
let comment = MESSAGE.MISSING_INFO(missingFields);
if (outdatedVersionLabel) {
const [, , issueVersion, latestVersion] = outdatedVersionLabel.split('-');
comment += `\n\n ${MESSAGE.OUTDATED_VERSION(
issueVersion,
latestVersion,
)}`;
}
await hidePreviousComments({github, context});
await createComment({github, context, body: comment});
}
updateLabelsForMissingInfo(labels);
};
const handleValidReport = async ({github, context, labels}) => {
let comment = MESSAGE.BUG_REPORT;
const outdatedVersionLabel = Array.from(labels).find((label) =>
label.startsWith('outdated-version'),
);
if (outdatedVersionLabel) {
const [, , issueVersion, latestVersion] = outdatedVersionLabel.split('-');
comment += `\n\n ${MESSAGE.OUTDATED_VERSION(issueVersion, latestVersion)}`;
labels.add('Newer Version Available');
}
await hidePreviousComments({github, context});
await createComment({github, context, body: comment});
labels.add('Repro Provided');
labels.add('Waiting for Review');
};
const createComment = async ({github, context, body}) => {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body,
});
};
const updateIssueLabels = async ({github, context, labels}) => {
const labelsToAdd = Array.from(labels).filter(
(label) => !label.startsWith('missing-') && !label.startsWith('outdated-'),
);
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
labels: labelsToAdd,
});
};
const hidePreviousComments = async ({github, context}) => {
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
});
const botComments = comments.data.filter(
(comment) => comment.user.type === 'Bot',
);
for (const comment of botComments) {
// Don't format string - it will broke the markdown
const hiddenBody = `
<details>
<summary>Previous bot comment (click to expand)</summary>
${comment.body}
</details>`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
body: hiddenBody,
});
}
};
module.exports = handleIssue;

60
.github/stale.yml vendored
View File

@ -1,60 +0,0 @@
# Configuration for probot-stale - https://github.com/probot/stale
# Number of days of inactivity before an Issue or Pull Request becomes stale
daysUntilStale: 60
# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
daysUntilClose: 3
# Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled)
onlyLabels: []
# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
exemptLabels:
- pinned
- security
# Set to true to ignore issues in a project (defaults to false)
exemptProjects: true
# Set to true to ignore issues in a milestone (defaults to false)
exemptMilestones: true
# Set to true to ignore issues with an assignee (defaults to false)
exemptAssignees: true
# Label to use when marking as stale
staleLabel: stale
# Comment to post when marking as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions. If you are having a similar problem, please open a
new issue and reference this one instead of commenting on a stale or closed
issue.
# Comment to post when removing the stale label.
unmarkComment: false
# Comment to post when closing a stale Issue or Pull Request.
closeComment: false
# Limit the number of actions per hour, from 1-30. Default is 30
limitPerRun: 50
# Limit to only `issues` or `pulls`
only: issues
# Optionally, specify configuration settings that are specific to just 'issues' or 'pulls':
# pulls:
# daysUntilStale: 30
# markComment: >
# This pull request has been automatically marked as stale because it has not had
# recent activity. It will be closed if no further activity occurs. Thank you
# for your contributions.
# issues:
# exemptLabels:
# - confirmed

24
.github/workflows/stale.yml vendored Normal file
View File

@ -0,0 +1,24 @@
name: Close inactive issues
on:
schedule:
- cron: "30 1 * * *"
workflow_dispatch:
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v5
with:
days-before-issue-stale: 30
days-before-issue-close: 14
stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity. If there won't be any activity in the next 14 days, this issue will be closed automatically."
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
days-before-pr-stale: -1
days-before-pr-close: -1
exempt-issue-labels: "feature,Accepted,good first issue"
repo-token: ${{ secrets.GITHUB_TOKEN }}

19
.github/workflows/validate-issue.yml vendored Normal file
View File

@ -0,0 +1,19 @@
name: Issue Validator and Labeler
on:
issues:
types: [opened, edited]
jobs:
validate-and-label:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Validate Issue Template and Add Labels
uses: actions/github-script@v7
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const script = require('./.github/scripts/validate.js')
await script({github, context})

View File

@ -1,5 +1,107 @@
## [6.6.4](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.6.3...v6.6.4) (2024-10-03)
### Features
* **android:** add live video label configuration ([#4190](https://github.com/TheWidlarzGroup/react-native-video/issues/4190)) ([149924f](https://github.com/TheWidlarzGroup/react-native-video/commit/149924ffcb0cbdeaa8c671ebb4b3b6115920131a))
## [6.6.3](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.6.2...v6.6.3) (2024-09-29)
### Bug Fixes
* **android:** bad rotation handling ([#4205](https://github.com/TheWidlarzGroup/react-native-video/issues/4205)) ([3ecf324](https://github.com/TheWidlarzGroup/react-native-video/commit/3ecf324bb30208ab8efbf00958ebd4590ddf8d39))
* **docs:** invalid URLs in updating section ([#4201](https://github.com/TheWidlarzGroup/react-native-video/issues/4201)) ([c81eea5](https://github.com/TheWidlarzGroup/react-native-video/commit/c81eea54d8291c5131fd59a93f198e0fd5f3673c))
* **ios:** Add safety checks and remove some of the ! in types declaration ([#4182](https://github.com/TheWidlarzGroup/react-native-video/issues/4182)) ([ae82c83](https://github.com/TheWidlarzGroup/react-native-video/commit/ae82c83eef2fc7c383fd844c7471613e4ac1c7ee))
* **tvos:** typo ([#4204](https://github.com/TheWidlarzGroup/react-native-video/issues/4204)) ([b11f05f](https://github.com/TheWidlarzGroup/react-native-video/commit/b11f05f1753a4cb963b94d1e1d8d1f6c37af2a9d))
### Features
* **android:** allow to hide specific controls ([#4183](https://github.com/TheWidlarzGroup/react-native-video/issues/4183)) ([279cc0e](https://github.com/TheWidlarzGroup/react-native-video/commit/279cc0e5ed712488fc3c153c62b14f13048103f2))
## [6.6.2](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.6.1...v6.6.2) (2024-09-20)
### Features
* **iOS:** rewrite DRM Module ([#4136](https://github.com/TheWidlarzGroup/react-native-video/issues/4136)) ([0e4c95d](https://github.com/TheWidlarzGroup/react-native-video/commit/0e4c95def968a4091fdd18d07215ba592eec99cb))
## [6.6.1](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.6.0...v6.6.1) (2024-09-18)
### Bug Fixes
* **ios:** fix side loaded text track management ([#4180](https://github.com/TheWidlarzGroup/react-native-video/issues/4180)) ([7d43d5d](https://github.com/TheWidlarzGroup/react-native-video/commit/7d43d5d3da72495e94468756be21442f96cc7a89))
# [6.6.0](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.5.0...v6.6.0) (2024-09-18)
### Bug Fixes
* **android:** ensure maxbitrate & selectedVideoTrack interact correctly ([#4155](https://github.com/TheWidlarzGroup/react-native-video/issues/4155)) ([7f6b500](https://github.com/TheWidlarzGroup/react-native-video/commit/7f6b500c82122325c326b6dcacaf7af8039b2b33))
* **android:** ensure pause is well tken in account after onEnd ([#4147](https://github.com/TheWidlarzGroup/react-native-video/issues/4147)) ([b2fd8d6](https://github.com/TheWidlarzGroup/react-native-video/commit/b2fd8d62a10ee64e6208b43120ca9231008309c2))
* **expo-plugin:** add check for existing service in AndroidManifest for notification controls ([#4172](https://github.com/TheWidlarzGroup/react-native-video/issues/4172)) ([0538b3b](https://github.com/TheWidlarzGroup/react-native-video/commit/0538b3b46801a535c76cf52db28cee76f2aeb0c5))
* **ios:** ensure onBandwidthUpdate is reported only when value change ([#4149](https://github.com/TheWidlarzGroup/react-native-video/issues/4149)) ([809a730](https://github.com/TheWidlarzGroup/react-native-video/commit/809a73019836f95385891c2bba5c72b0610ffcb1))
* **ios:** losing subtitle selection on foreground ([#3707](https://github.com/TheWidlarzGroup/react-native-video/issues/3707)) ([bee4123](https://github.com/TheWidlarzGroup/react-native-video/commit/bee4123402f4bc08dd2eb19ab0011ffdc795d0e3))
* **JS:** improve loader api to allow function call instead of component ([#4171](https://github.com/TheWidlarzGroup/react-native-video/issues/4171)) ([835186a](https://github.com/TheWidlarzGroup/react-native-video/commit/835186a321e1940932a045a59e26e43a040fa334))
* refactor side loaded text tracks management ([#4158](https://github.com/TheWidlarzGroup/react-native-video/issues/4158)) ([84a27f3](https://github.com/TheWidlarzGroup/react-native-video/commit/84a27f3d9f90624af3c5c3cbff50d754bab9baa4))
* **sample:** remove warning on ios with NavigationBar ([#4148](https://github.com/TheWidlarzGroup/react-native-video/issues/4148)) ([e18769a](https://github.com/TheWidlarzGroup/react-native-video/commit/e18769ab3a6a7f4ebc459ab550f105f4d18f8201))
* **visionOS:** remove unsupported apis ([#4154](https://github.com/TheWidlarzGroup/react-native-video/issues/4154)) ([2c1fc96](https://github.com/TheWidlarzGroup/react-native-video/commit/2c1fc964bf2cb97624c8cc37ff8138465619fc61))
### Features
* **android:** upgrade dependencies / media3 1.4.1 / androidxCore to 1.13.1 / androidxActivity 1.8.2 ([#4173](https://github.com/TheWidlarzGroup/react-native-video/issues/4173)) ([e57c7bd](https://github.com/TheWidlarzGroup/react-native-video/commit/e57c7bda5df7d624d90b20620859b8a4eb3f76b7))
# [6.5.0](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.4.5...v6.5.0) (2024-09-04)
### Bug Fixes
* **android:** show the status bar and navigation bar after exiting full-screen mode ([#4112](https://github.com/TheWidlarzGroup/react-native-video/issues/4112)) ([8b8ebe9](https://github.com/TheWidlarzGroup/react-native-video/commit/8b8ebe9410e95085e5602393c2ce3de814df4a96))
* **android:** add subtitleStyle.subtitlesFollowVideo prop to control subtitles positionning ([#4133](https://github.com/TheWidlarzGroup/react-native-video/issues/4133)) ([2fa6c43](https://github.com/TheWidlarzGroup/react-native-video/commit/2fa6c43615c1bc0a3bbcb5f472ffaeb8ae16a1af))
* **android:** hide surfaceView for loading time when shutter is hidden ([#4060](https://github.com/TheWidlarzGroup/react-native-video/issues/4060)) ([65faba3](https://github.com/TheWidlarzGroup/react-native-video/commit/65faba312d23de981972d2b6ffecefbc87ecac61))
* **expo-plugin:** adding bg mode if none exist yet ([#4126](https://github.com/TheWidlarzGroup/react-native-video/issues/4126)) ([451806c](https://github.com/TheWidlarzGroup/react-native-video/commit/451806c547591fbe5714b133e704ffac9efb05d8))
* **ios:** Add handler for Earpods play/pause command ([#4116](https://github.com/TheWidlarzGroup/react-native-video/issues/4116)) ([9c38d9f](https://github.com/TheWidlarzGroup/react-native-video/commit/9c38d9f4ef42c3e275ee39a08aa227e6b976fdb2))
* **ios:** build fail due to an unwrapped value ([#4101](https://github.com/TheWidlarzGroup/react-native-video/issues/4101)) ([0a1085c](https://github.com/TheWidlarzGroup/react-native-video/commit/0a1085ce03152d58d98da408dbe79e76fa5ebc1a))
* **ios:** ensure behavior is correct with empty text track list ([#4123](https://github.com/TheWidlarzGroup/react-native-video/issues/4123)) ([3a32d67](https://github.com/TheWidlarzGroup/react-native-video/commit/3a32d67087c39bcf7904043d15a2fdba65307f4e))
* **ios:** ensure we don't disable tracks when not necessary (causes black screen) ([#4130](https://github.com/TheWidlarzGroup/react-native-video/issues/4130)) ([89df9d6](https://github.com/TheWidlarzGroup/react-native-video/commit/89df9d69ff96f7d6ff3d493bf1a3eb9c3da51c3c))
* **ios:** fix onBandwidth update event (old ios api is deprecated and doens't work) ([#4140](https://github.com/TheWidlarzGroup/react-native-video/issues/4140)) ([d6bae3c](https://github.com/TheWidlarzGroup/react-native-video/commit/d6bae3cd076018f07556ab27af2779479bc7ff7d))
* **sample:** update dependencies to fix local asset playback ([#4121](https://github.com/TheWidlarzGroup/react-native-video/issues/4121)) ([7a2b401](https://github.com/TheWidlarzGroup/react-native-video/commit/7a2b4014f40758a025fcd6b388448d3559ec6a4a))
* set does not have `find` method ([#4110](https://github.com/TheWidlarzGroup/react-native-video/issues/4110)) ([7db7024](https://github.com/TheWidlarzGroup/react-native-video/commit/7db7024cb36ea34289fddf5c7f66e7b4d7827146))
* **tvos:** fix build (and update sample) ([#4134](https://github.com/TheWidlarzGroup/react-native-video/issues/4134)) ([688d98d](https://github.com/TheWidlarzGroup/react-native-video/commit/688d98d68f888a59bde1ee33aa844ac63c9026a5))
* **VisionOS:** do not access to isExternalPlaybackActive on VisionOS ([#4109](https://github.com/TheWidlarzGroup/react-native-video/issues/4109)) ([0576eac](https://github.com/TheWidlarzGroup/react-native-video/commit/0576eacfddb32c4dcc072b6fd3cbf74cf25946a4))
### Features
* add ads localize ([#4113](https://github.com/TheWidlarzGroup/react-native-video/issues/4113)) ([703ed43](https://github.com/TheWidlarzGroup/react-native-video/commit/703ed4399667e0142704d19686563dd62fb4883d))
* **android:** Support Common Media Client Data (CMCD) ([#4034](https://github.com/TheWidlarzGroup/react-native-video/issues/4034)) ([ca795f2](https://github.com/TheWidlarzGroup/react-native-video/commit/ca795f298a99a183b81561ef7e09d8d1e8addaf5))
* **android:** support hiding Exoplayer video duration on android ([#4090](https://github.com/TheWidlarzGroup/react-native-video/issues/4090)) ([41e2bed](https://github.com/TheWidlarzGroup/react-native-video/commit/41e2bed6b36f74a28d7dd640414c6d5ccbec0399))
* Correct isBehindLiveWindow Error Handling ([#4143](https://github.com/TheWidlarzGroup/react-native-video/issues/4143)) ([22c21ad](https://github.com/TheWidlarzGroup/react-native-video/commit/22c21ad249879fe4ff8fb119384ebc82766106c3))
## [6.4.5](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.4.4...v6.4.5) (2024-08-17)
### Bug Fixes
* **android:** resolve a release issue with DefaultDashChunkSource ([#4097](https://github.com/TheWidlarzGroup/react-native-video/issues/4097)) ([7e222e8](https://github.com/TheWidlarzGroup/react-native-video/commit/7e222e8fc4f3c47a1c9cd2fbf5ff012bcbe98a7f))
* refactor(android): migrate DefaultDashChunkSource to Kotlin (#4078) (b7d1cabf)
* fix(ios): remove resume logic in notification seek closure (#4068) (c6ae17e4)
* chore(doc): update document (props & method) (#4072) (cd41a1b2)
* fix(android): build warnings (#4058) (899bb822)
* infra: update feature request form (#4065) (6c03d0a7)
* fix(ios): override source metadata with custom metadata (#4050) (38aa2b05)
* fix(android): return the value as a float for the getCurrentPosition function (#4054) (af0302b1)
* refactor(android): migrate ReactExoplayerViewManager to Kotlin (#4011) (74c6dd62)
* fix(android): viewType is ignored when its value is ViewType.TEXTURE (#4031) (22cfd6ce)
* fix(ios): metadata update race (#4033) (08a57a3b)
* fix(ios): updated getLicense call to work with new syntax, and fixed spelling error (#4014) (#4042) (2348c5e4)
## [6.4.3](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.4.2...v6.4.3) (2024-07-24) ## [6.4.3](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.4.2...v6.4.3) (2024-07-24)

View File

@ -51,9 +51,9 @@ We have an discord server where you can ask questions and get help. [Join the di
## Enterprise Support ## Enterprise Support
<p> <p>
📱 <i>react-native-video</i> is provided <i>as it is</i>. For enterprise support or other business inquiries, <a href="https://www.thewidlarzgroup.com/">please contact us 🤝</a>. We can help you with the integration, customization and maintenance. We are providing both free and commercial support for this project. let's build something awesome together! 🚀 📱 <i>react-native-video</i> is provided <i>as it is</i>. For enterprise support or other business inquiries, <a href="https://www.thewidlarzgroup.com/?utm_source=rnv&utm_medium=readme#Contact">please contact us 🤝</a>. We can help you with the integration, customization and maintenance. We are providing both free and commercial support for this project. let's build something awesome together! 🚀
</p> </p>
<a href="https://www.thewidlarzgroup.com/"> <a href="https://www.thewidlarzgroup.com/?utm_source=rnv&utm_medium=readme">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="./docs/assets/baners/twg-dark.png" /> <source media="(prefers-color-scheme: dark)" srcset="./docs/assets/baners/twg-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./docs/assets/baners/twg-light.png" /> <source media="(prefers-color-scheme: light)" srcset="./docs/assets/baners/twg-light.png" />

View File

@ -216,7 +216,7 @@ dependencies {
//noinspection GradleDynamicVersion //noinspection GradleDynamicVersion
implementation "com.facebook.react:react-native:+" implementation "com.facebook.react:react-native:+"
implementation "androidx.core:core:$androidxCore_version" implementation "androidx.core:core-ktx:$androidxCore_version"
implementation "androidx.activity:activity-ktx:$androidxActivity_version" implementation "androidx.activity:activity-ktx:$androidxActivity_version"
// For media playback using ExoPlayer // For media playback using ExoPlayer

View File

@ -4,12 +4,12 @@ RNVideo_targetSdkVersion=34
RNVideo_compileSdkVersion=34 RNVideo_compileSdkVersion=34
RNVideo_ndkversion=26.1.10909125 RNVideo_ndkversion=26.1.10909125
RNVideo_buildToolsVersion=34.0.0 RNVideo_buildToolsVersion=34.0.0
RNVideo_media3Version=1.3.1 RNVideo_media3Version=1.4.1
RNVideo_useExoplayerIMA=false RNVideo_useExoplayerIMA=false
RNVideo_useExoplayerRtsp=false RNVideo_useExoplayerRtsp=false
RNVideo_useExoplayerSmoothStreaming=true RNVideo_useExoplayerSmoothStreaming=true
RNVideo_useExoplayerDash=true RNVideo_useExoplayerDash=true
RNVideo_useExoplayerHls=true RNVideo_useExoplayerHls=true
RNVideo_androidxCoreVersion=1.9.0 RNVideo_androidxCoreVersion=1.13.1
RNVideo_androidxActivityVersion=1.7.0 RNVideo_androidxActivityVersion=1.8.2
RNVideo_buildFromMedia3Source=false RNVideo_buildFromMedia3Source=false

View File

@ -7,4 +7,4 @@ public class DefaultDashChunkSource {
public Factory(DataSource.Factory mediaDataSourceFactory) { public Factory(DataSource.Factory mediaDataSourceFactory) {
} }
} }
} }

View File

@ -11,9 +11,17 @@ import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.source.ads.AdsLoader; import androidx.media3.exoplayer.source.ads.AdsLoader;
import androidx.media3.exoplayer.source.ads.AdsMediaSource; import androidx.media3.exoplayer.source.ads.AdsMediaSource;
import com.google.ads.interactivemedia.v3.api.ImaSdkSettings;
import java.io.IOException; import java.io.IOException;
public class ImaAdsLoader implements AdsLoader { public class ImaAdsLoader implements AdsLoader {
private final ImaSdkSettings imaSdkSettings;
public ImaAdsLoader(ImaSdkSettings imaSdkSettings) {
this.imaSdkSettings = imaSdkSettings;
}
public void setPlayer(ExoPlayer ignoredPlayer) { public void setPlayer(ExoPlayer ignoredPlayer) {
} }
@ -45,6 +53,7 @@ public class ImaAdsLoader implements AdsLoader {
} }
public static class Builder { public static class Builder {
private ImaSdkSettings imaSdkSettings;
public Builder(Context ignoredThemedReactContext) { public Builder(Context ignoredThemedReactContext) {
} }
@ -56,6 +65,11 @@ public class ImaAdsLoader implements AdsLoader {
return this; return this;
} }
public Builder setImaSdkSettings(ImaSdkSettings imaSdkSettings) {
this.imaSdkSettings = imaSdkSettings;
return this;
}
public ImaAdsLoader build() { public ImaAdsLoader build() {
return null; return null;
} }

View File

@ -0,0 +1,46 @@
package com.brentvatne.common.api
import android.net.Uri
import android.text.TextUtils
import com.brentvatne.common.toolbox.DebugLog
import com.brentvatne.common.toolbox.ReactBridgeUtils
import com.facebook.react.bridge.ReadableMap
class AdsProps {
var adTagUrl: Uri? = null
var adLanguage: String? = null
/** return true if this and src are equals */
override fun equals(other: Any?): Boolean {
if (other == null || other !is AdsProps) return false
return (
adTagUrl == other.adTagUrl &&
adLanguage == other.adLanguage
)
}
companion object {
private const val PROP_AD_TAG_URL = "adTagUrl"
private const val PROP_AD_LANGUAGE = "adLanguage"
@JvmStatic
fun parse(src: ReadableMap?): AdsProps {
val adsProps = AdsProps()
DebugLog.w("olivier", "uri: parse AdsProps")
if (src != null) {
val uriString = ReactBridgeUtils.safeGetString(src, PROP_AD_TAG_URL)
if (TextUtils.isEmpty(uriString)) {
adsProps.adTagUrl = null
} else {
adsProps.adTagUrl = Uri.parse(uriString)
}
val languageString = ReactBridgeUtils.safeGetString(src, PROP_AD_LANGUAGE)
if (!TextUtils.isEmpty(languageString)) {
adsProps.adLanguage = languageString
}
}
return adsProps
}
}
}

View File

@ -0,0 +1,51 @@
package com.brentvatne.common.api
import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetInt
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.ReadableType
data class CMCDProps(
val cmcdObject: List<Pair<String, Any>> = emptyList(),
val cmcdRequest: List<Pair<String, Any>> = emptyList(),
val cmcdSession: List<Pair<String, Any>> = emptyList(),
val cmcdStatus: List<Pair<String, Any>> = emptyList(),
val mode: Int = 1
) {
companion object {
private const val PROP_CMCD_OBJECT = "object"
private const val PROP_CMCD_REQUEST = "request"
private const val PROP_CMCD_SESSION = "session"
private const val PROP_CMCD_STATUS = "status"
private const val PROP_CMCD_MODE = "mode"
@JvmStatic
fun parse(src: ReadableMap?): CMCDProps? {
if (src == null) return null
return CMCDProps(
cmcdObject = parseKeyValuePairs(src.getArray(PROP_CMCD_OBJECT)),
cmcdRequest = parseKeyValuePairs(src.getArray(PROP_CMCD_REQUEST)),
cmcdSession = parseKeyValuePairs(src.getArray(PROP_CMCD_SESSION)),
cmcdStatus = parseKeyValuePairs(src.getArray(PROP_CMCD_STATUS)),
mode = safeGetInt(src, PROP_CMCD_MODE, 1)
)
}
private fun parseKeyValuePairs(array: ReadableArray?): List<Pair<String, Any>> {
if (array == null) return emptyList()
return (0 until array.size()).mapNotNull { i ->
val item = array.getMap(i)
val key = item?.getString("key")
val value = when (item?.getType("value")) {
ReadableType.Number -> item.getDouble("value")
ReadableType.String -> item.getString("value")
else -> null
}
if (key != null && value != null) Pair(key, value) else null
}
}
}
}

View File

@ -5,18 +5,42 @@ import com.facebook.react.bridge.ReadableMap
class ControlsConfig { class ControlsConfig {
var hideSeekBar: Boolean = false var hideSeekBar: Boolean = false
var hideDuration: Boolean = false
var hidePosition: Boolean = false
var hidePlayPause: Boolean = false
var hideForward: Boolean = false
var hideRewind: Boolean = false
var hideNext: Boolean = false
var hidePrevious: Boolean = false
var hideFullscreen: Boolean = false
var hideNavigationBarOnFullScreenMode: Boolean = true
var hideNotificationBarOnFullScreenMode: Boolean = true
var liveLabel: String? = null
var hideSettingButton: Boolean = true
var seekIncrementMS: Int = 10000 var seekIncrementMS: Int = 10000
companion object { companion object {
@JvmStatic @JvmStatic
fun parse(src: ReadableMap?): ControlsConfig { fun parse(controlsConfig: ReadableMap?): ControlsConfig {
val config = ControlsConfig() val config = ControlsConfig()
if (src != null) { if (controlsConfig != null) {
config.hideSeekBar = ReactBridgeUtils.safeGetBool(src, "hideSeekBar", false) config.hideSeekBar = ReactBridgeUtils.safeGetBool(controlsConfig, "hideSeekBar", false)
config.seekIncrementMS = ReactBridgeUtils.safeGetInt(src, "seekIncrementMS", 10000) config.hideDuration = ReactBridgeUtils.safeGetBool(controlsConfig, "hideDuration", false)
config.hidePosition = ReactBridgeUtils.safeGetBool(controlsConfig, "hidePosition", false)
config.hidePlayPause = ReactBridgeUtils.safeGetBool(controlsConfig, "hidePlayPause", false)
config.hideForward = ReactBridgeUtils.safeGetBool(controlsConfig, "hideForward", false)
config.hideRewind = ReactBridgeUtils.safeGetBool(controlsConfig, "hideRewind", false)
config.hideNext = ReactBridgeUtils.safeGetBool(controlsConfig, "hideNext", false)
config.hidePrevious = ReactBridgeUtils.safeGetBool(controlsConfig, "hidePrevious", false)
config.hideFullscreen = ReactBridgeUtils.safeGetBool(controlsConfig, "hideFullscreen", false)
config.seekIncrementMS = ReactBridgeUtils.safeGetInt(controlsConfig, "seekIncrementMS", 10000)
config.hideNavigationBarOnFullScreenMode = ReactBridgeUtils.safeGetBool(controlsConfig, "hideNavigationBarOnFullScreenMode", true)
config.hideNotificationBarOnFullScreenMode = ReactBridgeUtils.safeGetBool(controlsConfig, "hideNotificationBarOnFullScreenMode", true)
config.liveLabel = ReactBridgeUtils.safeGetString(controlsConfig, "liveLabel", null)
config.hideSettingButton = ReactBridgeUtils.safeGetBool(controlsConfig, "hideSettingButton", true)
} }
return config return config
} }
} }

View File

@ -1,8 +1,7 @@
package com.brentvatne.common.api package com.brentvatne.common.api
import androidx.annotation.IntDef import androidx.annotation.IntDef
import java.lang.annotation.Retention import kotlin.annotation.Retention
import java.lang.annotation.RetentionPolicy
internal object ResizeMode { internal object ResizeMode {
/** /**
@ -42,7 +41,7 @@ internal object ResizeMode {
else -> RESIZE_MODE_FIT else -> RESIZE_MODE_FIT
} }
@Retention(RetentionPolicy.SOURCE) @Retention(AnnotationRetention.SOURCE)
@IntDef( @IntDef(
RESIZE_MODE_FIT, RESIZE_MODE_FIT,
RESIZE_MODE_FIXED_WIDTH, RESIZE_MODE_FIXED_WIDTH,

View File

@ -11,12 +11,18 @@ import com.facebook.react.bridge.ReadableMap
class SideLoadedTextTrackList { class SideLoadedTextTrackList {
var tracks = ArrayList<SideLoadedTextTrack>() var tracks = ArrayList<SideLoadedTextTrack>()
/** return true if this and src are equals */
override fun equals(other: Any?): Boolean {
if (other == null || other !is SideLoadedTextTrackList) return false
return tracks == other.tracks
}
companion object { companion object {
fun parse(src: ReadableArray?): SideLoadedTextTrackList? { fun parse(src: ReadableArray?): SideLoadedTextTrackList? {
if (src == null) { if (src == null) {
return null return null
} }
var sideLoadedTextTrackList = SideLoadedTextTrackList() val sideLoadedTextTrackList = SideLoadedTextTrackList()
for (i in 0 until src.size()) { for (i in 0 until src.size()) {
val textTrack: ReadableMap = src.getMap(i) val textTrack: ReadableMap = src.getMap(i)
sideLoadedTextTrackList.tracks.add(SideLoadedTextTrack.parse(textTrack)) sideLoadedTextTrackList.tracks.add(SideLoadedTextTrack.parse(textTrack))

View File

@ -14,6 +14,7 @@ import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetBool
import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetInt import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetInt
import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetMap import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetMap
import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetString import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetString
import com.brentvatne.react.BuildConfig
import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableMap
import java.util.Locale import java.util.Locale
import java.util.Objects import java.util.Objects
@ -38,6 +39,9 @@ class Source {
/** Will crop content end at specified position */ /** Will crop content end at specified position */
var cropEndMs: Int = -1 var cropEndMs: Int = -1
/** Will virtually consider that content before contentStartTime is a preroll ad */
var contentStartTime: Int = -1
/** Allow to force stream content, necessary when uri doesn't contain content type (.mlp4, .m3u, ...) */ /** Allow to force stream content, necessary when uri doesn't contain content type (.mlp4, .m3u, ...) */
var extension: String? = null var extension: String? = null
@ -57,6 +61,21 @@ class Source {
*/ */
var textTracksAllowChunklessPreparation: Boolean = false var textTracksAllowChunklessPreparation: Boolean = false
/**
* CMCD properties linked to the source
*/
var cmcdProps: CMCDProps? = null
/**
* Ads playback properties
*/
var adsProps: AdsProps? = null
/**
* The list of sideLoaded text tracks
*/
var sideLoadedTextTracks: SideLoadedTextTrackList? = null
override fun hashCode(): Int = Objects.hash(uriString, uri, startPositionMs, cropStartMs, cropEndMs, extension, metadata, headers) override fun hashCode(): Int = Objects.hash(uriString, uri, startPositionMs, cropStartMs, cropEndMs, extension, metadata, headers)
/** return true if this and src are equals */ /** return true if this and src are equals */
@ -68,7 +87,11 @@ class Source {
cropEndMs == other.cropEndMs && cropEndMs == other.cropEndMs &&
startPositionMs == other.startPositionMs && startPositionMs == other.startPositionMs &&
extension == other.extension && extension == other.extension &&
drmProps == other.drmProps drmProps == other.drmProps &&
contentStartTime == other.contentStartTime &&
cmcdProps == other.cmcdProps &&
sideLoadedTextTracks == other.sideLoadedTextTracks &&
adsProps == other.adsProps
) )
} }
@ -127,11 +150,15 @@ class Source {
private const val PROP_SRC_START_POSITION = "startPosition" private const val PROP_SRC_START_POSITION = "startPosition"
private const val PROP_SRC_CROP_START = "cropStart" private const val PROP_SRC_CROP_START = "cropStart"
private const val PROP_SRC_CROP_END = "cropEnd" private const val PROP_SRC_CROP_END = "cropEnd"
private const val PROP_SRC_CONTENT_START_TIME = "contentStartTime"
private const val PROP_SRC_TYPE = "type" private const val PROP_SRC_TYPE = "type"
private const val PROP_SRC_METADATA = "metadata" private const val PROP_SRC_METADATA = "metadata"
private const val PROP_SRC_HEADERS = "requestHeaders" private const val PROP_SRC_HEADERS = "requestHeaders"
private const val PROP_SRC_DRM = "drm" private const val PROP_SRC_DRM = "drm"
private const val PROP_SRC_CMCD = "cmcd"
private const val PROP_SRC_ADS = "ad"
private const val PROP_SRC_TEXT_TRACKS_ALLOW_CHUNKLESS_PREPARATION = "textTracksAllowChunklessPreparation" private const val PROP_SRC_TEXT_TRACKS_ALLOW_CHUNKLESS_PREPARATION = "textTracksAllowChunklessPreparation"
private const val PROP_SRC_TEXT_TRACKS = "textTracks"
@SuppressLint("DiscouragedApi") @SuppressLint("DiscouragedApi")
private fun getUriFromAssetId(context: Context, uriString: String): Uri? { private fun getUriFromAssetId(context: Context, uriString: String): Uri? {
@ -187,9 +214,15 @@ class Source {
source.startPositionMs = safeGetInt(src, PROP_SRC_START_POSITION, -1) source.startPositionMs = safeGetInt(src, PROP_SRC_START_POSITION, -1)
source.cropStartMs = safeGetInt(src, PROP_SRC_CROP_START, -1) source.cropStartMs = safeGetInt(src, PROP_SRC_CROP_START, -1)
source.cropEndMs = safeGetInt(src, PROP_SRC_CROP_END, -1) source.cropEndMs = safeGetInt(src, PROP_SRC_CROP_END, -1)
source.contentStartTime = safeGetInt(src, PROP_SRC_CONTENT_START_TIME, -1)
source.extension = safeGetString(src, PROP_SRC_TYPE, null) source.extension = safeGetString(src, PROP_SRC_TYPE, null)
source.drmProps = parse(safeGetMap(src, PROP_SRC_DRM)) source.drmProps = parse(safeGetMap(src, PROP_SRC_DRM))
source.cmcdProps = CMCDProps.parse(safeGetMap(src, PROP_SRC_CMCD))
if (BuildConfig.USE_EXOPLAYER_IMA) {
source.adsProps = AdsProps.parse(safeGetMap(src, PROP_SRC_ADS))
}
source.textTracksAllowChunklessPreparation = safeGetBool(src, PROP_SRC_TEXT_TRACKS_ALLOW_CHUNKLESS_PREPARATION, true) source.textTracksAllowChunklessPreparation = safeGetBool(src, PROP_SRC_TEXT_TRACKS_ALLOW_CHUNKLESS_PREPARATION, true)
source.sideLoadedTextTracks = SideLoadedTextTrackList.parse(safeGetArray(src, PROP_SRC_TEXT_TRACKS))
val propSrcHeadersArray = safeGetArray(src, PROP_SRC_HEADERS) val propSrcHeadersArray = safeGetArray(src, PROP_SRC_HEADERS)
if (propSrcHeadersArray != null) { if (propSrcHeadersArray != null) {

View File

@ -6,7 +6,7 @@ import com.facebook.react.bridge.ReadableMap
/** /**
* Helper file to parse SubtitleStyle prop and build a dedicated class * Helper file to parse SubtitleStyle prop and build a dedicated class
*/ */
class SubtitleStyle private constructor() { class SubtitleStyle public constructor() {
var fontSize = -1 var fontSize = -1
private set private set
var paddingLeft = 0 var paddingLeft = 0
@ -19,6 +19,8 @@ class SubtitleStyle private constructor() {
private set private set
var opacity = 1f var opacity = 1f
private set private set
var subtitlesFollowVideo = true
private set
companion object { companion object {
private const val PROP_FONT_SIZE_TRACK = "fontSize" private const val PROP_FONT_SIZE_TRACK = "fontSize"
@ -27,6 +29,7 @@ class SubtitleStyle private constructor() {
private const val PROP_PADDING_LEFT = "paddingLeft" private const val PROP_PADDING_LEFT = "paddingLeft"
private const val PROP_PADDING_RIGHT = "paddingRight" private const val PROP_PADDING_RIGHT = "paddingRight"
private const val PROP_OPACITY = "opacity" private const val PROP_OPACITY = "opacity"
private const val PROP_SUBTITLES_FOLLOW_VIDEO = "subtitlesFollowVideo"
@JvmStatic @JvmStatic
fun parse(src: ReadableMap?): SubtitleStyle { fun parse(src: ReadableMap?): SubtitleStyle {
@ -37,6 +40,7 @@ class SubtitleStyle private constructor() {
subtitleStyle.paddingLeft = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_LEFT, 0) subtitleStyle.paddingLeft = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_LEFT, 0)
subtitleStyle.paddingRight = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_RIGHT, 0) subtitleStyle.paddingRight = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_RIGHT, 0)
subtitleStyle.opacity = ReactBridgeUtils.safeGetFloat(src, PROP_OPACITY, 1f) subtitleStyle.opacity = ReactBridgeUtils.safeGetFloat(src, PROP_OPACITY, 1f)
subtitleStyle.subtitlesFollowVideo = ReactBridgeUtils.safeGetBool(src, PROP_SUBTITLES_FOLLOW_VIDEO, true)
return subtitleStyle return subtitleStyle
} }
} }

View File

@ -65,11 +65,11 @@ class VideoEventEmitter {
audioTracks: ArrayList<Track>, audioTracks: ArrayList<Track>,
textTracks: ArrayList<Track>, textTracks: ArrayList<Track>,
videoTracks: ArrayList<VideoTrack>, videoTracks: ArrayList<VideoTrack>,
trackId: String trackId: String?
) -> Unit ) -> Unit
lateinit var onVideoError: (errorString: String, exception: Exception, errorCode: String) -> Unit lateinit var onVideoError: (errorString: String, exception: Exception, errorCode: String) -> Unit
lateinit var onVideoProgress: (currentPosition: Long, bufferedDuration: Long, seekableDuration: Long, currentPlaybackTime: Double) -> Unit lateinit var onVideoProgress: (currentPosition: Long, bufferedDuration: Long, seekableDuration: Long, currentPlaybackTime: Double) -> Unit
lateinit var onVideoBandwidthUpdate: (bitRateEstimate: Long, height: Int, width: Int, trackId: String) -> Unit lateinit var onVideoBandwidthUpdate: (bitRateEstimate: Long, height: Int, width: Int, trackId: String?) -> Unit
lateinit var onVideoPlaybackStateChanged: (isPlaying: Boolean, isSeeking: Boolean) -> Unit lateinit var onVideoPlaybackStateChanged: (isPlaying: Boolean, isSeeking: Boolean) -> Unit
lateinit var onVideoSeek: (currentPosition: Long, seekTime: Long) -> Unit lateinit var onVideoSeek: (currentPosition: Long, seekTime: Long) -> Unit
lateinit var onVideoSeekComplete: (currentPosition: Long) -> Unit lateinit var onVideoSeekComplete: (currentPosition: Long) -> Unit
@ -110,7 +110,7 @@ class VideoEventEmitter {
val naturalSize: WritableMap = aspectRatioToNaturalSize(videoWidth, videoHeight) val naturalSize: WritableMap = aspectRatioToNaturalSize(videoWidth, videoHeight)
putMap("naturalSize", naturalSize) putMap("naturalSize", naturalSize)
putString("trackId", trackId) trackId?.let { putString("trackId", it) }
putArray("videoTracks", videoTracksToArray(videoTracks)) putArray("videoTracks", videoTracksToArray(videoTracks))
putArray("audioTracks", audioTracksToArray(audioTracks)) putArray("audioTracks", audioTracksToArray(audioTracks))
putArray("textTracks", textTracksToArray(textTracks)) putArray("textTracks", textTracksToArray(textTracks))
@ -155,9 +155,13 @@ class VideoEventEmitter {
onVideoBandwidthUpdate = { bitRateEstimate, height, width, trackId -> onVideoBandwidthUpdate = { bitRateEstimate, height, width, trackId ->
event.dispatch(EventTypes.EVENT_BANDWIDTH) { event.dispatch(EventTypes.EVENT_BANDWIDTH) {
putDouble("bitrate", bitRateEstimate.toDouble()) putDouble("bitrate", bitRateEstimate.toDouble())
putInt("width", width) if (width > 0) {
putInt("height", height) putInt("width", width)
putString("trackId", trackId) }
if (height > 0) {
putInt("height", height)
}
trackId?.let { putString("trackId", it) }
} }
} }
onVideoPlaybackStateChanged = { isPlaying, isSeeking -> onVideoPlaybackStateChanged = { isPlaying, isSeeking ->
@ -216,7 +220,7 @@ class VideoEventEmitter {
putArray( putArray(
"metadata", "metadata",
Arguments.createArray().apply { Arguments.createArray().apply {
metadataArrayList.forEachIndexed { i, metadata -> metadataArrayList.forEachIndexed { _, metadata ->
pushMap( pushMap(
Arguments.createMap().apply { Arguments.createMap().apply {
putString("identifier", metadata.identifier) putString("identifier", metadata.identifier)
@ -310,7 +314,7 @@ class VideoEventEmitter {
private fun videoTracksToArray(videoTracks: java.util.ArrayList<VideoTrack>?): WritableArray = private fun videoTracksToArray(videoTracks: java.util.ArrayList<VideoTrack>?): WritableArray =
Arguments.createArray().apply { Arguments.createArray().apply {
videoTracks?.forEachIndexed { i, vTrack -> videoTracks?.forEachIndexed { _, vTrack ->
pushMap( pushMap(
Arguments.createMap().apply { Arguments.createMap().apply {
putInt("width", vTrack.width) putInt("width", vTrack.width)
@ -343,15 +347,19 @@ class VideoEventEmitter {
private fun aspectRatioToNaturalSize(videoWidth: Int, videoHeight: Int): WritableMap = private fun aspectRatioToNaturalSize(videoWidth: Int, videoHeight: Int): WritableMap =
Arguments.createMap().apply { Arguments.createMap().apply {
putInt("width", videoWidth) if (videoWidth > 0) {
putInt("height", videoHeight) putInt("width", videoWidth)
val orientation = if (videoWidth > videoHeight) {
"landscape"
} else if (videoWidth < videoHeight) {
"portrait"
} else {
"square"
} }
if (videoHeight > 0) {
putInt("height", videoHeight)
}
val orientation = when {
videoWidth > videoHeight -> "landscape"
videoWidth < videoHeight -> "portrait"
else -> "square"
}
putString("orientation", orientation) putString("orientation", orientation)
} }
} }

View File

@ -3,7 +3,6 @@ package com.brentvatne.common.toolbox
import com.facebook.react.bridge.Dynamic import com.facebook.react.bridge.Dynamic
import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableMap
import java.util.HashMap
/* /*
* Toolbox to safe parsing of <Video props * Toolbox to safe parsing of <Video props
@ -54,6 +53,17 @@ object ReactBridgeUtils {
@JvmStatic fun safeGetFloat(map: ReadableMap?, key: String?): Float = safeGetFloat(map, key, 0.0f) @JvmStatic fun safeGetFloat(map: ReadableMap?, key: String?): Float = safeGetFloat(map, key, 0.0f)
@JvmStatic fun safeParseInt(value: String?, default: Int): Int {
if (value == null) {
return default
}
return try {
value.toInt()
} catch (e: java.lang.Exception) {
default
}
}
/** /**
* toStringMap converts a [ReadableMap] into a HashMap. * toStringMap converts a [ReadableMap] into a HashMap.
* *

View File

@ -2,6 +2,7 @@ package com.brentvatne.exoplayer
import android.content.Context import android.content.Context
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.media3.common.Format
import com.brentvatne.common.api.ResizeMode import com.brentvatne.common.api.ResizeMode
import kotlin.math.abs import kotlin.math.abs
@ -94,4 +95,12 @@ class AspectRatioFrameLayout(context: Context) : FrameLayout(context) {
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
) )
} }
fun updateAspectRatio(format: Format) {
// There are weird cases when video height and width did not change with rotation so we need change aspect ration to fix it
when (format.rotationDegrees) {
90, 270 -> videoAspectRatio = if (format.width == 0) 1f else (format.height * format.pixelWidthHeightRatio) / format.width
else -> videoAspectRatio = if (format.height == 0) 1f else (format.width * format.pixelWidthHeightRatio) / format.height
}
}
} }

View File

@ -0,0 +1,41 @@
package com.brentvatne.exoplayer
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.upstream.CmcdConfiguration
import com.brentvatne.common.api.CMCDProps
import com.google.common.collect.ImmutableListMultimap
class CMCDConfig(private val props: CMCDProps) {
fun toCmcdConfigurationFactory(): CmcdConfiguration.Factory = CmcdConfiguration.Factory(::createCmcdConfiguration)
private fun createCmcdConfiguration(mediaItem: MediaItem): CmcdConfiguration =
CmcdConfiguration(
java.util.UUID.randomUUID().toString(),
mediaItem.mediaId,
object : CmcdConfiguration.RequestConfig {
override fun getCustomData(): ImmutableListMultimap<String, String> = buildCustomData()
},
props.mode
)
private fun buildCustomData(): ImmutableListMultimap<String, String> =
ImmutableListMultimap.builder<String, String>().apply {
addFormattedData(this, CmcdConfiguration.KEY_CMCD_OBJECT, props.cmcdObject)
addFormattedData(this, CmcdConfiguration.KEY_CMCD_REQUEST, props.cmcdRequest)
addFormattedData(this, CmcdConfiguration.KEY_CMCD_SESSION, props.cmcdSession)
addFormattedData(this, CmcdConfiguration.KEY_CMCD_STATUS, props.cmcdStatus)
}.build()
private fun addFormattedData(builder: ImmutableListMultimap.Builder<String, String>, key: String, dataList: List<Pair<String, Any>>) {
dataList.forEach { (dataKey, dataValue) ->
builder.put(key, formatKeyValue(dataKey, dataValue))
}
}
private fun formatKeyValue(key: String, value: Any): String =
when (value) {
is String -> "$key=\"$value\""
is Number -> "$key=$value"
else -> throw IllegalArgumentException("Unsupported value type: ${value::class.java}")
}
}

View File

@ -1,292 +0,0 @@
package com.brentvatne.exoplayer;
import android.annotation.SuppressLint;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.media3.common.AdViewProvider;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.Player;
import androidx.media3.common.Tracks;
import androidx.media3.common.VideoSize;
import androidx.media3.common.text.Cue;
import androidx.media3.common.util.Assertions;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.ui.SubtitleView;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.SurfaceView;
import android.view.TextureView;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import com.brentvatne.common.api.ResizeMode;
import com.brentvatne.common.api.SubtitleStyle;
import com.brentvatne.common.api.ViewType;
import com.brentvatne.common.toolbox.DebugLog;
import com.google.common.collect.ImmutableList;
import java.util.List;
@SuppressLint("ViewConstructor")
public final class ExoPlayerView extends FrameLayout implements AdViewProvider {
private final static String TAG = "ExoPlayerView";
private View surfaceView;
private final View shutterView;
private final SubtitleView subtitleLayout;
private final AspectRatioFrameLayout layout;
private final ComponentListener componentListener;
private ExoPlayer player;
private final Context context;
private final ViewGroup.LayoutParams layoutParams;
private final FrameLayout adOverlayFrameLayout;
private @ViewType.ViewType int viewType = ViewType.VIEW_TYPE_SURFACE;
private boolean hideShutterView = false;
public ExoPlayerView(Context context) {
super(context, null, 0);
this.context = context;
layoutParams = new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT);
componentListener = new ComponentListener();
FrameLayout.LayoutParams aspectRatioParams = new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT);
aspectRatioParams.gravity = Gravity.CENTER;
layout = new AspectRatioFrameLayout(context);
layout.setLayoutParams(aspectRatioParams);
shutterView = new View(getContext());
shutterView.setLayoutParams(layoutParams);
shutterView.setBackgroundColor(ContextCompat.getColor(context, android.R.color.black));
subtitleLayout = new SubtitleView(context);
subtitleLayout.setLayoutParams(layoutParams);
subtitleLayout.setUserDefaultStyle();
subtitleLayout.setUserDefaultTextSize();
updateSurfaceView(viewType);
adOverlayFrameLayout = new FrameLayout(context);
layout.addView(shutterView, 1, layoutParams);
layout.addView(adOverlayFrameLayout, 2, layoutParams);
addViewInLayout(layout, 0, aspectRatioParams);
addViewInLayout(subtitleLayout, 1, layoutParams);
}
private void clearVideoView() {
if (surfaceView instanceof TextureView) {
player.clearVideoTextureView((TextureView) surfaceView);
} else if (surfaceView instanceof SurfaceView) {
player.clearVideoSurfaceView((SurfaceView) surfaceView);
}
}
private void setVideoView() {
if (surfaceView instanceof TextureView) {
player.setVideoTextureView((TextureView) surfaceView);
} else if (surfaceView instanceof SurfaceView) {
player.setVideoSurfaceView((SurfaceView) surfaceView);
}
}
public boolean isPlaying() {
return player != null && player.isPlaying();
}
public void setSubtitleStyle(SubtitleStyle style) {
// ensure we reset subtile style before reapplying it
subtitleLayout.setUserDefaultStyle();
subtitleLayout.setUserDefaultTextSize();
if (style.getFontSize() > 0) {
subtitleLayout.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.getFontSize());
}
subtitleLayout.setPadding(style.getPaddingLeft(), style.getPaddingTop(), style.getPaddingRight(), style.getPaddingBottom());
if (style.getOpacity() != 0) {
subtitleLayout.setAlpha(style.getOpacity());
subtitleLayout.setVisibility(View.VISIBLE);
} else {
subtitleLayout.setVisibility(View.GONE);
}
}
public void setShutterColor(Integer color) {
shutterView.setBackgroundColor(color);
}
public void updateSurfaceView(@ViewType.ViewType int viewType) {
this.viewType = viewType;
boolean viewNeedRefresh = false;
if (viewType == ViewType.VIEW_TYPE_SURFACE || viewType == ViewType.VIEW_TYPE_SURFACE_SECURE) {
if (!(surfaceView instanceof SurfaceView)) {
surfaceView = new SurfaceView(context);
viewNeedRefresh = true;
}
((SurfaceView)surfaceView).setSecure(viewType == ViewType.VIEW_TYPE_SURFACE_SECURE);
} else if (viewType == ViewType.VIEW_TYPE_TEXTURE) {
if (!(surfaceView instanceof TextureView)) {
surfaceView = new TextureView(context);
viewNeedRefresh = true;
}
// Support opacity properly:
((TextureView) surfaceView).setOpaque(false);
} else {
DebugLog.wtf(TAG, "wtf is this texture " + viewType);
}
if (viewNeedRefresh) {
surfaceView.setLayoutParams(layoutParams);
if (layout.getChildAt(0) != null) {
layout.removeViewAt(0);
}
layout.addView(surfaceView, 0, layoutParams);
if (this.player != null) {
setVideoView();
}
}
}
private void updateShutterViewVisibility() {
shutterView.setVisibility(this.hideShutterView ? View.INVISIBLE : View.VISIBLE);
}
@Override
public void requestLayout() {
super.requestLayout();
post(measureAndLayout);
}
// AdsLoader.AdViewProvider implementation.
@Override
public ViewGroup getAdViewGroup() {
return Assertions.checkNotNull(adOverlayFrameLayout, "exo_ad_overlay must be present for ad playback");
}
/**
* Set the {@link ExoPlayer} to use. The {@link ExoPlayer#addListener} method of the
* player will be called and previous
* assignments are overridden.
*
* @param player The {@link ExoPlayer} to use.
*/
public void setPlayer(ExoPlayer player) {
if (this.player == player) {
return;
}
if (this.player != null) {
this.player.removeListener(componentListener);
clearVideoView();
}
this.player = player;
shutterView.setVisibility(this.hideShutterView ? View.INVISIBLE : View.VISIBLE);
if (player != null) {
setVideoView();
player.addListener(componentListener);
}
}
/**
* Sets the resize mode which can be of value {@link ResizeMode.Mode}
*
* @param resizeMode The resize mode.
*/
public void setResizeMode(@ResizeMode.Mode int resizeMode) {
if (layout != null && layout.getResizeMode() != resizeMode) {
layout.setResizeMode(resizeMode);
post(measureAndLayout);
}
}
public void setHideShutterView(boolean hideShutterView) {
this.hideShutterView = hideShutterView;
updateShutterViewVisibility();
}
private final Runnable measureAndLayout = () -> {
measure(
MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(getHeight(), MeasureSpec.EXACTLY));
layout(getLeft(), getTop(), getRight(), getBottom());
};
private void updateForCurrentTrackSelections(Tracks tracks) {
if (tracks == null) {
return;
}
ImmutableList<Tracks.Group> groups = tracks.getGroups();
for (Tracks.Group group: groups) {
if (group.getType() == C.TRACK_TYPE_VIDEO && group.length > 0) {
// get the first track of the group to identify aspect ratio
Format format = group.getTrackFormat(0);
// There are weird cases when video height and width did not change with rotation so we need change aspect ration to fix it
switch (format.rotationDegrees) {
// update aspect ratio !
case 90:
case 270:
layout.setVideoAspectRatio(format.width == 0 ? 1 : (format.height * format.pixelWidthHeightRatio) / format.width);
default:
layout.setVideoAspectRatio(format.height == 0 ? 1 : (format.width * format.pixelWidthHeightRatio) / format.height);
}
return;
}
}
// no video tracks, in that case refresh shutterView visibility
shutterView.setVisibility(this.hideShutterView ? View.INVISIBLE : View.VISIBLE);
}
public void invalidateAspectRatio() {
// Resetting aspect ratio will force layout refresh on next video size changed
layout.invalidateAspectRatio();
}
private final class ComponentListener implements Player.Listener {
@Override
public void onCues(@NonNull List<Cue> cues) {
subtitleLayout.setCues(cues);
}
@Override
public void onVideoSizeChanged(VideoSize videoSize) {
boolean isInitialRatio = layout.getVideoAspectRatio() == 0;
if (videoSize.height == 0 || videoSize.width == 0) {
// When changing video track we receive an ghost state with height / width = 0
// No need to resize the view in that case
return;
}
layout.setVideoAspectRatio((videoSize.width * videoSize.pixelWidthHeightRatio) / videoSize.height);
// React native workaround for measuring and layout on initial load.
if (isInitialRatio) {
post(measureAndLayout);
}
}
@Override
public void onRenderedFirstFrame() {
shutterView.setVisibility(INVISIBLE);
}
@Override
public void onTracksChanged(@NonNull Tracks tracks) {
updateForCurrentTrackSelections(tracks);
}
}
}

View File

@ -0,0 +1,338 @@
package com.brentvatne.exoplayer
import android.content.Context
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.view.SurfaceView
import android.view.TextureView
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.content.ContextCompat
import androidx.media3.common.AdViewProvider
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize
import androidx.media3.common.text.Cue
import androidx.media3.common.util.Assertions
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.SubtitleView
import com.brentvatne.common.api.ResizeMode
import com.brentvatne.common.api.SubtitleStyle
import com.brentvatne.common.api.ViewType
import com.brentvatne.common.toolbox.DebugLog
@UnstableApi
class ExoPlayerView(private val context: Context) :
FrameLayout(context, null, 0),
AdViewProvider {
private var surfaceView: View? = null
private var shutterView: View
private var subtitleLayout: SubtitleView
private var layout: AspectRatioFrameLayout
private var componentListener: ComponentListener
private var player: ExoPlayer? = null
private var layoutParams: ViewGroup.LayoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
private var adOverlayFrameLayout: FrameLayout
val isPlaying: Boolean
get() = player != null && player?.isPlaying == true
@ViewType.ViewType
private var viewType = ViewType.VIEW_TYPE_SURFACE
private var hideShutterView = false
private var localStyle = SubtitleStyle()
init {
componentListener = ComponentListener()
val aspectRatioParams = LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT
)
aspectRatioParams.gravity = Gravity.CENTER
layout = AspectRatioFrameLayout(context)
layout.layoutParams = aspectRatioParams
shutterView = View(context)
shutterView.layoutParams = layoutParams
shutterView.setBackgroundColor(ContextCompat.getColor(context, android.R.color.black))
subtitleLayout = SubtitleView(context)
subtitleLayout.layoutParams = layoutParams
subtitleLayout.setUserDefaultStyle()
subtitleLayout.setUserDefaultTextSize()
updateSurfaceView(viewType)
adOverlayFrameLayout = FrameLayout(context)
layout.addView(shutterView, 1, layoutParams)
if (localStyle.subtitlesFollowVideo) {
layout.addView(subtitleLayout, layoutParams)
layout.addView(adOverlayFrameLayout, layoutParams)
}
addViewInLayout(layout, 0, aspectRatioParams)
if (!localStyle.subtitlesFollowVideo) {
addViewInLayout(subtitleLayout, 1, layoutParams)
}
}
private fun clearVideoView() {
when (val view = surfaceView) {
is TextureView -> player?.clearVideoTextureView(view)
is SurfaceView -> player?.clearVideoSurfaceView(view)
else -> {
Log.w(
"clearVideoView",
"Unexpected surfaceView type: ${surfaceView?.javaClass?.name}"
)
}
}
}
private fun setVideoView() {
when (val view = surfaceView) {
is TextureView -> player?.setVideoTextureView(view)
is SurfaceView -> player?.setVideoSurfaceView(view)
else -> {
Log.w(
"setVideoView",
"Unexpected surfaceView type: ${surfaceView?.javaClass?.name}"
)
}
}
}
fun setSubtitleStyle(style: SubtitleStyle) {
// ensure we reset subtitle style before reapplying it
subtitleLayout.setUserDefaultStyle()
subtitleLayout.setUserDefaultTextSize()
if (style.fontSize > 0) {
subtitleLayout.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.fontSize.toFloat())
}
subtitleLayout.setPadding(
style.paddingLeft,
style.paddingTop,
style.paddingTop,
style.paddingBottom
)
if (style.opacity != 0.0f) {
subtitleLayout.alpha = style.opacity
subtitleLayout.visibility = View.VISIBLE
} else {
subtitleLayout.visibility = View.GONE
}
if (localStyle.subtitlesFollowVideo != style.subtitlesFollowVideo) {
// No need to manipulate layout if value didn't change
if (style.subtitlesFollowVideo) {
removeViewInLayout(subtitleLayout)
layout.addView(subtitleLayout, layoutParams)
} else {
layout.removeViewInLayout(subtitleLayout)
addViewInLayout(subtitleLayout, 1, layoutParams, false)
}
requestLayout()
}
localStyle = style
}
fun setShutterColor(color: Int) {
shutterView.setBackgroundColor(color)
}
fun updateSurfaceView(@ViewType.ViewType viewType: Int) {
this.viewType = viewType
var viewNeedRefresh = false
when (viewType) {
ViewType.VIEW_TYPE_SURFACE, ViewType.VIEW_TYPE_SURFACE_SECURE -> {
if (surfaceView !is SurfaceView) {
surfaceView = SurfaceView(context)
viewNeedRefresh = true
}
(surfaceView as SurfaceView).setSecure(viewType == ViewType.VIEW_TYPE_SURFACE_SECURE)
}
ViewType.VIEW_TYPE_TEXTURE -> {
if (surfaceView !is TextureView) {
surfaceView = TextureView(context)
viewNeedRefresh = true
}
// Support opacity properly:
(surfaceView as TextureView).isOpaque = false
}
else -> {
DebugLog.wtf(TAG, "Unexpected texture view type: $viewType")
}
}
if (viewNeedRefresh) {
surfaceView?.layoutParams = layoutParams
if (layout.getChildAt(0) != null) {
layout.removeViewAt(0)
}
layout.addView(surfaceView, 0, layoutParams)
if (this.player != null) {
setVideoView()
}
}
}
private fun hideShutterView() {
shutterView.setVisibility(INVISIBLE)
surfaceView?.setAlpha(1f)
}
private fun showShutterView() {
shutterView.setVisibility(VISIBLE)
surfaceView?.setAlpha(0f)
}
fun showAds() {
adOverlayFrameLayout.setVisibility(View.VISIBLE)
}
fun hideAds() {
adOverlayFrameLayout.setVisibility(View.GONE)
}
fun updateShutterViewVisibility() {
shutterView.visibility = if (this.hideShutterView) {
View.INVISIBLE
} else {
View.VISIBLE
}
}
override fun requestLayout() {
super.requestLayout()
post(measureAndLayout)
}
// AdsLoader.AdViewProvider implementation.
override fun getAdViewGroup(): ViewGroup =
Assertions.checkNotNull(
adOverlayFrameLayout,
"exo_ad_overlay must be present for ad playback"
)
/**
* Set the {@link ExoPlayer} to use. The {@link ExoPlayer#addListener} method of the
* player will be called and previous
* assignments are overridden.
*
* @param player The {@link ExoPlayer} to use.
*/
fun setPlayer(player: ExoPlayer?) {
if (this.player == player) {
return
}
if (this.player != null) {
this.player!!.removeListener(componentListener)
clearVideoView()
}
this.player = player
updateShutterViewVisibility()
if (player != null) {
setVideoView()
player.addListener(componentListener)
}
}
/**
* Sets the resize mode which can be of value {@link ResizeMode.Mode}
*
* @param resizeMode The resize mode.
*/
fun setResizeMode(@ResizeMode.Mode resizeMode: Int) {
if (layout.resizeMode != resizeMode) {
layout.resizeMode = resizeMode
post(measureAndLayout)
}
}
fun setHideShutterView(hideShutterView: Boolean) {
this.hideShutterView = hideShutterView
updateShutterViewVisibility()
}
private val measureAndLayout: Runnable = Runnable {
measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
)
layout(left, top, right, bottom)
}
private fun updateForCurrentTrackSelections(tracks: Tracks?) {
if (tracks == null) {
return
}
val groups = tracks.groups
for (group in groups) {
if (group.type == C.TRACK_TYPE_VIDEO && group.length > 0) {
// get the first track of the group to identify aspect ratio
val format = group.getTrackFormat(0)
layout.updateAspectRatio(format)
return
}
}
// no video tracks, in that case refresh shutterView visibility
updateShutterViewVisibility()
}
fun invalidateAspectRatio() {
// Resetting aspect ratio will force layout refresh on next video size changed
layout.invalidateAspectRatio()
}
private inner class ComponentListener : Player.Listener {
override fun onCues(cues: List<Cue>) {
subtitleLayout.setCues(cues)
}
override fun onVideoSizeChanged(videoSize: VideoSize) {
if (videoSize.height == 0 || videoSize.width == 0) {
// When changing video track we receive an ghost state with height / width = 0
// No need to resize the view in that case
return
}
// Here we use updateForCurrentTrackSelections to have a consistent behavior.
// according to: https://github.com/androidx/media/issues/1207
// sometimes media3 send bad Video size information
player?.let {
updateForCurrentTrackSelections(it.currentTracks)
}
}
override fun onRenderedFirstFrame() {
shutterView.visibility = INVISIBLE
}
override fun onTracksChanged(tracks: Tracks) {
updateForCurrentTrackSelections(tracks)
}
}
companion object {
private const val TAG = "ExoPlayerView"
}
}

View File

@ -6,11 +6,17 @@ import android.content.Context
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.view.ViewGroup import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager import android.view.WindowManager
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.media3.ui.LegacyPlayerControlView import androidx.media3.ui.LegacyPlayerControlView
import com.brentvatne.common.api.ControlsConfig
import com.brentvatne.common.toolbox.DebugLog import com.brentvatne.common.toolbox.DebugLog
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
@ -20,14 +26,22 @@ class FullScreenPlayerView(
private val exoPlayerView: ExoPlayerView, private val exoPlayerView: ExoPlayerView,
private val reactExoplayerView: ReactExoplayerView, private val reactExoplayerView: ReactExoplayerView,
private val playerControlView: LegacyPlayerControlView?, private val playerControlView: LegacyPlayerControlView?,
private val onBackPressedCallback: OnBackPressedCallback private val onBackPressedCallback: OnBackPressedCallback,
) : Dialog(context, android.R.style.Theme_Black_NoTitleBar_Fullscreen) { private val controlsConfig: ControlsConfig
) : Dialog(context, android.R.style.Theme_Black_NoTitleBar) {
private var parent: ViewGroup? = null private var parent: ViewGroup? = null
private val containerView = FrameLayout(context) private val containerView = FrameLayout(context)
private val mKeepScreenOnHandler = Handler(Looper.getMainLooper()) private val mKeepScreenOnHandler = Handler(Looper.getMainLooper())
private val mKeepScreenOnUpdater = KeepScreenOnUpdater(this) private val mKeepScreenOnUpdater = KeepScreenOnUpdater(this)
// As this view is fullscreen we need to save initial state and restore it afterward
// Following variables save UI state when open the view
// restoreUIState, will reapply these values
private var initialSystemBarsBehavior: Int? = null
private var initialNavigationBarIsVisible: Boolean? = null
private var initialNotificationBarIsVisible: Boolean? = null
private class KeepScreenOnUpdater(fullScreenPlayerView: FullScreenPlayerView) : Runnable { private class KeepScreenOnUpdater(fullScreenPlayerView: FullScreenPlayerView) : Runnable {
private val mFullscreenPlayer = WeakReference(fullScreenPlayerView) private val mFullscreenPlayer = WeakReference(fullScreenPlayerView)
@ -59,10 +73,15 @@ class FullScreenPlayerView(
init { init {
setContentView(containerView, generateDefaultLayoutParams()) setContentView(containerView, generateDefaultLayoutParams())
}
override fun onBackPressed() { window?.let {
super.onBackPressed() val inset = WindowInsetsControllerCompat(it, it.decorView)
onBackPressedCallback.handleOnBackPressed() initialSystemBarsBehavior = inset.systemBarsBehavior
initialNavigationBarIsVisible = ViewCompat.getRootWindowInsets(it.decorView)
?.isVisible(WindowInsetsCompat.Type.navigationBars()) == true
initialNotificationBarIsVisible = ViewCompat.getRootWindowInsets(it.decorView)
?.isVisible(WindowInsetsCompat.Type.statusBars()) == true
}
} }
override fun onStart() { override fun onStart() {
@ -75,6 +94,7 @@ class FullScreenPlayerView(
parent?.removeView(it) parent?.removeView(it)
containerView.addView(it, generateDefaultLayoutParams()) containerView.addView(it, generateDefaultLayoutParams())
} }
updateNavigationBarVisibility()
} }
override fun onStop() { override fun onStop() {
@ -89,6 +109,20 @@ class FullScreenPlayerView(
} }
parent?.requestLayout() parent?.requestLayout()
parent = null parent = null
onBackPressedCallback.handleOnBackPressed()
restoreSystemUI()
}
// restore system UI state
private fun restoreSystemUI() {
window?.let {
updateNavigationBarVisibility(
it,
initialNavigationBarIsVisible,
initialNotificationBarIsVisible,
initialSystemBarsBehavior
)
}
} }
private fun getFullscreenIconResource(isFullscreen: Boolean): Int = private fun getFullscreenIconResource(isFullscreen: Boolean): Int =
@ -127,4 +161,69 @@ class FullScreenPlayerView(
layoutParams.setMargins(0, 0, 0, 0) layoutParams.setMargins(0, 0, 0, 0)
return layoutParams return layoutParams
} }
private fun updateBarVisibility(
inset: WindowInsetsControllerCompat,
type: Int,
shouldHide: Boolean?,
initialVisibility: Boolean?,
systemBarsBehavior: Int? = null
) {
shouldHide?.takeIf { it != initialVisibility }?.let {
if (it) {
inset.hide(type)
systemBarsBehavior?.let { behavior -> inset.systemBarsBehavior = behavior }
} else {
inset.show(type)
}
}
}
// Move the UI to fullscreen.
// if you change this code, remember to check that the UI is well restored in restoreUIState
private fun updateNavigationBarVisibility(
window: Window,
hideNavigationBarOnFullScreenMode: Boolean?,
hideNotificationBarOnFullScreenMode: Boolean?,
systemBarsBehavior: Int?
) {
// Configure the behavior of the hidden system bars.
val inset = WindowInsetsControllerCompat(window, window.decorView)
// Update navigation bar visibility and apply systemBarsBehavior if hiding
updateBarVisibility(
inset,
WindowInsetsCompat.Type.navigationBars(),
hideNavigationBarOnFullScreenMode,
initialNavigationBarIsVisible,
systemBarsBehavior
)
// Update notification bar visibility (no need for systemBarsBehavior here)
updateBarVisibility(
inset,
WindowInsetsCompat.Type.statusBars(),
hideNotificationBarOnFullScreenMode,
initialNotificationBarIsVisible
)
}
private fun updateNavigationBarVisibility() {
window?.let {
updateNavigationBarVisibility(
it,
controlsConfig.hideNavigationBarOnFullScreenMode,
controlsConfig.hideNotificationBarOnFullScreenMode,
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
)
}
if (controlsConfig.hideNotificationBarOnFullScreenMode) {
val liveContainer = playerControlView?.findViewById<LinearLayout?>(com.brentvatne.react.R.id.exo_live_container)
liveContainer?.let {
val layoutParams = it.layoutParams as LinearLayout.LayoutParams
layoutParams.topMargin = 40
it.layoutParams = layoutParams
}
}
}
} }

View File

@ -1,14 +1,11 @@
package com.brentvatne.exoplayer package com.brentvatne.exoplayer
import android.graphics.Color import android.graphics.Color
import android.net.Uri
import android.text.TextUtils
import android.util.Log import android.util.Log
import com.brentvatne.common.api.BufferConfig import com.brentvatne.common.api.BufferConfig
import com.brentvatne.common.api.BufferingStrategy import com.brentvatne.common.api.BufferingStrategy
import com.brentvatne.common.api.ControlsConfig import com.brentvatne.common.api.ControlsConfig
import com.brentvatne.common.api.ResizeMode import com.brentvatne.common.api.ResizeMode
import com.brentvatne.common.api.SideLoadedTextTrackList
import com.brentvatne.common.api.Source import com.brentvatne.common.api.Source
import com.brentvatne.common.api.SubtitleStyle import com.brentvatne.common.api.SubtitleStyle
import com.brentvatne.common.api.ViewType import com.brentvatne.common.api.ViewType
@ -16,7 +13,6 @@ import com.brentvatne.common.react.EventTypes
import com.brentvatne.common.toolbox.DebugLog import com.brentvatne.common.toolbox.DebugLog
import com.brentvatne.common.toolbox.ReactBridgeUtils import com.brentvatne.common.toolbox.ReactBridgeUtils
import com.brentvatne.react.ReactNativeVideoManager import com.brentvatne.react.ReactNativeVideoManager
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableMap
import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewGroupManager
@ -28,7 +24,6 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
private const val TAG = "ExoViewManager" private const val TAG = "ExoViewManager"
private const val REACT_CLASS = "RCTVideo" private const val REACT_CLASS = "RCTVideo"
private const val PROP_SRC = "src" private const val PROP_SRC = "src"
private const val PROP_AD_TAG_URL = "adTagUrl"
private const val PROP_RESIZE_MODE = "resizeMode" private const val PROP_RESIZE_MODE = "resizeMode"
private const val PROP_REPEAT = "repeat" private const val PROP_REPEAT = "repeat"
private const val PROP_SELECTED_AUDIO_TRACK = "selectedAudioTrack" private const val PROP_SELECTED_AUDIO_TRACK = "selectedAudioTrack"
@ -37,7 +32,6 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
private const val PROP_SELECTED_TEXT_TRACK = "selectedTextTrack" private const val PROP_SELECTED_TEXT_TRACK = "selectedTextTrack"
private const val PROP_SELECTED_TEXT_TRACK_TYPE = "type" private const val PROP_SELECTED_TEXT_TRACK_TYPE = "type"
private const val PROP_SELECTED_TEXT_TRACK_VALUE = "value" private const val PROP_SELECTED_TEXT_TRACK_VALUE = "value"
private const val PROP_TEXT_TRACKS = "textTracks"
private const val PROP_PAUSED = "paused" private const val PROP_PAUSED = "paused"
private const val PROP_MUTED = "muted" private const val PROP_MUTED = "muted"
private const val PROP_AUDIO_OUTPUT = "audioOutput" private const val PROP_AUDIO_OUTPUT = "audioOutput"
@ -51,7 +45,6 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
private const val PROP_MIN_LOAD_RETRY_COUNT = "minLoadRetryCount" private const val PROP_MIN_LOAD_RETRY_COUNT = "minLoadRetryCount"
private const val PROP_MAXIMUM_BIT_RATE = "maxBitRate" private const val PROP_MAXIMUM_BIT_RATE = "maxBitRate"
private const val PROP_PLAY_IN_BACKGROUND = "playInBackground" private const val PROP_PLAY_IN_BACKGROUND = "playInBackground"
private const val PROP_CONTENT_START_TIME = "contentStartTime"
private const val PROP_DISABLE_FOCUS = "disableFocus" private const val PROP_DISABLE_FOCUS = "disableFocus"
private const val PROP_BUFFERING_STRATEGY = "bufferingStrategy" private const val PROP_BUFFERING_STRATEGY = "bufferingStrategy"
private const val PROP_DISABLE_DISCONNECT_ERROR = "disableDisconnectError" private const val PROP_DISABLE_DISCONNECT_ERROR = "disableDisconnectError"
@ -92,22 +85,7 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
@ReactProp(name = PROP_SRC) @ReactProp(name = PROP_SRC)
fun setSrc(videoView: ReactExoplayerView, src: ReadableMap?) { fun setSrc(videoView: ReactExoplayerView, src: ReadableMap?) {
val context = videoView.context.applicationContext val context = videoView.context.applicationContext
val source = Source.parse(src, context) videoView.setSrc(Source.parse(src, context))
if (source.uri == null) {
videoView.clearSrc()
} else {
videoView.setSrc(source)
}
}
@ReactProp(name = PROP_AD_TAG_URL)
fun setAdTagUrl(videoView: ReactExoplayerView, uriString: String?) {
if (TextUtils.isEmpty(uriString)) {
videoView.setAdTagUrl(null)
return
}
val adTagUrl = Uri.parse(uriString)
videoView.setAdTagUrl(adTagUrl)
} }
@ReactProp(name = PROP_RESIZE_MODE) @ReactProp(name = PROP_RESIZE_MODE)
@ -169,12 +147,6 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
videoView.setSelectedTextTrack(typeString, value) videoView.setSelectedTextTrack(typeString, value)
} }
@ReactProp(name = PROP_TEXT_TRACKS)
fun setTextTracks(videoView: ReactExoplayerView, textTracks: ReadableArray?) {
val sideLoadedTextTracks = SideLoadedTextTrackList.parse(textTracks)
videoView.setTextTracks(sideLoadedTextTracks)
}
@ReactProp(name = PROP_PAUSED, defaultBoolean = false) @ReactProp(name = PROP_PAUSED, defaultBoolean = false)
fun setPaused(videoView: ReactExoplayerView, paused: Boolean) { fun setPaused(videoView: ReactExoplayerView, paused: Boolean) {
videoView.setPausedModifier(paused) videoView.setPausedModifier(paused)
@ -235,11 +207,6 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
videoView.setFocusable(focusable) videoView.setFocusable(focusable)
} }
@ReactProp(name = PROP_CONTENT_START_TIME, defaultInt = -1)
fun setContentStartTime(videoView: ReactExoplayerView, contentStartTime: Int) {
videoView.setContentStartTime(contentStartTime)
}
@ReactProp(name = PROP_BUFFERING_STRATEGY) @ReactProp(name = PROP_BUFFERING_STRATEGY)
fun setBufferingStrategy(videoView: ReactExoplayerView, bufferingStrategy: String) { fun setBufferingStrategy(videoView: ReactExoplayerView, bufferingStrategy: String) {
val strategy = BufferingStrategy.parse(bufferingStrategy) val strategy = BufferingStrategy.parse(bufferingStrategy)
@ -276,9 +243,9 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
videoView.setSubtitleStyle(SubtitleStyle.parse(src)) videoView.setSubtitleStyle(SubtitleStyle.parse(src))
} }
@ReactProp(name = PROP_SHUTTER_COLOR, defaultInt = 0) @ReactProp(name = PROP_SHUTTER_COLOR, defaultInt = Color.BLACK)
fun setShutterColor(videoView: ReactExoplayerView, color: Int) { fun setShutterColor(videoView: ReactExoplayerView, color: Int) {
videoView.setShutterColor(if (color == 0) Color.BLACK else color) videoView.setShutterColor(color)
} }
@ReactProp(name = PROP_BUFFER_CONFIG) @ReactProp(name = PROP_BUFFER_CONFIG)

View File

@ -63,6 +63,7 @@ class VideoPlaybackService : MediaSessionService() {
mediaSessionsList[player] = mediaSession mediaSessionsList[player] = mediaSession
addSession(mediaSession) addSession(mediaSession)
startForeground(mediaSession.player.hashCode(), buildNotification(mediaSession))
} }
fun unregisterPlayer(player: ExoPlayer) { fun unregisterPlayer(player: ExoPlayer) {
@ -95,6 +96,10 @@ class VideoPlaybackService : MediaSessionService() {
override fun onDestroy() { override fun onDestroy() {
cleanup() cleanup()
val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.deleteNotificationChannel(NOTIFICATION_CHANEL_ID)
}
super.onDestroy() super.onDestroy()
} }
@ -121,7 +126,7 @@ class VideoPlaybackService : MediaSessionService() {
} }
private fun buildNotification(session: MediaSession): Notification { private fun buildNotification(session: MediaSession): Notification {
val returnToPlayer = Intent(this, sourceActivity).apply { val returnToPlayer = Intent(this, sourceActivity ?: this.javaClass).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
} }
@ -179,17 +184,17 @@ class VideoPlaybackService : MediaSessionService() {
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setSmallIcon(androidx.media3.session.R.drawable.media3_icon_circular_play) .setSmallIcon(androidx.media3.session.R.drawable.media3_icon_circular_play)
// Add media control buttons that invoke intents in your media service // Add media control buttons that invoke intents in your media service
.addAction(androidx.media3.session.R.drawable.media3_notification_seek_back, "Seek Backward", seekBackwardPendingIntent) // #0 .addAction(androidx.media3.session.R.drawable.media3_icon_rewind, "Seek Backward", seekBackwardPendingIntent) // #0
.addAction( .addAction(
if (session.player.isPlaying) { if (session.player.isPlaying) {
androidx.media3.session.R.drawable.media3_notification_pause androidx.media3.session.R.drawable.media3_icon_pause
} else { } else {
androidx.media3.session.R.drawable.media3_notification_play androidx.media3.session.R.drawable.media3_icon_play
}, },
"Toggle Play", "Toggle Play",
togglePlayPendingIntent togglePlayPendingIntent
) // #1 ) // #1
.addAction(androidx.media3.session.R.drawable.media3_notification_seek_forward, "Seek Forward", seekForwardPendingIntent) // #2 .addAction(androidx.media3.session.R.drawable.media3_icon_fast_forward, "Seek Forward", seekForwardPendingIntent) // #2
// Apply the media style template // Apply the media style template
.setStyle(MediaStyleNotificationHelper.MediaStyle(session).setShowActionsInCompactView(0, 1, 2)) .setStyle(MediaStyleNotificationHelper.MediaStyle(session).setShowActionsInCompactView(0, 1, 2))
.setContentTitle(session.player.mediaMetadata.title) .setContentTitle(session.player.mediaMetadata.title)
@ -209,9 +214,6 @@ class VideoPlaybackService : MediaSessionService() {
private fun hideAllNotifications() { private fun hideAllNotifications() {
val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancelAll() notificationManager.cancelAll()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.deleteNotificationChannel(NOTIFICATION_CHANEL_ID)
}
} }
private fun cleanup() { private fun cleanup() {

View File

@ -1,10 +1,12 @@
package com.brentvatne.react package com.brentvatne.react
import com.brentvatne.common.api.Source
import com.brentvatne.exoplayer.ReactExoplayerView import com.brentvatne.exoplayer.ReactExoplayerView
import com.facebook.react.bridge.Promise import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.common.UIManagerType import com.facebook.react.uimanager.common.UIManagerType
@ -42,6 +44,7 @@ class VideoManagerModule(reactContext: ReactApplicationContext?) : ReactContextB
} }
@ReactMethod @ReactMethod
@Suppress("UNUSED_PARAMETER") // codegen compatibility
fun seekCmd(reactTag: Int, time: Float, tolerance: Float) { fun seekCmd(reactTag: Int, time: Float, tolerance: Float) {
performOnPlayerView(reactTag) { performOnPlayerView(reactTag) {
it?.seekTo((time * 1000f).roundToInt().toLong()) it?.seekTo((time * 1000f).roundToInt().toLong())
@ -62,6 +65,13 @@ class VideoManagerModule(reactContext: ReactApplicationContext?) : ReactContextB
} }
} }
@ReactMethod
fun setSourceCmd(reactTag: Int, source: ReadableMap?) {
performOnPlayerView(reactTag) {
it?.setSrc(Source.parse(source, reactApplicationContext))
}
}
@ReactMethod @ReactMethod
fun getCurrentPosition(reactTag: Int, promise: Promise) { fun getCurrentPosition(reactTag: Int, promise: Promise) {
performOnPlayerView(reactTag) { performOnPlayerView(reactTag) {

View File

@ -0,0 +1,22 @@
package com.google.ads.interactivemedia.v3.api;
public abstract class ImaSdkFactory {
private static ImaSdkFactory instance;
public abstract ImaSdkSettings createImaSdkSettings();
public static ImaSdkFactory getInstance() {
if (instance == null) {
instance = new ConcreteImaSdkFactory();
}
return instance;
}
}
class ConcreteImaSdkFactory extends ImaSdkFactory {
@Override
public ImaSdkSettings createImaSdkSettings() {
return new ConcreteImaSdkSettings();
}
}

View File

@ -0,0 +1,24 @@
package com.google.ads.interactivemedia.v3.api;
import androidx.annotation.InspectableProperty;
public abstract class ImaSdkSettings {
public abstract String getLanguage();
public abstract void setLanguage(String language);
}
// Concrete Implementation
class ConcreteImaSdkSettings extends ImaSdkSettings {
private String language;
@Override
public String getLanguage() {
return language;
}
@Override
public void setLanguage(String language) {
this.language = language;
}
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/red"/>
<size android:width="10dp" android:height="10dp"/>
</shape>

View File

@ -1,17 +1,48 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_gravity="bottom"
android:layoutDirection="ltr" android:layoutDirection="ltr"
android:background="@color/midnight_black" android:background="@color/midnight_black"
android:orientation="vertical"> android:orientation="vertical">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:visibility="gone"
android:gravity="center_vertical"
android:layout_marginTop="@dimen/live_wrapper_margin_top"
android:id="@+id/exo_live_container">
<ImageView
android:id="@+id/exo_live_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/position_duration_horizontal_padding"
android:src="@drawable/circle" />
<TextView android:id="@+id/exo_live_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="@dimen/position_duration_text_size"
android:textStyle="bold"
android:includeFontPadding="false"
android:textColor="@color/white"/>
</LinearLayout>
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1"
/>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
android:paddingTop="@dimen/controller_wrapper_padding_top" android:paddingTop="@dimen/controller_wrapper_padding_top"
android:layout_gravity="bottom"
android:orientation="horizontal"> android:orientation="horizontal">
<ImageButton android:id="@+id/exo_prev" <ImageButton android:id="@+id/exo_prev"
@ -70,6 +101,13 @@
android:includeFontPadding="false" android:includeFontPadding="false"
android:textColor="@color/silver_gray"/> android:textColor="@color/silver_gray"/>
<ImageButton
android:id="@+id/exo_settings"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/ExoStyledControls.Button.Bottom.Settings"
/>
<ImageButton <ImageButton
android:id="@+id/exo_fullscreen" android:id="@+id/exo_fullscreen"
style="@style/ExoMediaButton.FullScreen" style="@style/ExoMediaButton.FullScreen"

View File

@ -2,4 +2,6 @@
<resources> <resources>
<color name="silver_gray">#FFBEBEBE</color> <color name="silver_gray">#FFBEBEBE</color>
<color name="midnight_black">#CC000000</color> <color name="midnight_black">#CC000000</color>
<color name="white">#FFFFFF</color>
<color name="red">#FF0000</color>
</resources> </resources>

View File

@ -3,6 +3,7 @@
<!-- margin & padding--> <!-- margin & padding-->
<dimen name="controller_wrapper_padding_top">4dp</dimen> <dimen name="controller_wrapper_padding_top">4dp</dimen>
<dimen name="seekBar_wrapper_margin_top">4dp</dimen> <dimen name="seekBar_wrapper_margin_top">4dp</dimen>
<dimen name="live_wrapper_margin_top">12dp</dimen>
<dimen name="position_duration_horizontal_padding">4dp</dimen> <dimen name="position_duration_horizontal_padding">4dp</dimen>
<dimen name="full_screen_margin">4dp</dimen> <dimen name="full_screen_margin">4dp</dimen>

View File

@ -16,4 +16,10 @@
<string name="error_drm_unsupported_scheme">This device does not support the required DRM scheme</string> <string name="error_drm_unsupported_scheme">This device does not support the required DRM scheme</string>
<string name="error_drm_unknown">An unknown DRM error occurred</string> <string name="error_drm_unknown">An unknown DRM error occurred</string>
<string name="settings">Settings</string>
<string name="playback_speed">Playback Speed</string>
<string name="select_playback_speed">Select Playback Speed</string>
</resources> </resources>

Binary file not shown.

View File

@ -23,3 +23,16 @@ Example:
onReceiveAdEvent={event => console.log(event)} onReceiveAdEvent={event => console.log(event)}
... ...
``` ```
### Localization
To change the language of the IMA SDK, you need to pass `adLanguage` prop to `Video` component. List of supported languages, you can find [here](https://developers.google.com/interactive-media-ads/docs/sdks/android/client-side/localization#locale-codes)
By default, ios will use system language and android will use `en`
Example:
```jsx
...
adLanguage="fr"
...
```

View File

@ -137,6 +137,20 @@ You can specify the DRM type, either by string or using the exported DRMType enu
Valid values are, for Android: DRMType.WIDEVINE / DRMType.PLAYREADY / DRMType.CLEARKEY. Valid values are, for Android: DRMType.WIDEVINE / DRMType.PLAYREADY / DRMType.CLEARKEY.
for iOS: DRMType.FAIRPLAY for iOS: DRMType.FAIRPLAY
### `localSourceEncryptionKeyScheme`
<PlatformsList types={['iOS']} />
Set the url scheme for stream encryption key for local assets
Type: String
Example:
```
localSourceEncryptionKeyScheme="my-offline-key"
```
## Common Usage Scenarios ## Common Usage Scenarios
### Send cookies to license server ### Send cookies to license server

View File

@ -67,7 +67,7 @@ Example:
### `onBandwidthUpdate` ### `onBandwidthUpdate`
<PlatformsList types={['Android']} /> <PlatformsList types={['Android', 'iOS']} />
Callback function that is called when the available bandwidth changes. Callback function that is called when the available bandwidth changes.
@ -103,7 +103,7 @@ Note: On Android, you must set the [reportBandwidth](#reportbandwidth) prop to e
### `onBuffer` ### `onBuffer`
<PlatformsList types={['Android', 'iOS']} /> <PlatformsList types={['Android', 'iOS', 'web']} />
Callback function that is called when the player buffers. Callback function that is called when the player buffers.
@ -219,16 +219,20 @@ Payload: none
Callback function that is called when the media is loaded and ready to play. Callback function that is called when the media is loaded and ready to play.
NOTE: tracks (`audioTracks`, `textTracks` & `videoTracks`) are not available on the web.
Payload: Payload:
| Property | Type | Description | | Property | Type | Description |
| ----------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | |-------------|--------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| currentTime | number | Time in seconds where the media will start | | currentTime | number | Time in seconds where the media will start |
| duration | number | Length of the media in seconds | | duration | number | Length of the media in seconds |
| naturalSize | object | Properties:<br/> _ width - Width in pixels that the video was encoded at<br/> _ height - Height in pixels that the video was encoded at<br/> \* orientation - "portrait", "landscape" or "square" | | naturalSize | object | Properties:<br/> _ width - Width in pixels that the video was encoded at<br/> _ height - Height in pixels that the video was encoded at<br/> \* orientation - "portrait", "landscape" or "square" |
| audioTracks | array | An array of audio track info objects with the following properties:<br/> _ index - Index number<br/> _ title - Description of the track<br/> _ language - 2 letter [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) or 3 letter [ISO639-2](https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes) language code<br/> _ type - Mime type of track | | audioTracks | array | An array of audio track info objects with the following properties:<br/> _ index - Index number<br/> _ title - Description of the track<br/> _ language - 2 letter [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) or 3 letter [ISO639-2](https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes) language code<br/> _ type - Mime type of track |
| textTracks | array | An array of text track info objects with the following properties:<br/> _ index - Index number<br/> _ title - Description of the track<br/> _ language - 2 letter [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) or 3 letter [ISO 639-2](https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes) language code<br/> _ type - Mime type of track | | textTracks | array | An array of text track info objects with the following properties:<br/> _ index - Index number<br/> _ title - Description of the track<br/> _ language - 2 letter [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) or 3 letter [ISO 639-2](https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes) language code<br/> _ type - Mime type of track |
| videoTracks | array | An array of video track info objects with the following properties:<br/> _ trackId - ID for the track<br/> _ bitrate - Bit rate in bits per second<br/> _ codecs - Comma separated list of codecs<br/> _ height - Height of the video<br/> \* width - Width of the video | | videoTracks | array | An array of video track info objects with the following properties:<br/> _ trackId - ID for the track<br/> _ bitrate - Bit rate in bits per second<br/> _ codecs - Comma separated list of codecs<br/> _ height - Height of the video<br/> \* width - Width of the video |
| trackId | string | Provide key information about the video track, typically including: `Resolution`, `Bitrate`. |
Example: Example:
@ -260,7 +264,8 @@ Example:
{ index: 0, bitrate: 3987904, codecs: "avc1.640028", height: 720, trackId: "f1-v1-x3", width: 1280 }, { index: 0, bitrate: 3987904, codecs: "avc1.640028", height: 720, trackId: "f1-v1-x3", width: 1280 },
{ index: 1, bitrate: 7981888, codecs: "avc1.640028", height: 1080, trackId: "f2-v1-x3", width: 1920 }, { index: 1, bitrate: 7981888, codecs: "avc1.640028", height: 1080, trackId: "f2-v1-x3", width: 1920 },
{ index: 2, bitrate: 1994979, codecs: "avc1.4d401f", height: 480, trackId: "f3-v1-x3", width: 848 } { index: 2, bitrate: 1994979, codecs: "avc1.4d401f", height: 480, trackId: "f3-v1-x3", width: 848 }
] ],
trackId: "720p 2400kbps"
} }
``` ```
@ -290,7 +295,7 @@ Example:
### `onPlaybackStateChanged` ### `onPlaybackStateChanged`
<PlatformsList types={['Android', 'iOS', 'visionOS']} /> <PlatformsList types={['Android', 'iOS', 'visionOS', 'web']} />
Callback function that is called when the playback state changes. Callback function that is called when the playback state changes.
@ -461,7 +466,7 @@ Payload: none
### `onSeek` ### `onSeek`
<PlatformsList types={['Android', 'iOS', 'Windows UWP']} /> <PlatformsList types={['Android', 'iOS', 'Windows UWP', 'web']} />
Callback function that is called when a seek completes. Callback function that is called when a seek completes.
@ -602,7 +607,7 @@ Example:
### `onVolumeChange` ### `onVolumeChange`
<PlatformsList types={['Android', 'iOS', 'visionOS']} /> <PlatformsList types={['Android', 'iOS', 'visionOS', 'web']} />
Callback function that is called when the volume of player changes. Callback function that is called when the volume of player changes.

View File

@ -6,7 +6,7 @@ This page shows the list of available methods
### `dismissFullscreenPlayer` ### `dismissFullscreenPlayer`
<PlatformsList types={['Android', 'iOS']} /> <PlatformsList types={['Android', 'iOS', 'web']} />
`dismissFullscreenPlayer(): Promise<void>` `dismissFullscreenPlayer(): Promise<void>`
@ -17,7 +17,7 @@ Take the player out of fullscreen mode.
### `pause` ### `pause`
<PlatformsList types={['Android', 'iOS']} /> <PlatformsList types={['Android', 'iOS', 'web']} />
`pause(): Promise<void>` `pause(): Promise<void>`
@ -25,7 +25,7 @@ Pause the video.
### `presentFullscreenPlayer` ### `presentFullscreenPlayer`
<PlatformsList types={['Android', 'iOS']} /> <PlatformsList types={['Android', 'iOS', 'web']} />
`presentFullscreenPlayer(): Promise<void>` `presentFullscreenPlayer(): Promise<void>`
@ -40,7 +40,7 @@ On Android, this puts the navigation controls in fullscreen mode. It is not a co
### `resume` ### `resume`
<PlatformsList types={['Android', 'iOS']} /> <PlatformsList types={['Android', 'iOS', 'web']} />
`resume(): Promise<void>` `resume(): Promise<void>`
@ -100,7 +100,7 @@ tolerance is the max distance in milliseconds from the seconds position that's a
### `setVolume` ### `setVolume`
<PlatformsList types={['Android', 'iOS']} /> <PlatformsList types={['Android', 'iOS', 'web']} />
`setVolume(value): Promise<void>` `setVolume(value): Promise<void>`
@ -108,17 +108,27 @@ This function will change the volume exactly like [volume](./props#volume) prope
### `getCurrentPosition` ### `getCurrentPosition`
<PlatformsList types={['Android', 'iOS']} /> <PlatformsList types={['Android', 'iOS', 'web']} />
`getCurrentPosition(): Promise<number>` `getCurrentPosition(): Promise<number>`
This function retrieves and returns the precise current position of the video playback, measured in seconds. This function retrieves and returns the precise current position of the video playback, measured in seconds.
This function will throw an error if player is not initialized. This function will throw an error if player is not initialized.
### `setFullScreen`
### `setSource`
<PlatformsList types={['Android', 'iOS']} /> <PlatformsList types={['Android', 'iOS']} />
`setSource(source: ReactVideoSource): Promise<void>`
This function will change the source exactly like [source](./props#source) property.
Changing source with this function will overide source provided as props.
### `setFullScreen`
<PlatformsList types={['Android', 'iOS', 'web']} />
`setFullScreen(fullscreen): Promise<void>` `setFullScreen(fullscreen): Promise<void>`
If you set it to `true`, the player enters fullscreen mode. If you set it to `false`, the player exits fullscreen mode. If you set it to `true`, the player enters fullscreen mode. If you set it to `false`, the player exits fullscreen mode.
@ -127,6 +137,13 @@ On iOS, this displays the video in a fullscreen view controller with controls.
On Android, this puts the navigation controls in fullscreen mode. It is not a complete fullscreen implementation, so you will still need to apply a style that makes the width and height match your screen dimensions to get a fullscreen video. On Android, this puts the navigation controls in fullscreen mode. It is not a complete fullscreen implementation, so you will still need to apply a style that makes the width and height match your screen dimensions to get a fullscreen video.
### `nativeHtmlVideoRef`
<PlatformsList types={['web']} />
A ref to the underlying html video element. This can be used if you need to integrate a 3d party, web only video library (like hls.js, shaka, video.js...).
### Example Usage ### Example Usage
```tsx ```tsx
@ -141,9 +158,9 @@ const someCoolFunctions = async () => {
videoRef.current.presentFullscreenPlayer(); videoRef.current.presentFullscreenPlayer();
videoRef.current.dismissFullscreenPlayer(); videoRef.current.dismissFullscreenPlayer();
// pause or play the video // pause or resume the video
videoRef.current.play();
videoRef.current.pause(); videoRef.current.pause();
videoRef.current.resume();
// save video to your Photos with current filter prop // save video to your Photos with current filter prop
const response = await videoRef.current.save(); const response = await videoRef.current.save();
@ -178,7 +195,7 @@ Possible values are:
### `isCodecSupported` ### `isCodecSupported`
<PlatformsList types={['Android']} /> <PlatformsList types={['Android', 'web']} />
Indicates whether the provided codec is supported level supported by device. Indicates whether the provided codec is supported level supported by device.

View File

@ -8,6 +8,9 @@ This page shows the list of available properties to configure player
### `adTagUrl` ### `adTagUrl`
> [!WARNING]
> Deprecated, use source.ad.adTagUrl instead
<PlatformsList types={['Android', 'iOS']} /> <PlatformsList types={['Android', 'iOS']} />
Sets the VAST uri to play AVOD ads. Sets the VAST uri to play AVOD ads.
@ -128,15 +131,14 @@ When playing an HLS live stream with a `EXT-X-PROGRAM-DATE-TIME` tag configured,
### `controls` ### `controls`
<PlatformsList types={['Android', 'iOS', 'visionOS']} /> <PlatformsList types={['Android', 'iOS', 'visionOS', 'web']} />
Determines whether to show player controls. Determines whether to show player controls.
- **false (default)** - Don't show player controls - **false (default)** - Don't show player controls
- **true** - Show player controls - **true** - Show player controls
Note on iOS, controls are always shown when in fullscreen mode. Controls are always shown in fullscreen mode, even when `controls={false}`.
Note on Android, native controls are available by default.
If needed, you can also add your controls or use a package like [react-native-video-controls](https://github.com/itsnubix/react-native-video-controls) or [react-native-media-console](https://github.com/criszz77/react-native-media-console), see [Useful Side Project](/projects). If needed, you can also add your controls or use a package like [react-native-video-controls](https://github.com/itsnubix/react-native-video-controls) or [react-native-media-console](https://github.com/criszz77/react-native-media-console), see [Useful Side Project](/projects).
### `controlsStyles` ### `controlsStyles`
@ -145,25 +147,53 @@ If needed, you can also add your controls or use a package like [react-native-vi
Adjust the control styles. This prop is need only if `controls={true}` and is an object. See the list of prop supported below. Adjust the control styles. This prop is need only if `controls={true}` and is an object. See the list of prop supported below.
| Property | Type | Description | | Property | Type | Description |
|-----------------|---------|-----------------------------------------------------------------------------------------| |-------------------------------------|---------|---------------------------------------------------------------------------------------------|
| hideSeekBar | boolean | The default value is `false`, allowing you to hide the seek bar for live broadcasts. | | hidePosition | boolean | Hides the position indicator. Default is `false`. |
| seekIncrementMS | number | The default value is `10000`. You can change the value to increment forward and rewind. | | hidePlayPause | boolean | Hides the play/pause button. Default is `false`. |
| hideForward | boolean | Hides the forward button. Default is `false`. |
| hideRewind | boolean | Hides the rewind button. Default is `false`. |
| hideNext | boolean | Hides the next button. Default is `false`. |
| hidePrevious | boolean | Hides the previous button. Default is `false`. |
| hideFullscreen | boolean | Hides the fullscreen button. Default is `false`. |
| hideSeekBar | boolean | The default value is `false`, allowing you to hide the seek bar for live broadcasts. |
| hideDuration | boolean | The default value is `false`, allowing you to hide the duration. |
| hideNavigationBarOnFullScreenMode | boolean | The default value is `true`, allowing you to hide the navigation bar on full-screen mode. |
| hideNotificationBarOnFullScreenMode | boolean | The default value is `true`, allowing you to hide the notification bar on full-screen mode. |
| hideSettingButton | boolean | The default value is `true`, allowing you to hide the setting button. |
| seekIncrementMS | number | The default value is `10000`. You can change the value to increment forward and rewind. |
| liveLabel | string | Allowing you to set a label for live video. |
Example with default values: Example with default values:
```javascript ```javascript
controlsStyles={{ controlsStyles={{
hidePosition: false,
hidePlayPause: false,
hideForward: false,
hideRewind: false,
hideNext: false,
hidePrevious: false,
hideFullscreen: false,
hideSeekBar: false, hideSeekBar: false,
hideDuration: false,
hideNavigationBarOnFullScreenMode: true,
hideNotificationBarOnFullScreenMode: true,
hideSettingButton: true,
seekIncrementMS: 10000, seekIncrementMS: 10000,
liveLabel: "LIVE"
}} }}
``` ```
### `contentStartTime` ### `contentStartTime`
> [!WARNING]
> Deprecated, use source.contentStartTime instead
<PlatformsList types={['Android']} /> <PlatformsList types={['Android']} />
The start time in ms for SSAI content. This determines at what time to load the video info like resolutions. Use this only when you have SSAI stream where ads resolution is not the same as content resolution. The start time in ms for SSAI content. This determines at what time to load the video info like resolutions. Use this only when you have SSAI stream where ads resolution is not the same as content resolution.
Note: This feature only works on DASH streams
### `debug` ### `debug`
@ -270,7 +300,7 @@ Whether this video view should be focusable with a non-touch input device, eg. r
### `fullscreen` ### `fullscreen`
<PlatformsList types={['Android', 'iOS', 'visionOS']} /> <PlatformsList types={['Android', 'iOS', 'visionOS', 'web']} />
Controls whether the player enters fullscreen on play. Controls whether the player enters fullscreen on play.
See [presentFullscreenPlayer](#presentfullscreenplayer) for details. See [presentFullscreenPlayer](#presentfullscreenplayer) for details.
@ -286,7 +316,7 @@ If a preferred [fullscreenOrientation](#fullscreenorientation) is set, causes th
### `fullscreenOrientation` ### `fullscreenOrientation`
<PlatformsList types={['iOS', 'visionOS']} /> <PlatformsList types={['iOS', 'visionOS', 'web']} />
- **all (default)** - - **all (default)** -
- **landscape** - **landscape**
@ -330,19 +360,6 @@ Controls the iOS silent switch behavior
- **"ignore"** - Play audio even if the silent switch is set - **"ignore"** - Play audio even if the silent switch is set
- **"obey"** - Don't play audio if the silent switch is set - **"obey"** - Don't play audio if the silent switch is set
### `localSourceEncryptionKeyScheme`
<PlatformsList types={['iOS']} />
Set the url scheme for stream encryption key for local assets
Type: String
Example:
```
localSourceEncryptionKeyScheme="my-offline-key"
```
### `maxBitRate` ### `maxBitRate`
@ -352,6 +369,9 @@ Sets the desired limit, in bits per second, of network bandwidth consumption whe
Default: 0. Don't limit the maxBitRate. Default: 0. Don't limit the maxBitRate.
Note: This property can interact with selectedVideoTrack.
To use `maxBitrate`, selectedVideoTrack shall be undefined or `{type: SelectedVideoTrackType.AUTO}`.
Example: Example:
```javascript ```javascript
@ -506,14 +526,29 @@ Speed at which the media should play.
<PlatformsList types={['All']} /> <PlatformsList types={['All']} />
Allows you to create custom components to display while the video is loading. If `renderLoader` is provided, `poster` and `posterResizeMode` will be ignored. Allows you to create custom components to display while the video is loading.
If `renderLoader` is provided, `poster` and `posterResizeMode` will be ignored.
renderLoader is either a component or a function returning a component.
It is recommended to use the function for optimization matter.
`renderLoader` function be called with parameters of type `ReactVideoRenderLoaderProps` to be able to adapt loader
```typescript
interface ReactVideoRenderLoaderProps {
source?: ReactVideoSource; /// source of the video
style?: StyleProp<ImageStyle>; /// style to apply
resizeMode?: EnumValues<VideoResizeMode>; /// resizeMode provided to the video component
}
````
Sample:
```javascript ```javascript
<Video> <Video>
renderLoader={ renderLoader={() => (
<View> <View>
<Text>Custom Loader</Text> <Text>Custom Loader</Text>
</View> </View>)
} }
</Video> </Video>
```` ````
@ -672,6 +707,8 @@ The docs for this prop are incomplete and will be updated as each option is inve
> ⚠️ on iOS, you file name must not contain spaces eg. `my video.mp4` will not work, use `my-video.mp4` instead > ⚠️ on iOS, you file name must not contain spaces eg. `my video.mp4` will not work, use `my-video.mp4` instead
<PlatformsList types={['Android', 'iOS', 'visionOS', 'Windows UWP']} />
Example: Example:
Pass directly the asset to play (deprecated) Pass directly the asset to play (deprecated)
@ -762,7 +799,7 @@ The following other types are supported on some platforms, but aren't fully docu
#### Using DRM content #### Using DRM content
<PlatformsList types={['Android', 'iOS']} /> <PlatformsList types={['Android', 'iOS', 'visionOS', 'tvOS']} />
To setup DRM please follow [this guide](/component/drm) To setup DRM please follow [this guide](/component/drm)
@ -780,12 +817,10 @@ Example:
}, },
``` ```
> ⚠️ DRM is not supported on visionOS yet
#### Start playback at a specific point in time #### Start playback at a specific point in time
<PlatformsList types={['Android', 'iOS']} /> <PlatformsList types={['Android', 'iOS', 'web']} />
Provide an optional `startPosition` for video. Value is in milliseconds. If the `cropStart` prop is applied, it will be applied from that point forward. Provide an optional `startPosition` for video. Value is in milliseconds. If the `cropStart` prop is applied, it will be applied from that point forward.
(If it is negative or undefined or null, it is ignored) (If it is negative or undefined or null, it is ignored)
@ -828,6 +863,33 @@ source={{
}} }}
``` ```
### `ad`
<PlatformsList types={['Android', 'iOS']} />
Sets the ad configuration.
Example:
```
ad: {
adTagUrl="https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpremidpostoptimizedpodbumper&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&cmsid=496&vid=short_onecue&correlator="
adLanguage="fr"
}
```
See: [./ads.md] for more informations
Note: You need enable IMA SDK in gradle or pod file - [enable client side ads insertion](/installation)
#### `contentStartTime`
<PlatformsList types={['Android']} />
The start time in ms for SSAI content. This determines at what time to load the video info like resolutions. Use this only when you have SSAI stream where ads resolution is not the same as content resolution.
Note: This feature only works on DASH streams
#### `textTracksAllowChunklessPreparation` #### `textTracksAllowChunklessPreparation`
<PlatformsList types={['Android']} /> <PlatformsList types={['Android']} />
@ -843,37 +905,19 @@ source={{
}} }}
``` ```
### `subtitleStyle` #### `textTracks`
| Property | Description | Platforms |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
| fontSize | Adjust the font size of the subtitles. Default: font size of the device | Android |
| paddingTop | Adjust the top padding of the subtitles. Default: 0 | Android |
| paddingBottom | Adjust the bottom padding of the subtitles. Default: 0 | Android |
| paddingLeft | Adjust the left padding of the subtitles. Default: 0 | Android |
| paddingRight | Adjust the right padding of the subtitles. Default: 0 | Android |
| opacity | Adjust the visibility of subtitles with 0 hiding and 1 fully showing them. Android supports float values between 0 and 1 for varying opacity levels, whereas iOS supports only 0 or 1. Default: 1. | Android, iOS |
Example:
```javascript
subtitleStyle={{ paddingBottom: 50, fontSize: 20, opacity: 0 }}
```
### `textTracks`
<PlatformsList types={['Android', 'iOS', 'visionOS']} /> <PlatformsList types={['Android', 'iOS', 'visionOS']} />
Load one or more "sidecar" text tracks. This takes an array of objects representing each track. Each object should have the format: Load one or more "sidecar" text tracks. This takes an array of objects representing each track. Each object should have the format:
> ⚠️ This feature does not work with HLS playlists (e.g m3u8) on iOS > ⚠️ This feature does not work with HLS playlists (e.g m3u8) on iOS
| Property | Description | | Property | Description |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| title | Descriptive name for the track | | title | Descriptive name for the track |
| language | 2 letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) representing the language | | language | 2 letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) representing the language |
| type | Mime type of the track _ TextTrackType.SRT - SubRip (.srt) _ TextTrackType.TTML - TTML (.ttml) \* TextTrackType.VTT - WebVTT (.vtt)iOS only supports VTT, Android supports all 3 | | type | Mime type of the track _ TextTrackType.SUBRIP - SubRip (.srt) _ TextTrackType.TTML - TTML (.ttml) \* TextTrackType.VTT - WebVTT (.vtt)iOS only supports VTT, Android supports all 3 |
| uri | URL for the text track. Currently, only tracks hosted on a webserver are supported | | uri | URL for the text track. Currently, only tracks hosted on a webserver are supported |
On iOS, sidecar text tracks are only supported for individual files, not HLS playlists. For HLS, you should include the text tracks as part of the playlist. On iOS, sidecar text tracks are only supported for individual files, not HLS playlists. For HLS, you should include the text tracks as part of the playlist.
@ -894,7 +938,92 @@ textTracks={[
{ {
title: "Spanish Subtitles", title: "Spanish Subtitles",
language: "es", language: "es",
type: TextTrackType.SRT, // "application/x-subrip" type: TextTrackType.SUBRIP, // "application/x-subrip"
uri: "https://durian.blender.org/wp-content/content/subtitles/sintel_es.srt"
}
]}
```
### `subtitleStyle`
<PlatformsList types={['Android', 'iOS']} />
| Property | Platform | Description | Platforms |
| ------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
| fontSize | Android | Adjust the font size of the subtitles. Default: font size of the device | Android |
| paddingTop | Android | Adjust the top padding of the subtitles. Default: 0 | Android |
| paddingBottom | Android | Adjust the bottom padding of the subtitles. Default: 0 | Android |
| paddingLeft | Android | Adjust the left padding of the subtitles. Default: 0 | Android |
| paddingRight | Android | Adjust the right padding of the subtitles. Default: 0 | Android |
| opacity | Android, iOS | Adjust the visibility of subtitles with 0 hiding and 1 fully showing them. Android supports float values between 0 and 1 for varying opacity levels, whereas iOS supports only 0 or 1. Default: 1. | Android, iOS |
| subtitlesFollowVideo | Android | Boolean to adjust position of subtitles. Default: true |
Example:
```javascript
subtitleStyle={{ paddingBottom: 50, fontSize: 20, opacity: 0 }}
```
Note for `subtitlesFollowVideo`
`subtitlesFollowVideo` helps to determine how the subtitles are positionned.
To understand this prop you need to understand how views management works.
The main View style passed to react native video is the position reserved to display the video component.
It may not match exactly the real video size.
For exemple, you can pass a 4:3 video view and render a 16:9 video inside.
So there is a second view, the video view.
Subtitles are managed in a third view.
First react-native-video resize the video to keep aspect ratio (depending on `resizeMode` property) and put it in main view.
* When putting subtitlesFollowVideo to true, the subtitle view will be adapt to the video view.
It means that if the video is displayed out of screen, the subtitles may also be displayed out of screen.
* When putting subtitlesFollowVideo to false, the subtitle view will keep adapting to the main view.
It means that if the video is displayed out of screen, the subtitles may also be displayed out of screen.
This prop can be changed on runtime.
### `textTracks`
> [!WARNING]
> deprecated, use source.textTracks instead. changing text tracks will restart playback
<PlatformsList types={['Android', 'iOS', 'visionOS']} />
Load one or more "sidecar" text tracks. This takes an array of objects representing each track. Each object should have the format:
> ⚠️ This feature does not work with HLS playlists (e.g m3u8) on iOS
| Property | Description |
|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| title | Descriptive name for the track |
| language | 2 letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) representing the language |
| type | Mime type of the track _ TextTrackType.SUBRIP - SubRip (.srt) _ TextTrackType.TTML - TTML (.ttml) \* TextTrackType.VTT - WebVTT (.vtt)iOS only supports VTT, Android supports all 3 |
| uri | URL for the text track. Currently, only tracks hosted on a webserver are supported |
On iOS, sidecar text tracks are only supported for individual files, not HLS playlists. For HLS, you should include the text tracks as part of the playlist.
Note: Due to iOS limitations, sidecar text tracks are not compatible with Airplay. If textTracks are specified, AirPlay support will be automatically disabled.
Example:
```javascript
import { TextTrackType }, Video from 'react-native-video';
textTracks={[
{
title: "English CC",
language: "en",
type: TextTrackType.VTT, // "text/vtt"
uri: "https://bitdash-a.akamaihd.net/content/sintel/subtitles/subtitles_en.vtt"
},
{
title: "Spanish Subtitles",
language: "es",
type: TextTrackType.SUBRIP, // "application/x-subrip"
uri: "https://durian.blender.org/wp-content/content/subtitles/sintel_es.srt" uri: "https://durian.blender.org/wp-content/content/subtitles/sintel_es.srt"
} }
]} ]}
@ -902,7 +1031,7 @@ textTracks={[
### `showNotificationControls` ### `showNotificationControls`
<PlatformsList types={['Android', 'iOS']} /> <PlatformsList types={['Android', 'iOS', 'web']} />
Controls whether to show media controls in the notification area. Controls whether to show media controls in the notification area.
For Android each Video component will have its own notification controls and for iOS only one notification control will be shown for the last Active Video component. For Android each Video component will have its own notification controls and for iOS only one notification control will be shown for the last Active Video component.
@ -999,3 +1128,68 @@ Adjust the volume.
- **1.0 (default)** - Play at full volume - **1.0 (default)** - Play at full volume
- **0.0** - Mute the audio - **0.0** - Mute the audio
- **Other values** - Reduce volume - **Other values** - Reduce volume
### `cmcd`
<PlatformsList types={['Android']} />
Configure CMCD (Common Media Client Data) parameters. CMCD is a standard for conveying client-side metrics and capabilities to servers, which can help improve streaming quality and performance.
For detailed information about CMCD, please refer to the [CTA-5004 Final Specification](https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf).
- **false (default)** - Don't use CMCD
- **true** - Use default CMCD configuration
- **object** - Use custom CMCD configuration
When providing an object, you can configure the following properties:
| Property | Type | Description |
|----------|-------------------------|----------------------------------------------------|
| `mode` | `CmcdMode` | The mode for sending CMCD data |
| `request` | `CmcdData` | Custom key-value pairs for the request object |
| `session` | `CmcdData` | Custom key-value pairs for the session object |
| `object` | `CmcdData` | Custom key-value pairs for the object metadata |
| `status` | `CmcdData` | Custom key-value pairs for the status information |
Note: The `mode` property defaults to `CmcdMode.MODE_QUERY_PARAMETER` if not specified.
#### `CmcdMode`
CmcdMode is an enum that defines how CMCD data should be sent:
- `CmcdMode.MODE_REQUEST_HEADER` (0) - Send CMCD data in the HTTP request headers.
- `CmcdMode.MODE_QUERY_PARAMETER` (1) - Send CMCD data as query parameters in the URL.
#### `CmcdData`
CmcdData is a type representing custom key-value pairs for CMCD data. It's defined as:
```typescript
type CmcdData = Record<`${string}-${string}`, string | number>;
```
Custom key names MUST include a hyphenated prefix to prevent namespace collisions. It's recommended to use a reverse-DNS syntax for custom prefixes.
Example:
```javascript
<Video
source={{
uri: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8',
cmcd: {
mode: CmcdMode.MODE_QUERY_PARAMETER,
request: {
'com-custom-key': 'custom-value'
},
session: {
sid: 'session-id'
},
object: {
br: '3000',
d: '4000'
},
status: {
rtp: '1200'
}
}
}}
// or other video props
/>
```

View File

@ -8,6 +8,7 @@ It allows to stream video files (m3u, mpd, mp4, ...) inside your react native ap
- Exoplayer for android - Exoplayer for android
- AVplayer for iOS, tvOS and visionOS - AVplayer for iOS, tvOS and visionOS
- Windows UWP for windows - Windows UWP for windows
- HTML5 for web
- Trick mode support - Trick mode support
- Subtitles (embeded or side loaded) - Subtitles (embeded or side loaded)
- DRM support - DRM support

View File

@ -181,3 +181,12 @@ Select RCTVideo-tvOS
Run `pod install` in the `visionos` directory of your project Run `pod install` in the `visionos` directory of your project
</details> </details>
<details>
<summary>web</summary>
Nothing to do, everything should work out of the box.
Note that only basic video support is present, no hls/dash or ads/drm for now.
</details>

View File

@ -86,11 +86,6 @@ buildscript {
} }
``` ```
### Desugaring
to be able to link you may also need to enable coreLibraryDesugaringEnabled in your app.
See: https://developer.android.com/studio/write/java8-support?hl=fr#library-desugaring for more informations.
## It's still not working ## It's still not working
You can try to open a ticket now ! You can try to open a ticket or contact us for [premium support](https://www.thewidlarzgroup.com/?utm_source=rnv&utm_medium=docs#Contact)!

View File

@ -22,7 +22,7 @@ In your project Podfile add support for static dependency linking. This is requi
Add `use_frameworks! :linkage => :static` just under `platform :ios` in your ios project Podfile. Add `use_frameworks! :linkage => :static` just under `platform :ios` in your ios project Podfile.
[See the example ios project for reference](examples/basic/ios/Podfile#L5) [See the example ios project for reference](https://github.com/TheWidlarzGroup/react-native-video/blob/master/examples/basic/ios/Podfile#L5)
##### podspec ##### podspec
@ -34,7 +34,7 @@ You can remove following lines from your podfile as they are not necessary anymo
- `pod 'react-native-video/VideoCaching', :path => '../node_modules/react-native-video/react-native-video.podspec'` - `pod 'react-native-video/VideoCaching', :path => '../node_modules/react-native-video/react-native-video.podspec'`
``` ```
If you were previously using VideoCaching, you should $RNVideoUseVideoCaching flag in your podspec, see: [installation section](https://react-native-video.github.io/react-native-video/installation#video-caching) If you were previously using VideoCaching, you should $RNVideoUseVideoCaching flag in your podspec, see: [installation section](https://thewidlarzgroup.github.io/react-native-video/installation#video-caching)
#### Android #### Android
@ -66,4 +66,4 @@ allprojects {
} }
} }
``` ```
If you encounter an error `Could not find com.android.support:support-annotations:27.0.0.` reinstall your Android Support Repository. If you encounter an error `Could not find com.android.support:support-annotations:27.0.0.` reinstall your Android Support Repository.

View File

@ -52,6 +52,62 @@ export default {
</span> </span>
), ),
}, },
toc: {
extraContent: (
<>
<style>{`
:is(html[class~=dark]) .extra-container {
background-color: #87ccef;
}
:is(html[class~=dark]) .extra-text {
color: #171717;
}
:is(html[class~=dark]) .extra-button {
background-color: #171717;
}
.extra-container {
display: flex;
flex-direction: column;
margin-top: 0.5rem;
text-align: center;
background-color: #171717;
padding: 1rem;
gap: 1rem;
border-radius: 0.5rem;
}
.extra-text {
padding-left: 0.5rem;
padding-right: 0.5rem;
font-weight: bold;
color: #fff;
}
.extra-button {
width: 100%;
border: none;
padding: 0.5rem 1rem;
font-weight: 500;
background-color: #f9d85b;
transition: transform 0.3s ease, background-color 0.3s ease;
}
.extra-button:hover {
transform: scale(1.05);
background-color: #fff;
}
`}</style>
<div className="extra-container">
<span className="extra-text">We are TheWidlarzGroup</span>
<a
target="_blank"
href="https://www.thewidlarzgroup.com/?utm_source=rnv&utm_medium=docs#Contact"
className="extra-button"
rel="noreferrer">
Premium support
</a>
</div>
</>
),
},
useNextSeoProps() { useNextSeoProps() {
return { return {
titleTemplate: '%s Video', titleTemplate: '%s Video',

View File

@ -83,10 +83,6 @@ android {
namespace "com.videoplayer" namespace "com.videoplayer"
compileOptions { compileOptions {
// These options are necessary to be able to build from source
// coreLibraryDesugaringEnabled is mandatory to be able to build exoplayer from source
// uncomment this line if you want to build from source
// coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_11 sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11
} }
@ -157,8 +153,6 @@ dependencies {
because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib") because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib")
} }
} }
// coreLibraryDesugaring is mandatory to be able to build exoplayer from source
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
} }
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project) apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"web": "expo start --web",
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios",
"windows": "react-native run-windows", "windows": "react-native run-windows",
@ -13,13 +14,17 @@
"pod-install:newarch": "cd ios && RCT_NEW_ARCH_ENABLED=1 bundle exec pod install && cd .." "pod-install:newarch": "cd ios && RCT_NEW_ARCH_ENABLED=1 bundle exec pod install && cd .."
}, },
"dependencies": { "dependencies": {
"@expo/metro-runtime": "~3.2.1",
"@react-native-picker/picker": "2.7.5", "@react-native-picker/picker": "2.7.5",
"expo": "^51.0.17", "expo": "^51.0.32",
"expo-asset": "^10.0.9", "expo-asset": "~10.0.10",
"expo-image": "^1.12.12", "expo-image": "^1.12.15",
"expo-navigation-bar": "~3.0.7",
"react": "18.2.0", "react": "18.2.0",
"react-native": "0.74.3", "react-native": "0.74.5",
"react-native-windows": "0.74.1" "react-dom": "18.2.0",
"react-native-web": "~0.19.10",
"react-native-windows": "0.74.19"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.24.0", "@babel/core": "^7.24.0",

View File

@ -1,8 +1,8 @@
'use strict'; 'use strict';
import React, {type FC, useCallback, useRef, useState} from 'react'; import React, {type FC, useCallback, useRef, useState, useEffect} from 'react';
import {Platform, TouchableOpacity, View} from 'react-native'; import {Platform, TouchableOpacity, View, StatusBar} from 'react-native';
import Video, { import Video, {
VideoRef, VideoRef,
@ -30,11 +30,20 @@ import Video, {
type SelectedTrack, type SelectedTrack,
type SelectedVideoTrack, type SelectedVideoTrack,
type EnumValues, type EnumValues,
OnBandwidthUpdateData,
ControlsStyles,
} from 'react-native-video'; } from 'react-native-video';
import styles from './styles'; import styles from './styles';
import {type AdditionalSourceInfo} from './types'; import {type AdditionalSourceInfo} from './types';
import {bufferConfig, srcList, textTracksSelectionBy} from './constants'; import {
bufferConfig,
isAndroid,
srcList,
textTracksSelectionBy,
audioTracksSelectionBy,
} from './constants';
import {Overlay, toast, VideoLoader} from './components'; import {Overlay, toast, VideoLoader} from './components';
import * as NavigationBar from 'expo-navigation-bar';
type Props = NonNullable<unknown>; type Props = NonNullable<unknown>;
@ -103,19 +112,30 @@ const VideoPlayer: FC<Props> = ({}) => {
goToChannel((srcListId + srcList.length - 1) % srcList.length); goToChannel((srcListId + srcList.length - 1) % srcList.length);
}, [goToChannel, srcListId]); }, [goToChannel, srcListId]);
useEffect(() => {
if (isAndroid) {
NavigationBar.setVisibilityAsync('visible');
}
}, []);
const onAudioTracks = (data: OnAudioTracksData) => { const onAudioTracks = (data: OnAudioTracksData) => {
console.log('onAudioTracks', data);
const selectedTrack = data.audioTracks?.find((x: AudioTrack) => { const selectedTrack = data.audioTracks?.find((x: AudioTrack) => {
return x.selected; return x.selected;
}); });
if (selectedTrack?.index) { let value;
setAudioTracks(data.audioTracks); if (audioTracksSelectionBy === SelectedTrackType.INDEX) {
setSelectedAudioTrack({ value = selectedTrack?.index;
type: SelectedTrackType.INDEX, } else if (audioTracksSelectionBy === SelectedTrackType.LANGUAGE) {
value: selectedTrack.index, value = selectedTrack?.language;
}); } else if (audioTracksSelectionBy === SelectedTrackType.TITLE) {
} else { value = selectedTrack?.title;
setAudioTracks(data.audioTracks);
} }
setAudioTracks(data.audioTracks);
setSelectedAudioTrack({
type: audioTracksSelectionBy,
value: value,
});
}; };
const onVideoTracks = (data: OnVideoTracksData) => { const onVideoTracks = (data: OnVideoTracksData) => {
@ -128,22 +148,19 @@ const VideoPlayer: FC<Props> = ({}) => {
return x?.selected; return x?.selected;
}); });
if (selectedTrack?.language) { setTextTracks(data.textTracks);
setTextTracks(data.textTracks); let value;
if (textTracksSelectionBy === 'index') { if (textTracksSelectionBy === SelectedTrackType.INDEX) {
setSelectedTextTrack({ value = selectedTrack?.index;
type: SelectedTrackType.INDEX, } else if (textTracksSelectionBy === SelectedTrackType.LANGUAGE) {
value: selectedTrack?.index, value = selectedTrack?.language;
}); } else if (textTracksSelectionBy === SelectedTrackType.TITLE) {
} else { value = selectedTrack?.title;
setSelectedTextTrack({
type: SelectedTrackType.LANGUAGE,
value: selectedTrack?.language,
});
}
} else {
setTextTracks(data.textTracks);
} }
setSelectedTextTrack({
type: textTracksSelectionBy,
value: value,
});
}; };
const onLoad = (data: OnLoadData) => { const onLoad = (data: OnLoadData) => {
@ -213,21 +230,42 @@ const VideoPlayer: FC<Props> = ({}) => {
console.log('onPlaybackStateChanged', data); console.log('onPlaybackStateChanged', data);
}; };
const onVideoBandwidthUpdate = (data: OnBandwidthUpdateData) => {
console.log('onVideoBandwidthUpdate', data);
};
const onFullScreenExit = () => { const onFullScreenExit = () => {
// iOS pauses video on exit from full screen // iOS pauses video on exit from full screen
Platform.OS === 'ios' && setPaused(true); Platform.OS === 'ios' && setPaused(true);
}; };
const _renderLoader = showPoster ? () => <VideoLoader /> : undefined;
const _subtitleStyle = {subtitlesFollowVideo: true};
const _controlsStyles : ControlsStyles = {
hideNavigationBarOnFullScreenMode: true,
hideNotificationBarOnFullScreenMode: true,
liveLabel: "LIVE"
};
const _bufferConfig = {
...bufferConfig,
cacheSizeMB: useCache ? 200 : 0,
};
useEffect(() => {
videoRef.current?.setSource(currentSrc)
}, [currentSrc])
return ( return (
<View style={styles.container}> <View style={styles.container}>
<StatusBar animated={true} backgroundColor="black" hidden={false} />
{(srcList[srcListId] as AdditionalSourceInfo)?.noView ? null : ( {(srcList[srcListId] as AdditionalSourceInfo)?.noView ? null : (
<TouchableOpacity style={viewStyle}> <TouchableOpacity style={viewStyle}>
<Video <Video
showNotificationControls={showNotificationControls} showNotificationControls={showNotificationControls}
ref={videoRef} ref={videoRef}
source={currentSrc as ReactVideoSource} // source={currentSrc as ReactVideoSource}
textTracks={additional?.textTracks}
adTagUrl={additional?.adTagUrl}
drm={additional?.drm} drm={additional?.drm}
style={viewStyle} style={viewStyle}
rate={rate} rate={rate}
@ -252,22 +290,22 @@ const VideoPlayer: FC<Props> = ({}) => {
onAspectRatio={onAspectRatio} onAspectRatio={onAspectRatio}
onReadyForDisplay={onReadyForDisplay} onReadyForDisplay={onReadyForDisplay}
onBuffer={onVideoBuffer} onBuffer={onVideoBuffer}
onBandwidthUpdate={onVideoBandwidthUpdate}
onSeek={onSeek} onSeek={onSeek}
repeat={repeat} repeat={repeat}
selectedTextTrack={selectedTextTrack} selectedTextTrack={selectedTextTrack}
selectedAudioTrack={selectedAudioTrack} selectedAudioTrack={selectedAudioTrack}
selectedVideoTrack={selectedVideoTrack} selectedVideoTrack={selectedVideoTrack}
playInBackground={false} playInBackground={false}
bufferConfig={{ bufferConfig={_bufferConfig}
...bufferConfig,
cacheSizeMB: useCache ? 200 : 0,
}}
preventsDisplaySleepDuringVideoPlayback={true} preventsDisplaySleepDuringVideoPlayback={true}
renderLoader={showPoster ? <VideoLoader /> : undefined} renderLoader={_renderLoader}
onPlaybackRateChange={onPlaybackRateChange} onPlaybackRateChange={onPlaybackRateChange}
onPlaybackStateChanged={onPlaybackStateChanged} onPlaybackStateChanged={onPlaybackStateChanged}
bufferingStrategy={BufferingStrategyType.DEFAULT} bufferingStrategy={BufferingStrategyType.DEFAULT}
debug={{enable: true, thread: true}} debug={{enable: true, thread: true}}
subtitleStyle={_subtitleStyle}
controlsStyles={_controlsStyles}
/> />
</TouchableOpacity> </TouchableOpacity>
)} )}

View File

@ -1,19 +1,25 @@
import {Picker} from '@react-native-picker/picker'; import {Picker} from '@react-native-picker/picker';
import {Text} from 'react-native'; import {Text} from 'react-native';
import type {AudioTrack, SelectedTrack} from 'react-native-video'; import {
SelectedTrackType,
type AudioTrack,
type SelectedTrack,
} from 'react-native-video';
import styles from '../styles'; import styles from '../styles';
import React from 'react'; import React from 'react';
export interface AudioTrackSelectorType { export interface AudioTrackSelectorType {
audioTracks: Array<AudioTrack>; audioTracks: Array<AudioTrack>;
selectedAudioTrack: SelectedTrack | undefined; selectedAudioTrack: SelectedTrack | undefined;
onValueChange: (arg0: string) => void; onValueChange: (arg0: string | number) => void;
audioTracksSelectionBy: SelectedTrackType;
} }
export const AudioTrackSelector = ({ export const AudioTrackSelector = ({
audioTracks, audioTracks,
selectedAudioTrack, selectedAudioTrack,
onValueChange, onValueChange,
audioTracksSelectionBy,
}: AudioTrackSelectorType) => { }: AudioTrackSelectorType) => {
return ( return (
<> <>
@ -25,7 +31,7 @@ export const AudioTrackSelector = ({
onValueChange={itemValue => { onValueChange={itemValue => {
if (itemValue !== 'empty') { if (itemValue !== 'empty') {
console.log('on audio value change ' + itemValue); console.log('on audio value change ' + itemValue);
onValueChange(`${itemValue}`); onValueChange(itemValue);
} }
}}> }}>
{audioTracks?.length <= 0 ? ( {audioTracks?.length <= 0 ? (
@ -37,11 +43,19 @@ export const AudioTrackSelector = ({
if (!track) { if (!track) {
return; return;
} }
let value;
if (audioTracksSelectionBy === SelectedTrackType.INDEX) {
value = track.index;
} else if (audioTracksSelectionBy === SelectedTrackType.LANGUAGE) {
value = track.language;
} else if (audioTracksSelectionBy === SelectedTrackType.TITLE) {
value = track.title;
}
return ( return (
<Picker.Item <Picker.Item
label={`${track.language} - ${track.title} - ${track.selected}`} label={`${value} - ${track.selected}`}
value={`${track.index}`} value={`${value}`}
key={`${track.index}`} key={`${value}`}
/> />
); );
})} })}

View File

@ -21,7 +21,7 @@ interface MultiValueControlType<T> {
onPress: (arg: T) => void; onPress: (arg: T) => void;
} }
const MultiValueControl = <T extends number | string | ResizeMode>({ export const MultiValueControl = <T extends number | string | ResizeMode>({
values, values,
selected, selected,
onPress, onPress,

View File

@ -7,9 +7,12 @@ import React, {
} from 'react'; } from 'react';
import {View} from 'react-native'; import {View} from 'react-native';
import styles from '../styles.tsx'; import styles from '../styles.tsx';
import ToggleControl from '../ToggleControl.tsx'; import {
import {isAndroid, isIos, textTracksSelectionBy} from '../constants'; isAndroid,
import MultiValueControl from '../MultiValueControl.tsx'; isIos,
textTracksSelectionBy,
audioTracksSelectionBy,
} from '../constants';
import { import {
ResizeMode, ResizeMode,
VideoRef, VideoRef,
@ -23,14 +26,15 @@ import {
type VideoTrack, type VideoTrack,
type AudioTrack, type AudioTrack,
} from 'react-native-video'; } from 'react-native-video';
import {
toast, import {toast} from './Toast';
Seeker, import {Seeker} from './Seeker';
AudioTrackSelector, import {AudioTrackSelector} from './AudioTracksSelector';
TextTrackSelector, import {VideoTrackSelector} from './VideoTracksSelector';
VideoTrackSelector, import {TextTrackSelector} from './TextTracksSelector';
TopControl, import {TopControl} from './TopControl';
} from '../components'; import {ToggleControl} from './ToggleControl';
import {MultiValueControl} from './MultiValueControl';
type Props = { type Props = {
channelDown: () => void; channelDown: () => void;
@ -149,27 +153,20 @@ const _Overlay = forwardRef<VideoRef, Props>((props, ref) => {
setShowNotificationControls(prev => !prev); setShowNotificationControls(prev => !prev);
}; };
const onSelectedAudioTrackChange = (itemValue: string) => { const onSelectedAudioTrackChange = (itemValue: string | number) => {
console.log('on audio value change ' + itemValue); console.log('on audio value change ' + itemValue);
if (itemValue === 'none') { if (itemValue === 'none') {
setSelectedAudioTrack({ setSelectedAudioTrack({
type: SelectedTrackType.DISABLED, type: SelectedTrackType.DISABLED,
}); });
} else { } else {
setSelectedAudioTrack({ setSelectedAudioTrack({type: audioTracksSelectionBy, value: itemValue});
type: SelectedTrackType.INDEX,
value: itemValue,
});
} }
}; };
const onSelectedTextTrackChange = (itemValue: string) => { const onSelectedTextTrackChange = (itemValue: string) => {
console.log('on value change ' + itemValue); console.log('on value change ' + itemValue);
const type = setSelectedTextTrack({type: textTracksSelectionBy, value: itemValue});
textTracksSelectionBy === 'index'
? SelectedTrackType.INDEX
: SelectedTrackType.LANGUAGE;
setSelectedTextTrack({type, value: itemValue});
}; };
const onSelectedVideoTrackChange = (itemValue: string) => { const onSelectedVideoTrackChange = (itemValue: string) => {
@ -329,6 +326,7 @@ const _Overlay = forwardRef<VideoRef, Props>((props, ref) => {
audioTracks={audioTracks} audioTracks={audioTracks}
selectedAudioTrack={selectedAudioTrack} selectedAudioTrack={selectedAudioTrack}
onValueChange={onSelectedAudioTrackChange} onValueChange={onSelectedAudioTrackChange}
audioTracksSelectionBy={audioTracksSelectionBy}
/> />
<TextTrackSelector <TextTrackSelector
textTracks={textTracks} textTracks={textTracks}

View File

@ -1,6 +1,10 @@
import {Picker} from '@react-native-picker/picker'; import {Picker} from '@react-native-picker/picker';
import {Text} from 'react-native'; import {Text} from 'react-native';
import type {TextTrack, SelectedTrack} from 'react-native-video'; import {
type TextTrack,
type SelectedTrack,
SelectedTrackType,
} from 'react-native-video';
import styles from '../styles'; import styles from '../styles';
import React from 'react'; import React from 'react';
@ -38,23 +42,15 @@ export const TextTrackSelector = ({
if (!track) { if (!track) {
return; return;
} }
if (textTracksSelectionBy === 'index') { let value;
return ( if (textTracksSelectionBy === SelectedTrackType.INDEX) {
<Picker.Item value = track.index;
label={`${track.index}`} } else if (textTracksSelectionBy === SelectedTrackType.LANGUAGE) {
value={track.index} value = track.language;
key={track.index} } else if (textTracksSelectionBy === SelectedTrackType.TITLE) {
/> value = track.title;
);
} else {
return (
<Picker.Item
label={track.language}
value={track.language}
key={track.language}
/>
);
} }
return <Picker.Item label={`${value}`} value={value} key={value} />;
})} })}
</Picker> </Picker>
</> </>

View File

@ -25,7 +25,7 @@ interface ToggleControlType {
onPress: () => void; onPress: () => void;
} }
const ToggleControl = ({ export const ToggleControl = ({
isSelected, isSelected,
selectedText, selectedText,
unselectedText, unselectedText,

View File

@ -7,3 +7,5 @@ export * from './TextTracksSelector';
export * from './Overlay'; export * from './Overlay';
export * from './TopControl'; export * from './TopControl';
export * from './Toast'; export * from './Toast';
export * from './ToggleControl';
export * from './MultiValueControl';

View File

@ -2,13 +2,19 @@ import {
BufferConfig, BufferConfig,
DRMType, DRMType,
ISO639_1, ISO639_1,
SelectedTrackType,
TextTrackType, TextTrackType,
} from 'react-native-video'; } from 'react-native-video';
import {SampleVideoSource} from '../types'; import {SampleVideoSource} from '../types';
import {localeVideo} from '../assets'; import {localeVideo} from '../assets';
import {Platform} from 'react-native'; import {Platform} from 'react-native';
export const textTracksSelectionBy = 'index'; // This constant allows to change how the sample behaves regarding to audio and texts selection.
// You can change it to change how selector will use tracks information.
// by default, index will be displayed and index will be applied to selected tracks.
// You can also use LANGUAGE or TITLE
export const textTracksSelectionBy = SelectedTrackType.INDEX;
export const audioTracksSelectionBy = SelectedTrackType.INDEX;
export const isIos = Platform.OS === 'ios'; export const isIos = Platform.OS === 'ios';
@ -25,6 +31,10 @@ export const srcAllPlatformList = [
cropStart: 3000, cropStart: 3000,
cropEnd: 10000, cropEnd: 10000,
}, },
{
description: 'video with 90° rotation',
uri: 'https://bn-dev.fra1.digitaloceanspaces.com/km-tournament/uploads/rn_image_picker_lib_temp_2ee86a27_9312_4548_84af_7fd75d9ad4dd_ad8b20587a.mp4',
},
{ {
description: 'local file portrait', description: 'local file portrait',
uri: localeVideo.portrait, uri: localeVideo.portrait,
@ -68,6 +78,14 @@ export const srcAllPlatformList = [
description: 'another bunny (can be saved)', description: 'another bunny (can be saved)',
uri: 'https://rawgit.com/mediaelement/mediaelement-files/master/big_buck_bunny.mp4', uri: 'https://rawgit.com/mediaelement/mediaelement-files/master/big_buck_bunny.mp4',
headers: {referer: 'www.github.com', 'User-Agent': 'react.native.video'}, headers: {referer: 'www.github.com', 'User-Agent': 'react.native.video'},
metadata: {
title: 'Custom Title',
subtitle: 'Custom Subtitle',
artist: 'Custom Artist',
description: 'Custom Description',
imageUri:
'https://pbs.twimg.com/profile_images/1498641868397191170/6qW2XkuI_400x400.png',
},
}, },
{ {
description: 'sintel with subtitles', description: 'sintel with subtitles',
@ -78,6 +96,11 @@ export const srcAllPlatformList = [
uri: 'https://bitmovin-a.akamaihd.net/content/sintel/hls/playlist.m3u8', uri: 'https://bitmovin-a.akamaihd.net/content/sintel/hls/playlist.m3u8',
startPosition: 50000, startPosition: 50000,
}, },
{
description: 'mp3 with texttrack',
uri: 'https://traffic.libsyn.com/democracynow/wx2024-0702_SOT_DeadCalm-LucileSmith-FULL-V2.mxf-audio.mp3', // an mp3 file
textTracks: [], // empty text track list
},
{ {
description: 'BigBugBunny sideLoaded subtitles', description: 'BigBugBunny sideLoaded subtitles',
// sideloaded subtitles wont work for streaming like HLS on ios // sideloaded subtitles wont work for streaming like HLS on ios
@ -92,11 +115,19 @@ export const srcAllPlatformList = [
}, },
], ],
}, },
{
description: '(mp4) big buck bunny With Ads',
ad: {
adTagUrl:
'https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpremidpostoptimizedpodbumper&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&cmsid=496&vid=short_onecue&correlator=',
},
uri: 'https://d23dyxeqlo5psv.cloudfront.net/big_buck_bunny.mp4',
},
]; ];
export const srcIosList = []; export const srcIosList: SampleVideoSource[] = [];
export const srcAndroidList = [ export const srcAndroidList: SampleVideoSource[] = [
{ {
description: 'Another live sample', description: 'Another live sample',
uri: 'https://live.forstreet.cl/live/livestream.m3u8', uri: 'https://live.forstreet.cl/live/livestream.m3u8',
@ -118,12 +149,6 @@ export const srcAndroidList = [
uri: 'http://www.youtube.com/api/manifest/dash/id/bf5bb2419360daf1/source/youtube?as=fmp4_audio_clear,fmp4_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=51AF5F39AB0CEC3E5497CD9C900EBFEAECCCB5C7.8506521BFC350652163895D4C26DEE124209AA9E&key=ik0', uri: 'http://www.youtube.com/api/manifest/dash/id/bf5bb2419360daf1/source/youtube?as=fmp4_audio_clear,fmp4_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=51AF5F39AB0CEC3E5497CD9C900EBFEAECCCB5C7.8506521BFC350652163895D4C26DEE124209AA9E&key=ik0',
type: 'mpd', type: 'mpd',
}, },
{
description: '(mp4) big buck bunny With Ads',
adTagUrl:
'https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpremidpostoptimizedpodbumper&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&cmsid=496&vid=short_onecue&correlator=',
uri: 'http://d23dyxeqlo5psv.cloudfront.net/big_buck_bunny.mp4',
},
{ {
description: 'WV: Secure SD & HD (cbcs,MP4,H264)', description: 'WV: Secure SD & HD (cbcs,MP4,H264)',
uri: 'https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs.mpd', uri: 'https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs.mpd',
@ -149,9 +174,12 @@ export const srcAndroidList = [
}, },
]; ];
export const srcList: SampleVideoSource[] = srcAllPlatformList.concat( const platformSrc: SampleVideoSource[] = isAndroid
isAndroid ? srcAndroidList : srcIosList, ? srcAndroidList
); : srcIosList;
export const srcList: SampleVideoSource[] =
platformSrc.concat(srcAllPlatformList);
export const bufferConfig: BufferConfig = { export const bufferConfig: BufferConfig = {
minBufferMs: 15000, minBufferMs: 15000,

View File

@ -1,5 +1,4 @@
import {AppRegistry} from 'react-native'; import {registerRootComponent} from 'expo';
import VideoPlayer from './VideoPlayer'; import VideoPlayer from './VideoPlayer';
import {name as appName} from '../app.json';
AppRegistry.registerComponent(appName, () => VideoPlayer); registerRootComponent(VideoPlayer);

View File

@ -1,11 +1,11 @@
import {Drm, ReactVideoSource, TextTracks} from 'react-native-video'; import {Drm, ReactVideoSource, TextTracks} from 'react-native-video';
export type AdditionalSourceInfo = { export type AdditionalSourceInfo = {
textTracks: TextTracks; textTracks?: TextTracks;
adTagUrl: string; adTagUrl?: string;
description: string; description?: string;
drm: Drm; drm?: Drm;
noView: boolean; noView?: boolean;
}; };
export type SampleVideoSource = ReactVideoSource | AdditionalSourceInfo; export type SampleVideoSource = ReactVideoSource | AdditionalSourceInfo;

File diff suppressed because it is too large Load Diff

View File

@ -316,11 +316,11 @@ PODS:
- React-jsinspector (0.71.12-0) - React-jsinspector (0.71.12-0)
- React-logger (0.71.12-0): - React-logger (0.71.12-0):
- glog - glog
- react-native-video (6.0.0): - react-native-video (6.6.2):
- RCT-Folly (= 2021.07.22.00) - RCT-Folly (= 2021.07.22.00)
- React-Core - React-Core
- react-native-video/Video (= 6.0.0) - react-native-video/Video (= 6.6.2)
- react-native-video/Video (6.0.0): - react-native-video/Video (6.6.2):
- RCT-Folly (= 2021.07.22.00) - RCT-Folly (= 2021.07.22.00)
- React-Core - React-Core
- React-perflogger (0.71.12-0) - React-perflogger (0.71.12-0)
@ -592,7 +592,7 @@ SPEC CHECKSUMS:
React-jsiexecutor: 0c8c5e8b2171be52295f59097923babf84d1cf66 React-jsiexecutor: 0c8c5e8b2171be52295f59097923babf84d1cf66
React-jsinspector: f8e6919523047a9bd1270ade75b4eca0108963b4 React-jsinspector: f8e6919523047a9bd1270ade75b4eca0108963b4
React-logger: 16c56636d4209cc204d06c5ba347cee21b960012 React-logger: 16c56636d4209cc204d06c5ba347cee21b960012
react-native-video: fc60911540a69935cc7950829163f6f41259cb0d react-native-video: 5d1e10262d6986e1ce911634a3b8d8f32f2dd97e
React-perflogger: 355109dc9d6f34e35bc35dabb32310f8ed2d29a2 React-perflogger: 355109dc9d6f34e35bc35dabb32310f8ed2d29a2
React-RCTActionSheet: 9d1be4d43972f2aae4b31d9e53ffb030115fa445 React-RCTActionSheet: 9d1be4d43972f2aae4b31d9e53ffb030115fa445
React-RCTAnimation: aab7e1ecd325db67e1f2a947d85a52adf86594b7 React-RCTAnimation: aab7e1ecd325db67e1f2a947d85a52adf86594b7
@ -609,6 +609,6 @@ SPEC CHECKSUMS:
Yoga: 8b8c06e142662150974d1c70b4c5ffb08eb468db Yoga: 8b8c06e142662150974d1c70b4c5ffb08eb468db
YogaKit: 1e22bf2228b3a5ac8cc88965153061ae92c494b5 YogaKit: 1e22bf2228b3a5ac8cc88965153061ae92c494b5
PODFILE CHECKSUM: 26d254806a611a4bc6b6c39cff790dd08f770ccf PODFILE CHECKSUM: e20830ba1d59fa52a9075c08861e37e5f2ac113c
COCOAPODS: 1.15.2 COCOAPODS: 1.15.2

View File

@ -48,7 +48,7 @@ class VideoPluginSample: NSObject, RNVPlugin {
* custom functions to be able to track AVPlayer state change * custom functions to be able to track AVPlayer state change
*/ */
func handlePlaybackRateChange(player: AVPlayer, change: NSKeyValueObservedChange<Float>) { func handlePlaybackRateChange(player: AVPlayer, change: NSKeyValueObservedChange<Float>) {
NSLog("plugin: handlePlaybackRateChange \(change.oldValue)") NSLog("plugin: handlePlaybackRateChange \(String(describing: change.oldValue))")
} }
func handlePlayerItemStatusChange(playerItem: AVPlayerItem, change _: NSKeyValueObservedChange<AVPlayerItem.Status>) { func handlePlayerItemStatusChange(playerItem: AVPlayerItem, change _: NSKeyValueObservedChange<AVPlayerItem.Status>) {
@ -56,7 +56,7 @@ class VideoPluginSample: NSObject, RNVPlugin {
} }
func handleCurrentItemChange(player: AVPlayer, change: NSKeyValueObservedChange<AVPlayerItem?>) { func handleCurrentItemChange(player: AVPlayer, change: NSKeyValueObservedChange<AVPlayerItem?>) {
NSLog("plugin: handleCurrentItemChange \(player.currentItem)") NSLog("plugin: handleCurrentItemChange \(String(describing: player.currentItem))")
guard let playerItem = player.currentItem else { guard let playerItem = player.currentItem else {
_playerItemStatusObserver?.invalidate() _playerItemStatusObserver?.invalidate()
return return

View File

@ -0,0 +1,18 @@
struct AdParams {
let adTagUrl: String?
let adLanguage: String?
let json: NSDictionary?
init(_ json: NSDictionary!) {
guard json != nil else {
self.json = nil
adTagUrl = nil
adLanguage = nil
return
}
self.json = json
adTagUrl = json["adTagUrl"] as? String
adLanguage = json["adLanguage"] as? String
}
}

View File

@ -5,6 +5,7 @@ struct DRMParams {
let contentId: String? let contentId: String?
let certificateUrl: String? let certificateUrl: String?
let base64Certificate: Bool? let base64Certificate: Bool?
let localSourceEncryptionKeyScheme: String?
let json: NSDictionary? let json: NSDictionary?
@ -17,6 +18,7 @@ struct DRMParams {
self.certificateUrl = nil self.certificateUrl = nil
self.base64Certificate = nil self.base64Certificate = nil
self.headers = nil self.headers = nil
self.localSourceEncryptionKeyScheme = nil
return return
} }
self.json = json self.json = json
@ -36,5 +38,6 @@ struct DRMParams {
} else { } else {
self.headers = nil self.headers = nil
} }
localSourceEncryptionKeyScheme = json["localSourceEncryptionKeyScheme"] as? String
} }
} }

View File

@ -15,4 +15,8 @@ struct SelectedTrackCriteria {
self.type = json["type"] as? String ?? "" self.type = json["type"] as? String ?? ""
self.value = json["value"] as? String self.value = json["value"] as? String
} }
static func none() -> SelectedTrackCriteria {
return SelectedTrackCriteria(["type": "none", "value": ""])
}
} }

View File

@ -10,7 +10,9 @@ struct VideoSource {
let cropEnd: Int64? let cropEnd: Int64?
let customMetadata: CustomMetadata? let customMetadata: CustomMetadata?
/* DRM */ /* DRM */
let drm: DRMParams? let drm: DRMParams
var textTracks: [TextTrack] = []
let adParams: AdParams
let json: NSDictionary? let json: NSDictionary?
@ -27,7 +29,8 @@ struct VideoSource {
self.cropStart = nil self.cropStart = nil
self.cropEnd = nil self.cropEnd = nil
self.customMetadata = nil self.customMetadata = nil
self.drm = nil self.drm = DRMParams(nil)
adParams = AdParams(nil)
return return
} }
self.json = json self.json = json
@ -52,5 +55,9 @@ struct VideoSource {
self.cropEnd = (json["cropEnd"] as? Float64).flatMap { Int64(round($0)) } self.cropEnd = (json["cropEnd"] as? Float64).flatMap { Int64(round($0)) }
self.customMetadata = CustomMetadata(json["metadata"] as? NSDictionary) self.customMetadata = CustomMetadata(json["metadata"] as? NSDictionary)
self.drm = DRMParams(json["drm"] as? NSDictionary) self.drm = DRMParams(json["drm"] as? NSDictionary)
self.textTracks = (json["textTracks"] as? NSArray)?.map { trackDict in
return TextTrack(trackDict as? NSDictionary)
} ?? []
adParams = AdParams(json["ad"] as? NSDictionary)
} }
} }

View File

@ -0,0 +1,41 @@
//
// DRMManager+AVContentKeySessionDelegate.swift
// react-native-video
//
// Created by Krzysztof Moch on 14/08/2024.
//
import AVFoundation
extension DRMManager: AVContentKeySessionDelegate {
func contentKeySession(_: AVContentKeySession, didProvide keyRequest: AVContentKeyRequest) {
handleContentKeyRequest(keyRequest: keyRequest)
}
func contentKeySession(_: AVContentKeySession, didProvideRenewingContentKeyRequest keyRequest: AVContentKeyRequest) {
handleContentKeyRequest(keyRequest: keyRequest)
}
func contentKeySession(_: AVContentKeySession, shouldRetry _: AVContentKeyRequest, reason retryReason: AVContentKeyRequest.RetryReason) -> Bool {
let retryReasons: [AVContentKeyRequest.RetryReason] = [
.timedOut,
.receivedResponseWithExpiredLease,
.receivedObsoleteContentKey,
]
return retryReasons.contains(retryReason)
}
func contentKeySession(_: AVContentKeySession, didProvide keyRequest: AVPersistableContentKeyRequest) {
Task {
do {
try await handlePersistableKeyRequest(keyRequest: keyRequest)
} catch {
handleError(error, for: keyRequest)
}
}
}
func contentKeySession(_: AVContentKeySession, contentKeyRequest _: AVContentKeyRequest, didFailWithError error: Error) {
DebugLog(String(describing: error))
}
}

View File

@ -0,0 +1,68 @@
//
// DRMManager+OnGetLicense.swift
// react-native-video
//
// Created by Krzysztof Moch on 14/08/2024.
//
import AVFoundation
extension DRMManager {
func requestLicenseFromJS(spcData: Data, assetId: String, keyRequest: AVContentKeyRequest) async throws {
guard let onGetLicense else {
throw RCTVideoError.noDataFromLicenseRequest
}
guard let licenseServerUrl = drmParams?.licenseServer, !licenseServerUrl.isEmpty else {
throw RCTVideoError.noLicenseServerURL
}
guard let loadedLicenseUrl = keyRequest.identifier as? String else {
throw RCTVideoError.invalidContentId
}
pendingLicenses[loadedLicenseUrl] = keyRequest
DispatchQueue.main.async { [weak self] in
onGetLicense([
"licenseUrl": licenseServerUrl,
"loadedLicenseUrl": loadedLicenseUrl,
"contentId": assetId,
"spcBase64": spcData.base64EncodedString(),
"target": self?.reactTag as Any,
])
}
}
func setJSLicenseResult(license: String, licenseUrl: String) {
guard let keyContentRequest = pendingLicenses[licenseUrl] else {
setJSLicenseError(error: "Loading request for licenseUrl \(licenseUrl) not found", licenseUrl: licenseUrl)
return
}
guard let responseData = Data(base64Encoded: license) else {
setJSLicenseError(error: "Invalid license data", licenseUrl: licenseUrl)
return
}
do {
try finishProcessingContentKeyRequest(keyRequest: keyContentRequest, license: responseData)
pendingLicenses.removeValue(forKey: licenseUrl)
} catch {
handleError(error, for: keyContentRequest)
}
}
func setJSLicenseError(error: String, licenseUrl: String) {
let rctError = RCTVideoError.fromJSPart(error)
DispatchQueue.main.async { [weak self] in
self?.onVideoError?([
"error": RCTVideoErrorHandler.createError(from: rctError),
"target": self?.reactTag as Any,
])
}
pendingLicenses.removeValue(forKey: licenseUrl)
}
}

View File

@ -0,0 +1,34 @@
//
// DRMManager+Persitable.swift
// react-native-video
//
// Created by Krzysztof Moch on 19/08/2024.
//
import AVFoundation
extension DRMManager {
func handlePersistableKeyRequest(keyRequest: AVPersistableContentKeyRequest) async throws {
if let localSourceEncryptionKeyScheme = drmParams?.localSourceEncryptionKeyScheme {
try handleEmbeddedKey(keyRequest: keyRequest, scheme: localSourceEncryptionKeyScheme)
} else {
// Offline DRM is not supported yet - if you need it please check out the following issue:
// https://github.com/TheWidlarzGroup/react-native-video/issues/3539
throw RCTVideoError.offlineDRMNotSupported
}
}
private func handleEmbeddedKey(keyRequest: AVPersistableContentKeyRequest, scheme: String) throws {
guard let uri = keyRequest.identifier as? String,
let url = URL(string: uri) else {
throw RCTVideoError.invalidContentId
}
guard let persistentKeyData = RCTVideoUtils.extractDataFromCustomSchemeUrl(from: url, scheme: scheme) else {
throw RCTVideoError.embeddedKeyExtractionFailed
}
let persistentKey = try keyRequest.persistableContentKey(fromKeyVendorResponse: persistentKeyData)
try finishProcessingContentKeyRequest(keyRequest: keyRequest, license: persistentKey)
}
}

View File

@ -0,0 +1,213 @@
//
// DRMManager.swift
// react-native-video
//
// Created by Krzysztof Moch on 13/08/2024.
//
import AVFoundation
class DRMManager: NSObject {
static let queue = DispatchQueue(label: "RNVideoContentKeyDelegateQueue")
let contentKeySession: AVContentKeySession?
var drmParams: DRMParams?
var reactTag: NSNumber?
var onVideoError: RCTDirectEventBlock?
var onGetLicense: RCTDirectEventBlock?
// Licenses handled by onGetLicense (from JS side)
var pendingLicenses: [String: AVContentKeyRequest] = [:]
override init() {
#if targetEnvironment(simulator)
contentKeySession = nil
super.init()
#else
contentKeySession = AVContentKeySession(keySystem: .fairPlayStreaming)
super.init()
contentKeySession?.setDelegate(self, queue: DRMManager.queue)
#endif
}
func createContentKeyRequest(
asset: AVContentKeyRecipient,
drmParams: DRMParams?,
reactTag: NSNumber?,
onVideoError: RCTDirectEventBlock?,
onGetLicense: RCTDirectEventBlock?
) {
self.reactTag = reactTag
self.onVideoError = onVideoError
self.onGetLicense = onGetLicense
self.drmParams = drmParams
if drmParams?.type != "fairplay" {
self.onVideoError?([
"error": RCTVideoErrorHandler.createError(from: RCTVideoError.unsupportedDRMType),
"target": self.reactTag as Any,
])
return
}
#if targetEnvironment(simulator)
DebugLog("Simulator is not supported for FairPlay DRM.")
self.onVideoError?([
"error": RCTVideoErrorHandler.createError(from: RCTVideoError.simulatorDRMNotSupported),
"target": self.reactTag as Any,
])
#endif
contentKeySession?.addContentKeyRecipient(asset)
}
// MARK: - Internal
func handleContentKeyRequest(keyRequest: AVContentKeyRequest) {
Task {
do {
if drmParams?.localSourceEncryptionKeyScheme != nil {
#if os(iOS)
try keyRequest.respondByRequestingPersistableContentKeyRequestAndReturnError()
return
#else
throw RCTVideoError.offlineDRMNotSupported
#endif
}
try await processContentKeyRequest(keyRequest: keyRequest)
} catch {
handleError(error, for: keyRequest)
}
}
}
func finishProcessingContentKeyRequest(keyRequest: AVContentKeyRequest, license: Data) throws {
let keyResponse = AVContentKeyResponse(fairPlayStreamingKeyResponseData: license)
keyRequest.processContentKeyResponse(keyResponse)
}
func handleError(_ error: Error, for keyRequest: AVContentKeyRequest) {
let rctError: RCTVideoError
if let videoError = error as? RCTVideoError {
// handle RCTVideoError errors
rctError = videoError
DispatchQueue.main.async { [weak self] in
self?.onVideoError?([
"error": RCTVideoErrorHandler.createError(from: rctError),
"target": self?.reactTag as Any,
])
}
} else {
let err = error as NSError
// handle Other errors
DispatchQueue.main.async { [weak self] in
self?.onVideoError?([
"error": [
"code": err.code,
"localizedDescription": err.localizedDescription,
"localizedFailureReason": err.localizedFailureReason ?? "",
"localizedRecoverySuggestion": err.localizedRecoverySuggestion ?? "",
"domain": err.domain,
],
"target": self?.reactTag as Any,
])
}
}
keyRequest.processContentKeyResponseError(error)
contentKeySession?.expire()
}
// MARK: - Private
private func processContentKeyRequest(keyRequest: AVContentKeyRequest) async throws {
guard let assetId = getAssetId(keyRequest: keyRequest),
let assetIdData = assetId.data(using: .utf8) else {
throw RCTVideoError.invalidContentId
}
let appCertificate = try await requestApplicationCertificate()
let spcData = try await keyRequest.makeStreamingContentKeyRequestData(forApp: appCertificate, contentIdentifier: assetIdData)
if onGetLicense != nil {
try await requestLicenseFromJS(spcData: spcData, assetId: assetId, keyRequest: keyRequest)
} else {
let license = try await requestLicense(spcData: spcData)
try finishProcessingContentKeyRequest(keyRequest: keyRequest, license: license)
}
}
private func requestApplicationCertificate() async throws -> Data {
guard let urlString = drmParams?.certificateUrl,
let url = URL(string: urlString) else {
throw RCTVideoError.noCertificateURL
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw RCTVideoError.noCertificateData
}
if drmParams?.base64Certificate == true {
guard let certData = Data(base64Encoded: data) else {
throw RCTVideoError.noCertificateData
}
return certData
}
return data
}
private func requestLicense(spcData: Data) async throws -> Data {
guard let licenseServerUrlString = drmParams?.licenseServer,
let licenseServerUrl = URL(string: licenseServerUrlString) else {
throw RCTVideoError.noLicenseServerURL
}
var request = URLRequest(url: licenseServerUrl)
request.httpMethod = "POST"
request.httpBody = spcData
if let headers = drmParams?.headers {
for (key, value) in headers {
if let stringValue = value as? String {
request.setValue(stringValue, forHTTPHeaderField: key)
}
}
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw RCTVideoError.licenseRequestFailed(0)
}
guard httpResponse.statusCode == 200 else {
throw RCTVideoError.licenseRequestFailed(httpResponse.statusCode)
}
guard !data.isEmpty else {
throw RCTVideoError.noDataFromLicenseRequest
}
return data
}
private func getAssetId(keyRequest: AVContentKeyRequest) -> String? {
if let assetId = drmParams?.contentId {
return assetId
}
if let url = keyRequest.identifier as? String {
return url.replacingOccurrences(of: "skd://", with: "")
}
return nil
}
}

View File

@ -19,7 +19,12 @@
} }
func setUpAdsLoader() { func setUpAdsLoader() {
adsLoader = IMAAdsLoader(settings: nil) guard let _video else { return }
let settings = IMASettings()
if let adLanguage = _video.getAdLanguage() {
settings.language = adLanguage
}
adsLoader = IMAAdsLoader(settings: settings)
adsLoader.delegate = self adsLoader.delegate = self
} }

View File

@ -234,10 +234,9 @@ class RCTPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVPla
/* Cancels the previously registered time observer. */ /* Cancels the previously registered time observer. */
func removePlayerTimeObserver() { func removePlayerTimeObserver() {
if _timeObserver != nil { guard let timeObserver = _timeObserver else { return }
player?.removeTimeObserver(_timeObserver) player?.removeTimeObserver(timeObserver)
_timeObserver = nil _timeObserver = nil
}
} }
func addTimeObserverIfNotSet() { func addTimeObserverIfNotSet() {
@ -284,11 +283,11 @@ class RCTPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVPla
name: NSNotification.Name.AVPlayerItemFailedToPlayToEndTime, name: NSNotification.Name.AVPlayerItemFailedToPlayToEndTime,
object: nil) object: nil)
NotificationCenter.default.removeObserver(_handlers, name: NSNotification.Name.AVPlayerItemNewAccessLogEntry, object: player?.currentItem) NotificationCenter.default.removeObserver(_handlers, name: AVPlayerItem.newAccessLogEntryNotification, object: player?.currentItem)
NotificationCenter.default.addObserver(_handlers, NotificationCenter.default.addObserver(_handlers,
selector: #selector(RCTPlayerObserverHandlerObjc.handleAVPlayerAccess(notification:)), selector: #selector(RCTPlayerObserverHandlerObjc.handleAVPlayerAccess(notification:)),
name: NSNotification.Name.AVPlayerItemNewAccessLogEntry, name: AVPlayerItem.newAccessLogEntryNotification,
object: player?.currentItem) object: player?.currentItem)
} }

View File

@ -15,11 +15,15 @@ enum RCTPlayerOperations {
let trackCount: Int! = player?.currentItem?.tracks.count ?? 0 let trackCount: Int! = player?.currentItem?.tracks.count ?? 0
// The first few tracks will be audio & video track // The first few tracks will be audio & video track
var firstTextIndex = 0 var firstTextIndex = -1
for i in 0 ..< trackCount where player?.currentItem?.tracks[i].assetTrack?.hasMediaCharacteristic(.legible) ?? false { for i in 0 ..< trackCount where player?.currentItem?.tracks[i].assetTrack?.hasMediaCharacteristic(.legible) ?? false {
firstTextIndex = i firstTextIndex = i
break break
} }
if firstTextIndex == -1 {
// no sideLoaded text track available (can happen with invalid vtt url)
return
}
var selectedTrackIndex: Int = RCTVideoUnset var selectedTrackIndex: Int = RCTVideoUnset

View File

@ -1,186 +0,0 @@
import AVFoundation
class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSessionDelegate {
private var _loadingRequests: [String: AVAssetResourceLoadingRequest?] = [:]
private var _requestingCertificate = false
private var _requestingCertificateErrored = false
private var _drm: DRMParams?
private var _localSourceEncryptionKeyScheme: String?
private var _reactTag: NSNumber?
private var _onVideoError: RCTDirectEventBlock?
private var _onGetLicense: RCTDirectEventBlock?
init(
asset: AVURLAsset,
drm: DRMParams?,
localSourceEncryptionKeyScheme: String?,
onVideoError: RCTDirectEventBlock?,
onGetLicense: RCTDirectEventBlock?,
reactTag: NSNumber
) {
super.init()
let queue = DispatchQueue(label: "assetQueue")
asset.resourceLoader.setDelegate(self, queue: queue)
_reactTag = reactTag
_onVideoError = onVideoError
_onGetLicense = onGetLicense
_drm = drm
_localSourceEncryptionKeyScheme = localSourceEncryptionKeyScheme
}
deinit {
for request in _loadingRequests.values {
request?.finishLoading()
}
}
func resourceLoader(_: AVAssetResourceLoader, shouldWaitForRenewalOfRequestedResource renewalRequest: AVAssetResourceRenewalRequest) -> Bool {
return loadingRequestHandling(renewalRequest)
}
func resourceLoader(_: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
return loadingRequestHandling(loadingRequest)
}
func resourceLoader(_: AVAssetResourceLoader, didCancel _: AVAssetResourceLoadingRequest) {
RCTLog("didCancelLoadingRequest")
}
func setLicenseResult(_ license: String!, _ licenseUrl: String!) {
// Check if the loading request exists in _loadingRequests based on licenseUrl
guard let loadingRequest = _loadingRequests[licenseUrl] else {
setLicenseResultError("Loading request for licenseUrl \(licenseUrl) not found", licenseUrl)
return
}
// Check if the license data is valid
guard let respondData = RCTVideoUtils.base64DataFromBase64String(base64String: license) else {
setLicenseResultError("No data from JS license response", licenseUrl)
return
}
let dataRequest: AVAssetResourceLoadingDataRequest! = loadingRequest?.dataRequest
dataRequest.respond(with: respondData)
loadingRequest!.finishLoading()
_loadingRequests.removeValue(forKey: licenseUrl)
}
func setLicenseResultError(_ error: String!, _ licenseUrl: String!) {
// Check if the loading request exists in _loadingRequests based on licenseUrl
guard let loadingRequest = _loadingRequests[licenseUrl] else {
print("Loading request for licenseUrl \(licenseUrl) not found. Error: \(error)")
return
}
self.finishLoadingWithError(error: RCTVideoErrorHandler.fromJSPart(error), licenseUrl: licenseUrl)
}
func finishLoadingWithError(error: Error!, licenseUrl: String!) -> Bool {
// Check if the loading request exists in _loadingRequests based on licenseUrl
guard let loadingRequest = _loadingRequests[licenseUrl], let error = error as NSError? else {
// Handle the case where the loading request is not found or error is nil
return false
}
loadingRequest!.finishLoading(with: error)
_loadingRequests.removeValue(forKey: licenseUrl)
_onVideoError?([
"error": [
"code": NSNumber(value: error.code),
"localizedDescription": error.localizedDescription ?? "",
"localizedFailureReason": error.localizedFailureReason ?? "",
"localizedRecoverySuggestion": error.localizedRecoverySuggestion ?? "",
"domain": error.domain,
],
"target": _reactTag,
])
return false
}
func loadingRequestHandling(_ loadingRequest: AVAssetResourceLoadingRequest!) -> Bool {
if handleEmbeddedKey(loadingRequest) {
return true
}
if _drm != nil {
return handleDrm(loadingRequest)
}
return false
}
func handleEmbeddedKey(_ loadingRequest: AVAssetResourceLoadingRequest!) -> Bool {
guard let url = loadingRequest.request.url,
let _localSourceEncryptionKeyScheme,
let persistentKeyData = RCTVideoUtils.extractDataFromCustomSchemeUrl(from: url, scheme: _localSourceEncryptionKeyScheme)
else {
return false
}
loadingRequest.contentInformationRequest?.contentType = AVStreamingKeyDeliveryPersistentContentKeyType
loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = true
loadingRequest.contentInformationRequest?.contentLength = Int64(persistentKeyData.count)
loadingRequest.dataRequest?.respond(with: persistentKeyData)
loadingRequest.finishLoading()
return true
}
func handleDrm(_ loadingRequest: AVAssetResourceLoadingRequest!) -> Bool {
if _requestingCertificate {
return true
} else if _requestingCertificateErrored {
return false
}
let requestKey: String = loadingRequest.request.url?.absoluteString ?? ""
_loadingRequests[requestKey] = loadingRequest
guard let _drm, let drmType = _drm.type, drmType == "fairplay" else {
return finishLoadingWithError(error: RCTVideoErrorHandler.noDRMData, licenseUrl: requestKey)
}
Task {
do {
if _onGetLicense != nil {
let contentId = _drm.contentId ?? loadingRequest.request.url?.host
let spcData = try await RCTVideoDRM.handleWithOnGetLicense(
loadingRequest: loadingRequest,
contentId: contentId,
certificateUrl: _drm.certificateUrl,
base64Certificate: _drm.base64Certificate
)
self._requestingCertificate = true
self._onGetLicense?(["licenseUrl": self._drm?.licenseServer ?? "",
"loadedLicenseUrl": loadingRequest.request.url?.absoluteString ?? "",
"contentId": contentId ?? "",
"spcBase64": spcData.base64EncodedString(options: []),
"target": self._reactTag])
} else {
let data = try await RCTVideoDRM.handleInternalGetLicense(
loadingRequest: loadingRequest,
contentId: _drm.contentId,
licenseServer: _drm.licenseServer,
certificateUrl: _drm.certificateUrl,
base64Certificate: _drm.base64Certificate,
headers: _drm.headers
)
guard let dataRequest = loadingRequest.dataRequest else {
throw RCTVideoErrorHandler.noCertificateData
}
dataRequest.respond(with: data)
loadingRequest.finishLoading()
}
} catch {
self.finishLoadingWithError(error: error, licenseUrl: requestKey)
self._requestingCertificateErrored = true
}
}
return true
}
}

View File

@ -1,161 +0,0 @@
import AVFoundation
enum RCTVideoDRM {
static func fetchLicense(
licenseServer: String,
spcData: Data?,
contentId: String,
headers: [String: Any]?
) async throws -> Data {
let request = createLicenseRequest(licenseServer: licenseServer, spcData: spcData, contentId: contentId, headers: headers)
let (data, response) = try await URLSession.shared.data(from: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw RCTVideoErrorHandler.noDataFromLicenseRequest
}
if httpResponse.statusCode != 200 {
print("Error getting license from \(licenseServer), HTTP status code \(httpResponse.statusCode)")
throw RCTVideoErrorHandler.licenseRequestNotOk(httpResponse.statusCode)
}
guard let decodedData = Data(base64Encoded: data, options: []) else {
throw RCTVideoErrorHandler.noDataFromLicenseRequest
}
return decodedData
}
static func createLicenseRequest(
licenseServer: String,
spcData: Data?,
contentId: String,
headers: [String: Any]?
) -> URLRequest {
var request = URLRequest(url: URL(string: licenseServer)!)
request.httpMethod = "POST"
if let headers {
for item in headers {
guard let key = item.key as? String, let value = item.value as? String else {
continue
}
request.setValue(value, forHTTPHeaderField: key)
}
}
let spcEncoded = spcData?.base64EncodedString(options: [])
let spcUrlEncoded = CFURLCreateStringByAddingPercentEscapes(
kCFAllocatorDefault,
spcEncoded as? CFString? as! CFString,
nil,
"?=&+" as CFString,
CFStringBuiltInEncodings.UTF8.rawValue
) as? String
let post = String(format: "spc=%@&%@", spcUrlEncoded as! CVarArg, contentId)
let postData = post.data(using: String.Encoding.utf8, allowLossyConversion: true)
request.httpBody = postData
return request
}
static func fetchSpcData(
loadingRequest: AVAssetResourceLoadingRequest,
certificateData: Data,
contentIdData: Data
) throws -> Data {
#if os(visionOS)
// TODO: DRM is not supported yet on visionOS. See #3467
throw NSError(domain: "DRM is not supported yet on visionOS", code: 0, userInfo: nil)
#else
guard let spcData = try? loadingRequest.streamingContentKeyRequestData(
forApp: certificateData,
contentIdentifier: contentIdData as Data,
options: nil
) else {
throw RCTVideoErrorHandler.noSPC
}
return spcData
#endif
}
static func createCertificateData(certificateStringUrl: String?, base64Certificate: Bool?) throws -> Data {
guard let certificateStringUrl,
let certificateURL = URL(string: certificateStringUrl.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) ?? "") else {
throw RCTVideoErrorHandler.noCertificateURL
}
var certificateData: Data?
do {
certificateData = try Data(contentsOf: certificateURL)
if base64Certificate != nil {
certificateData = Data(base64Encoded: certificateData! as Data, options: .ignoreUnknownCharacters)
}
} catch {}
guard let certificateData else {
throw RCTVideoErrorHandler.noCertificateData
}
return certificateData
}
static func handleWithOnGetLicense(loadingRequest: AVAssetResourceLoadingRequest, contentId: String?, certificateUrl: String?,
base64Certificate: Bool?) throws -> Data {
let contentIdData = contentId?.data(using: .utf8)
let certificateData = try? RCTVideoDRM.createCertificateData(certificateStringUrl: certificateUrl, base64Certificate: base64Certificate)
guard let contentIdData else {
throw RCTVideoError.invalidContentId as! Error
}
guard let certificateData else {
throw RCTVideoError.noCertificateData as! Error
}
return try RCTVideoDRM.fetchSpcData(
loadingRequest: loadingRequest,
certificateData: certificateData,
contentIdData: contentIdData
)
}
static func handleInternalGetLicense(
loadingRequest: AVAssetResourceLoadingRequest,
contentId: String?,
licenseServer: String?,
certificateUrl: String?,
base64Certificate: Bool?,
headers: [String: Any]?
) async throws -> Data {
let url = loadingRequest.request.url
let parsedContentId = contentId != nil && !contentId!.isEmpty ? contentId : nil
guard let contentId = parsedContentId ?? url?.absoluteString.replacingOccurrences(of: "skd://", with: "") else {
throw RCTVideoError.invalidContentId as! Error
}
let contentIdData = NSData(bytes: contentId.cString(using: String.Encoding.utf8), length: contentId.lengthOfBytes(using: String.Encoding.utf8)) as Data
let certificateData = try RCTVideoDRM.createCertificateData(certificateStringUrl: certificateUrl, base64Certificate: base64Certificate)
let spcData = try RCTVideoDRM.fetchSpcData(
loadingRequest: loadingRequest,
certificateData: certificateData,
contentIdData: contentIdData
)
guard let licenseServer else {
throw RCTVideoError.noLicenseServerURL as! Error
}
return try await RCTVideoDRM.fetchLicense(
licenseServer: licenseServer,
spcData: spcData,
contentId: contentId,
headers: headers
)
}
}

View File

@ -1,114 +1,188 @@
import Foundation
// MARK: - RCTVideoError // MARK: - RCTVideoError
enum RCTVideoError: Int { enum RCTVideoError: Error, Hashable {
case fromJSPart case fromJSPart(String)
case noLicenseServerURL case noLicenseServerURL
case licenseRequestNotOk case licenseRequestFailed(Int)
case noDataFromLicenseRequest case noDataFromLicenseRequest
case noSPC case noSPC
case noDataRequest
case noCertificateData case noCertificateData
case noCertificateURL case noCertificateURL
case noFairplayDRM
case noDRMData case noDRMData
case invalidContentId case invalidContentId
case invalidAppCert
case keyRequestCreationFailed
case persistableKeyRequestFailed
case embeddedKeyExtractionFailed
case offlineDRMNotSupported
case unsupportedDRMType
case simulatorDRMNotSupported
var errorCode: Int {
switch self {
case .fromJSPart:
return 1000
case .noLicenseServerURL:
return 1001
case .licenseRequestFailed:
return 1002
case .noDataFromLicenseRequest:
return 1003
case .noSPC:
return 1004
case .noCertificateData:
return 1005
case .noCertificateURL:
return 1006
case .noDRMData:
return 1007
case .invalidContentId:
return 1008
case .invalidAppCert:
return 1009
case .keyRequestCreationFailed:
return 1010
case .persistableKeyRequestFailed:
return 1011
case .embeddedKeyExtractionFailed:
return 1012
case .offlineDRMNotSupported:
return 1013
case .unsupportedDRMType:
return 1014
case .simulatorDRMNotSupported:
return 1015
}
}
}
// MARK: LocalizedError
extension RCTVideoError: LocalizedError {
var errorDescription: String? {
switch self {
case let .fromJSPart(error):
return NSLocalizedString("Error from JavaScript: \(error)", comment: "")
case .noLicenseServerURL:
return NSLocalizedString("No license server URL provided", comment: "")
case let .licenseRequestFailed(statusCode):
return NSLocalizedString("License request failed with status code: \(statusCode)", comment: "")
case .noDataFromLicenseRequest:
return NSLocalizedString("No data received from license server", comment: "")
case .noSPC:
return NSLocalizedString("Failed to create Server Playback Context (SPC)", comment: "")
case .noCertificateData:
return NSLocalizedString("No certificate data obtained", comment: "")
case .noCertificateURL:
return NSLocalizedString("No certificate URL provided", comment: "")
case .noDRMData:
return NSLocalizedString("No DRM data available", comment: "")
case .invalidContentId:
return NSLocalizedString("Invalid content ID", comment: "")
case .invalidAppCert:
return NSLocalizedString("Invalid application certificate", comment: "")
case .keyRequestCreationFailed:
return NSLocalizedString("Failed to create content key request", comment: "")
case .persistableKeyRequestFailed:
return NSLocalizedString("Failed to create persistable content key request", comment: "")
case .embeddedKeyExtractionFailed:
return NSLocalizedString("Failed to extract embedded key", comment: "")
case .offlineDRMNotSupported:
return NSLocalizedString("Offline DRM is not supported, see https://github.com/TheWidlarzGroup/react-native-video/issues/3539", comment: "")
case .unsupportedDRMType:
return NSLocalizedString("Unsupported DRM type", comment: "")
case .simulatorDRMNotSupported:
return NSLocalizedString("DRM on simulators is not supported", comment: "")
}
}
var failureReason: String? {
switch self {
case .fromJSPart:
return NSLocalizedString("An error occurred in the JavaScript part of the application.", comment: "")
case .noLicenseServerURL:
return NSLocalizedString("The license server URL is missing in the DRM configuration.", comment: "")
case .licenseRequestFailed:
return NSLocalizedString("The license server responded with an error status code.", comment: "")
case .noDataFromLicenseRequest:
return NSLocalizedString("The license server did not return any data.", comment: "")
case .noSPC:
return NSLocalizedString("Failed to generate the Server Playback Context (SPC) for the content.", comment: "")
case .noCertificateData:
return NSLocalizedString("Unable to retrieve certificate data from the specified URL.", comment: "")
case .noCertificateURL:
return NSLocalizedString("The certificate URL is missing in the DRM configuration.", comment: "")
case .noDRMData:
return NSLocalizedString("The required DRM data is not available or is invalid.", comment: "")
case .invalidContentId:
return NSLocalizedString("The content ID provided is not valid or recognized.", comment: "")
case .invalidAppCert:
return NSLocalizedString("The application certificate is invalid or not recognized.", comment: "")
case .keyRequestCreationFailed:
return NSLocalizedString("Unable to create a content key request for DRM.", comment: "")
case .persistableKeyRequestFailed:
return NSLocalizedString("Failed to create a persistable content key request for offline playback.", comment: "")
case .embeddedKeyExtractionFailed:
return NSLocalizedString("Unable to extract the embedded key from the custom scheme URL.", comment: "")
case .offlineDRMNotSupported:
return NSLocalizedString("You tried to use Offline DRM but it is not supported yet", comment: "")
case .unsupportedDRMType:
return NSLocalizedString("You tried to use unsupported DRM type", comment: "")
case .simulatorDRMNotSupported:
return NSLocalizedString("You tried to DRM on a simulator", comment: "")
}
}
var recoverySuggestion: String? {
switch self {
case .fromJSPart:
return NSLocalizedString("Check the JavaScript logs for more details and fix any issues in the JS code.", comment: "")
case .noLicenseServerURL:
return NSLocalizedString("Ensure that you have specified the 'licenseServer' property in the DRM configuration.", comment: "")
case .licenseRequestFailed:
return NSLocalizedString("Verify that the license server is functioning correctly and that you're sending the correct data.", comment: "")
case .noDataFromLicenseRequest:
return NSLocalizedString("Check if the license server is operational and responding with the expected data.", comment: "")
case .noSPC:
return NSLocalizedString("Verify that the content key request is properly configured and that the DRM setup is correct.", comment: "")
case .noCertificateData:
return NSLocalizedString("Check if the certificate URL is correct and accessible, and that it returns valid certificate data.", comment: "")
case .noCertificateURL:
return NSLocalizedString("Make sure you have specified the 'certificateUrl' property in the DRM configuration.", comment: "")
case .noDRMData:
return NSLocalizedString("Ensure that you have provided all necessary DRM-related data in the configuration.", comment: "")
case .invalidContentId:
return NSLocalizedString("Verify that the content ID is correct and matches the expected format for your DRM system.", comment: "")
case .invalidAppCert:
return NSLocalizedString("Check if the application certificate is valid and properly formatted for your DRM system.", comment: "")
case .keyRequestCreationFailed:
return NSLocalizedString("Review your DRM configuration and ensure all required parameters are correctly set.", comment: "")
case .persistableKeyRequestFailed:
return NSLocalizedString("Verify that offline playback is supported and properly configured for your content.", comment: "")
case .embeddedKeyExtractionFailed:
return NSLocalizedString("Check if the embedded key is present in the URL and the custom scheme is correctly implemented.", comment: "")
case .offlineDRMNotSupported:
return NSLocalizedString("Check if localSourceEncryptionKeyScheme is set", comment: "")
case .unsupportedDRMType:
return NSLocalizedString("Verify that you are using fairplay (on Apple devices)", comment: "")
case .simulatorDRMNotSupported:
return NSLocalizedString("You need to test DRM content on real device", comment: "")
}
}
} }
// MARK: - RCTVideoErrorHandler // MARK: - RCTVideoErrorHandler
enum RCTVideoErrorHandler { enum RCTVideoErrorHandler {
static let noDRMData = NSError( static func createError(from error: RCTVideoError) -> [String: Any] {
domain: "RCTVideo", return [
code: RCTVideoError.noDRMData.rawValue, "code": error.errorCode,
userInfo: [ "localizedDescription": error.localizedDescription,
NSLocalizedDescriptionKey: "Error obtaining DRM license.", "localizedFailureReason": error.failureReason ?? "",
NSLocalizedFailureReasonErrorKey: "No drm object found.", "localizedRecoverySuggestion": error.recoverySuggestion ?? "",
NSLocalizedRecoverySuggestionErrorKey: "Have you specified the 'drm' prop?", "domain": "RCTVideo",
] ]
)
static let noCertificateURL = NSError(
domain: "RCTVideo",
code: RCTVideoError.noCertificateURL.rawValue,
userInfo: [
NSLocalizedDescriptionKey: "Error obtaining DRM License.",
NSLocalizedFailureReasonErrorKey: "No certificate URL has been found.",
NSLocalizedRecoverySuggestionErrorKey: "Did you specified the prop certificateUrl?",
]
)
static let noCertificateData = NSError(
domain: "RCTVideo",
code: RCTVideoError.noCertificateData.rawValue,
userInfo: [
NSLocalizedDescriptionKey: "Error obtaining DRM license.",
NSLocalizedFailureReasonErrorKey: "No certificate data obtained from the specificied url.",
NSLocalizedRecoverySuggestionErrorKey: "Have you specified a valid 'certificateUrl'?",
]
)
static let noSPC = NSError(
domain: "RCTVideo",
code: RCTVideoError.noSPC.rawValue,
userInfo: [
NSLocalizedDescriptionKey: "Error obtaining license.",
NSLocalizedFailureReasonErrorKey: "No spc received.",
NSLocalizedRecoverySuggestionErrorKey: "Check your DRM config.",
]
)
static let noLicenseServerURL = NSError(
domain: "RCTVideo",
code: RCTVideoError.noLicenseServerURL.rawValue,
userInfo: [
NSLocalizedDescriptionKey: "Error obtaining DRM License.",
NSLocalizedFailureReasonErrorKey: "No license server URL has been found.",
NSLocalizedRecoverySuggestionErrorKey: "Did you specified the prop licenseServer?",
]
)
static let noDataFromLicenseRequest = NSError(
domain: "RCTVideo",
code: RCTVideoError.noDataFromLicenseRequest.rawValue,
userInfo: [
NSLocalizedDescriptionKey: "Error obtaining DRM license.",
NSLocalizedFailureReasonErrorKey: "No data received from the license server.",
NSLocalizedRecoverySuggestionErrorKey: "Is the licenseServer ok?",
]
)
static func licenseRequestNotOk(_ statusCode: Int) -> NSError {
return NSError(
domain: "RCTVideo",
code: RCTVideoError.licenseRequestNotOk.rawValue,
userInfo: [
NSLocalizedDescriptionKey: "Error obtaining license.",
NSLocalizedFailureReasonErrorKey: String(
format: "License server responded with status code %li",
statusCode
),
NSLocalizedRecoverySuggestionErrorKey: "Did you send the correct data to the license Server? Is the server ok?",
]
)
} }
static func fromJSPart(_ error: String) -> NSError {
return NSError(domain: "RCTVideo",
code: RCTVideoError.fromJSPart.rawValue,
userInfo: [
NSLocalizedDescriptionKey: error,
NSLocalizedFailureReasonErrorKey: error,
NSLocalizedRecoverySuggestionErrorKey: error,
])
}
static let invalidContentId = NSError(
domain: "RCTVideo",
code: RCTVideoError.invalidContentId.rawValue,
userInfo: [
NSLocalizedDescriptionKey: "Error obtaining DRM license.",
NSLocalizedFailureReasonErrorKey: "No valide content Id received",
NSLocalizedRecoverySuggestionErrorKey: "Is the contentId and url ok?",
]
)
} }

View File

@ -19,26 +19,32 @@ enum RCTVideoSave {
reject("ERROR_COULD_NOT_CREATE_EXPORT_SESSION", "Could not create export session", nil) reject("ERROR_COULD_NOT_CREATE_EXPORT_SESSION", "Could not create export session", nil)
return return
} }
var path: String!
path = RCTVideoSave.generatePathInDirectory( #if !os(visionOS)
directory: URL(fileURLWithPath: RCTVideoSave.cacheDirectoryPath() ?? "").appendingPathComponent("Videos").path, var path: String!
withExtension: ".mp4" path = RCTVideoSave.generatePathInDirectory(
) directory: URL(fileURLWithPath: RCTVideoSave.cacheDirectoryPath() ?? "").appendingPathComponent("Videos").path,
let url: NSURL! = NSURL.fileURL(withPath: path) as NSURL withExtension: ".mp4"
exportSession.outputFileType = AVFileType.mp4 )
exportSession.outputURL = url as URL? let url: NSURL! = NSURL.fileURL(withPath: path) as NSURL
exportSession.videoComposition = playerItem?.videoComposition exportSession.outputFileType = .mp4
exportSession.shouldOptimizeForNetworkUse = true exportSession.outputFileType = AVFileType.mp4
exportSession.exportAsynchronously(completionHandler: { exportSession.outputURL = url as URL?
switch exportSession.status { exportSession.videoComposition = playerItem?.videoComposition
case .failed: exportSession.shouldOptimizeForNetworkUse = true
reject("ERROR_COULD_NOT_EXPORT_VIDEO", "Could not export video", exportSession.error) exportSession.exportAsynchronously(completionHandler: {
case .cancelled: switch exportSession.status {
reject("ERROR_EXPORT_SESSION_CANCELLED", "Export session was cancelled", exportSession.error) case .failed:
default: reject("ERROR_COULD_NOT_EXPORT_VIDEO", "Could not export video", exportSession.error)
resolve(["uri": url.absoluteString]) case .cancelled:
} reject("ERROR_EXPORT_SESSION_CANCELLED", "Export session was cancelled", exportSession.error)
}) default:
resolve(["uri": url.absoluteString])
}
})
#else
reject("ERROR_EXPORT_SESSION_CANCELLED", "this function is not supported on visionOS", nil)
#endif
} }
static func generatePathInDirectory(directory: String?, withExtension extension: String?) -> String? { static func generatePathInDirectory(directory: String?, withExtension extension: String?) -> String? {
@ -54,13 +60,11 @@ enum RCTVideoSave {
static func ensureDirExists(withPath path: String?) -> Bool { static func ensureDirExists(withPath path: String?) -> Bool {
var isDir: ObjCBool = false var isDir: ObjCBool = false
var error: Error?
let exists = FileManager.default.fileExists(atPath: path ?? "", isDirectory: &isDir) let exists = FileManager.default.fileExists(atPath: path ?? "", isDirectory: &isDir)
if !(exists && isDir.boolValue) { if !(exists && isDir.boolValue) {
do { do {
try FileManager.default.createDirectory(atPath: path ?? "", withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(atPath: path ?? "", withIntermediateDirectories: true, attributes: nil)
} catch {} } catch {
if error != nil {
return false return false
} }
} }

View File

@ -9,7 +9,15 @@ enum RCTVideoAssetsUtils {
for mediaCharacteristic: AVMediaCharacteristic for mediaCharacteristic: AVMediaCharacteristic
) async -> AVMediaSelectionGroup? { ) async -> AVMediaSelectionGroup? {
if #available(iOS 15, tvOS 15, visionOS 1.0, *) { if #available(iOS 15, tvOS 15, visionOS 1.0, *) {
return try? await asset?.loadMediaSelectionGroup(for: mediaCharacteristic) do {
guard let asset else {
return nil
}
return try await asset.loadMediaSelectionGroup(for: mediaCharacteristic)
} catch {
return nil
}
} else { } else {
#if !os(visionOS) #if !os(visionOS)
return asset?.mediaSelectionGroup(forMediaCharacteristic: mediaCharacteristic) return asset?.mediaSelectionGroup(forMediaCharacteristic: mediaCharacteristic)
@ -73,22 +81,25 @@ enum RCTVideoUtils {
return 0 return 0
} }
static func urlFilePath(filepath: NSString!, searchPath: FileManager.SearchPathDirectory) -> NSURL! { static func urlFilePath(filepath: NSString?, searchPath: FileManager.SearchPathDirectory) -> NSURL! {
if filepath.contains("file://") { guard let _filepath = filepath else { return nil }
return NSURL(string: filepath as String)
if _filepath.contains("file://") {
return NSURL(string: _filepath as String)
} }
// if no file found, check if the file exists in the Document directory // if no file found, check if the file exists in the Document directory
let paths: [String]! = NSSearchPathForDirectoriesInDomains(searchPath, .userDomainMask, true) let paths: [String] = NSSearchPathForDirectoriesInDomains(searchPath, .userDomainMask, true)
var relativeFilePath: String! = filepath.lastPathComponent var relativeFilePath: String = _filepath.lastPathComponent
// the file may be multiple levels below the documents directory // the file may be multiple levels below the documents directory
let directoryString: String! = searchPath == .cachesDirectory ? "Library/Caches/" : "Documents" let directoryString: String = searchPath == .cachesDirectory ? "Library/Caches/" : "Documents"
let fileComponents: [String]! = filepath.components(separatedBy: directoryString) let fileComponents: [String] = _filepath.components(separatedBy: directoryString)
if fileComponents.count > 1 { if fileComponents.count > 1 {
relativeFilePath = fileComponents[1] relativeFilePath = fileComponents[1]
} }
let path: String! = (paths.first! as NSString).appendingPathComponent(relativeFilePath) guard let _pathFirst = paths.first else { return nil }
let path: String = (_pathFirst as NSString).appendingPathComponent(relativeFilePath)
if FileManager.default.fileExists(atPath: path) { if FileManager.default.fileExists(atPath: path) {
return NSURL.fileURL(withPath: path) as NSURL return NSURL.fileURL(withPath: path) as NSURL
} }
@ -127,7 +138,7 @@ enum RCTVideoUtils {
return [] return []
} }
let audioTracks: NSMutableArray! = NSMutableArray() let audioTracks = NSMutableArray()
let group = await RCTVideoAssetsUtils.getMediaSelectionGroup(asset: asset, for: .audible) let group = await RCTVideoAssetsUtils.getMediaSelectionGroup(asset: asset, for: .audible)
@ -138,14 +149,14 @@ enum RCTVideoUtils {
if (values?.count ?? 0) > 0, let value = values?[0] { if (values?.count ?? 0) > 0, let value = values?[0] {
title = value as! String title = value as! String
} }
let language: String! = currentOption?.extendedLanguageTag ?? "" let language: String = currentOption?.extendedLanguageTag ?? ""
let selectedOption: AVMediaSelectionOption? = player.currentItem?.currentMediaSelection.selectedMediaOption(in: group!) let selectedOption: AVMediaSelectionOption? = player.currentItem?.currentMediaSelection.selectedMediaOption(in: group!)
let audioTrack = [ let audioTrack = [
"index": NSNumber(value: i), "index": NSNumber(value: i),
"title": title, "title": title,
"language": language ?? "", "language": language,
"selected": currentOption?.displayName == selectedOption?.displayName, "selected": currentOption?.displayName == selectedOption?.displayName,
] as [String: Any] ] as [String: Any]
audioTracks.add(audioTrack) audioTracks.add(audioTrack)
@ -170,13 +181,12 @@ enum RCTVideoUtils {
if (values?.count ?? 0) > 0, let value = values?[0] { if (values?.count ?? 0) > 0, let value = values?[0] {
title = value as! String title = value as! String
} }
let language: String! = currentOption?.extendedLanguageTag ?? "" let language: String = currentOption?.extendedLanguageTag ?? ""
let selectedOpt = player.currentItem?.currentMediaSelection
let selectedOption: AVMediaSelectionOption? = player.currentItem?.currentMediaSelection.selectedMediaOption(in: group!) let selectedOption: AVMediaSelectionOption? = player.currentItem?.currentMediaSelection.selectedMediaOption(in: group!)
let textTrack = TextTrack([ let textTrack = TextTrack([
"index": NSNumber(value: i), "index": NSNumber(value: i),
"title": title, "title": title,
"language": language, "language": language as Any,
"selected": currentOption?.displayName == selectedOption?.displayName, "selected": currentOption?.displayName == selectedOption?.displayName,
]) ])
textTracks.append(textTrack) textTracks.append(textTrack)
@ -356,10 +366,11 @@ enum RCTVideoUtils {
static func prepareAsset(source: VideoSource) -> (asset: AVURLAsset?, assetOptions: NSMutableDictionary?)? { static func prepareAsset(source: VideoSource) -> (asset: AVURLAsset?, assetOptions: NSMutableDictionary?)? {
guard let sourceUri = source.uri, sourceUri != "" else { return nil } guard let sourceUri = source.uri, sourceUri != "" else { return nil }
var asset: AVURLAsset! var asset: AVURLAsset!
let bundlePath = Bundle.main.path(forResource: source.uri, ofType: source.type) ?? "" let bundlePath = Bundle.main.path(forResource: sourceUri, ofType: source.type) ?? ""
let url = source.isNetwork || source.isAsset guard let url = source.isNetwork || source.isAsset
? URL(string: source.uri ?? "") ? URL(string: sourceUri)
: URL(fileURLWithPath: bundlePath) : URL(fileURLWithPath: bundlePath) else { return nil }
let assetOptions: NSMutableDictionary! = NSMutableDictionary() let assetOptions: NSMutableDictionary! = NSMutableDictionary()
if source.isNetwork { if source.isNetwork {
@ -367,10 +378,10 @@ enum RCTVideoUtils {
assetOptions.setObject(headers, forKey: "AVURLAssetHTTPHeaderFieldsKey" as NSCopying) assetOptions.setObject(headers, forKey: "AVURLAssetHTTPHeaderFieldsKey" as NSCopying)
} }
let cookies: [AnyObject]! = HTTPCookieStorage.shared.cookies let cookies: [AnyObject]! = HTTPCookieStorage.shared.cookies
assetOptions.setObject(cookies, forKey: AVURLAssetHTTPCookiesKey as NSCopying) assetOptions.setObject(cookies as Any, forKey: AVURLAssetHTTPCookiesKey as NSCopying)
asset = AVURLAsset(url: url!, options: assetOptions as! [String: Any]) asset = AVURLAsset(url: url, options: assetOptions as? [String: Any])
} else { } else {
asset = AVURLAsset(url: url!) asset = AVURLAsset(url: url)
} }
return (asset, assetOptions) return (asset, assetOptions)
} }
@ -423,14 +434,10 @@ enum RCTVideoUtils {
return try? await AVVideoComposition.videoComposition( return try? await AVVideoComposition.videoComposition(
with: asset, with: asset,
applyingCIFiltersWithHandler: { (request: AVAsynchronousCIImageFilteringRequest) in applyingCIFiltersWithHandler: { (request: AVAsynchronousCIImageFilteringRequest) in
if filter == nil { let image: CIImage! = request.sourceImage.clampedToExtent()
request.finish(with: request.sourceImage, context: nil) filter.setValue(image, forKey: kCIInputImageKey)
} else { let output: CIImage! = filter.outputImage?.cropped(to: request.sourceImage.extent)
let image: CIImage! = request.sourceImage.clampedToExtent() request.finish(with: output, context: nil)
filter.setValue(image, forKey: kCIInputImageKey)
let output: CIImage! = filter.outputImage?.cropped(to: request.sourceImage.extent)
request.finish(with: output, context: nil)
}
} }
) )
} else { } else {
@ -438,14 +445,10 @@ enum RCTVideoUtils {
return AVVideoComposition( return AVVideoComposition(
asset: asset, asset: asset,
applyingCIFiltersWithHandler: { (request: AVAsynchronousCIImageFilteringRequest) in applyingCIFiltersWithHandler: { (request: AVAsynchronousCIImageFilteringRequest) in
if filter == nil { let image: CIImage! = request.sourceImage.clampedToExtent()
request.finish(with: request.sourceImage, context: nil) filter.setValue(image, forKey: kCIInputImageKey)
} else { let output: CIImage! = filter.outputImage?.cropped(to: request.sourceImage.extent)
let image: CIImage! = request.sourceImage.clampedToExtent() request.finish(with: output, context: nil)
filter.setValue(image, forKey: kCIInputImageKey)
let output: CIImage! = filter.outputImage?.cropped(to: request.sourceImage.extent)
request.finish(with: output, context: nil)
}
} }
) )
#endif #endif

View File

@ -18,6 +18,7 @@ class NowPlayingInfoCenterManager {
private var skipBackwardTarget: Any? private var skipBackwardTarget: Any?
private var playbackPositionTarget: Any? private var playbackPositionTarget: Any?
private var seekTarget: Any? private var seekTarget: Any?
private var togglePlayPauseTarget: Any?
private let remoteCommandCenter = MPRemoteCommandCenter.shared() private let remoteCommandCenter = MPRemoteCommandCenter.shared()
@ -167,13 +168,26 @@ class NowPlayingInfoCenterManager {
return .commandFailed return .commandFailed
} }
if let event = event as? MPChangePlaybackPositionCommandEvent { if let event = event as? MPChangePlaybackPositionCommandEvent {
player.seek(to: CMTime(seconds: event.positionTime, preferredTimescale: .max)) { _ in player.seek(to: CMTime(seconds: event.positionTime, preferredTimescale: .max))
player.play()
}
return .success return .success
} }
return .commandFailed 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() { private func invalidateCommandTargets() {
@ -182,6 +196,7 @@ class NowPlayingInfoCenterManager {
remoteCommandCenter.skipForwardCommand.removeTarget(skipForwardTarget) remoteCommandCenter.skipForwardCommand.removeTarget(skipForwardTarget)
remoteCommandCenter.skipBackwardCommand.removeTarget(skipBackwardTarget) remoteCommandCenter.skipBackwardCommand.removeTarget(skipBackwardTarget)
remoteCommandCenter.changePlaybackPositionCommand.removeTarget(playbackPositionTarget) remoteCommandCenter.changePlaybackPositionCommand.removeTarget(playbackPositionTarget)
remoteCommandCenter.togglePlayPauseCommand.removeTarget(togglePlayPauseTarget)
} }
public func updateNowPlayingInfo() { public func updateNowPlayingInfo() {

View File

@ -17,7 +17,6 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
private var _playerViewController: RCTVideoPlayerViewController? private var _playerViewController: RCTVideoPlayerViewController?
private var _videoURL: NSURL? private var _videoURL: NSURL?
private var _localSourceEncryptionKeyScheme: String?
/* Required to publish events */ /* Required to publish events */
private var _eventDispatcher: RCTEventDispatcher? private var _eventDispatcher: RCTEventDispatcher?
@ -42,20 +41,19 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
private var _repeat = false private var _repeat = false
private var _isPlaying = false private var _isPlaying = false
private var _allowsExternalPlayback = true private var _allowsExternalPlayback = true
private var _textTracks: [TextTrack]? private var _selectedTextTrackCriteria: SelectedTrackCriteria = .none()
private var _selectedTextTrackCriteria: SelectedTrackCriteria? private var _selectedAudioTrackCriteria: SelectedTrackCriteria = .none()
private var _selectedAudioTrackCriteria: SelectedTrackCriteria?
private var _playbackStalled = false private var _playbackStalled = false
private var _playInBackground = false private var _playInBackground = false
private var _preventsDisplaySleepDuringVideoPlayback = true private var _preventsDisplaySleepDuringVideoPlayback = true
private var _preferredForwardBufferDuration: Float = 0.0 private var _preferredForwardBufferDuration: Float = 0.0
private var _playWhenInactive = false private var _playWhenInactive = false
private var _ignoreSilentSwitch: String! = "inherit" // inherit, ignore, obey private var _ignoreSilentSwitch: String = "inherit" // inherit, ignore, obey
private var _mixWithOthers: String! = "inherit" // inherit, mix, duck private var _mixWithOthers: String = "inherit" // inherit, mix, duck
private var _resizeMode: String! = "cover" private var _resizeMode: String = "cover"
private var _fullscreen = false private var _fullscreen = false
private var _fullscreenAutorotate = true private var _fullscreenAutorotate = true
private var _fullscreenOrientation: String! = "all" private var _fullscreenOrientation: String = "all"
private var _fullscreenPlayerPresented = false private var _fullscreenPlayerPresented = false
private var _fullscreenUncontrolPlayerPresented = false // to call events switching full screen mode from player controls private var _fullscreenUncontrolPlayerPresented = false // to call events switching full screen mode from player controls
private var _filterName: String! private var _filterName: String!
@ -63,6 +61,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
private var _presentingViewController: UIViewController? private var _presentingViewController: UIViewController?
private var _startPosition: Float64 = -1 private var _startPosition: Float64 = -1
private var _showNotificationControls = false private var _showNotificationControls = false
// Buffer last bitrate value received. Initialized to -2 to ensure -1 (sometimes reported by AVPlayer) is not missed
private var _lastBitrate = -2.0
private var _pictureInPictureEnabled = false { private var _pictureInPictureEnabled = false {
didSet { didSet {
#if os(iOS) #if os(iOS)
@ -86,7 +86,6 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
/* IMA Ads */ /* IMA Ads */
private var _adTagUrl: String?
#if USE_GOOGLE_IMA #if USE_GOOGLE_IMA
private var _imaAdsManager: RCTIMAAdsManager! private var _imaAdsManager: RCTIMAAdsManager!
/* Playhead used by the SDK to track content video progress and insert mid-rolls. */ /* Playhead used by the SDK to track content video progress and insert mid-rolls. */
@ -95,7 +94,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
private var _didRequestAds = false private var _didRequestAds = false
private var _adPlaying = false private var _adPlaying = false
private var _resouceLoaderDelegate: RCTResourceLoaderDelegate? private lazy var _drmManager: DRMManager? = DRMManager()
private var _playerObserver: RCTPlayerObserver = .init() private var _playerObserver: RCTPlayerObserver = .init()
#if USE_VIDEO_CACHING #if USE_VIDEO_CACHING
@ -285,9 +284,18 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
// MARK: - App lifecycle handlers // MARK: - App lifecycle handlers
func getIsExternalPlaybackActive() -> Bool {
#if os(visionOS)
let isExternalPlaybackActive = false
#else
let isExternalPlaybackActive = _player?.isExternalPlaybackActive ?? false
#endif
return isExternalPlaybackActive
}
@objc @objc
func applicationWillResignActive(notification _: NSNotification!) { func applicationWillResignActive(notification _: NSNotification!) {
let isExternalPlaybackActive = _player?.isExternalPlaybackActive ?? false let isExternalPlaybackActive = getIsExternalPlaybackActive()
if _playInBackground || _playWhenInactive || !_isPlaying || isExternalPlaybackActive { return } if _playInBackground || _playWhenInactive || !_isPlaying || isExternalPlaybackActive { return }
_player?.pause() _player?.pause()
@ -296,7 +304,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
@objc @objc
func applicationDidBecomeActive(notification _: NSNotification!) { func applicationDidBecomeActive(notification _: NSNotification!) {
let isExternalPlaybackActive = _player?.isExternalPlaybackActive ?? false let isExternalPlaybackActive = getIsExternalPlaybackActive()
if _playInBackground || _playWhenInactive || !_isPlaying || isExternalPlaybackActive { return } if _playInBackground || _playWhenInactive || !_isPlaying || isExternalPlaybackActive { return }
// Resume the player or any other tasks that should continue when the app becomes active. // Resume the player or any other tasks that should continue when the app becomes active.
@ -306,7 +314,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
@objc @objc
func applicationDidEnterBackground(notification _: NSNotification!) { func applicationDidEnterBackground(notification _: NSNotification!) {
let isExternalPlaybackActive = _player?.isExternalPlaybackActive ?? false let isExternalPlaybackActive = getIsExternalPlaybackActive()
if !_playInBackground || isExternalPlaybackActive || isPipActive() { return } if !_playInBackground || isExternalPlaybackActive || isPipActive() { return }
// Needed to play sound in background. See https://developer.apple.com/library/ios/qa/qa1668/_index.html // Needed to play sound in background. See https://developer.apple.com/library/ios/qa/qa1668/_index.html
_playerLayer?.player = nil _playerLayer?.player = nil
@ -342,7 +350,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
#endif #endif
if let video = _player?.currentItem, if let video = _player?.currentItem,
video == nil || video.status != AVPlayerItem.Status.readyToPlay { video.status != AVPlayerItem.Status.readyToPlay {
return return
} }
@ -365,7 +373,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
if currentTimeSecs >= 0 { if currentTimeSecs >= 0 {
#if USE_GOOGLE_IMA #if USE_GOOGLE_IMA
if !_didRequestAds && currentTimeSecs >= 0.0001 && _adTagUrl != nil { if !_didRequestAds && currentTimeSecs >= 0.0001 && _source?.adParams.adTagUrl != nil {
_imaAdsManager.requestAds() _imaAdsManager.requestAds()
_didRequestAds = true _didRequestAds = true
} }
@ -375,7 +383,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
"playableDuration": RCTVideoUtils.calculatePlayableDuration(_player, withSource: _source), "playableDuration": RCTVideoUtils.calculatePlayableDuration(_player, withSource: _source),
"atValue": currentTime?.value ?? .zero, "atValue": currentTime?.value ?? .zero,
"currentPlaybackTime": NSNumber(value: Double(currentPlaybackTime?.timeIntervalSince1970 ?? 0 * 1000)).int64Value, "currentPlaybackTime": NSNumber(value: Double(currentPlaybackTime?.timeIntervalSince1970 ?? 0 * 1000)).int64Value,
"target": reactTag, "target": reactTag as Any,
"seekableDuration": RCTVideoUtils.calculateSeekableDuration(_player), "seekableDuration": RCTVideoUtils.calculateSeekableDuration(_player),
]) ])
} }
@ -407,17 +415,17 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
// Perform on next run loop, otherwise onVideoLoadStart is nil // Perform on next run loop, otherwise onVideoLoadStart is nil
onVideoLoadStart?([ onVideoLoadStart?([
"src": [ "src": [
"uri": _source?.uri ?? NSNull(), "uri": _source?.uri ?? NSNull() as Any,
"type": _source?.type ?? NSNull(), "type": _source?.type ?? NSNull(),
"isNetwork": NSNumber(value: _source?.isNetwork ?? false), "isNetwork": NSNumber(value: _source?.isNetwork ?? false),
], ],
"drm": source.drm?.json ?? NSNull(), "drm": source.drm.json ?? NSNull(),
"target": reactTag, "target": reactTag as Any,
]) ])
if let uri = source.uri, uri.starts(with: "ph://") { if let uri = source.uri, uri.starts(with: "ph://") {
let photoAsset = await RCTVideoUtils.preparePHAsset(uri: uri) let photoAsset = await RCTVideoUtils.preparePHAsset(uri: uri)
return await playerItemPrepareText(asset: photoAsset, assetOptions: nil, uri: source.uri ?? "") return await playerItemPrepareText(source: source, asset: photoAsset, assetOptions: nil, uri: source.uri ?? "")
} }
guard let assetResult = RCTVideoUtils.prepareAsset(source: source), guard let assetResult = RCTVideoUtils.prepareAsset(source: source),
@ -443,23 +451,26 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
#if USE_VIDEO_CACHING #if USE_VIDEO_CACHING
if _videoCache.shouldCache(source: source, textTracks: _textTracks) { if _videoCache.shouldCache(source: source) {
return try await _videoCache.playerItemForSourceUsingCache(uri: source.uri, assetOptions: assetOptions) return try await _videoCache.playerItemForSourceUsingCache(source: source, assetOptions: assetOptions)
} }
#endif #endif
if source.drm != nil || _localSourceEncryptionKeyScheme != nil { if source.drm.json != nil {
_resouceLoaderDelegate = RCTResourceLoaderDelegate( if _drmManager == nil {
_drmManager = DRMManager()
}
_drmManager?.createContentKeyRequest(
asset: asset, asset: asset,
drm: source.drm, drmParams: source.drm,
localSourceEncryptionKeyScheme: _localSourceEncryptionKeyScheme, reactTag: reactTag,
onVideoError: onVideoError, onVideoError: onVideoError,
onGetLicense: onGetLicense, onGetLicense: onGetLicense
reactTag: reactTag
) )
} }
return await playerItemPrepareText(asset: asset, assetOptions: assetOptions, uri: source.uri ?? "") return await playerItemPrepareText(source: source, asset: asset, assetOptions: assetOptions, uri: source.uri ?? "")
} }
func setupPlayer(playerItem: AVPlayerItem) async throws { func setupPlayer(playerItem: AVPlayerItem) async throws {
@ -480,7 +491,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
if _player == nil { if _player == nil {
_player = AVPlayer() _player = AVPlayer()
ReactNativeVideoManager.shared.onInstanceCreated(id: instanceId, player: _player) ReactNativeVideoManager.shared.onInstanceCreated(id: instanceId, player: _player as Any)
_player!.replaceCurrentItem(with: playerItem) _player!.replaceCurrentItem(with: playerItem)
@ -489,8 +500,19 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
NowPlayingInfoCenterManager.shared.registerPlayer(player: _player!) NowPlayingInfoCenterManager.shared.registerPlayer(player: _player!)
} }
} else { } else {
#if !os(tvOS) && !os(visionOS)
if #available(iOS 16.0, *) {
// This feature caused crashes, if the app was put in bg, before the source change
// https://github.com/TheWidlarzGroup/react-native-video/issues/3900
self._playerViewController?.allowsVideoFrameAnalysis = false
}
#endif
_player?.replaceCurrentItem(with: playerItem) _player?.replaceCurrentItem(with: playerItem)
#if !os(tvOS) && !os(visionOS)
if #available(iOS 16.0, *) {
self._playerViewController?.allowsVideoFrameAnalysis = true
}
#endif
// later we can just call "updateNowPlayingInfo: // later we can just call "updateNowPlayingInfo:
NowPlayingInfoCenterManager.shared.updateNowPlayingInfo() NowPlayingInfoCenterManager.shared.updateNowPlayingInfo()
} }
@ -504,7 +526,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
#if USE_GOOGLE_IMA #if USE_GOOGLE_IMA
if _adTagUrl != nil { if _source?.adParams.adTagUrl != nil {
// Set up your content playhead and contentComplete callback. // Set up your content playhead and contentComplete callback.
_contentPlayhead = IMAAVPlayerContentPlayhead(avPlayer: _player!) _contentPlayhead = IMAAVPlayerContentPlayhead(avPlayer: _player!)
@ -541,7 +563,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
self.removePlayerLayer() self.removePlayerLayer()
self._playerObserver.player = nil self._playerObserver.player = nil
self._resouceLoaderDelegate = nil self._drmManager = nil
self._playerObserver.playerItem = nil self._playerObserver.playerItem = nil
// perform on next run loop, otherwise other passed react-props may not be set // perform on next run loop, otherwise other passed react-props may not be set
@ -573,13 +595,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
DispatchQueue.global(qos: .default).async(execute: initializeSource) DispatchQueue.global(qos: .default).async(execute: initializeSource)
} }
@objc func playerItemPrepareText(source: VideoSource, asset: AVAsset!, assetOptions: NSDictionary?, uri: String) async -> AVPlayerItem {
func setLocalSourceEncryptionKeyScheme(_ keyScheme: String) { if source.textTracks.isEmpty == true || uri.hasSuffix(".m3u8") {
_localSourceEncryptionKeyScheme = keyScheme
}
func playerItemPrepareText(asset: AVAsset!, assetOptions: NSDictionary?, uri: String) async -> AVPlayerItem {
if (self._textTracks == nil) || self._textTracks?.isEmpty == true || (uri.hasSuffix(".m3u8")) {
return await self.playerItemPropegateMetadata(AVPlayerItem(asset: asset)) return await self.playerItemPropegateMetadata(AVPlayerItem(asset: asset))
} }
@ -590,11 +607,15 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
asset: asset, asset: asset,
assetOptions: assetOptions, assetOptions: assetOptions,
mixComposition: mixComposition, mixComposition: mixComposition,
textTracks: self._textTracks textTracks: source.textTracks
) )
if validTextTracks.count != self._textTracks?.count { if validTextTracks.isEmpty {
self.setTextTracks(validTextTracks) DebugLog("Strange state, not valid textTrack")
}
if validTextTracks.count != source.textTracks.count {
setSelectedTextTrack(_selectedTextTrackCriteria)
} }
return await self.playerItemPropegateMetadata(AVPlayerItem(asset: mixComposition)) return await self.playerItemPropegateMetadata(AVPlayerItem(asset: mixComposition))
@ -719,14 +740,14 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
@objc @objc
func setIgnoreSilentSwitch(_ ignoreSilentSwitch: String?) { func setIgnoreSilentSwitch(_ ignoreSilentSwitch: String?) {
_ignoreSilentSwitch = ignoreSilentSwitch _ignoreSilentSwitch = ignoreSilentSwitch ?? "inherit"
RCTPlayerOperations.configureAudio(ignoreSilentSwitch: _ignoreSilentSwitch, mixWithOthers: _mixWithOthers, audioOutput: _audioOutput) RCTPlayerOperations.configureAudio(ignoreSilentSwitch: _ignoreSilentSwitch, mixWithOthers: _mixWithOthers, audioOutput: _audioOutput)
applyModifiers() applyModifiers()
} }
@objc @objc
func setMixWithOthers(_ mixWithOthers: String?) { func setMixWithOthers(_ mixWithOthers: String?) {
_mixWithOthers = mixWithOthers _mixWithOthers = mixWithOthers ?? "inherit"
applyModifiers() applyModifiers()
} }
@ -796,16 +817,13 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
self._playerObserver.addTimeObserverIfNotSet() self._playerObserver.addTimeObserverIfNotSet()
if !wasPaused { self.setPaused(self._paused)
self.setPaused(false)
}
let currentTimeAfterSeek = CMTimeGetSeconds(item.currentTime()) let newCurrentTime = NSNumber(value: Float(CMTimeGetSeconds(item.currentTime())))
let newCurrentTime = NSNumber(value: Float(currentTimeAfterSeek))
self.onVideoSeekComplete?(["currentTime": newCurrentTime, self.onVideoSeekComplete?(["currentTime": newCurrentTime,
"seekTime": time, "seekTime": time,
"target": self.reactTag]) "target": self.reactTag as Any])
} }
player.seek(to: seekTime, toleranceBefore: toleranceTime, toleranceAfter: toleranceTime, completionHandler: seekCompletionHandler) player.seek(to: seekTime, toleranceBefore: toleranceTime, toleranceAfter: toleranceTime, completionHandler: seekCompletionHandler)
@ -903,7 +921,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
func applyModifiers() { func applyModifiers() {
if let video = _player?.currentItem, if let video = _player?.currentItem,
video == nil || video.status != AVPlayerItem.Status.readyToPlay { video.status != AVPlayerItem.Status.readyToPlay {
return return
} }
if _muted { if _muted {
@ -928,9 +946,9 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
setMaxBitRate(_maxBitRate) setMaxBitRate(_maxBitRate)
} }
setSelectedTextTrack(_selectedTextTrackCriteria)
setAudioOutput(_audioOutput) setAudioOutput(_audioOutput)
setSelectedAudioTrack(_selectedAudioTrackCriteria) setSelectedAudioTrack(_selectedAudioTrackCriteria)
setSelectedTextTrack(_selectedTextTrackCriteria)
setResizeMode(_resizeMode) setResizeMode(_resizeMode)
setRepeat(_repeat) setRepeat(_repeat)
setControls(_controls) setControls(_controls)
@ -949,7 +967,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
func setSelectedAudioTrack(_ selectedAudioTrack: SelectedTrackCriteria?) { func setSelectedAudioTrack(_ selectedAudioTrack: SelectedTrackCriteria?) {
_selectedAudioTrackCriteria = selectedAudioTrack _selectedAudioTrackCriteria = selectedAudioTrack ?? SelectedTrackCriteria.none()
Task { Task {
await RCTPlayerOperations.setMediaSelectionTrackForCharacteristic(player: _player, characteristic: AVMediaCharacteristic.audible, await RCTPlayerOperations.setMediaSelectionTrackForCharacteristic(player: _player, characteristic: AVMediaCharacteristic.audible,
criteria: _selectedAudioTrackCriteria) criteria: _selectedAudioTrackCriteria)
@ -962,9 +980,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
func setSelectedTextTrack(_ selectedTextTrack: SelectedTrackCriteria?) { func setSelectedTextTrack(_ selectedTextTrack: SelectedTrackCriteria?) {
_selectedTextTrackCriteria = selectedTextTrack _selectedTextTrackCriteria = selectedTextTrack ?? SelectedTrackCriteria.none()
if _textTracks != nil { // sideloaded text tracks guard let source = _source else { return }
RCTPlayerOperations.setSideloadedText(player: _player, textTracks: _textTracks!, criteria: _selectedTextTrackCriteria) if !source.textTracks.isEmpty { // sideloaded text tracks
RCTPlayerOperations.setSideloadedText(player: _player, textTracks: source.textTracks, criteria: _selectedTextTrackCriteria)
} else { // text tracks included in the HLS playlist§ } else { // text tracks included in the HLS playlist§
Task { Task {
await RCTPlayerOperations.setMediaSelectionTrackForCharacteristic(player: _player, characteristic: AVMediaCharacteristic.legible, await RCTPlayerOperations.setMediaSelectionTrackForCharacteristic(player: _player, characteristic: AVMediaCharacteristic.legible,
@ -973,18 +992,6 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
} }
@objc
func setTextTracks(_ textTracks: [NSDictionary]?) {
setTextTracks(textTracks?.map { TextTrack($0) })
}
func setTextTracks(_ textTracks: [TextTrack]?) {
_textTracks = textTracks
// in case textTracks was set after selectedTextTrack
if _selectedTextTrackCriteria != nil { setSelectedTextTrack(_selectedTextTrackCriteria) }
}
@objc @objc
func setChapters(_ chapters: [NSDictionary]?) { func setChapters(_ chapters: [NSDictionary]?) {
setChapters(chapters?.map { Chapter($0) }) setChapters(chapters?.map { Chapter($0) })
@ -996,7 +1003,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
@objc @objc
func setFullscreen(_ fullscreen: Bool) { func setFullscreen(_ fullscreen: Bool) {
var alreadyFullscreenPresented = _presentingViewController?.presentedViewController != nil let alreadyFullscreenPresented = _presentingViewController?.presentedViewController != nil
if fullscreen && !_fullscreenPlayerPresented && _player != nil && !alreadyFullscreenPresented { if fullscreen && !_fullscreenPlayerPresented && _player != nil && !alreadyFullscreenPresented {
// Ensure player view controller is not null // Ensure player view controller is not null
// Controls will be displayed even if it is disabled in configuration // Controls will be displayed even if it is disabled in configuration
@ -1035,7 +1042,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
self._fullscreenPlayerPresented = fullscreen self._fullscreenPlayerPresented = fullscreen
self._playerViewController?.autorotate = self._fullscreenAutorotate self._playerViewController?.autorotate = self._fullscreenAutorotate
self.onVideoFullscreenPlayerDidPresent?(["target": self.reactTag]) self.onVideoFullscreenPlayerDidPresent?(["target": self.reactTag as Any])
}) })
} }
} }
@ -1058,9 +1065,9 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
@objc @objc
func setFullscreenOrientation(_ orientation: String?) { func setFullscreenOrientation(_ orientation: String?) {
_fullscreenOrientation = orientation _fullscreenOrientation = orientation ?? "all"
if _fullscreenPlayerPresented { if _fullscreenPlayerPresented {
_playerViewController?.preferredOrientation = orientation _playerViewController?.preferredOrientation = _fullscreenOrientation
} }
} }
@ -1223,13 +1230,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
// MARK: - RCTIMAAdsManager // MARK: - RCTIMAAdsManager
func getAdTagUrl() -> String? { func getAdLanguage() -> String? {
return _adTagUrl return _source?.adParams.adLanguage
} }
@objc func getAdTagUrl() -> String? {
func setAdTagUrl(_ adTagUrl: String!) { return _source?.adParams.adTagUrl
_adTagUrl = adTagUrl
} }
#if USE_GOOGLE_IMA #if USE_GOOGLE_IMA
@ -1290,14 +1296,13 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
_playerItem = nil _playerItem = nil
_source = nil _source = nil
_chapters = nil _chapters = nil
_textTracks = nil _selectedTextTrackCriteria = SelectedTrackCriteria.none()
_selectedTextTrackCriteria = nil _selectedAudioTrackCriteria = SelectedTrackCriteria.none()
_selectedAudioTrackCriteria = nil
_presentingViewController = nil _presentingViewController = nil
ReactNativeVideoManager.shared.onInstanceRemoved(id: instanceId, player: _player) ReactNativeVideoManager.shared.onInstanceRemoved(id: instanceId, player: _player as Any)
_player = nil _player = nil
_resouceLoaderDelegate = nil _drmManager = nil
_playerObserver.clearPlayer() _playerObserver.clearPlayer()
self.removePlayerLayer() self.removePlayerLayer()
@ -1330,12 +1335,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
) )
} }
func setLicenseResult(_ license: String!, _ licenseUrl: String!) { func setLicenseResult(_ license: String, _ licenseUrl: String) {
_resouceLoaderDelegate?.setLicenseResult(license, licenseUrl) _drmManager?.setJSLicenseResult(license: license, licenseUrl: licenseUrl)
} }
func setLicenseResultError(_ error: String!, _ licenseUrl: String!) { func setLicenseResultError(_ error: String, _ licenseUrl: String) {
_resouceLoaderDelegate?.setLicenseResultError(error, licenseUrl) _drmManager?.setJSLicenseError(error: error, licenseUrl: licenseUrl)
} }
// MARK: - RCTPlayerObserverHandler // MARK: - RCTPlayerObserverHandler
@ -1349,7 +1354,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
_isBuffering = false _isBuffering = false
} }
onReadyForDisplay?([ onReadyForDisplay?([
"target": reactTag, "target": reactTag as Any,
]) ])
} }
@ -1368,7 +1373,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
onTimedMetadata?([ onTimedMetadata?([
"target": reactTag, "target": reactTag as Any,
"metadata": metadata, "metadata": metadata,
]) ])
} }
@ -1386,9 +1391,23 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
} }
func extractJsonWithIndex(from tracks: [TextTrack]) -> [NSDictionary]? {
if tracks.isEmpty {
// No tracks, need to return nil to handle
return nil
}
// Map each enumerated pair to include the index in the json dictionary
let mappedTracks = tracks.enumerated().compactMap { index, track -> NSDictionary? in
guard let json = track.json?.mutableCopy() as? NSMutableDictionary else { return nil }
json["index"] = index // Insert the index into the json dictionary
return json
}
return mappedTracks
}
func handleReadyToPlay() { func handleReadyToPlay() {
guard let _playerItem else { return } guard let _playerItem else { return }
guard let source = _source else { return }
Task { Task {
if self._pendingSeek { if self._pendingSeek {
self.setSeek(NSNumber(value: self._pendingSeekTime), NSNumber(value: 100)) self.setSeek(NSNumber(value: self._pendingSeekTime), NSNumber(value: 100))
@ -1417,7 +1436,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
var orientation = "undefined" var orientation = "undefined"
let tracks = await RCTVideoAssetsUtils.getTracks(asset: _playerItem.asset, withMediaType: .video) let tracks = await RCTVideoAssetsUtils.getTracks(asset: _playerItem.asset, withMediaType: .video)
var presentationSize = _playerItem.presentationSize let presentationSize = _playerItem.presentationSize
if presentationSize.height != 0.0 { if presentationSize.height != 0.0 {
width = Float(presentationSize.width) width = Float(presentationSize.width)
height = Float(presentationSize.height) height = Float(presentationSize.height)
@ -1444,7 +1463,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
"orientation": orientation, "orientation": orientation,
], ],
"audioTracks": audioTracks, "audioTracks": audioTracks,
"textTracks": self._textTracks?.compactMap { $0.json } ?? textTracks.map(\.json), "textTracks": extractJsonWithIndex(from: source.textTracks) ?? textTracks.map(\.json),
"target": self.reactTag as Any]) "target": self.reactTag as Any])
} }
@ -1464,14 +1483,14 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
[ [
"error": [ "error": [
"code": NSNumber(value: (_playerItem.error! as NSError).code), "code": NSNumber(value: (_playerItem.error! as NSError).code),
"localizedDescription": _playerItem.error?.localizedDescription == nil ? "" : _playerItem.error?.localizedDescription, "localizedDescription": _playerItem.error?.localizedDescription == nil ? "" : _playerItem.error?.localizedDescription as Any,
"localizedFailureReason": ((_playerItem.error! as NSError).localizedFailureReason == nil ? "localizedFailureReason": ((_playerItem.error! as NSError).localizedFailureReason == nil ?
"" : (_playerItem.error! as NSError).localizedFailureReason) ?? "", "" : (_playerItem.error! as NSError).localizedFailureReason) ?? "",
"localizedRecoverySuggestion": ((_playerItem.error! as NSError).localizedRecoverySuggestion == nil ? "localizedRecoverySuggestion": ((_playerItem.error! as NSError).localizedRecoverySuggestion == nil ?
"" : (_playerItem.error! as NSError).localizedRecoverySuggestion) ?? "", "" : (_playerItem.error! as NSError).localizedRecoverySuggestion) ?? "",
"domain": (_playerItem.error as! NSError).domain, "domain": (_playerItem.error as! NSError).domain,
], ],
"target": reactTag, "target": reactTag as Any,
] ]
) )
} }
@ -1584,12 +1603,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
[ [
"error": [ "error": [
"code": NSNumber(value: (error as NSError).code), "code": NSNumber(value: (error as NSError).code),
"localizedDescription": error.localizedDescription ?? "", "localizedDescription": error.localizedDescription,
"localizedFailureReason": (error as NSError).localizedFailureReason ?? "", "localizedFailureReason": (error as NSError).localizedFailureReason ?? "",
"localizedRecoverySuggestion": (error as NSError).localizedRecoverySuggestion ?? "", "localizedRecoverySuggestion": (error as NSError).localizedRecoverySuggestion ?? "",
"domain": (error as NSError).domain, "domain": (error as NSError).domain,
], ],
"target": reactTag, "target": reactTag as Any,
] ]
) )
} }
@ -1622,7 +1641,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
) )
} else { } else {
_playerObserver.removePlayerTimeObserver() _player?.pause()
_player?.rate = 0.0
} }
} }
@ -1633,16 +1653,19 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
guard let accessLog = (notification.object as? AVPlayerItem)?.accessLog() else { guard let accessLog = (notification.object as? AVPlayerItem)?.accessLog() else {
return return
} }
guard let lastEvent = accessLog.events.last else { return } guard let lastEvent = accessLog.events.last else { return }
onVideoBandwidthUpdate?(["bitrate": lastEvent.observedBitrate, "target": reactTag]) if lastEvent.indicatedBitrate != _lastBitrate {
_lastBitrate = lastEvent.indicatedBitrate
onVideoBandwidthUpdate?(["bitrate": _lastBitrate, "target": reactTag as Any])
}
} }
func handleTracksChange(playerItem _: AVPlayerItem, change _: NSKeyValueObservedChange<[AVPlayerItemTrack]>) { func handleTracksChange(playerItem _: AVPlayerItem, change _: NSKeyValueObservedChange<[AVPlayerItemTrack]>) {
guard let source = _source else { return }
if onTextTracks != nil { if onTextTracks != nil {
Task { Task {
let textTracks = await RCTVideoUtils.getTextTrackInfo(self._player) let textTracks = await RCTVideoUtils.getTextTrackInfo(self._player)
self.onTextTracks?(["textTracks": self._textTracks?.compactMap { $0.json } ?? textTracks.compactMap(\.json)]) self.onTextTracks?(["textTracks": extractJsonWithIndex(from: source.textTracks) ?? textTracks.compactMap(\.json)])
} }
} }

View File

@ -5,7 +5,6 @@
RCT_EXPORT_VIEW_PROPERTY(src, NSDictionary); RCT_EXPORT_VIEW_PROPERTY(src, NSDictionary);
RCT_EXPORT_VIEW_PROPERTY(drm, NSDictionary); RCT_EXPORT_VIEW_PROPERTY(drm, NSDictionary);
RCT_EXPORT_VIEW_PROPERTY(adTagUrl, NSString);
RCT_EXPORT_VIEW_PROPERTY(maxBitRate, float); RCT_EXPORT_VIEW_PROPERTY(maxBitRate, float);
RCT_EXPORT_VIEW_PROPERTY(resizeMode, NSString); RCT_EXPORT_VIEW_PROPERTY(resizeMode, NSString);
RCT_EXPORT_VIEW_PROPERTY(repeat, BOOL); RCT_EXPORT_VIEW_PROPERTY(repeat, BOOL);
@ -75,6 +74,7 @@ RCT_EXTERN_METHOD(setLicenseResultErrorCmd : (nonnull NSNumber*)reactTag error :
RCT_EXTERN_METHOD(setPlayerPauseStateCmd : (nonnull NSNumber*)reactTag paused : (nonnull BOOL)paused) RCT_EXTERN_METHOD(setPlayerPauseStateCmd : (nonnull NSNumber*)reactTag paused : (nonnull BOOL)paused)
RCT_EXTERN_METHOD(setVolumeCmd : (nonnull NSNumber*)reactTag volume : (nonnull float*)volume) RCT_EXTERN_METHOD(setVolumeCmd : (nonnull NSNumber*)reactTag volume : (nonnull float*)volume)
RCT_EXTERN_METHOD(setFullScreenCmd : (nonnull NSNumber*)reactTag fullscreen : (nonnull BOOL)fullScreen) RCT_EXTERN_METHOD(setFullScreenCmd : (nonnull NSNumber*)reactTag fullscreen : (nonnull BOOL)fullScreen)
RCT_EXTERN_METHOD(setSourceCmd : (nonnull NSNumber*)reactTag source : (NSDictionary*)source)
RCT_EXTERN_METHOD(save RCT_EXTERN_METHOD(save
: (nonnull NSNumber*)reactTag options : (nonnull NSNumber*)reactTag options

View File

@ -72,6 +72,13 @@ class RCTVideoManager: RCTViewManager {
}) })
} }
@objc(setSourceCmd:source:)
func setSourceCmd(_ reactTag: NSNumber, source: NSDictionary) {
performOnVideoView(withReactTag: reactTag, callback: { videoView in
videoView?.setSrc(source)
})
}
@objc(save:options:resolve:reject:) @objc(save:options:resolve:reject:)
func save(_ reactTag: NSNumber, options: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { func save(_ reactTag: NSNumber, options: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
performOnVideoView(withReactTag: reactTag, callback: { videoView in performOnVideoView(withReactTag: reactTag, callback: { videoView in

View File

@ -1,7 +1,7 @@
import AVKit import AVKit
import Foundation import Foundation
protocol RCTVideoPlayerViewControllerDelegate: class { protocol RCTVideoPlayerViewControllerDelegate: AnyObject {
func videoPlayerViewControllerWillDismiss(playerViewController: AVPlayerViewController) func videoPlayerViewControllerWillDismiss(playerViewController: AVPlayerViewController)
func videoPlayerViewControllerDidDismiss(playerViewController: AVPlayerViewController) func videoPlayerViewControllerDidDismiss(playerViewController: AVPlayerViewController)
} }

View File

@ -4,20 +4,20 @@ import Foundation
class RCTVideoCachingHandler: NSObject, DVAssetLoaderDelegatesDelegate { class RCTVideoCachingHandler: NSObject, DVAssetLoaderDelegatesDelegate {
private var _videoCache: RCTVideoCache! = RCTVideoCache.sharedInstance() private var _videoCache: RCTVideoCache! = RCTVideoCache.sharedInstance()
var playerItemPrepareText: ((AVAsset?, NSDictionary?, String) async -> AVPlayerItem)? var playerItemPrepareText: ((VideoSource, AVAsset?, NSDictionary?, String) async -> AVPlayerItem)?
override init() { override init() {
super.init() super.init()
} }
func shouldCache(source: VideoSource, textTracks: [TextTrack]?) -> Bool { func shouldCache(source: VideoSource) -> Bool {
if source.isNetwork && source.shouldCache && ((textTracks == nil) || (textTracks!.isEmpty)) { if source.isNetwork && source.shouldCache && source.textTracks.isEmpty {
/* The DVURLAsset created by cache doesn't have a tracksWithMediaType property, so trying /* The DVURLAsset created by cache doesn't have a tracksWithMediaType property, so trying
* to bring in the text track code will crash. I suspect this is because the asset hasn't fully loaded. * to bring in the text track code will crash. I suspect this is because the asset hasn't fully loaded.
* Until this is fixed, we need to bypass caching when text tracks are specified. * Until this is fixed, we need to bypass caching when text tracks are specified.
*/ */
DebugLog(""" DebugLog("""
Caching is not supported for uri '\(source.uri)' because text tracks are not compatible with the cache. Caching is not supported for uri '\(source.uri ?? "NO URI")' because text tracks are not compatible with the cache.
Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md
""") """)
return true return true
@ -25,7 +25,8 @@ class RCTVideoCachingHandler: NSObject, DVAssetLoaderDelegatesDelegate {
return false return false
} }
func playerItemForSourceUsingCache(uri: String!, assetOptions options: NSDictionary!) async throws -> AVPlayerItem { func playerItemForSourceUsingCache(source: VideoSource, assetOptions options: NSDictionary) async throws -> AVPlayerItem {
let uri = source.uri!
let url = URL(string: uri) let url = URL(string: uri)
let (videoCacheStatus, cachedAsset) = await getItemForUri(uri) let (videoCacheStatus, cachedAsset) = await getItemForUri(uri)
@ -36,33 +37,33 @@ class RCTVideoCachingHandler: NSObject, DVAssetLoaderDelegatesDelegate {
switch videoCacheStatus { switch videoCacheStatus {
case .missingFileExtension: case .missingFileExtension:
DebugLog(""" DebugLog("""
Could not generate cache key for uri '\(uri ?? "NO_URI")'. Could not generate cache key for uri '\(uri)'.
It is currently not supported to cache urls that do not include a file extension. It is currently not supported to cache urls that do not include a file extension.
The video file will not be cached. The video file will not be cached.
Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md
""") """)
let asset: AVURLAsset! = AVURLAsset(url: url!, options: options as! [String: Any]) let asset: AVURLAsset! = AVURLAsset(url: url!, options: options as? [String: Any])
return await playerItemPrepareText(asset, options, "") return await playerItemPrepareText(source, asset, options, "")
case .unsupportedFileExtension: case .unsupportedFileExtension:
DebugLog(""" DebugLog("""
Could not generate cache key for uri '\(uri ?? "NO_URI")'. Could not generate cache key for uri '\(uri)'.
The file extension of that uri is currently not supported. The file extension of that uri is currently not supported.
The video file will not be cached. The video file will not be cached.
Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md
""") """)
let asset: AVURLAsset! = AVURLAsset(url: url!, options: options as! [String: Any]) let asset: AVURLAsset! = AVURLAsset(url: url!, options: options as? [String: Any])
return await playerItemPrepareText(asset, options, "") return await playerItemPrepareText(source, asset, options, "")
default: default:
if let cachedAsset { if let cachedAsset {
DebugLog("Playing back uri '\(uri ?? "NO_URI")' from cache") DebugLog("Playing back uri '\(uri)' from cache")
// See note in playerItemForSource about not being able to support text tracks & caching // See note in playerItemForSource about not being able to support text tracks & caching
return AVPlayerItem(asset: cachedAsset) return AVPlayerItem(asset: cachedAsset)
} }
} }
let asset: DVURLAsset! = DVURLAsset(url: url, options: options as! [String: Any], networkTimeout: 10000) let asset: DVURLAsset! = DVURLAsset(url: url, options: options as? [String: Any], networkTimeout: 10000)
asset.loaderDelegate = self asset.loaderDelegate = self
/* More granular code to have control over the DVURLAsset /* More granular code to have control over the DVURLAsset

View File

@ -1,9 +1,9 @@
{ {
"name": "react-native-video", "name": "react-native-video",
"version": "6.4.3", "version": "6.6.4",
"description": "A <Video /> element for react-native", "description": "A <Video /> element for react-native",
"main": "lib/index", "main": "lib/index",
"source": "src/index", "source": "src/index.ts",
"react-native": "src/index", "react-native": "src/index",
"license": "MIT", "license": "MIT",
"author": "Community Contributors", "author": "Community Contributors",
@ -32,9 +32,12 @@
"react-native": "0.73.2", "react-native": "0.73.2",
"react-native-windows": "^0.61.0-0", "react-native-windows": "^0.61.0-0",
"release-it": "^16.2.1", "release-it": "^16.2.1",
"typescript": "5.1.6" "typescript": "5.1.6",
"patch-package": "^8.0.0"
},
"dependencies": {
"shaka-player": "^4.11.7"
}, },
"dependencies": {},
"peerDependencies": { "peerDependencies": {
"react": "*", "react": "*",
"react-native": "*" "react-native": "*"

View File

@ -0,0 +1,39 @@
diff --git a/node_modules/shaka-player/dist/shaka-player.compiled.d.ts b/node_modules/shaka-player/dist/shaka-player.compiled.d.ts
index 19c0930..cc0a3fd 100644
--- a/node_modules/shaka-player/dist/shaka-player.compiled.d.ts
+++ b/node_modules/shaka-player/dist/shaka-player.compiled.d.ts
@@ -5117,3 +5117,5 @@ declare namespace shaka.extern {
declare namespace shaka.extern {
type TransmuxerPlugin = ( ) => shaka.extern.Transmuxer ;
}
+
+export default shaka;
diff --git a/node_modules/shaka-player/dist/shaka-player.ui.d.ts b/node_modules/shaka-player/dist/shaka-player.ui.d.ts
index 1618ca0..a6076c6 100644
--- a/node_modules/shaka-player/dist/shaka-player.ui.d.ts
+++ b/node_modules/shaka-player/dist/shaka-player.ui.d.ts
@@ -5830,3 +5830,5 @@ declare namespace shaka.extern {
declare namespace shaka.extern {
type UIVolumeBarColors = { base : string , level : string } ;
}
+
+export default shaka;
diff --git a/node_modules/shaka-player/index.d.ts b/node_modules/shaka-player/index.d.ts
new file mode 100644
index 0000000..3ebfd96
--- /dev/null
+++ b/node_modules/shaka-player/index.d.ts
@@ -0,0 +1,2 @@
+/// <reference path="./dist/shaka-player.compiled.d.ts" />
+/// <reference path="./dist/shaka-player.ui.d.ts" />
\ No newline at end of file
diff --git a/node_modules/shaka-player/ui.d.ts b/node_modules/shaka-player/ui.d.ts
new file mode 100644
index 0000000..84a3be0
--- /dev/null
+++ b/node_modules/shaka-player/ui.d.ts
@@ -0,0 +1,3 @@
+import shaka from 'shaka-player/dist/shaka-player.ui'
+export * from 'shaka-player/dist/shaka-player.ui'
+export default shaka;
\ No newline at end of file

13
shell.nix Normal file
View File

@ -0,0 +1,13 @@
{pkgs ? import <nixpkgs> {}}:
pkgs.mkShell {
packages = with pkgs; [
nodejs-18_x
nodePackages.yarn
bun
eslint_d
prettierd
jdk11
(jdt-language-server.override { jdk = jdk11; })
];
}

View File

@ -16,7 +16,9 @@ import type {
ImageResizeMode, ImageResizeMode,
} from 'react-native'; } from 'react-native';
import NativeVideoComponent from './specs/VideoNativeComponent'; import NativeVideoComponent, {
NativeCmcdConfiguration,
} from './specs/VideoNativeComponent';
import type { import type {
OnAudioFocusChangedData, OnAudioFocusChangedData,
OnAudioTracksData, OnAudioTracksData,
@ -44,13 +46,14 @@ import {
resolveAssetSourceForVideo, resolveAssetSourceForVideo,
} from './utils'; } from './utils';
import NativeVideoManager from './specs/NativeVideoManager'; import NativeVideoManager from './specs/NativeVideoManager';
import type {VideoSaveData} from './specs/NativeVideoManager'; import {type VideoSaveData, CmcdMode, ViewType} from './types';
import {ViewType} from './types';
import type { import type {
OnLoadData, OnLoadData,
OnTextTracksData, OnTextTracksData,
OnReceiveAdEventData, OnReceiveAdEventData,
ReactVideoProps, ReactVideoProps,
CmcdData,
ReactVideoSource,
} from './types'; } from './types';
export interface VideoRef { export interface VideoRef {
@ -64,6 +67,7 @@ export interface VideoRef {
) => void; ) => void;
setVolume: (volume: number) => void; setVolume: (volume: number) => void;
setFullScreen: (fullScreen: boolean) => void; setFullScreen: (fullScreen: boolean) => void;
setSource: (source?: ReactVideoSource) => void;
save: (options: object) => Promise<VideoSaveData> | void; save: (options: object) => Promise<VideoSaveData> | void;
getCurrentPosition: () => Promise<number>; getCurrentPosition: () => Promise<number>;
} }
@ -77,6 +81,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
poster, poster,
posterResizeMode, posterResizeMode,
renderLoader, renderLoader,
contentStartTime,
drm, drm,
textTracks, textTracks,
selectedVideoTrack, selectedVideoTrack,
@ -86,6 +91,8 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
useSecureView, useSecureView,
viewType, viewType,
shutterColor, shutterColor,
adTagUrl,
adLanguage,
onLoadStart, onLoadStart,
onLoad, onLoad,
onError, onError,
@ -117,6 +124,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
onTextTrackDataChanged, onTextTrackDataChanged,
onVideoTracks, onVideoTracks,
onAspectRatio, onAspectRatio,
localSourceEncryptionKeyScheme,
...rest ...rest
}, },
ref, ref,
@ -125,8 +133,18 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
const isPosterDeprecated = typeof poster === 'string'; const isPosterDeprecated = typeof poster === 'string';
const _renderLoader = useMemo(
() =>
!renderLoader
? undefined
: renderLoader instanceof Function
? renderLoader
: () => renderLoader,
[renderLoader],
);
const hasPoster = useMemo(() => { const hasPoster = useMemo(() => {
if (renderLoader) { if (_renderLoader) {
return true; return true;
} }
@ -135,7 +153,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
} }
return !!poster?.source; return !!poster?.source;
}, [isPosterDeprecated, poster, renderLoader]); }, [isPosterDeprecated, poster, _renderLoader]);
const [showPoster, setShowPoster] = useState(hasPoster); const [showPoster, setShowPoster] = useState(hasPoster);
@ -144,58 +162,114 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
setRestoreUserInterfaceForPIPStopCompletionHandler, setRestoreUserInterfaceForPIPStopCompletionHandler,
] = useState<boolean | undefined>(); ] = useState<boolean | undefined>();
const sourceToUnternalSource = useCallback(
(_source?: ReactVideoSource) => {
if (!_source) {
return undefined;
}
const resolvedSource = resolveAssetSourceForVideo(_source);
let uri = resolvedSource.uri || '';
if (uri && uri.match(/^\//)) {
uri = `file://${uri}`;
}
if (!uri) {
console.log('Trying to load empty source');
}
const isNetwork = !!(uri && uri.match(/^(rtp|rtsp|http|https):/));
const isAsset = !!(
uri &&
uri.match(
/^(assets-library|ipod-library|file|content|ms-appx|ms-appdata):/,
)
);
const selectedDrm = _source.drm || drm;
const _textTracks = _source.textTracks || textTracks;
const _drm = !selectedDrm
? undefined
: {
type: selectedDrm.type,
licenseServer: selectedDrm.licenseServer,
headers: generateHeaderForNative(selectedDrm.headers),
contentId: selectedDrm.contentId,
certificateUrl: selectedDrm.certificateUrl,
base64Certificate: selectedDrm.base64Certificate,
useExternalGetLicense: !!selectedDrm.getLicense,
multiDrm: selectedDrm.multiDrm,
localSourceEncryptionKeyScheme:
selectedDrm.localSourceEncryptionKeyScheme ||
localSourceEncryptionKeyScheme,
};
let _cmcd: NativeCmcdConfiguration | undefined;
if (Platform.OS === 'android' && source?.cmcd) {
const cmcd = source.cmcd;
if (typeof cmcd === 'boolean') {
_cmcd = cmcd ? {mode: CmcdMode.MODE_QUERY_PARAMETER} : undefined;
} else if (typeof cmcd === 'object' && !Array.isArray(cmcd)) {
const createCmcdHeader = (property?: CmcdData) =>
property ? generateHeaderForNative(property) : undefined;
_cmcd = {
mode: cmcd.mode ?? CmcdMode.MODE_QUERY_PARAMETER,
request: createCmcdHeader(cmcd.request),
session: createCmcdHeader(cmcd.session),
object: createCmcdHeader(cmcd.object),
status: createCmcdHeader(cmcd.status),
};
} else {
throw new Error(
'Invalid CMCD configuration: Expected a boolean or an object.',
);
}
}
const selectedContentStartTime =
_source.contentStartTime || contentStartTime;
const _ad =
_source.ad ||
(adTagUrl || adLanguage
? {adTagUrl: adTagUrl, adLanguage: adLanguage}
: undefined);
return {
uri,
isNetwork,
isAsset,
shouldCache: resolvedSource.shouldCache || false,
type: resolvedSource.type || '',
mainVer: resolvedSource.mainVer || 0,
patchVer: resolvedSource.patchVer || 0,
requestHeaders: generateHeaderForNative(resolvedSource.headers),
startPosition: resolvedSource.startPosition ?? -1,
cropStart: resolvedSource.cropStart || 0,
cropEnd: resolvedSource.cropEnd,
contentStartTime: selectedContentStartTime,
metadata: resolvedSource.metadata,
drm: _drm,
ad: _ad,
cmcd: _cmcd,
textTracks: _textTracks,
textTracksAllowChunklessPreparation:
resolvedSource.textTracksAllowChunklessPreparation,
};
},
[
adLanguage,
adTagUrl,
contentStartTime,
drm,
localSourceEncryptionKeyScheme,
source?.cmcd,
textTracks,
],
);
const src = useMemo<VideoSrc | undefined>(() => { const src = useMemo<VideoSrc | undefined>(() => {
if (!source) { return sourceToUnternalSource(source);
return undefined; }, [sourceToUnternalSource, source]);
}
const resolvedSource = resolveAssetSourceForVideo(source);
let uri = resolvedSource.uri || '';
if (uri && uri.match(/^\//)) {
uri = `file://${uri}`;
}
if (!uri) {
console.log('Trying to load empty source');
}
const isNetwork = !!(uri && uri.match(/^(rtp|rtsp|http|https):/));
const isAsset = !!(
uri &&
uri.match(
/^(assets-library|ipod-library|file|content|ms-appx|ms-appdata):/,
)
);
const selectedDrm = source.drm || drm;
const _drm = !selectedDrm
? undefined
: {
type: selectedDrm.type,
licenseServer: selectedDrm.licenseServer,
headers: generateHeaderForNative(selectedDrm.headers),
contentId: selectedDrm.contentId,
certificateUrl: selectedDrm.certificateUrl,
base64Certificate: selectedDrm.base64Certificate,
useExternalGetLicense: !!selectedDrm.getLicense,
multiDrm: selectedDrm.multiDrm,
};
return {
uri,
isNetwork,
isAsset,
shouldCache: resolvedSource.shouldCache || false,
type: resolvedSource.type || '',
mainVer: resolvedSource.mainVer || 0,
patchVer: resolvedSource.patchVer || 0,
requestHeaders: generateHeaderForNative(resolvedSource.headers),
startPosition: resolvedSource.startPosition ?? -1,
cropStart: resolvedSource.cropStart || 0,
cropEnd: resolvedSource.cropEnd,
metadata: resolvedSource.metadata,
drm: _drm,
textTracksAllowChunklessPreparation:
resolvedSource.textTracksAllowChunklessPreparation,
};
}, [drm, source]);
const _selectedTextTrack = useMemo(() => { const _selectedTextTrack = useMemo(() => {
if (!selectedTextTrack) { if (!selectedTextTrack) {
@ -317,6 +391,16 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
); );
}, []); }, []);
const setSource = useCallback(
(_source?: ReactVideoSource) => {
return NativeVideoManager.setSourceCmd(
getReactTag(nativeRef),
sourceToUnternalSource(_source),
);
},
[sourceToUnternalSource],
);
const presentFullscreenPlayer = useCallback( const presentFullscreenPlayer = useCallback(
() => setFullScreen(true), () => setFullScreen(true),
[setFullScreen], [setFullScreen],
@ -582,6 +666,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
setVolume, setVolume,
getCurrentPosition, getCurrentPosition,
setFullScreen, setFullScreen,
setSource,
}), }),
[ [
seek, seek,
@ -594,6 +679,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
setVolume, setVolume,
getCurrentPosition, getCurrentPosition,
setFullScreen, setFullScreen,
setSource,
], ],
); );
@ -662,15 +748,23 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
} }
// render poster // render poster
if (renderLoader && (poster || posterResizeMode)) { if (_renderLoader && (poster || posterResizeMode)) {
console.warn( console.warn(
'You provided both `renderLoader` and `poster` or `posterResizeMode` props. `renderLoader` will be used.', 'You provided both `renderLoader` and `poster` or `posterResizeMode` props. `renderLoader` will be used.',
); );
} }
// render loader // render loader
if (renderLoader) { if (_renderLoader) {
return <View style={StyleSheet.absoluteFill}>{renderLoader}</View>; return (
<View style={StyleSheet.absoluteFill}>
{_renderLoader({
source: source,
style: posterStyle,
resizeMode: resizeMode,
})}
</View>
);
} }
return ( return (
@ -685,8 +779,10 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
isPosterDeprecated, isPosterDeprecated,
poster, poster,
posterResizeMode, posterResizeMode,
renderLoader, _renderLoader,
showPoster, showPoster,
source,
resizeMode,
]); ]);
const _style: StyleProp<ViewStyle> = useMemo( const _style: StyleProp<ViewStyle> = useMemo(
@ -708,7 +804,6 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
restoreUserInterfaceForPIPStopCompletionHandler={ restoreUserInterfaceForPIPStopCompletionHandler={
_restoreUserInterfaceForPIPStopCompletionHandler _restoreUserInterfaceForPIPStopCompletionHandler
} }
textTracks={textTracks}
selectedTextTrack={_selectedTextTrack} selectedTextTrack={_selectedTextTrack}
selectedAudioTrack={_selectedAudioTrack} selectedAudioTrack={_selectedAudioTrack}
selectedVideoTrack={_selectedVideoTrack} selectedVideoTrack={_selectedVideoTrack}

453
src/Video.web.tsx Normal file
View File

@ -0,0 +1,453 @@
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
type RefObject,
} from 'react';
//@ts-ignore
import shaka from 'shaka-player';
import type {VideoRef, ReactVideoProps, VideoMetadata} from './types';
const Video = forwardRef<VideoRef, ReactVideoProps>(
(
{
source,
paused,
muted,
volume,
rate,
repeat,
controls,
showNotificationControls = false,
poster,
fullscreen,
fullscreenAutorotate,
fullscreenOrientation,
onBuffer,
onLoad,
onProgress,
onPlaybackRateChange,
onError,
onReadyForDisplay,
onSeek,
onVolumeChange,
onEnd,
onPlaybackStateChanged,
},
ref,
) => {
const nativeRef = useRef<HTMLVideoElement>(null);
const shakaPlayerRef = useRef<shaka.Player | null>(null);
const isSeeking = useRef(false);
const seek = useCallback(
async (time: number, _tolerance?: number) => {
if (isNaN(time)) {
throw new Error('Specified time is not a number');
}
if (!nativeRef.current) {
console.warn('Video Component is not mounted');
return;
}
time = Math.max(0, Math.min(time, nativeRef.current.duration));
nativeRef.current.currentTime = time;
onSeek?.({seekTime: time, currentTime: nativeRef.current.currentTime});
},
[onSeek],
);
const pause = useCallback(() => {
if (!nativeRef.current) {
return;
}
nativeRef.current.pause();
}, []);
const resume = useCallback(() => {
if (!nativeRef.current) {
return;
}
nativeRef.current.play();
}, []);
const setVolume = useCallback((vol: number) => {
if (!nativeRef.current) {
return;
}
nativeRef.current.volume = Math.max(0, Math.min(vol, 100)) / 100;
}, []);
const getCurrentPosition = useCallback(async () => {
if (!nativeRef.current) {
throw new Error('Video Component is not mounted');
}
return nativeRef.current.currentTime;
}, []);
const unsupported = useCallback(() => {
throw new Error('This is unsupported on the web');
}, []);
// Stock this in a ref to not invalidate memoization when those changes.
const fsPrefs = useRef({
fullscreenAutorotate,
fullscreenOrientation,
});
fsPrefs.current = {
fullscreenOrientation,
fullscreenAutorotate,
};
const setFullScreen = useCallback(
(
newVal: boolean,
orientation?: ReactVideoProps['fullscreenOrientation'],
autorotate?: boolean,
) => {
orientation ??= fsPrefs.current.fullscreenOrientation;
autorotate ??= fsPrefs.current.fullscreenAutorotate;
const run = async () => {
try {
if (newVal) {
await nativeRef.current?.requestFullscreen({
navigationUI: 'hide',
});
if (orientation === 'all' || !orientation || autorotate) {
screen.orientation.unlock();
} else {
await screen.orientation.lock(orientation);
}
} else {
if (document.fullscreenElement) {
await document.exitFullscreen();
}
screen.orientation.unlock();
}
} catch (e) {
// Changing fullscreen status without a button click is not allowed so it throws.
// Some browsers also used to throw when locking screen orientation was not supported.
console.error('Could not toggle fullscreen/screen lock status', e);
}
};
run();
},
[],
);
useEffect(() => {
setFullScreen(
fullscreen || false,
fullscreenOrientation,
fullscreenAutorotate,
);
}, [
setFullScreen,
fullscreen,
fullscreenAutorotate,
fullscreenOrientation,
]);
const presentFullscreenPlayer = useCallback(
() => setFullScreen(true),
[setFullScreen],
);
const dismissFullscreenPlayer = useCallback(
() => setFullScreen(false),
[setFullScreen],
);
useImperativeHandle(
ref,
() => ({
seek,
pause,
resume,
setVolume,
getCurrentPosition,
presentFullscreenPlayer,
dismissFullscreenPlayer,
setFullScreen,
save: unsupported,
restoreUserInterfaceForPictureInPictureStopCompleted: unsupported,
nativeHtmlVideoRef: nativeRef,
}),
[
seek,
pause,
resume,
unsupported,
setVolume,
getCurrentPosition,
nativeRef,
presentFullscreenPlayer,
dismissFullscreenPlayer,
setFullScreen,
],
);
useEffect(() => {
if (paused) {
pause();
} else {
resume();
}
}, [paused, pause, resume]);
useEffect(() => {
if (volume === undefined) {
return;
}
setVolume(volume);
}, [volume, setVolume]);
// we use a ref to prevent triggerring the useEffect when the component rerender with a non-stable `onPlaybackStateChanged`.
const playbackStateRef = useRef(onPlaybackStateChanged);
playbackStateRef.current = onPlaybackStateChanged;
useEffect(() => {
// Not sure about how to do this but we want to wait for nativeRef to be initialized
setTimeout(() => {
if (!nativeRef.current) {
return;
}
// Set play state to the player's value (if autoplay is denied)
// This is useful if our UI is in a play state but autoplay got denied so
// the video is actaully in a paused state.
playbackStateRef.current?.({
isPlaying: !nativeRef.current.paused,
isSeeking: isSeeking.current,
});
}, 500);
}, []);
useEffect(() => {
if (!nativeRef.current || rate === undefined) {
return;
}
nativeRef.current.playbackRate = rate;
}, [rate]);
useEffect(() => {
if (!nativeRef.current) {
console.log("Not starting shaka yet bc undefined")
return;
}
if (shakaPlayerRef.current) {
shakaPlayerRef.current.unload()
}
shakaPlayerRef.current = new shaka.Player();
//@ts-ignore
shakaPlayerRef.current.addEventListener("error", (event) => {
//@ts-ignore
const shakaError = event.detail;
console.error('Shaka Player Error', shakaError);
onError?.({
error: {
errorString: shakaError.message,
code: shakaError.code,
},
});
});
console.log("Initializing and attaching shaka")
shakaPlayerRef.current.attach(nativeRef.current, true);
//@ts-ignore
shakaPlayerRef.current.load(source?.uri).then(
() => console.log(`${source?.uri} finished loading`)
);
console.log("Started shaka loading");
}, [source, nativeRef.current])
useMediaSession(source?.metadata, nativeRef, showNotificationControls);
return (
<video
ref={nativeRef}
muted={muted}
autoPlay={!paused}
controls={controls}
loop={repeat}
playsInline
//@ts-ignore
poster={poster}
onCanPlay={() => onBuffer?.({isBuffering: false})}
onWaiting={() => onBuffer?.({isBuffering: true})}
onRateChange={() => {
if (!nativeRef.current) {
return;
}
onPlaybackRateChange?.({
playbackRate: nativeRef.current?.playbackRate,
});
}}
onDurationChange={() => {
if (!nativeRef.current) {
return;
}
onLoad?.({
currentTime: nativeRef.current.currentTime,
duration: nativeRef.current.duration,
videoTracks: [],
textTracks: [],
audioTracks: [],
naturalSize: {
width: nativeRef.current.videoWidth,
height: nativeRef.current.videoHeight,
orientation: 'landscape',
},
});
}}
onTimeUpdate={() => {
if (!nativeRef.current) {
return;
}
onProgress?.({
currentTime: nativeRef.current.currentTime,
playableDuration: nativeRef.current.buffered.length
? nativeRef.current.buffered.end(
nativeRef.current.buffered.length - 1,
)
: 0,
seekableDuration: 0,
});
}}
onLoadedData={() => onReadyForDisplay?.()}
onError={() => {
if (!nativeRef.current?.error) {
return;
}
onError?.({
error: {
errorString: nativeRef.current.error.message ?? 'Unknown error',
code: nativeRef.current.error.code,
},
});
}}
onLoadedMetadata={() => {
if (source?.startPosition) {
seek(source.startPosition / 1000);
}
}}
onPlay={() =>
onPlaybackStateChanged?.({
isPlaying: true,
isSeeking: isSeeking.current,
})
}
onPause={() =>
onPlaybackStateChanged?.({
isPlaying: false,
isSeeking: isSeeking.current,
})
}
onSeeking={() => (isSeeking.current = true)}
onSeeked={() => (isSeeking.current = false)}
onVolumeChange={() => {
if (!nativeRef.current) {
return;
}
onVolumeChange?.({volume: nativeRef.current.volume});
}}
onEnded={onEnd}
style={videoStyle}
/>
);
},
);
const videoStyle = {
position: 'absolute',
inset: 0,
objectFit: 'contain',
width: '100%',
height: '100%',
} satisfies React.CSSProperties;
const useMediaSession = (
metadata: VideoMetadata | undefined,
nativeRef: RefObject<HTMLVideoElement>,
showNotification: boolean,
) => {
const isPlaying = !nativeRef.current?.paused ?? false;
const progress = nativeRef.current?.currentTime ?? 0;
const duration = Number.isFinite(nativeRef.current?.duration)
? nativeRef.current?.duration
: undefined;
const playbackRate = nativeRef.current?.playbackRate ?? 1;
const enabled = 'mediaSession' in navigator && showNotification;
useEffect(() => {
if (enabled) {
navigator.mediaSession.metadata = new MediaMetadata({
title: metadata?.title,
artist: metadata?.artist,
artwork: metadata?.imageUri ? [{src: metadata.imageUri}] : undefined,
});
}
}, [enabled, metadata]);
useEffect(() => {
if (!enabled) {
return;
}
const seekTo = (time: number) => {
if (nativeRef.current) {
nativeRef.current.currentTime = time;
}
};
const seekRelative = (offset: number) => {
if (nativeRef.current) {
nativeRef.current.currentTime = nativeRef.current.currentTime + offset;
}
};
const mediaActions: [
MediaSessionAction,
MediaSessionActionHandler | null,
][] = [
['play', () => nativeRef.current?.play()],
['pause', () => nativeRef.current?.pause()],
[
'seekbackward',
(evt: MediaSessionActionDetails) =>
seekRelative(evt.seekOffset ? -evt.seekOffset : -10),
],
[
'seekforward',
(evt: MediaSessionActionDetails) =>
seekRelative(evt.seekOffset ? evt.seekOffset : 10),
],
['seekto', (evt: MediaSessionActionDetails) => seekTo(evt.seekTime!)],
];
for (const [action, handler] of mediaActions) {
try {
navigator.mediaSession.setActionHandler(action, handler);
} catch {
// ignored
}
}
}, [enabled, nativeRef]);
useEffect(() => {
if (enabled) {
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
}
}, [isPlaying, enabled]);
useEffect(() => {
if (enabled && duration !== undefined) {
navigator.mediaSession.setPositionState({
position: Math.min(progress, duration),
duration,
playbackRate: playbackRate,
});
}
}, [progress, duration, playbackRate, enabled]);
};
Video.displayName = 'Video';
export default Video;

View File

@ -0,0 +1,34 @@
/// <reference lib="dom" />
import type {VideoDecoderInfoModuleType} from './specs/NativeVideoDecoderInfoModule';
const canPlay = (codec: string): boolean => {
// most chrome based browser (and safari I think) supports matroska but reports they do not.
// for those browsers, only check the codecs and not the container.
if (navigator.userAgent.search('Firefox') === -1) {
codec = codec.replace('video/x-matroska', 'video/mp4');
}
return !!MediaSource.isTypeSupported(codec);
};
export const VideoDecoderProperties = {
async getWidevineLevel() {
return 0;
},
async isCodecSupported(
mimeType: string,
_width: number,
_height: number,
): Promise<'unsupported' | 'hardware' | 'software'> {
// TODO: Figure out if we can get hardware support information
return canPlay(mimeType) ? 'software' : 'unsupported';
},
async isHEVCSupported(): Promise<'unsupported' | 'hardware' | 'software'> {
// Just a dummy vidoe mime type codec with HEVC to check.
return canPlay('video/x-matroska; codecs="hvc1.1.4.L96.BO"')
? 'software'
: 'unsupported';
},
} satisfies VideoDecoderInfoModuleType;

View File

@ -13,7 +13,7 @@ export const withBackgroundAudio: ConfigPlugin<boolean> = (
if (enableBackgroundAudio) { if (enableBackgroundAudio) {
if (!modes.includes('audio')) { if (!modes.includes('audio')) {
modes.push('audio'); config.modResults.UIBackgroundModes = [...modes, 'audio'];
} }
} else { } else {
config.modResults.UIBackgroundModes = modes.filter( config.modResults.UIBackgroundModes = modes.filter(

View File

@ -24,6 +24,19 @@ export const withNotificationControls: ConfigPlugin<boolean> = (
application.service = []; application.service = [];
} }
// We check if the VideoPlaybackService is already defined in the AndroidManifest.xml
// to prevent adding duplicate service entries. If the service exists, we will remove
// it before adding the updated configuration to ensure there are no conflicts or redundant
// service declarations in the manifest.
const existingServiceIndex = application.service.findIndex(
(service) =>
service?.$?.['android:name'] ===
'com.brentvatne.exoplayer.VideoPlaybackService',
);
if (existingServiceIndex !== -1) {
application.service.splice(existingServiceIndex, 1);
}
application.service.push({ application.service.push({
$: { $: {
'android:name': 'com.brentvatne.exoplayer.VideoPlaybackService', 'android:name': 'com.brentvatne.exoplayer.VideoPlaybackService',

View File

@ -1,5 +1,5 @@
import Video from './Video'; import Video from './Video';
export {VideoDecoderProperties} from './VideoDecoderProperties'; export {VideoDecoderProperties} from './VideoDecoderProperties';
export * from './types'; export * from './types';
export type {VideoRef} from './Video'; export {Video};
export default Video; export default Video;

View File

@ -2,7 +2,7 @@ import {NativeModules} from 'react-native';
import type {Int32} from 'react-native/Libraries/Types/CodegenTypes'; import type {Int32} from 'react-native/Libraries/Types/CodegenTypes';
// @TODO rename to "Spec" when applying new arch // @TODO rename to "Spec" when applying new arch
interface VideoDecoderInfoModuleType { export interface VideoDecoderInfoModuleType {
getWidevineLevel: () => Promise<Int32>; getWidevineLevel: () => Promise<Int32>;
isCodecSupported: ( isCodecSupported: (
mimeType: string, mimeType: string,

View File

@ -4,10 +4,7 @@ import type {
Float, Float,
UnsafeObject, UnsafeObject,
} from 'react-native/Libraries/Types/CodegenTypes'; } from 'react-native/Libraries/Types/CodegenTypes';
import type {VideoSaveData} from '../types/video-ref';
export type VideoSaveData = {
uri: string;
};
// @TODO rename to "Spec" when applying new arch // @TODO rename to "Spec" when applying new arch
export interface VideoManagerType { export interface VideoManagerType {
@ -24,6 +21,7 @@ export interface VideoManagerType {
licenseUrl: string, licenseUrl: string,
) => Promise<void>; ) => Promise<void>;
setFullScreenCmd: (reactTag: Int32, fullScreen: boolean) => Promise<void>; setFullScreenCmd: (reactTag: Int32, fullScreen: boolean) => Promise<void>;
setSourceCmd: (reactTag: Int32, source?: UnsafeObject) => Promise<void>;
setVolumeCmd: (reactTag: Int32, volume: number) => Promise<void>; setVolumeCmd: (reactTag: Int32, volume: number) => Promise<void>;
save: (reactTag: Int32, option: UnsafeObject) => Promise<VideoSaveData>; save: (reactTag: Int32, option: UnsafeObject) => Promise<VideoSaveData>;
getCurrentPosition: (reactTag: Int32) => Promise<Int32>; getCurrentPosition: (reactTag: Int32) => Promise<Int32>;

View File

@ -26,6 +26,11 @@ type VideoMetadata = Readonly<{
imageUri?: string; imageUri?: string;
}>; }>;
export type AdsConfig = Readonly<{
adTagUrl?: string;
adLanguage?: string;
}>;
export type VideoSrc = Readonly<{ export type VideoSrc = Readonly<{
uri?: string; uri?: string;
isNetwork?: boolean; isNetwork?: boolean;
@ -38,9 +43,13 @@ export type VideoSrc = Readonly<{
startPosition?: Float; startPosition?: Float;
cropStart?: Float; cropStart?: Float;
cropEnd?: Float; cropEnd?: Float;
contentStartTime?: Int32; // Android
metadata?: VideoMetadata; metadata?: VideoMetadata;
drm?: Drm; drm?: Drm;
cmcd?: NativeCmcdConfiguration; // android
textTracksAllowChunklessPreparation?: boolean; // android textTracksAllowChunklessPreparation?: boolean; // android
textTracks?: TextTracks;
ad?: AdsConfig;
}>; }>;
type DRMType = WithDefault<string, 'widevine'>; type DRMType = WithDefault<string, 'widevine'>;
@ -59,6 +68,16 @@ type Drm = Readonly<{
base64Certificate?: boolean; // ios default: false base64Certificate?: boolean; // ios default: false
useExternalGetLicense?: boolean; // ios useExternalGetLicense?: boolean; // ios
multiDrm?: WithDefault<boolean, false>; // android multiDrm?: WithDefault<boolean, false>; // android
localSourceEncryptionKeyScheme?: string; // ios
}>;
type CmcdMode = WithDefault<Int32, 1>;
export type NativeCmcdConfiguration = Readonly<{
mode?: CmcdMode; // default: MODE_QUERY_PARAMETER
request?: Headers;
session?: Headers;
object?: Headers;
status?: Headers;
}>; }>;
type TextTracks = ReadonlyArray< type TextTracks = ReadonlyArray<
@ -121,6 +140,7 @@ type SubtitleStyle = Readonly<{
paddingLeft?: WithDefault<Float, 0>; paddingLeft?: WithDefault<Float, 0>;
paddingRight?: WithDefault<Float, 0>; paddingRight?: WithDefault<Float, 0>;
opacity?: WithDefault<Float, 1>; opacity?: WithDefault<Float, 1>;
subtitlesFollowVideo?: WithDefault<boolean, true>;
}>; }>;
type OnLoadData = Readonly<{ type OnLoadData = Readonly<{
@ -270,12 +290,12 @@ type OnReceiveAdEventData = Readonly<{
export type OnVideoErrorData = Readonly<{ export type OnVideoErrorData = Readonly<{
error: Readonly<{ error: Readonly<{
errorString?: string; // android errorString?: string; // android | web
errorException?: string; // android errorException?: string; // android
errorStackTrace?: string; // android errorStackTrace?: string; // android
errorCode?: string; // android errorCode?: string; // android
error?: string; // ios error?: string; // ios
code?: Int32; // ios code?: Int32; // ios | web
localizedDescription?: string; // ios localizedDescription?: string; // ios
localizedFailureReason?: string; // ios localizedFailureReason?: string; // ios
localizedRecoverySuggestion?: string; // ios localizedRecoverySuggestion?: string; // ios
@ -289,8 +309,20 @@ export type OnAudioFocusChangedData = Readonly<{
}>; }>;
type ControlsStyles = Readonly<{ type ControlsStyles = Readonly<{
hideSeekBar?: boolean; hidePosition?: WithDefault<boolean, false>;
hidePlayPause?: WithDefault<boolean, false>;
hideForward?: WithDefault<boolean, false>;
hideRewind?: WithDefault<boolean, false>;
hideNext?: WithDefault<boolean, false>;
hidePrevious?: WithDefault<boolean, false>;
hideFullscreen?: WithDefault<boolean, false>;
hideSeekBar?: WithDefault<boolean, false>;
hideDuration?: WithDefault<boolean, false>;
hideNavigationBarOnFullScreenMode?: WithDefault<boolean, true>;
hideNotificationBarOnFullScreenMode?: WithDefault<boolean, true>;
hideSettingButton?: WithDefault<boolean, true>;
seekIncrementMS?: Int32; seekIncrementMS?: Int32;
liveLabel?: string;
}>; }>;
export type OnControlsVisibilityChange = Readonly<{ export type OnControlsVisibilityChange = Readonly<{
@ -299,7 +331,6 @@ export type OnControlsVisibilityChange = Readonly<{
export interface VideoNativeProps extends ViewProps { export interface VideoNativeProps extends ViewProps {
src?: VideoSrc; src?: VideoSrc;
adTagUrl?: string;
allowsExternalPlayback?: boolean; // ios, true allowsExternalPlayback?: boolean; // ios, true
disableFocus?: boolean; // android disableFocus?: boolean; // android
maxBitRate?: Float; maxBitRate?: Float;
@ -308,7 +339,6 @@ export interface VideoNativeProps extends ViewProps {
automaticallyWaitsToMinimizeStalling?: boolean; automaticallyWaitsToMinimizeStalling?: boolean;
shutterColor?: Int32; shutterColor?: Int32;
audioOutput?: WithDefault<string, 'speaker'>; audioOutput?: WithDefault<string, 'speaker'>;
textTracks?: TextTracks;
selectedTextTrack?: SelectedTextTrack; selectedTextTrack?: SelectedTextTrack;
selectedAudioTrack?: SelectedAudioTrack; selectedAudioTrack?: SelectedAudioTrack;
selectedVideoTrack?: SelectedVideoTrack; // android selectedVideoTrack?: SelectedVideoTrack; // android
@ -331,11 +361,9 @@ export interface VideoNativeProps extends ViewProps {
fullscreenOrientation?: WithDefault<string, 'all'>; fullscreenOrientation?: WithDefault<string, 'all'>;
progressUpdateInterval?: Float; progressUpdateInterval?: Float;
restoreUserInterfaceForPIPStopCompletionHandler?: boolean; restoreUserInterfaceForPIPStopCompletionHandler?: boolean;
localSourceEncryptionKeyScheme?: string;
debug?: DebugConfig; debug?: DebugConfig;
showNotificationControls?: WithDefault<boolean, false>; // Android, iOS showNotificationControls?: WithDefault<boolean, false>; // Android, iOS
bufferConfig?: BufferConfig; // Android bufferConfig?: BufferConfig; // Android
contentStartTime?: Int32; // Android
currentPlaybackTime?: Double; // Android currentPlaybackTime?: Double; // Android
disableDisconnectError?: boolean; // Android disableDisconnectError?: boolean; // Android
focusable?: boolean; // Android focusable?: boolean; // Android

View File

@ -21,6 +21,8 @@ import type {
OnVolumeChangeData, OnVolumeChangeData,
} from '../specs/VideoNativeComponent'; } from '../specs/VideoNativeComponent';
export type * from '../specs/VideoNativeComponent';
export type AudioTrack = OnAudioTracksData['audioTracks'][number]; export type AudioTrack = OnAudioTracksData['audioTracks'][number];
export type TextTrack = OnTextTracksData['textTracks'][number]; export type TextTrack = OnTextTracksData['textTracks'][number];
export type VideoTrack = OnVideoTracksData['videoTracks'][number]; export type VideoTrack = OnVideoTracksData['videoTracks'][number];

View File

@ -7,4 +7,4 @@ export {default as ResizeMode} from './ResizeMode';
export {default as TextTrackType} from './TextTrackType'; export {default as TextTrackType} from './TextTrackType';
export {default as ViewType} from './ViewType'; export {default as ViewType} from './ViewType';
export * from './video'; export * from './video';
export * from '../specs/VideoNativeComponent'; export * from './video-ref';

Some files were not shown because too many files have changed in this diff Show More