From 2633f087d238e6b48e6df266c380eec64a29754d Mon Sep 17 00:00:00 2001 From: Olivier Bouillet <62574056+freeboub@users.noreply.github.com> Date: Wed, 3 Apr 2024 22:19:13 +0200 Subject: [PATCH 01/12] doc: fix table format (#3649) --- docs/pages/component/events.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/pages/component/events.mdx b/docs/pages/component/events.mdx index 09d50a2944..afd2c0ec05 100644 --- a/docs/pages/component/events.mdx +++ b/docs/pages/component/events.mdx @@ -250,7 +250,7 @@ Callback function that is called when the media starts loading. Payload: -| Property | Description | +| Property | Type | Description | | --------- | ----------- | ---------------------------------------------------------------- | | isNetwork | boolean | Boolean indicating if the media is being loaded from the network | | type | string | Type of the media. Not available on Windows | @@ -274,7 +274,7 @@ Callback function that is called when the playback state changes. Payload: -| Property | Description | +| Property | Type | Description | | --------- | ----------- | ------------------------------------------------- | | isPlaying | boolean | Boolean indicating if the media is playing or not | From 10b100de44830f8c6d502ddbaae6d6c72c04f625 Mon Sep 17 00:00:00 2001 From: Krzysztof Moch Date: Thu, 4 Apr 2024 13:23:44 +0200 Subject: [PATCH 02/12] feat!(ios): remove native dependency `promises` (#3631) --- docs/pages/index.md | 3 +- docs/pages/installation.md | 11 +- examples/basic/ios/Podfile.lock | 10 +- .../ios/videoplayer.xcodeproj/project.pbxproj | 8 +- examples/exampletvOS/ios/Podfile.lock | 18 +- ios/Video/Features/RCTPlayerOperations.swift | 270 +++++++------ .../Features/RCTResourceLoaderDelegate.swift | 72 ++-- ios/Video/Features/RCTVideoDRM.swift | 175 ++++----- ios/Video/Features/RCTVideoUtils.swift | 354 ++++++++---------- ios/Video/Features/URLSession+data.swift | 24 ++ ios/Video/RCTVideo.swift | 328 ++++++++-------- ios/VideoCaching/RCTVideoCachingHandler.swift | 107 +++--- ios/patches/PromisesObjC.podspec | 41 -- ios/patches/PromisesSwift.podspec | 21 -- react-native-video.podspec | 3 +- 15 files changed, 673 insertions(+), 772 deletions(-) create mode 100644 ios/Video/Features/URLSession+data.swift delete mode 100644 ios/patches/PromisesObjC.podspec delete mode 100644 ios/patches/PromisesSwift.podspec diff --git a/docs/pages/index.md b/docs/pages/index.md index 9652d953e2..3388cba343 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -6,7 +6,8 @@ ## Beta Information > ⚠️ **Version 6 Beta**: The following documentation may refer to features only available through the v6.0.0 alpha releases, [please see version 5.2.x](https://github.com/react-native-video/react-native-video/blob/v5.2.0/README.md) for the current documentation! -Version 6.x recommends react-native >= 0.68.2. +Version 6.x requires **react-native >= 0.68.2** +> ⚠️ from **6.0.0-beta.8** requires also **iOS >= 13.0** (default in react-native 0.73) For older versions of react-native, [please use version 5.x](https://github.com/react-native-video/react-native-video/tree/v5.2.0). diff --git a/docs/pages/installation.md b/docs/pages/installation.md index 8714ea3418..4f59c88373 100644 --- a/docs/pages/installation.md +++ b/docs/pages/installation.md @@ -22,6 +22,7 @@ Then follow the instructions for your platform to link react-native-video into y ## iOS ### Standard Method +Run `pod install` in the `ios` directory of your project. ### Enable custom feature in podfile file @@ -155,16 +156,8 @@ Select RCTVideo-tvOS visionOS ## visionOS -Add patch for `promises` pods to your pod files to make it work with `visionOS` target. -> This patch is required only for `visionOS` target and will be removed in future. -```diff -+ pod 'PromisesSwift', :podspec => '../node_modules/react-native-video/ios/patches/PromisesSwift.podspec' -+ pod 'PromisesObjC', :podspec => '../node_modules/react-native-video/ios/patches/PromisesObjC.podspec' -``` - -**Remember** to run `pod install` after adding this patch. +Run `pod install` in the `visionos` directory of your project -After this you can follow the same steps as for `iOS` target. ## Examples diff --git a/examples/basic/ios/Podfile.lock b/examples/basic/ios/Podfile.lock index 8c26296392..c54c33e930 100644 --- a/examples/basic/ios/Podfile.lock +++ b/examples/basic/ios/Podfile.lock @@ -7,9 +7,6 @@ PODS: - hermes-engine (0.74.0-rc.4): - hermes-engine/Pre-built (= 0.74.0-rc.4) - hermes-engine/Pre-built (0.74.0-rc.4) - - PromisesObjC (2.4.0) - - PromisesSwift (2.4.0): - - PromisesObjC (= 2.4.0) - RCT-Folly (2024.01.01.00): - boost - DoubleConversion @@ -942,7 +939,6 @@ PODS: - React-Core - react-native-video/Video (= 6.0.0-beta.6) - react-native-video/Video (6.0.0-beta.6): - - PromisesSwift - React-Core - React-nativeconfig (0.74.0-rc.4) - React-NativeModulesApple (0.74.0-rc.4): @@ -1239,8 +1235,6 @@ DEPENDENCIES: SPEC REPOS: trunk: - - PromisesObjC - - PromisesSwift - SocketRocket EXTERNAL SOURCES: @@ -1365,8 +1359,6 @@ SPEC CHECKSUMS: fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120 glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2 hermes-engine: dfdcadd89a22aa872ef552b07e415d88df68af55 - PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 RCT-Folly: 045d6ecaa59d826c5736dfba0b2f4083ff8d79df RCTDeprecation: 1c5ab5895f9fc7e8ae9fcde04859f0d246283209 RCTRequired: 79e2e81174db06336f470c49aea7603ff29817a7 @@ -1391,7 +1383,7 @@ SPEC CHECKSUMS: React-jsitracing: 50e3ea936a199a2a7fcab922f156507c97f0b88c React-logger: 6004e0cf41b7e9714ca26b1648e5d76fcfd638b5 React-Mapbuffer: 9b163fa28e549d5f36f89a39a1145fcaf262d0d0 - react-native-video: dc3118548cf8864a83f57df4345cf6c692402e8f + react-native-video: d340c162bf7974c2935fbeec0c5dea362f9dd74a React-nativeconfig: 3948d6fb6acfec364625cffbb1cf420346fb37c0 React-NativeModulesApple: 46745aba687c1019983d56b6d5fa39265152f64f React-perflogger: 0d62c0261b6fd3920605850de91abc8135dd3ee9 diff --git a/examples/basic/ios/videoplayer.xcodeproj/project.pbxproj b/examples/basic/ios/videoplayer.xcodeproj/project.pbxproj index b3956a3ca1..e4833eb2f9 100644 --- a/examples/basic/ios/videoplayer.xcodeproj/project.pbxproj +++ b/examples/basic/ios/videoplayer.xcodeproj/project.pbxproj @@ -211,7 +211,7 @@ }; }; }; - buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "videoplayer" */; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "VideoPlayer" */; compatibilityVersion = "Xcode 12.0"; developmentRegion = en; hasScannedForEncodings = 0; @@ -619,7 +619,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx", "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers", ); - IPHONEOS_DEPLOYMENT_TARGET = 12.4; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( /usr/lib/swift, "$(inherited)", @@ -729,7 +729,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx", "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers", ); - IPHONEOS_DEPLOYMENT_TARGET = 12.4; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( /usr/lib/swift, "$(inherited)", @@ -776,7 +776,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "videoplayer" */ = { + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "VideoPlayer" */ = { isa = XCConfigurationList; buildConfigurations = ( 83CBBA201A601CBA00E9B192 /* Debug */, diff --git a/examples/exampletvOS/ios/Podfile.lock b/examples/exampletvOS/ios/Podfile.lock index 42cd04f243..70d89da749 100644 --- a/examples/exampletvOS/ios/Podfile.lock +++ b/examples/exampletvOS/ios/Podfile.lock @@ -78,9 +78,6 @@ PODS: - hermes-engine/Pre-built (0.71.12-0) - libevent (2.1.12.1) - OpenSSL-Universal (1.1.1100) - - PromisesObjC (2.3.1) - - PromisesSwift (2.3.1): - - PromisesObjC (= 2.3.1) - RCT-Folly (2021.07.22.00): - boost - DoubleConversion @@ -319,12 +316,9 @@ PODS: - React-jsinspector (0.71.12-0) - React-logger (0.71.12-0): - glog - - react-native-video (6.0.0-alpha.8): - - React-Core - - react-native-video/Video (= 6.0.0-alpha.8) - - react-native-video/Video (6.0.0-alpha.8): - - PromisesSwift + - react-native-video (6.0.0-beta.6): - React-Core + - react-native-video/Video (= 6.0.0-beta.6) - React-perflogger (0.71.12-0) - React-RCTActionSheet (0.71.12-0): - React-Core/RCTActionSheetHeaders (= 0.71.12-0) @@ -486,8 +480,6 @@ SPEC REPOS: - Flipper-RSocket - FlipperKit - OpenSSL-Universal - - PromisesObjC - - PromisesSwift - SocketRocket EXTERNAL SOURCES: @@ -582,8 +574,6 @@ SPEC CHECKSUMS: hermes-engine: 3d04f537177e132da926803412639dacd59a0ee9 libevent: a6d75fcd7be07cbc5070300ea8dbc8d55dfab88e OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c - PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4 - PromisesSwift: 28dca69a9c40779916ac2d6985a0192a5cb4a265 RCT-Folly: 136e9161a833a162fe3e8b647098759aae227036 RCTRequired: 0c0d97ba9f1e2b2b70e0522d65992a2993a714cd RCTTypeSafety: 5a484bd8f18408b8918a668ac8bd8b9f9138142b @@ -598,7 +588,7 @@ SPEC CHECKSUMS: React-jsiexecutor: 0c8c5e8b2171be52295f59097923babf84d1cf66 React-jsinspector: f8e6919523047a9bd1270ade75b4eca0108963b4 React-logger: 16c56636d4209cc204d06c5ba347cee21b960012 - react-native-video: 86950ad481cec184d7c9420ec3bca0c27904bbcd + react-native-video: 98040e05dace82fbbe8709cf42fd4496b0aed744 React-perflogger: 355109dc9d6f34e35bc35dabb32310f8ed2d29a2 React-RCTActionSheet: 9d1be4d43972f2aae4b31d9e53ffb030115fa445 React-RCTAnimation: aab7e1ecd325db67e1f2a947d85a52adf86594b7 @@ -617,4 +607,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 49dad183688257f9360c15d54e77f8de0f8048f7 -COCOAPODS: 1.12.1 +COCOAPODS: 1.13.0 diff --git a/ios/Video/Features/RCTPlayerOperations.swift b/ios/Video/Features/RCTPlayerOperations.swift index 809ea4ee62..265ab23529 100644 --- a/ios/Video/Features/RCTPlayerOperations.swift +++ b/ios/Video/Features/RCTPlayerOperations.swift @@ -1,6 +1,5 @@ import AVFoundation import MediaAccessibility -import Promises let RCTVideoUnset = -1 @@ -10,187 +9,184 @@ let RCTVideoUnset = -1 * Collection of mutating functions */ enum RCTPlayerOperations { - static func setSideloadedText(player: AVPlayer?, textTracks: [TextTrack], criteria: SelectedTrackCriteria?) -> Promise { - return Promise { - let type = criteria?.type - - let trackCount: Int! = player?.currentItem?.tracks.count ?? 0 + static func setSideloadedText(player: AVPlayer?, textTracks: [TextTrack], criteria: SelectedTrackCriteria?) { + let type = criteria?.type - // The first few tracks will be audio & video track - var firstTextIndex = 0 - for i in 0 ..< trackCount where player?.currentItem?.tracks[i].assetTrack?.hasMediaCharacteristic(.legible) ?? false { - firstTextIndex = i - break - } + let trackCount: Int! = player?.currentItem?.tracks.count ?? 0 - var selectedTrackIndex: Int = RCTVideoUnset + // The first few tracks will be audio & video track + var firstTextIndex = 0 + for i in 0 ..< trackCount where player?.currentItem?.tracks[i].assetTrack?.hasMediaCharacteristic(.legible) ?? false { + firstTextIndex = i + break + } - if type == "disabled" { - // Select the last text index which is the disabled text track - selectedTrackIndex = trackCount - firstTextIndex - } else if type == "language" { - let selectedValue = criteria?.value as? String - for i in 0 ..< textTracks.count { - let currentTextTrack = textTracks[i] - if selectedValue == currentTextTrack.language { - selectedTrackIndex = i - break - } + var selectedTrackIndex: Int = RCTVideoUnset + + if type == "disabled" { + // Select the last text index which is the disabled text track + selectedTrackIndex = trackCount - firstTextIndex + } else if type == "language" { + let selectedValue = criteria?.value as? String + for i in 0 ..< textTracks.count { + let currentTextTrack = textTracks[i] + if selectedValue == currentTextTrack.language { + selectedTrackIndex = i + break } - } else if type == "title" { - let selectedValue = criteria?.value as? String - for i in 0 ..< textTracks.count { - let currentTextTrack = textTracks[i] - if selectedValue == currentTextTrack.title { - selectedTrackIndex = i - break - } + } + } else if type == "title" { + let selectedValue = criteria?.value as? String + for i in 0 ..< textTracks.count { + let currentTextTrack = textTracks[i] + if selectedValue == currentTextTrack.title { + selectedTrackIndex = i + break } - } else if type == "index" { - if let value = criteria?.value, let index = value as? Int { - if textTracks.count > index { - selectedTrackIndex = index - } + } + } else if type == "index" { + if let value = criteria?.value, let index = value as? Int { + if textTracks.count > index { + selectedTrackIndex = index } } + } - // in the situation that a selected text track is not available (eg. specifies a textTrack not available) - if (type != "disabled") && selectedTrackIndex == RCTVideoUnset { - let captioningMediaCharacteristics = MACaptionAppearanceCopyPreferredCaptioningMediaCharacteristics(.user) - let captionSettings = captioningMediaCharacteristics as? [AnyHashable] - if (captionSettings?.contains(AVMediaCharacteristic.transcribesSpokenDialogForAccessibility)) != nil { - selectedTrackIndex = 0 // If we can't find a match, use the first available track - let systemLanguage = NSLocale.preferredLanguages.first - for i in 0 ..< textTracks.count { - let currentTextTrack = textTracks[i] - if systemLanguage == currentTextTrack.language { - selectedTrackIndex = i - break - } + // in the situation that a selected text track is not available (eg. specifies a textTrack not available) + if (type != "disabled") && selectedTrackIndex == RCTVideoUnset { + let captioningMediaCharacteristics = MACaptionAppearanceCopyPreferredCaptioningMediaCharacteristics(.user) + let captionSettings = captioningMediaCharacteristics as? [AnyHashable] + if (captionSettings?.contains(AVMediaCharacteristic.transcribesSpokenDialogForAccessibility)) != nil { + selectedTrackIndex = 0 // If we can't find a match, use the first available track + let systemLanguage = NSLocale.preferredLanguages.first + for i in 0 ..< textTracks.count { + let currentTextTrack = textTracks[i] + if systemLanguage == currentTextTrack.language { + selectedTrackIndex = i + break } } } + } - for i in firstTextIndex ..< trackCount { - var isEnabled = false - if selectedTrackIndex != RCTVideoUnset { - isEnabled = i == selectedTrackIndex + firstTextIndex - } - player?.currentItem?.tracks[i].isEnabled = isEnabled + for i in firstTextIndex ..< trackCount { + var isEnabled = false + if selectedTrackIndex != RCTVideoUnset { + isEnabled = i == selectedTrackIndex + firstTextIndex } + player?.currentItem?.tracks[i].isEnabled = isEnabled } } // UNUSED - static func setStreamingText(player: AVPlayer?, criteria: SelectedTrackCriteria?) { + static func setStreamingText(player: AVPlayer?, criteria: SelectedTrackCriteria?) async { let type = criteria?.type var mediaOption: AVMediaSelectionOption! - RCTVideoAssetsUtils.getMediaSelectionGroup(asset: player?.currentItem?.asset, for: .legible).then { group in - guard let group else { return } - - if type == "disabled" { - // Do nothing. We want to ensure option is nil - } else if (type == "language") || (type == "title") { - let value = criteria?.value as? String - for i in 0 ..< group.options.count { - let currentOption: AVMediaSelectionOption! = group.options[i] - var optionValue: String! - if type == "language" { - optionValue = currentOption.extendedLanguageTag - } else { - optionValue = currentOption.commonMetadata.map(\.value)[0] as! String - } - if value == optionValue { - mediaOption = currentOption - break - } + guard let group = await RCTVideoAssetsUtils.getMediaSelectionGroup(asset: player?.currentItem?.asset, for: .legible) else { + return + } + + if type == "disabled" { + // Do nothing. We want to ensure option is nil + } else if (type == "language") || (type == "title") { + let value = criteria?.value as? String + for i in 0 ..< group.options.count { + let currentOption: AVMediaSelectionOption! = group.options[i] + var optionValue: String! + if type == "language" { + optionValue = currentOption.extendedLanguageTag + } else { + optionValue = currentOption.commonMetadata.map(\.value)[0] as! String } - // } else if ([type isEqualToString:@"default"]) { - // option = group.defaultOption; */ - } else if type == "index" { - if let value = criteria?.value, let index = value as? Int { - if group.options.count > index { - mediaOption = group.options[index] - } + if value == optionValue { + mediaOption = currentOption + break } - } else { // default. invalid type or "system" - #if os(tvOS) - // Do noting. Fix for tvOS native audio menu language selector - #else - player?.currentItem?.selectMediaOptionAutomatically(in: group) - return - #endif } - + // } else if ([type isEqualToString:@"default"]) { + // option = group.defaultOption; */ + } else if type == "index" { + if let value = criteria?.value, let index = value as? Int { + if group.options.count > index { + mediaOption = group.options[index] + } + } + } else { // default. invalid type or "system" #if os(tvOS) // Do noting. Fix for tvOS native audio menu language selector #else - // If a match isn't found, option will be nil and text tracks will be disabled - player?.currentItem?.select(mediaOption, in: group) + await player?.currentItem?.selectMediaOptionAutomatically(in: group) + return #endif } + + #if os(tvOS) + // Do noting. Fix for tvOS native audio menu language selector + #else + // If a match isn't found, option will be nil and text tracks will be disabled + await player?.currentItem?.select(mediaOption, in: group) + #endif } - static func setMediaSelectionTrackForCharacteristic(player: AVPlayer?, characteristic: AVMediaCharacteristic, criteria: SelectedTrackCriteria?) { + static func setMediaSelectionTrackForCharacteristic(player: AVPlayer?, characteristic: AVMediaCharacteristic, criteria: SelectedTrackCriteria?) async { let type = criteria?.type var mediaOption: AVMediaSelectionOption! - RCTVideoAssetsUtils.getMediaSelectionGroup(asset: player?.currentItem?.asset, for: characteristic).then { group in - guard let group else { return } - - if type == "disabled" { - // Do nothing. We want to ensure option is nil - } else if (type == "language") || (type == "title") { - let value = criteria?.value as? String - for i in 0 ..< group.options.count { - let currentOption: AVMediaSelectionOption! = group.options[i] - var optionValue: String! - if type == "language" { - optionValue = currentOption.extendedLanguageTag - } else { - optionValue = currentOption.commonMetadata.map(\.value)[0] as? String - } - if value == optionValue { - mediaOption = currentOption - break - } + guard let group = await RCTVideoAssetsUtils.getMediaSelectionGroup(asset: player?.currentItem?.asset, for: characteristic) else { + return + } + + if type == "disabled" { + // Do nothing. We want to ensure option is nil + } else if (type == "language") || (type == "title") { + let value = criteria?.value as? String + for i in 0 ..< group.options.count { + let currentOption: AVMediaSelectionOption! = group.options[i] + var optionValue: String! + if type == "language" { + optionValue = currentOption.extendedLanguageTag + } else { + optionValue = currentOption.commonMetadata.map(\.value)[0] as? String } - // } else if ([type isEqualToString:@"default"]) { - // option = group.defaultOption; */ - } else if type == "index" { - if let value = criteria?.value, let index = value as? Int { - if group.options.count > index { - mediaOption = group.options[index] - } + if value == optionValue { + mediaOption = currentOption + break } - } else { // default. invalid type or "system" - player?.currentItem?.selectMediaOptionAutomatically(in: group) - return } - - // If a match isn't found, option will be nil and text tracks will be disabled - player?.currentItem?.select(mediaOption, in: group) + // } else if ([type isEqualToString:@"default"]) { + // option = group.defaultOption; */ + } else if type == "index" { + if let value = criteria?.value, let index = value as? Int { + if group.options.count > index { + mediaOption = group.options[index] + } + } + } else { // default. invalid type or "system" + await player?.currentItem?.selectMediaOptionAutomatically(in: group) + return } + + // If a match isn't found, option will be nil and text tracks will be disabled + await player?.currentItem?.select(mediaOption, in: group) } - static func seek(player: AVPlayer, playerItem: AVPlayerItem, paused: Bool, seekTime: Float, seekTolerance: Float) -> Promise { + static func seek(player: AVPlayer, playerItem: AVPlayerItem, paused: Bool, seekTime: Float, seekTolerance: Float, completion: @escaping (Bool) -> Void) { let timeScale = 1000 let cmSeekTime: CMTime = CMTimeMakeWithSeconds(Float64(seekTime), preferredTimescale: Int32(timeScale)) let current: CMTime = playerItem.currentTime() let tolerance: CMTime = CMTimeMake(value: Int64(seekTolerance), timescale: Int32(timeScale)) - return Promise(on: .global()) { fulfill, reject in - guard CMTimeCompare(current, cmSeekTime) != 0 else { - reject(NSError(domain: "", code: 0, userInfo: nil)) - return - } - if !paused { player.pause() } - - player.seek(to: cmSeekTime, toleranceBefore: tolerance, toleranceAfter: tolerance, completionHandler: { (finished: Bool) in - fulfill(finished) - }) + guard CMTimeCompare(current, cmSeekTime) != 0 else { + // skip if there is no diff in current time and seek time + return } + + if !paused { player.pause() } + + player.seek(to: cmSeekTime, toleranceBefore: tolerance, toleranceAfter: tolerance, completionHandler: { (finished: Bool) in + completion(finished) + }) } static func configureAudio(ignoreSilentSwitch: String, mixWithOthers: String, audioOutput: String) { diff --git a/ios/Video/Features/RCTResourceLoaderDelegate.swift b/ios/Video/Features/RCTResourceLoaderDelegate.swift index 047151faca..3f3eab27dd 100644 --- a/ios/Video/Features/RCTResourceLoaderDelegate.swift +++ b/ios/Video/Features/RCTResourceLoaderDelegate.swift @@ -1,5 +1,4 @@ import AVFoundation -import Promises class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSessionDelegate { private var _loadingRequests: [String: AVAssetResourceLoadingRequest?] = [:] @@ -135,7 +134,7 @@ class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSes return false } - var requestKey: String = loadingRequest.request.url?.absoluteString ?? "" + let requestKey: String = loadingRequest.request.url?.absoluteString ?? "" _loadingRequests[requestKey] = loadingRequest @@ -143,44 +142,45 @@ class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSes return finishLoadingWithError(error: RCTVideoErrorHandler.noDRMData, licenseUrl: requestKey) } - var promise: Promise - if _onGetLicense != nil { - let contentId = _drm.contentId ?? loadingRequest.request.url?.host - promise = RCTVideoDRM.handleWithOnGetLicense( - loadingRequest: loadingRequest, - contentId: contentId, - certificateUrl: _drm.certificateUrl, - base64Certificate: _drm.base64Certificate - ).then { spcData in - self._requestingCertificate = true - self._onGetLicense?(["licenseUrl": self._drm?.licenseServer ?? "", - "loadedLicenseUrl": loadingRequest.request.url?.absoluteString ?? "", - "contentId": contentId ?? "", - "spcBase64": spcData.base64EncodedString(options: []), - "target": self._reactTag]) - } - } else { - promise = RCTVideoDRM.handleInternalGetLicense( - loadingRequest: loadingRequest, - contentId: _drm.contentId, - licenseServer: _drm.licenseServer, - certificateUrl: _drm.certificateUrl, - base64Certificate: _drm.base64Certificate, - headers: _drm.headers - ).then { data in - guard let dataRequest = loadingRequest.dataRequest else { - throw RCTVideoErrorHandler.noCertificateData + 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() } - dataRequest.respond(with: data) - loadingRequest.finishLoading() + } catch { + self.finishLoadingWithError(error: error, licenseUrl: requestKey) + self._requestingCertificateErrored = true } } - promise.catch { error in - self.finishLoadingWithError(error: error, licenseUrl: requestKey) - self._requestingCertificateErrored = true - } - return true } } diff --git a/ios/Video/Features/RCTVideoDRM.swift b/ios/Video/Features/RCTVideoDRM.swift index d3cf2ec60b..bc73d48df3 100644 --- a/ios/Video/Features/RCTVideoDRM.swift +++ b/ios/Video/Features/RCTVideoDRM.swift @@ -1,5 +1,4 @@ import AVFoundation -import Promises enum RCTVideoDRM { static func fetchLicense( @@ -7,36 +6,25 @@ enum RCTVideoDRM { spcData: Data?, contentId: String, headers: [String: Any]? - ) -> Promise { + ) async throws -> Data { let request = createLicenseRequest(licenseServer: licenseServer, spcData: spcData, contentId: contentId, headers: headers) - return Promise(on: .global()) { fulfill, reject in - let postDataTask = URLSession.shared.dataTask( - with: request as URLRequest, - completionHandler: { (data: Data!, response: URLResponse!, error: Error!) in - let httpResponse: HTTPURLResponse! = (response as! HTTPURLResponse) - - guard error == nil else { - print("Error getting license from \(licenseServer), HTTP status code \(httpResponse.statusCode)") - reject(error) - return - } - guard httpResponse.statusCode == 200 else { - print("Error getting license from \(licenseServer), HTTP status code \(httpResponse.statusCode)") - reject(RCTVideoErrorHandler.licenseRequestNotOk(httpResponse.statusCode)) - return - } - - guard data != nil, let decodedData = Data(base64Encoded: data, options: []) else { - reject(RCTVideoErrorHandler.noDataFromLicenseRequest) - return - } - - fulfill(decodedData) - } - ) - postDataTask.resume() + 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( @@ -76,67 +64,63 @@ enum RCTVideoDRM { loadingRequest: AVAssetResourceLoadingRequest, certificateData: Data, contentIdData: Data - ) -> Promise { - return Promise(on: .global()) { fulfill, reject in - #if os(visionOS) - // TODO: DRM is not supported yet on visionOS. See #3467 - reject(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 { - reject(RCTVideoErrorHandler.noSPC) - return - } + ) 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 + } - fulfill(spcData) - #endif - } + return spcData + #endif } - static func createCertificateData(certificateStringUrl: String?, base64Certificate: Bool?) -> Promise { - return Promise(on: .global()) { fulfill, reject in - guard let certificateStringUrl, - let certificateURL = URL(string: certificateStringUrl.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) ?? "") else { - reject(RCTVideoErrorHandler.noCertificateURL) - return - } - - var certificateData: Data? - do { - certificateData = try Data(contentsOf: certificateURL) - if base64Certificate != nil { - certificateData = Data(base64Encoded: certificateData! as Data, options: .ignoreUnknownCharacters) - } - } catch {} + static func createCertificateData(certificateStringUrl: String?, base64Certificate: Bool?) throws -> Data { + guard let certificateStringUrl, + let certificateURL = URL(string: certificateStringUrl.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) ?? "") else { + throw RCTVideoErrorHandler.noCertificateURL + } - guard let certificateData else { - reject(RCTVideoErrorHandler.noCertificateData) - return + var certificateData: Data? + do { + certificateData = try Data(contentsOf: certificateURL) + if base64Certificate != nil { + certificateData = Data(base64Encoded: certificateData! as Data, options: .ignoreUnknownCharacters) } + } catch {} - fulfill(certificateData) + guard let certificateData else { + throw RCTVideoErrorHandler.noCertificateData } + + return certificateData } static func handleWithOnGetLicense(loadingRequest: AVAssetResourceLoadingRequest, contentId: String?, certificateUrl: String?, - base64Certificate: Bool?) -> Promise { + base64Certificate: Bool?) throws -> Data { let contentIdData = contentId?.data(using: .utf8) - return RCTVideoDRM.createCertificateData(certificateStringUrl: certificateUrl, base64Certificate: base64Certificate) - .then { certificateData -> Promise in - guard let contentIdData else { - throw RCTVideoError.invalidContentId as! Error - } + let certificateData = try? RCTVideoDRM.createCertificateData(certificateStringUrl: certificateUrl, base64Certificate: base64Certificate) - return RCTVideoDRM.fetchSpcData( - loadingRequest: loadingRequest, - certificateData: certificateData, - contentIdData: contentIdData - ) - } + 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( @@ -146,35 +130,32 @@ enum RCTVideoDRM { certificateUrl: String?, base64Certificate: Bool?, headers: [String: Any]? - ) -> Promise { + ) 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 { - return Promise(RCTVideoError.invalidContentId as! Error) + 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 RCTVideoDRM.createCertificateData(certificateStringUrl: certificateUrl, base64Certificate: base64Certificate) - .then { certificateData in - return RCTVideoDRM.fetchSpcData( - loadingRequest: loadingRequest, - certificateData: certificateData, - contentIdData: contentIdData - ) - } - .then { spcData -> Promise in - guard let licenseServer else { - throw RCTVideoError.noLicenseServerURL as! Error - } - return RCTVideoDRM.fetchLicense( - licenseServer: licenseServer, - spcData: spcData, - contentId: contentId, - headers: headers - ) - } + return try await RCTVideoDRM.fetchLicense( + licenseServer: licenseServer, + spcData: spcData, + contentId: contentId, + headers: headers + ) } } diff --git a/ios/Video/Features/RCTVideoUtils.swift b/ios/Video/Features/RCTVideoUtils.swift index 9d4e6f66a8..7eec29f058 100644 --- a/ios/Video/Features/RCTVideoUtils.swift +++ b/ios/Video/Features/RCTVideoUtils.swift @@ -1,6 +1,5 @@ import AVFoundation import Photos -import Promises // MARK: - RCTVideoAssetsUtils @@ -8,30 +7,22 @@ enum RCTVideoAssetsUtils { static func getMediaSelectionGroup( asset: AVAsset?, for mediaCharacteristic: AVMediaCharacteristic - ) -> Promise { + ) async -> AVMediaSelectionGroup? { if #available(iOS 15, tvOS 15, visionOS 1.0, *) { - return wrap { handler in - asset?.loadMediaSelectionGroup(for: mediaCharacteristic, completionHandler: handler) - } + return try? await asset?.loadMediaSelectionGroup(for: mediaCharacteristic) } else { #if !os(visionOS) - return Promise { fulfill, _ in - fulfill(asset?.mediaSelectionGroup(forMediaCharacteristic: mediaCharacteristic)) - } + return asset?.mediaSelectionGroup(forMediaCharacteristic: mediaCharacteristic) #endif } } - static func getTracks(asset: AVAsset, withMediaType: AVMediaType) -> Promise<[AVAssetTrack]?> { + static func getTracks(asset: AVAsset, withMediaType: AVMediaType) async -> [AVAssetTrack]? { if #available(iOS 15, tvOS 15, visionOS 1.0, *) { - return wrap { handler in - asset.loadTracks(withMediaType: withMediaType, completionHandler: handler) - } + return try? await asset.loadTracks(withMediaType: withMediaType) } else { #if !os(visionOS) - return Promise { fulfill, _ in - fulfill(asset.tracks(withMediaType: withMediaType)) - } + return asset.tracks(withMediaType: withMediaType) #endif } } @@ -131,73 +122,67 @@ enum RCTVideoUtils { return 0 } - static func getAudioTrackInfo(_ player: AVPlayer?) -> Promise<[AnyObject]> { - return Promise { fulfill, _ in - guard let player, let asset = player.currentItem?.asset else { - fulfill([]) - return - } + static func getAudioTrackInfo(_ player: AVPlayer?) async -> [AnyObject] { + guard let player, let asset = player.currentItem?.asset else { + return [] + } - let audioTracks: NSMutableArray! = NSMutableArray() + let audioTracks: NSMutableArray! = NSMutableArray() - RCTVideoAssetsUtils.getMediaSelectionGroup(asset: asset, for: .audible).then { group in - for i in 0 ..< (group?.options.count ?? 0) { - let currentOption = group?.options[i] - var title = "" - let values = currentOption?.commonMetadata.map(\.value) - if (values?.count ?? 0) > 0, let value = values?[0] { - title = value as! String - } - let language: String! = currentOption?.extendedLanguageTag ?? "" + let group = await RCTVideoAssetsUtils.getMediaSelectionGroup(asset: asset, for: .audible) - let selectedOption: AVMediaSelectionOption? = player.currentItem?.currentMediaSelection.selectedMediaOption(in: group!) + for i in 0 ..< (group?.options.count ?? 0) { + let currentOption = group?.options[i] + var title = "" + let values = currentOption?.commonMetadata.map(\.value) + if (values?.count ?? 0) > 0, let value = values?[0] { + title = value as! String + } + let language: String! = currentOption?.extendedLanguageTag ?? "" - let audioTrack = [ - "index": NSNumber(value: i), - "title": title, - "language": language ?? "", - "selected": currentOption?.displayName == selectedOption?.displayName, - ] as [String: Any] - audioTracks.add(audioTrack) - } + let selectedOption: AVMediaSelectionOption? = player.currentItem?.currentMediaSelection.selectedMediaOption(in: group!) - fulfill(audioTracks as [AnyObject]) - } + let audioTrack = [ + "index": NSNumber(value: i), + "title": title, + "language": language ?? "", + "selected": currentOption?.displayName == selectedOption?.displayName, + ] as [String: Any] + audioTracks.add(audioTrack) } + + return audioTracks as [AnyObject] } - static func getTextTrackInfo(_ player: AVPlayer?) -> Promise<[TextTrack]> { - return Promise { fulfill, _ in - guard let player, let asset = player.currentItem?.asset else { - fulfill([]) - return - } + static func getTextTrackInfo(_ player: AVPlayer?) async -> [TextTrack] { + guard let player, let asset = player.currentItem?.asset else { + return [] + } - // if streaming video, we extract the text tracks - var textTracks: [TextTrack] = [] - RCTVideoAssetsUtils.getMediaSelectionGroup(asset: asset, for: .legible).then { group in - for i in 0 ..< (group?.options.count ?? 0) { - let currentOption = group?.options[i] - var title = "" - let values = currentOption?.commonMetadata.map(\.value) - if (values?.count ?? 0) > 0, let value = values?[0] { - title = value as! String - } - let language: String! = currentOption?.extendedLanguageTag ?? "" - let selectedOpt = player.currentItem?.currentMediaSelection - let selectedOption: AVMediaSelectionOption? = player.currentItem?.currentMediaSelection.selectedMediaOption(in: group!) - let textTrack = TextTrack([ - "index": NSNumber(value: i), - "title": title, - "language": language, - "selected": currentOption?.displayName == selectedOption?.displayName, - ]) - textTracks.append(textTrack) - } + // if streaming video, we extract the text tracks + var textTracks: [TextTrack] = [] + let group = await RCTVideoAssetsUtils.getMediaSelectionGroup(asset: asset, for: .legible) - fulfill(textTracks) + for i in 0 ..< (group?.options.count ?? 0) { + let currentOption = group?.options[i] + var title = "" + let values = currentOption?.commonMetadata.map(\.value) + if (values?.count ?? 0) > 0, let value = values?[0] { + title = value as! String } + let language: String! = currentOption?.extendedLanguageTag ?? "" + let selectedOpt = player.currentItem?.currentMediaSelection + let selectedOption: AVMediaSelectionOption? = player.currentItem?.currentMediaSelection.selectedMediaOption(in: group!) + let textTrack = TextTrack([ + "index": NSNumber(value: i), + "title": title, + "language": language, + "selected": currentOption?.displayName == selectedOption?.displayName, + ]) + textTracks.append(textTrack) } + + return textTracks } // UNUSED @@ -226,111 +211,96 @@ enum RCTVideoUtils { return Data(base64Encoded: adoptURL.absoluteString) } - static func generateMixComposition(_ asset: AVAsset) -> Promise { - return Promise { fulfill, _ in - all( - RCTVideoAssetsUtils.getTracks(asset: asset, withMediaType: .video), - RCTVideoAssetsUtils.getTracks(asset: asset, withMediaType: .audio) - ).then { tracks in - let mixComposition = AVMutableComposition() - - if let videoAsset = tracks.0?.first, let audioAsset = tracks.1?.first { - let videoCompTrack: AVMutableCompositionTrack! = mixComposition.addMutableTrack( - withMediaType: AVMediaType.video, - preferredTrackID: kCMPersistentTrackID_Invalid - ) - try? videoCompTrack.insertTimeRange( - CMTimeRangeMake(start: .zero, duration: videoAsset.timeRange.duration), - of: videoAsset, - at: .zero - ) - - let audioCompTrack: AVMutableCompositionTrack! = mixComposition.addMutableTrack( - withMediaType: AVMediaType.audio, - preferredTrackID: kCMPersistentTrackID_Invalid - ) - - try? audioCompTrack.insertTimeRange( - CMTimeRangeMake(start: .zero, duration: audioAsset.timeRange.duration), - of: audioAsset, - at: .zero - ) - - fulfill(mixComposition) - } else { - fulfill(mixComposition) - } - } + static func generateMixComposition(_ asset: AVAsset) async -> AVMutableComposition { + let videoTracks = await RCTVideoAssetsUtils.getTracks(asset: asset, withMediaType: .video) + let audioTracks = await RCTVideoAssetsUtils.getTracks(asset: asset, withMediaType: .audio) + + let mixComposition = AVMutableComposition() + + if let videoAsset = videoTracks?.first, let audioAsset = audioTracks?.first { + let videoCompTrack: AVMutableCompositionTrack! = mixComposition.addMutableTrack( + withMediaType: AVMediaType.video, + preferredTrackID: kCMPersistentTrackID_Invalid + ) + try? videoCompTrack.insertTimeRange( + CMTimeRangeMake(start: .zero, duration: videoAsset.timeRange.duration), + of: videoAsset, + at: .zero + ) + + let audioCompTrack: AVMutableCompositionTrack! = mixComposition.addMutableTrack( + withMediaType: AVMediaType.audio, + preferredTrackID: kCMPersistentTrackID_Invalid + ) + + try? audioCompTrack.insertTimeRange( + CMTimeRangeMake(start: .zero, duration: audioAsset.timeRange.duration), + of: audioAsset, + at: .zero + ) } + + return mixComposition } static func getValidTextTracks(asset: AVAsset, assetOptions: NSDictionary?, mixComposition: AVMutableComposition, - textTracks: [TextTrack]?) -> Promise<[TextTrack]> { + textTracks: [TextTrack]?) async -> [TextTrack] { var validTextTracks: [TextTrack] = [] - var queue: [Promise<[AVAssetTrack]?>] = [] + var tracks: [[AVAssetTrack]] = [] - return Promise { fulfill, _ in - RCTVideoAssetsUtils.getTracks(asset: asset, withMediaType: .video).then { tracks in - guard let videoAsset = tracks?.first else { - return - } + let videoTracks = await RCTVideoAssetsUtils.getTracks(asset: asset, withMediaType: .video) + guard let videoAsset = videoTracks?.first else { return validTextTracks } - if let textTracks, !textTracks.isEmpty { - for track in textTracks { - var textURLAsset: AVURLAsset! - let textUri: String = track.uri + if let textTracks, !textTracks.isEmpty { + for textTrack in textTracks { + var textURLAsset: AVURLAsset! + let textUri: String = textTrack.uri - if textUri.lowercased().hasPrefix("http") { - textURLAsset = AVURLAsset(url: NSURL(string: textUri)! as URL, options: (assetOptions as! [String: Any])) - } else { - let isDisabledTrack: Bool! = track.type == "disabled" - let searchPath: FileManager.SearchPathDirectory = isDisabledTrack ? .cachesDirectory : .documentDirectory - textURLAsset = AVURLAsset( - url: RCTVideoUtils.urlFilePath(filepath: textUri as NSString?, searchPath: searchPath) as URL, - options: nil - ) - } + if textUri.lowercased().hasPrefix("http") { + textURLAsset = AVURLAsset(url: NSURL(string: textUri)! as URL, options: (assetOptions as! [String: Any])) + } else { + let isDisabledTrack: Bool! = textTrack.type == "disabled" + let searchPath: FileManager.SearchPathDirectory = isDisabledTrack ? .cachesDirectory : .documentDirectory + textURLAsset = AVURLAsset( + url: RCTVideoUtils.urlFilePath(filepath: textUri as NSString?, searchPath: searchPath) as URL, + options: nil + ) + } - queue.append(RCTVideoAssetsUtils.getTracks(asset: textURLAsset, withMediaType: .text)) - } + if let track = await RCTVideoAssetsUtils.getTracks(asset: textURLAsset, withMediaType: .text) { + tracks.append(track) } + } - all(queue).then { tracks in - if let textTracks { - for i in 0 ..< tracks.count { - guard let track = tracks[i]?.first else { continue } // fix when there's no textTrackAsset - - let textCompTrack: AVMutableCompositionTrack! = mixComposition.addMutableTrack(withMediaType: AVMediaType.text, - preferredTrackID: kCMPersistentTrackID_Invalid) - - do { - try textCompTrack.insertTimeRange( - CMTimeRangeMake(start: .zero, duration: videoAsset.timeRange.duration), - of: track, - at: .zero - ) - validTextTracks.append(textTracks[i]) - } catch { - // TODO: upgrade error by call some props callback to better inform user - print("Error occurred on textTrack insert attempt: \(error.localizedDescription)") - continue - } - } - } + for i in 0 ..< tracks.count { + guard let track = tracks[i].first else { continue } // fix when there's no textTrackAsset - return - }.then { - if !validTextTracks.isEmpty { - let emptyVttFile: TextTrack? = self.createEmptyVttFile() - if emptyVttFile != nil { - validTextTracks.append(emptyVttFile!) - } - } + let textCompTrack: AVMutableCompositionTrack! = mixComposition.addMutableTrack(withMediaType: AVMediaType.text, + preferredTrackID: kCMPersistentTrackID_Invalid) - fulfill(validTextTracks) + do { + try textCompTrack.insertTimeRange( + CMTimeRangeMake(start: .zero, duration: videoAsset.timeRange.duration), + of: track, + at: .zero + ) + validTextTracks.append(textTracks[i]) + } catch { + // TODO: upgrade error by call some props callback to better inform user + print("Error occurred on textTrack insert attempt: \(error.localizedDescription)") + continue } } } + + if !validTextTracks.isEmpty { + let emptyVttFile: TextTrack? = self.createEmptyVttFile() + if emptyVttFile != nil { + validTextTracks.append(emptyVttFile!) + } + } + + return validTextTracks } /* @@ -362,25 +332,26 @@ enum RCTVideoUtils { ]) } - static func delay(seconds: Int = 0) -> Promise { - return Promise(on: .global()) { fulfill, _ in - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(seconds)) { - fulfill(()) + static func delay(seconds: Int = 0, completion: @escaping () async throws -> Void) { + return DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(seconds)) { + Task.detached(priority: .userInitiated) { + try await completion() } } } - static func preparePHAsset(uri: String) -> Promise { - return Promise(on: .global()) { fulfill, reject in - let assetId = String(uri[uri.index(uri.startIndex, offsetBy: "ph://".count)...]) - guard let phAsset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else { - reject(NSError(domain: "", code: 0, userInfo: nil)) - return - } - let options = PHVideoRequestOptions() - options.isNetworkAccessAllowed = true + static func preparePHAsset(uri: String) async -> AVAsset? { + let assetId = String(uri[uri.index(uri.startIndex, offsetBy: "ph://".count)...]) + guard let phAsset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else { + return nil + } + + let options = PHVideoRequestOptions() + options.isNetworkAccessAllowed = true + + return await withCheckedContinuation { continuation in PHCachingImageManager().requestAVAsset(forVideo: phAsset, options: options) { data, _, _ in - fulfill(data) + continuation.resume(returning: data) } } } @@ -444,10 +415,11 @@ enum RCTVideoUtils { } } - static func generateVideoComposition(asset: AVAsset, filter: CIFilter) -> Promise { + static func generateVideoComposition(asset: AVAsset, filter: CIFilter) async -> AVVideoComposition? { if #available(iOS 16, tvOS 16, visionOS 1.0, *) { - return wrap { handler in - AVVideoComposition.videoComposition(with: asset, applyingCIFiltersWithHandler: { (request: AVAsynchronousCIImageFilteringRequest) in + return try? await AVVideoComposition.videoComposition( + with: asset, + applyingCIFiltersWithHandler: { (request: AVAsynchronousCIImageFilteringRequest) in if filter == nil { request.finish(with: request.sourceImage, context: nil) } else { @@ -456,25 +428,23 @@ enum RCTVideoUtils { let output: CIImage! = filter.outputImage?.cropped(to: request.sourceImage.extent) request.finish(with: output, context: nil) } - }, completionHandler: handler) - } + } + ) } else { #if !os(visionOS) - return Promise { fulfill, _ in - fulfill(AVVideoComposition( - asset: asset, - applyingCIFiltersWithHandler: { (request: AVAsynchronousCIImageFilteringRequest) in - if filter == nil { - request.finish(with: request.sourceImage, context: nil) - } else { - let image: CIImage! = request.sourceImage.clampedToExtent() - filter.setValue(image, forKey: kCIInputImageKey) - let output: CIImage! = filter.outputImage?.cropped(to: request.sourceImage.extent) - request.finish(with: output, context: nil) - } + return AVVideoComposition( + asset: asset, + applyingCIFiltersWithHandler: { (request: AVAsynchronousCIImageFilteringRequest) in + if filter == nil { + request.finish(with: request.sourceImage, context: nil) + } else { + let image: CIImage! = request.sourceImage.clampedToExtent() + filter.setValue(image, forKey: kCIInputImageKey) + let output: CIImage! = filter.outputImage?.cropped(to: request.sourceImage.extent) + request.finish(with: output, context: nil) } - )) - } + } + ) #endif } } diff --git a/ios/Video/Features/URLSession+data.swift b/ios/Video/Features/URLSession+data.swift new file mode 100644 index 0000000000..3e8ac526a6 --- /dev/null +++ b/ios/Video/Features/URLSession+data.swift @@ -0,0 +1,24 @@ +import Foundation + +@available(iOS, deprecated: 15.0, message: "Use the built-in API instead") +@available(tvOS, deprecated: 15.0, message: "Use the built-in API instead") +extension URLSession { + func data(from request: URLRequest) async throws -> (Data, URLResponse) { + if #available(iOS 15, tvOS 15, *) { + return try await URLSession.shared.data(for: request) + } else { + return try await withCheckedThrowingContinuation { continuation in + let task = self.dataTask(with: request, completionHandler: { data, response, error in + guard let data, let response else { + let error = error ?? URLError(.badServerResponse) + return continuation.resume(throwing: error) + } + + continuation.resume(returning: (data, response)) + }) + + task.resume() + } + } + } +} diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index 760ec1353a..8f8c753700 100644 --- a/ios/Video/RCTVideo.swift +++ b/ios/Video/RCTVideo.swift @@ -4,7 +4,6 @@ import Foundation #if USE_GOOGLE_IMA import GoogleInteractiveMediaAds #endif -import Promises import React // MARK: - RCTVideo @@ -316,6 +315,111 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH // MARK: - Player and source + func preparePlayerItem() async throws -> AVPlayerItem { + guard let source = self._source else { + DebugLog("The source not exist") + self.isSetSourceOngoing = false + self.applyNextSource() + throw NSError(domain: "", code: 0, userInfo: nil) + } + + if let uri = source.uri, uri.starts(with: "ph://") { + let photoAsset = await RCTVideoUtils.preparePHAsset(uri: uri) + return await self.playerItemPrepareText(asset: photoAsset, assetOptions: nil, uri: source.uri ?? "") + } + + guard let assetResult = RCTVideoUtils.prepareAsset(source: source), + let asset = assetResult.asset, + let assetOptions = assetResult.assetOptions else { + DebugLog("Could not find video URL in source '\(String(describing: self._source))'") + self.isSetSourceOngoing = false + self.applyNextSource() + throw NSError(domain: "", code: 0, userInfo: nil) + } + + guard let assetResult = RCTVideoUtils.prepareAsset(source: source), + let asset = assetResult.asset, + let assetOptions = assetResult.assetOptions else { + DebugLog("Could not find video URL in source '\(String(describing: self._source))'") + self.isSetSourceOngoing = false + self.applyNextSource() + throw NSError(domain: "", code: 0, userInfo: nil) + } + + if let startPosition = self._source?.startPosition { + self._startPosition = startPosition / 1000 + } + + #if USE_VIDEO_CACHING + if self._videoCache.shouldCache(source: source, textTracks: self._textTracks) { + return try await self._videoCache.playerItemForSourceUsingCache(uri: source.uri, assetOptions: assetOptions) + } + #endif + + if self._drm != nil || self._localSourceEncryptionKeyScheme != nil { + self._resouceLoaderDelegate = RCTResourceLoaderDelegate( + asset: asset, + drm: self._drm, + localSourceEncryptionKeyScheme: self._localSourceEncryptionKeyScheme, + onVideoError: self.onVideoError, + onGetLicense: self.onGetLicense, + reactTag: self.reactTag + ) + } + + return await playerItemPrepareText(asset: asset, assetOptions: assetOptions, uri: source.uri ?? "") + } + + func setupPlayer(playerItem: AVPlayerItem) async throws { + if !self.isSetSourceOngoing { + DebugLog("setSrc has been canceled last step") + return + } + + self._player?.pause() + self._playerItem = playerItem + self._playerObserver.playerItem = self._playerItem + self.setPreferredForwardBufferDuration(self._preferredForwardBufferDuration) + self.setPlaybackRange(playerItem, withCropStart: self._source?.cropStart, withCropEnd: self._source?.cropEnd) + self.setFilter(self._filterName) + if let maxBitRate = self._maxBitRate { + self._playerItem?.preferredPeakBitRate = Double(maxBitRate) + } + + self._player = self._player ?? AVPlayer() + + self._player?.replaceCurrentItem(with: playerItem) + + self._playerObserver.player = self._player + self.applyModifiers() + self._player?.actionAtItemEnd = .none + + if #available(iOS 10.0, *) { + self.setAutomaticallyWaitsToMinimizeStalling(self._automaticallyWaitsToMinimizeStalling) + } + + #if USE_GOOGLE_IMA + if self._adTagUrl != nil { + // Set up your content playhead and contentComplete callback. + self._contentPlayhead = IMAAVPlayerContentPlayhead(avPlayer: self._player!) + + self._imaAdsManager.setUpAdsLoader() + } + #endif + // Perform on next run loop, otherwise onVideoLoadStart is nil + self.onVideoLoadStart?([ + "src": [ + "uri": self._source?.uri ?? NSNull(), + "type": self._source?.type ?? NSNull(), + "isNetwork": NSNumber(value: self._source?.isNetwork ?? false), + ], + "drm": self._drm?.json ?? NSNull(), + "target": self.reactTag, + ]) + self.isSetSourceOngoing = false + self.applyNextSource() + } + @objc func setSrc(_ source: NSDictionary!) { if self.isSetSourceOngoing || self.nextSource != nil { @@ -326,7 +430,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } self.isSetSourceOngoing = true - let dispatchClosure = { + let initializeSource = { self._source = VideoSource(source) if self._source?.uri == nil || self._source?.uri == "" { self._player?.replaceCurrentItem(with: nil) @@ -341,111 +445,28 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH self._playerObserver.playerItem = nil // perform on next run loop, otherwise other passed react-props may not be set - RCTVideoUtils.delay() - .then { [weak self] in + RCTVideoUtils.delay { [weak self] in + do { guard let self else { throw NSError(domain: "", code: 0, userInfo: nil) } - guard let source = self._source else { - DebugLog("The source not exist") - self.isSetSourceOngoing = false - self.applyNextSource() - throw NSError(domain: "", code: 0, userInfo: nil) - } - if let uri = source.uri, uri.starts(with: "ph://") { - return Promise { - RCTVideoUtils.preparePHAsset(uri: uri).then { asset in - return self.playerItemPrepareText(asset: asset, assetOptions: nil, uri: source.uri ?? "") - } - } - } - guard let assetResult = RCTVideoUtils.prepareAsset(source: source), - let asset = assetResult.asset, - let assetOptions = assetResult.assetOptions else { - DebugLog("Could not find video URL in source '\(String(describing: self._source))'") - self.isSetSourceOngoing = false - self.applyNextSource() - throw NSError(domain: "", code: 0, userInfo: nil) - } - - if let startPosition = self._source?.startPosition { - self._startPosition = startPosition / 1000 - } - - #if USE_VIDEO_CACHING - if self._videoCache.shouldCache(source: source, textTracks: self._textTracks) { - return self._videoCache.playerItemForSourceUsingCache(uri: source.uri, assetOptions: assetOptions) - } - #endif - - if self._drm != nil || self._localSourceEncryptionKeyScheme != nil { - self._resouceLoaderDelegate = RCTResourceLoaderDelegate( - asset: asset, - drm: self._drm, - localSourceEncryptionKeyScheme: self._localSourceEncryptionKeyScheme, - onVideoError: self.onVideoError, - onGetLicense: self.onGetLicense, - reactTag: self.reactTag - ) - } - - return self.playerItemPrepareText(asset: asset, assetOptions: assetOptions, uri: source.uri ?? "") - }.then { [weak self] (playerItem: AVPlayerItem!) in - guard let self else { throw NSError(domain: "", code: 0, userInfo: nil) } - if !self.isSetSourceOngoing { - DebugLog("setSrc has been canceled last step") - return - } - self._player?.pause() - self._playerItem = playerItem - self._playerObserver.playerItem = self._playerItem - self.setPreferredForwardBufferDuration(self._preferredForwardBufferDuration) - self.setPlaybackRange(playerItem, withCropStart: self._source?.cropStart, withCropEnd: self._source?.cropEnd) - self.setFilter(self._filterName) - if let maxBitRate = self._maxBitRate { - self._playerItem?.preferredPeakBitRate = Double(maxBitRate) - } - self._player = self._player ?? AVPlayer() - - self._player?.replaceCurrentItem(with: playerItem) - - self._playerObserver.player = self._player - self.applyModifiers() - self._player?.actionAtItemEnd = .none + let playerItem = try await self.preparePlayerItem() + try await setupPlayer(playerItem: playerItem) + } catch { + DebugLog("An error occurred: \(error.localizedDescription)") - if #available(iOS 10.0, *) { - self.setAutomaticallyWaitsToMinimizeStalling(self._automaticallyWaitsToMinimizeStalling) + if let self { + self.onVideoError?(["error": error.localizedDescription]) + self.isSetSourceOngoing = false + self.applyNextSource() } - - #if USE_GOOGLE_IMA - if self._adTagUrl != nil { - // Set up your content playhead and contentComplete callback. - self._contentPlayhead = IMAAVPlayerContentPlayhead(avPlayer: self._player!) - - self._imaAdsManager.setUpAdsLoader() - } - #endif - // Perform on next run loop, otherwise onVideoLoadStart is nil - self.onVideoLoadStart?([ - "src": [ - "uri": self._source?.uri ?? NSNull(), - "type": self._source?.type ?? NSNull(), - "isNetwork": NSNumber(value: self._source?.isNetwork ?? false), - ], - "drm": self._drm?.json ?? NSNull(), - "target": self.reactTag, - ]) - self.isSetSourceOngoing = false - self.applyNextSource() - }.catch { error in - DebugLog("An error occurred: \(error.localizedDescription)") - self.onVideoError?(["error": error.localizedDescription]) - self.isSetSourceOngoing = false - self.applyNextSource() } + } + self._videoLoadStarted = true self.applyNextSource() } - DispatchQueue.global(qos: .default).async(execute: dispatchClosure) + + DispatchQueue.global(qos: .default).async(execute: initializeSource) } @objc @@ -458,32 +479,26 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _localSourceEncryptionKeyScheme = keyScheme } - func playerItemPrepareText(asset: AVAsset!, assetOptions: NSDictionary?, uri: String) -> Promise { - return Promise { [weak self] fulfill, _ in - guard let self else { return } - - if (self._textTracks == nil) || self._textTracks?.isEmpty == true || (uri.hasSuffix(".m3u8")) { - fulfill(self.playerItemPropegateMetadata(AVPlayerItem(asset: asset))) - return - } + func playerItemPrepareText(asset: AVAsset!, assetOptions: NSDictionary?, uri: String) async -> AVPlayerItem { + if (self._textTracks == nil) || self._textTracks?.isEmpty == true || (uri.hasSuffix(".m3u8")) { + return self.playerItemPropegateMetadata(AVPlayerItem(asset: asset)) + } - // AVPlayer can't airplay AVMutableCompositions - self._allowsExternalPlayback = false - RCTVideoUtils.generateMixComposition(asset).then { mixComposition in - RCTVideoUtils.getValidTextTracks( - asset: asset, - assetOptions: assetOptions, - mixComposition: mixComposition, - textTracks: self._textTracks - ).then { [self] validTextTracks in - if validTextTracks.count != self._textTracks?.count { - self.setTextTracks(validTextTracks) - } + // AVPlayer can't airplay AVMutableCompositions + self._allowsExternalPlayback = false + let mixComposition = await RCTVideoUtils.generateMixComposition(asset) + let validTextTracks = await RCTVideoUtils.getValidTextTracks( + asset: asset, + assetOptions: assetOptions, + mixComposition: mixComposition, + textTracks: self._textTracks + ) - fulfill(self.playerItemPropegateMetadata(AVPlayerItem(asset: mixComposition))) - } - } + if validTextTracks.count != self._textTracks?.count { + self.setTextTracks(validTextTracks) } + + return self.playerItemPropegateMetadata(AVPlayerItem(asset: mixComposition)) } func playerItemPropegateMetadata(_ playerItem: AVPlayerItem!) -> AVPlayerItem { @@ -658,8 +673,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH paused: wasPaused, seekTime: seekTime.floatValue, seekTolerance: seekTolerance.floatValue - ) - .then { [weak self] (_: Bool) in + ) { [weak self] (_: Bool) in guard let self else { return } self._playerObserver.addTimeObserverIfNotSet() @@ -669,7 +683,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH self.onVideoSeek?(["currentTime": NSNumber(value: Float(CMTimeGetSeconds(item.currentTime()))), "seekTime": seekTime, "target": self.reactTag]) - }.catch { _ in } + } _pendingSeek = false } @@ -801,8 +815,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH func setSelectedAudioTrack(_ selectedAudioTrack: SelectedTrackCriteria?) { _selectedAudioTrackCriteria = selectedAudioTrack - RCTPlayerOperations.setMediaSelectionTrackForCharacteristic(player: _player, characteristic: AVMediaCharacteristic.audible, - criteria: _selectedAudioTrackCriteria) + Task { + await RCTPlayerOperations.setMediaSelectionTrackForCharacteristic(player: _player, characteristic: AVMediaCharacteristic.audible, + criteria: _selectedAudioTrackCriteria) + } } @objc @@ -815,8 +831,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH if _textTracks != nil { // sideloaded text tracks RCTPlayerOperations.setSideloadedText(player: _player, textTracks: _textTracks!, criteria: _selectedTextTrackCriteria) } else { // text tracks included in the HLS playlist§ - RCTPlayerOperations.setMediaSelectionTrackForCharacteristic(player: _player, characteristic: AVMediaCharacteristic.legible, - criteria: _selectedTextTrackCriteria) + Task { + await RCTPlayerOperations.setMediaSelectionTrackForCharacteristic(player: _player, characteristic: AVMediaCharacteristic.legible, + criteria: _selectedTextTrackCriteria) + } } } @@ -1035,8 +1053,9 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } let filter: CIFilter! = CIFilter(name: filterName) - RCTVideoUtils.generateVideoComposition(asset: _playerItem!.asset, filter: filter).then { [weak self] composition in - self?._playerItem?.videoComposition = composition + Task { + let composition = await RCTVideoUtils.generateVideoComposition(asset: _playerItem!.asset, filter: filter) + self._playerItem?.videoComposition = composition } } @@ -1213,9 +1232,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH var height: Float? var orientation = "undefined" - RCTVideoAssetsUtils.getTracks(asset: _playerItem.asset, withMediaType: .video).then { [weak self] tracks in - guard let self else { return } - + Task { + let tracks = await RCTVideoAssetsUtils.getTracks(asset: _playerItem.asset, withMediaType: .video) if let videoTrack = tracks?.first { width = Float(videoTrack.naturalSize.width) height = Float(videoTrack.naturalSize.height) @@ -1251,25 +1269,26 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } if self._videoLoadStarted { - all(RCTVideoUtils.getAudioTrackInfo(self._player), RCTVideoUtils.getTextTrackInfo(self._player)).then { audioTracks, textTracks in - self.onVideoLoad?(["duration": NSNumber(value: duration), - "currentTime": NSNumber(value: Float(CMTimeGetSeconds(_playerItem.currentTime()))), - "canPlayReverse": NSNumber(value: _playerItem.canPlayReverse), - "canPlayFastForward": NSNumber(value: _playerItem.canPlayFastForward), - "canPlaySlowForward": NSNumber(value: _playerItem.canPlaySlowForward), - "canPlaySlowReverse": NSNumber(value: _playerItem.canPlaySlowReverse), - "canStepBackward": NSNumber(value: _playerItem.canStepBackward), - "canStepForward": NSNumber(value: _playerItem.canStepForward), - "naturalSize": [ - "width": width != nil ? NSNumber(value: width!) : "undefinded", - "height": width != nil ? NSNumber(value: height!) : "undefinded", - "orientation": orientation, - ], - "audioTracks": audioTracks, - "textTracks": self._textTracks?.compactMap { $0.json } ?? textTracks.map(\.json), - "target": self.reactTag as Any]) - } + let audioTracks = await RCTVideoUtils.getAudioTrackInfo(self._player) + let textTracks = await RCTVideoUtils.getTextTrackInfo(self._player) + self.onVideoLoad?(["duration": NSNumber(value: duration), + "currentTime": NSNumber(value: Float(CMTimeGetSeconds(_playerItem.currentTime()))), + "canPlayReverse": NSNumber(value: _playerItem.canPlayReverse), + "canPlayFastForward": NSNumber(value: _playerItem.canPlayFastForward), + "canPlaySlowForward": NSNumber(value: _playerItem.canPlaySlowForward), + "canPlaySlowReverse": NSNumber(value: _playerItem.canPlaySlowReverse), + "canStepBackward": NSNumber(value: _playerItem.canStepBackward), + "canStepForward": NSNumber(value: _playerItem.canStepForward), + "naturalSize": [ + "width": width != nil ? NSNumber(value: width!) : "undefinded", + "height": width != nil ? NSNumber(value: height!) : "undefinded", + "orientation": orientation, + ], + "audioTracks": audioTracks, + "textTracks": self._textTracks?.compactMap { $0.json } ?? textTracks.map(\.json), + "target": self.reactTag as Any]) } + self._videoLoadStarted = false self._playerObserver.attachPlayerEventListeners() self.applyModifiers() @@ -1432,7 +1451,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } func handleTracksChange(playerItem _: AVPlayerItem, change _: NSKeyValueObservedChange<[AVPlayerItemTrack]>) { - all(RCTVideoUtils.getAudioTrackInfo(self._player), RCTVideoUtils.getTextTrackInfo(self._player)).then { audioTracks, textTracks in + Task { + let audioTracks = await RCTVideoUtils.getAudioTrackInfo(self._player) + let textTracks = await RCTVideoUtils.getTextTrackInfo(self._player) + self.onTextTracks?(["textTracks": textTracks]) self.onAudioTracks?(["audioTracks": audioTracks]) } diff --git a/ios/VideoCaching/RCTVideoCachingHandler.swift b/ios/VideoCaching/RCTVideoCachingHandler.swift index 73e02dafbe..aeab331aed 100644 --- a/ios/VideoCaching/RCTVideoCachingHandler.swift +++ b/ios/VideoCaching/RCTVideoCachingHandler.swift @@ -1,11 +1,10 @@ import AVFoundation import DVAssetLoaderDelegate import Foundation -import Promises class RCTVideoCachingHandler: NSObject, DVAssetLoaderDelegatesDelegate { private var _videoCache: RCTVideoCache! = RCTVideoCache.sharedInstance() - var playerItemPrepareText: ((AVAsset?, NSDictionary?, String) -> Promise)? + var playerItemPrepareText: ((AVAsset?, NSDictionary?, String) async -> AVPlayerItem)? override init() { super.init() @@ -26,69 +25,65 @@ class RCTVideoCachingHandler: NSObject, DVAssetLoaderDelegatesDelegate { return false } - func playerItemForSourceUsingCache(uri: String!, assetOptions options: NSDictionary!) -> Promise { + func playerItemForSourceUsingCache(uri: String!, assetOptions options: NSDictionary!) async throws -> AVPlayerItem { let url = URL(string: uri) - return getItemForUri(uri) - .then { [weak self] (videoCacheStatus: RCTVideoCacheStatus, cachedAsset: AVAsset?) -> Promise in - guard let self, let playerItemPrepareText = self.playerItemPrepareText else { throw NSError(domain: "", code: 0, userInfo: nil) } - switch videoCacheStatus { - case .missingFileExtension: - DebugLog(""" - Could not generate cache key for uri '\(uri)'. - It is currently not supported to cache urls that do not include a file extension. - The video file will not be cached. - 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]) - return playerItemPrepareText(asset, options, "") + let (videoCacheStatus, cachedAsset) = await getItemForUri(uri) - case .unsupportedFileExtension: - DebugLog(""" - Could not generate cache key for uri '\(uri)'. - The file extension of that uri is currently not supported. - The video file will not be cached. - 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]) - return playerItemPrepareText(asset, options, "") - - default: - if let cachedAsset { - DebugLog("Playing back uri '\(uri)' from cache") - // See note in playerItemForSource about not being able to support text tracks & caching - return Promise { - AVPlayerItem(asset: cachedAsset) - } - } - } + guard let playerItemPrepareText else { + throw NSError(domain: "", code: 0, userInfo: nil) + } - let asset: DVURLAsset! = DVURLAsset(url: url, options: options as! [String: Any], networkTimeout: 10000) - asset.loaderDelegate = self + switch videoCacheStatus { + case .missingFileExtension: + DebugLog(""" + Could not generate cache key for uri '\(uri ?? "NO_URI")'. + It is currently not supported to cache urls that do not include a file extension. + The video file will not be cached. + 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]) + return await playerItemPrepareText(asset, options, "") - /* More granular code to have control over the DVURLAsset - let resourceLoaderDelegate = DVAssetLoaderDelegate(url: url) - resourceLoaderDelegate.delegate = self - let components = NSURLComponents(url: url, resolvingAgainstBaseURL: false) - components?.scheme = DVAssetLoaderDelegate.scheme() - var asset: AVURLAsset? = nil - if let url = components?.url { - asset = AVURLAsset(url: url, options: options) - } - asset?.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.main) - */ + case .unsupportedFileExtension: + DebugLog(""" + Could not generate cache key for uri '\(uri ?? "NO_URI")'. + The file extension of that uri is currently not supported. + The video file will not be cached. + 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]) + return await playerItemPrepareText(asset, options, "") - return Promise { - AVPlayerItem(asset: asset) - } - }.then { playerItem -> AVPlayerItem in - return playerItem + default: + if let cachedAsset { + DebugLog("Playing back uri '\(uri ?? "NO_URI")' from cache") + // See note in playerItemForSource about not being able to support text tracks & caching + return AVPlayerItem(asset: cachedAsset) } + } + + let asset: DVURLAsset! = DVURLAsset(url: url, options: options as! [String: Any], networkTimeout: 10000) + asset.loaderDelegate = self + + /* More granular code to have control over the DVURLAsset + let resourceLoaderDelegate = DVAssetLoaderDelegate(url: url) + resourceLoaderDelegate.delegate = self + let components = NSURLComponents(url: url, resolvingAgainstBaseURL: false) + components?.scheme = DVAssetLoaderDelegate.scheme() + var asset: AVURLAsset? = nil + if let url = components?.url { + asset = AVURLAsset(url: url, options: options) + } + asset?.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.main) + */ + + return AVPlayerItem(asset: asset) } - func getItemForUri(_ uri: String) -> Promise<(videoCacheStatus: RCTVideoCacheStatus, cachedAsset: AVAsset?)> { - return Promise<(videoCacheStatus: RCTVideoCacheStatus, cachedAsset: AVAsset?)> { fulfill, _ in + func getItemForUri(_ uri: String) async -> (videoCacheStatus: RCTVideoCacheStatus, cachedAsset: AVAsset?) { + await withCheckedContinuation { continuation in self._videoCache.getItemForUri(uri, withCallback: { (videoCacheStatus: RCTVideoCacheStatus, cachedAsset: AVAsset?) in - fulfill((videoCacheStatus, cachedAsset)) + continuation.resume(returning: (videoCacheStatus, cachedAsset)) }) } } diff --git a/ios/patches/PromisesObjC.podspec b/ios/patches/PromisesObjC.podspec deleted file mode 100644 index 11ece8746d..0000000000 --- a/ios/patches/PromisesObjC.podspec +++ /dev/null @@ -1,41 +0,0 @@ -Pod::Spec.new do |s| - s.name = 'PromisesObjC' - s.version = '2.3.1.1' - s.authors = 'Google Inc.' - s.license = { :type => 'Apache-2.0', :file => 'LICENSE' } - s.homepage = 'https://github.com/google/promises' - s.source = { :git => 'https://github.com/google/promises.git', :tag => '2.3.1' } - s.summary = 'Synchronization construct for Objective-C' - s.description = <<-DESC - - Promises is a modern framework that provides a synchronization construct for - Objective-C to facilitate writing asynchronous code. - DESC - - s.platforms = { :ios => '9.0', :osx => '10.11', :tvos => '9.0', :watchos => '2.0', :visionos => '1.0' } - - s.module_name = 'FBLPromises' - s.prefix_header_file = false - s.header_dir = "./" - s.public_header_files = "Sources/#{s.module_name}/include/**/*.h" - s.private_header_files = "Sources/#{s.module_name}/include/FBLPromisePrivate.h" - s.source_files = "Sources/#{s.module_name}/**/*.{h,m}" - s.pod_target_xcconfig = { - 'DEFINES_MODULE' => 'YES' - } - - s.test_spec 'Tests' do |ts| - # Note: Omits watchOS as a workaround since XCTest is not available to watchOS for now. - # Reference: https://github.com/CocoaPods/CocoaPods/issues/8283, https://github.com/CocoaPods/CocoaPods/issues/4185. - ts.platforms = {:ios => nil, :osx => nil, :tvos => nil} - ts.source_files = "Tests/#{s.module_name}Tests/*.m", - "Sources/#{s.module_name}TestHelpers/include/#{s.module_name}TestHelpers.h" - end - s.test_spec 'PerformanceTests' do |ts| - # Note: Omits watchOS as a workaround since XCTest is not available to watchOS for now. - # Reference: https://github.com/CocoaPods/CocoaPods/issues/8283, https://github.com/CocoaPods/CocoaPods/issues/4185. - ts.platforms = {:ios => nil, :osx => nil, :tvos => nil} - ts.source_files = "Tests/#{s.module_name}PerformanceTests/*.m", - "Sources/#{s.module_name}TestHelpers/include/#{s.module_name}TestHelpers.h" - end -end \ No newline at end of file diff --git a/ios/patches/PromisesSwift.podspec b/ios/patches/PromisesSwift.podspec deleted file mode 100644 index 66f077cde3..0000000000 --- a/ios/patches/PromisesSwift.podspec +++ /dev/null @@ -1,21 +0,0 @@ -Pod::Spec.new do |s| - s.name = 'PromisesSwift' - s.version = '2.3.1.1' - s.authors = 'Google Inc.' - s.license = { :type => 'Apache-2.0', :file => 'LICENSE' } - s.homepage = 'https://github.com/google/promises' - s.source = { :git => 'https://github.com/google/promises.git', :tag => '2.3.1' } - s.summary = 'Synchronization construct for Swift' - s.description = <<-DESC - - Promises is a modern framework that provides a synchronization construct for - Swift to facilitate writing asynchronous code. - DESC - - s.platforms = { :ios => '9.0', :osx => '10.11', :tvos => '9.0', :watchos => '2.0', :visionos => '1.0' } - s.swift_versions = ['5.0', '5.2'] - - s.module_name = 'Promises' - s.source_files = "Sources/#{s.module_name}/*.{swift}" - s.dependency 'PromisesObjC', "#{s.version}" -end \ No newline at end of file diff --git a/react-native-video.podspec b/react-native-video.podspec index c88e8570f7..3d7a5a6d40 100644 --- a/react-native-video.podspec +++ b/react-native-video.podspec @@ -14,11 +14,10 @@ Pod::Spec.new do |s| s.homepage = 'https://github.com/react-native-video/react-native-video' s.source = { :git => "https://github.com/react-native-video/react-native-video.git", :tag => "v#{s.version}" } - s.platforms = { :ios => "9.0", :tvos => "10.0", :visionos => "1.0" } + s.platforms = { :ios => "13.0", :tvos => "13.0", :visionos => "1.0" } s.subspec "Video" do |ss| ss.source_files = "ios/Video/**/*.{h,m,swift}" - ss.dependency "PromisesSwift" if defined?($RNVideoUseGoogleIMA) Pod::UI.puts "RNVideo: enable IMA SDK" From 2a858df8bce4dd6c529853bba5bac85f798c662e Mon Sep 17 00:00:00 2001 From: Olivier Bouillet <62574056+freeboub@users.noreply.github.com> Date: Thu, 4 Apr 2024 14:45:39 +0200 Subject: [PATCH 03/12] fix(ts): onPlaybackRateChangeData was not correctly typed (#3651) --- examples/basic/src/VideoPlayer.tsx | 12 ++++++++++++ src/specs/VideoNativeComponent.ts | 4 ++-- src/types/events.ts | 4 ++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/examples/basic/src/VideoPlayer.tsx b/examples/basic/src/VideoPlayer.tsx index e719279dbe..98e1e17f02 100644 --- a/examples/basic/src/VideoPlayer.tsx +++ b/examples/basic/src/VideoPlayer.tsx @@ -36,6 +36,8 @@ import Video, { OnTextTrackDataChangedData, TextTrackType, ISO639_1, + OnPlaybackStateChangedData, + OnPlaybackRateChangeData, } from 'react-native-video'; import ToggleControl from './ToggleControl'; import MultiValueControl, { @@ -335,6 +337,14 @@ class VideoPlayer extends Component { this.channelUp(); }; + onPlaybackRateChange = (data: OnPlaybackRateChangeData) => { + console.log('onPlaybackRateChange', data); + } + + onPlaybackStateChanged = (data: OnPlaybackStateChangedData) => { + console.log('onPlaybackStateChanged', data); + } + toggleFullscreen() { this.setState({fullscreen: !this.state.fullscreen}); } @@ -805,6 +815,8 @@ class VideoPlayer extends Component { selectedAudioTrack={this.state.selectedAudioTrack} playInBackground={false} preventsDisplaySleepDuringVideoPlayback={true} + onPlaybackRateChange={this.onPlaybackRateChange} + onPlaybackStateChanged={this.onPlaybackStateChanged} /> ); diff --git a/src/specs/VideoNativeComponent.ts b/src/specs/VideoNativeComponent.ts index 11752957c7..8f8d09a12d 100644 --- a/src/specs/VideoNativeComponent.ts +++ b/src/specs/VideoNativeComponent.ts @@ -218,7 +218,7 @@ export type OnVideoTracksData = Readonly<{ }[]; }>; -export type OnPlaybackData = Readonly<{ +export type OnPlaybackRateChangeData = Readonly<{ playbackRate: Float; }>; @@ -328,7 +328,7 @@ export interface VideoNativeProps extends ViewProps { onVideoFullscreenPlayerWillDismiss?: DirectEventHandler<{}>; // ios, android onVideoFullscreenPlayerDidDismiss?: DirectEventHandler<{}>; // ios, android onReadyForDisplay?: DirectEventHandler<{}>; - onPlaybackRateChange?: DirectEventHandler; // all + onPlaybackRateChange?: DirectEventHandler; // all onVolumeChange?: DirectEventHandler; // android, ios onVideoExternalPlaybackChange?: DirectEventHandler; onGetLicense?: DirectEventHandler; diff --git a/src/types/events.ts b/src/types/events.ts index 4f9e92380b..c97cd0862b 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -7,7 +7,7 @@ import type { OnExternalPlaybackChangeData, OnLoadStartData, OnPictureInPictureStatusChangedData, - OnPlaybackData, + OnPlaybackRateChangeData, OnPlaybackStateChangedData, OnProgressData, OnSeekData, @@ -239,7 +239,7 @@ export interface ReactVideoEvents { onPictureInPictureStatusChanged?: ( e: OnPictureInPictureStatusChangedData, ) => void; //iOS - onPlaybackRateChange?: (e: OnPlaybackData) => void; //All + onPlaybackRateChange?: (e: OnPlaybackRateChangeData) => void; //All onVolumeChange?: (e: OnVolumeChangeData) => void; //Android, iOS onProgress?: (e: OnProgressData) => void; //All onReadyForDisplay?: () => void; //Android, iOS From 051e884c8f34755c887b66d8715a6ee38efc5f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Kueny?= Date: Thu, 4 Apr 2024 15:08:48 +0200 Subject: [PATCH 04/12] fix(ios): call PictureInPicture callbacks with native controls (#3603) * fix(ios): call PictureInPictureStatusChanged callback with native controls We add RCTPlayerObserver as playerViewController delegate to be notified with PiP lifecycle should partially fix #3602 * fix(ios): call onRestoreUserInterfaceForPictureInPictureStop callback with native controls should partially fix #3602 --- ios/Video/Features/RCTPlayerObserver.swift | 39 +++++++++++++++++++++- ios/Video/RCTVideo.swift | 18 +++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/ios/Video/Features/RCTPlayerObserver.swift b/ios/Video/Features/RCTPlayerObserver.swift index 22db23a8fe..0e19571f3b 100644 --- a/ios/Video/Features/RCTPlayerObserver.swift +++ b/ios/Video/Features/RCTPlayerObserver.swift @@ -27,11 +27,14 @@ protocol RCTPlayerObserverHandler: RCTPlayerObserverHandlerObjc { func handleViewControllerOverlayViewFrameChange(overlayView: UIView, change: NSKeyValueObservedChange) func handleTracksChange(playerItem: AVPlayerItem, change: NSKeyValueObservedChange<[AVPlayerItemTrack]>) func handleLegibleOutput(strings: [NSAttributedString]) + func handlePictureInPictureEnter() + func handlePictureInPictureExit() + func handleRestoreUserInterfaceForPictureInPictureStop() } // MARK: - RCTPlayerObserver -class RCTPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVPlayerItemLegibleOutputPushDelegate { +class RCTPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVPlayerItemLegibleOutputPushDelegate, AVPlayerViewControllerDelegate { weak var _handlers: RCTPlayerObserverHandler? var player: AVPlayer? { @@ -105,6 +108,7 @@ class RCTPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVPla private var _playerLayerReadyForDisplayObserver: NSKeyValueObservation? private var _playerViewControllerOverlayFrameObserver: NSKeyValueObservation? private var _playerTracksObserver: NSKeyValueObservation? + private var _restoreUserInterfaceForPIPStopCompletionHandler: ((Bool) -> Void)? deinit { if let _handlers { @@ -192,11 +196,14 @@ class RCTPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVPla options: [.new, .old], changeHandler: _handlers.handleViewControllerOverlayViewFrameChange ) + + playerViewController.delegate = self } func removePlayerViewControllerObservers() { _playerViewControllerReadyForDisplayObserver?.invalidate() _playerViewControllerOverlayFrameObserver?.invalidate() + playerViewController?.delegate = nil } func addPlayerLayerObserver() { @@ -288,4 +295,34 @@ class RCTPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVPla NotificationCenter.default.removeObserver(_handlers) } } + + func playerViewControllerDidStartPictureInPicture(_: AVPlayerViewController) { + guard let _handlers else { return } + + _handlers.handlePictureInPictureEnter() + } + + func playerViewControllerDidStopPictureInPicture(_: AVPlayerViewController) { + guard let _handlers else { return } + + _handlers.handlePictureInPictureExit() + } + + func playerViewController( + _: AVPlayerViewController, + restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void + ) { + guard let _handlers else { return } + + _handlers.handleRestoreUserInterfaceForPictureInPictureStop() + + _restoreUserInterfaceForPIPStopCompletionHandler = completionHandler + } + + func setRestoreUserInterfaceForPIPStopCompletionHandler(_ restore: Bool) { + guard let _restoreUserInterfaceForPIPStopCompletionHandler else { return } + + _restoreUserInterfaceForPIPStopCompletionHandler(restore) + self._restoreUserInterfaceForPIPStopCompletionHandler = nil + } } diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index 8f8c753700..cb22e870f0 100644 --- a/ios/Video/RCTVideo.swift +++ b/ios/Video/RCTVideo.swift @@ -128,6 +128,18 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH onPictureInPictureStatusChanged?(["isActive": NSNumber(value: false)]) } + func handlePictureInPictureEnter() { + onPictureInPictureStatusChanged?(["isActive": NSNumber(value: true)]) + } + + func handlePictureInPictureExit() { + onPictureInPictureStatusChanged?(["isActive": NSNumber(value: false)]) + } + + func handleRestoreUserInterfaceForPictureInPictureStop() { + onRestoreUserInterfaceForPictureInPictureStop?([:]) + } + func isPipEnabled() -> Bool { return _pictureInPictureEnabled } @@ -606,7 +618,11 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH @objc func setRestoreUserInterfaceForPIPStopCompletionHandler(_ restore: Bool) { #if os(iOS) - _pip?.setRestoreUserInterfaceForPIPStopCompletionHandler(restore) + if _pip != nil { + _pip?.setRestoreUserInterfaceForPIPStopCompletionHandler(restore) + } else { + _playerObserver.setRestoreUserInterfaceForPIPStopCompletionHandler(restore) + } #endif } From d6941392e071f2bd50fbe832dde203b7f18da769 Mon Sep 17 00:00:00 2001 From: Olivier Bouillet <62574056+freeboub@users.noreply.github.com> Date: Fri, 5 Apr 2024 10:35:57 +0200 Subject: [PATCH 05/12] fix: ensure poster works as expected and add it to the sample (#3643) * fix: ensure poster works as expected and add it to the sample * chore: drop audioOnly property as not implemented on any platform * fix(ios): do not save pause state before seeking * fix(ts): onPlaybackRateChangeData was not correctly typed --- .eslintrc | 3 +++ docs/pages/component/props.mdx | 11 --------- examples/basic/src/VideoPlayer.tsx | 39 +++++++++++++++++++++++++++--- ios/Video/RCTVideo.swift | 7 ++---- package.json | 1 + src/Video.tsx | 15 +++++++----- src/types/video.ts | 1 - 7 files changed, 50 insertions(+), 27 deletions(-) diff --git a/.eslintrc b/.eslintrc index 2c39af834b..05692c26c5 100644 --- a/.eslintrc +++ b/.eslintrc @@ -7,6 +7,9 @@ "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended" ], + "rules": { + "no-trailing-spaces": 1 + }, "parserOptions": { "requireConfigFile": false } diff --git a/docs/pages/component/props.mdx b/docs/pages/component/props.mdx index d5a346d887..42a56af051 100644 --- a/docs/pages/component/props.mdx +++ b/docs/pages/component/props.mdx @@ -29,17 +29,6 @@ Indicates whether the player allows switching to external playback mode such as - **true (default)** - allow switching to external playback mode - **false** - Don't allow switching to external playback mode -### `audioOnly` - - - -Indicates whether the player should only play the audio track and instead of displaying the video track, show the poster instead. - -- **false (default)** - Display the video as normal -- **true** - Show the poster and play the audio - -For this to work, the poster prop must be set. - ### `audioOutput` diff --git a/examples/basic/src/VideoPlayer.tsx b/examples/basic/src/VideoPlayer.tsx index 98e1e17f02..1e12d4c4e2 100644 --- a/examples/basic/src/VideoPlayer.tsx +++ b/examples/basic/src/VideoPlayer.tsx @@ -36,6 +36,7 @@ import Video, { OnTextTrackDataChangedData, TextTrackType, ISO639_1, + OnSeekData, OnPlaybackStateChangedData, OnPlaybackRateChangeData, } from 'react-native-video'; @@ -68,6 +69,7 @@ interface StateType { srcListId: number; loop: boolean; showRNVControls: boolean; + poster?: string; } class VideoPlayer extends Component { @@ -95,6 +97,7 @@ class VideoPlayer extends Component { srcListId: 0, loop: false, showRNVControls: false, + poster: undefined, }; seekerWidth = 0; @@ -140,7 +143,7 @@ class VideoPlayer extends Component { type: TextTrackType.VTT, uri: 'https://bitdash-a.akamaihd.net/content/sintel/subtitles/subtitles_en.vtt', }, - ] + ], }, ]; @@ -190,6 +193,10 @@ class VideoPlayer extends Component { }, ]; + // poster which can be displayed + samplePoster = + 'https://upload.wikimedia.org/wikipedia/commons/1/18/React_Native_Logo.png'; + srcList = this.srcAllPlatformList.concat( Platform.OS === 'android' ? this.srcAndroidList : this.srcIosList, ); @@ -223,14 +230,26 @@ class VideoPlayer extends Component { this.onTextTracks(data); }; - onProgress = (data: OnProgressData) => { - if (!this.state.seeking) { + updateSeeker = () => { + // put this code in timeout as because it may be put just after a setState + setTimeout(()=> { const position = this.calculateSeekerPosition(); this.setSeekerPosition(position); - } + }, 1) + } + + onProgress = (data: OnProgressData) => { this.setState({currentTime: data.currentTime}); + if (!this.state.seeking) { + this.updateSeeker() + } }; + onSeek = (data: OnSeekData) => { + this.setState({currentTime: data.currentTime}); + this.updateSeeker() + } + onVideoLoadStart = () => { console.log('onVideoLoadStart'); this.setState({isLoading: true}); @@ -662,6 +681,16 @@ class VideoPlayer extends Component { }} text="decoration" /> + { + this.setState({ + poster: this.state.poster ? undefined : this.samplePoster, + }); + }} + selectedText="poster" + unselectedText="no poster" + /> {/* shall be replaced by slider */} @@ -810,11 +839,13 @@ class VideoPlayer extends Component { onAspectRatio={this.onAspectRatio} onReadyForDisplay={this.onReadyForDisplay} onBuffer={this.onVideoBuffer} + onSeek={this.onSeek} repeat={this.state.loop} selectedTextTrack={this.state.selectedTextTrack} selectedAudioTrack={this.state.selectedAudioTrack} playInBackground={false} preventsDisplaySleepDuringVideoPlayback={true} + poster={this.state.poster} onPlaybackRateChange={this.onPlaybackRateChange} onPlaybackStateChanged={this.onPlaybackStateChanged} /> diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index cb22e870f0..571cc8c0e2 100644 --- a/ios/Video/RCTVideo.swift +++ b/ios/Video/RCTVideo.swift @@ -681,21 +681,18 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _pendingSeekTime = seekTime.floatValue return } - let wasPaused = _paused RCTPlayerOperations.seek( player: player, playerItem: item, - paused: wasPaused, + paused: _paused, seekTime: seekTime.floatValue, seekTolerance: seekTolerance.floatValue ) { [weak self] (_: Bool) in guard let self else { return } self._playerObserver.addTimeObserverIfNotSet() - if !wasPaused { - self.setPaused(false) - } + self.setPaused(_paused) self.onVideoSeek?(["currentTime": NSNumber(value: Float(CMTimeGetSeconds(item.currentTime()))), "seekTime": seekTime, "target": self.reactTag]) diff --git a/package.json b/package.json index 0cdfa5a918..52990f37fe 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "test": "echo no test available", "check-ios": "scripts/swift-format.sh && scripts/swift-lint.sh && scripts/clang-format.sh", "check-android": "scripts/kotlin-lint.sh", + "check-all": "yarn check-android; yarn check-ios; yarn lint", "codegen": "node ./node_modules/react-native/scripts/generate-codegen-artifacts.js --path ./ ./output" }, "files": [ diff --git a/src/Video.tsx b/src/Video.tsx index 6afa510bbd..f357ee1385 100644 --- a/src/Video.tsx +++ b/src/Video.tsx @@ -121,6 +121,8 @@ const Video = forwardRef( setRestoreUserInterfaceForPIPStopCompletionHandler, ] = useState(); + const hasPoster = !!poster; + const posterStyle = useMemo>( () => ({ ...StyleSheet.absoluteFillObject, @@ -286,19 +288,20 @@ const Video = forwardRef( const onVideoLoadStart = useCallback( (e: NativeSyntheticEvent) => { + hasPoster && setShowPoster(true); onLoadStart?.(e.nativeEvent); }, - [onLoadStart], + [hasPoster, onLoadStart], ); const onVideoLoad = useCallback( (e: NativeSyntheticEvent) => { if (Platform.OS === 'windows') { - setShowPoster(false); + hasPoster && setShowPoster(false); } onLoad?.(e.nativeEvent); }, - [onLoad, setShowPoster], + [onLoad, hasPoster, setShowPoster], ); const onVideoError = useCallback( @@ -388,9 +391,9 @@ const Video = forwardRef( ); const _onReadyForDisplay = useCallback(() => { - setShowPoster(false); + hasPoster && setShowPoster(false); onReadyForDisplay?.(); - }, [setShowPoster, onReadyForDisplay]); + }, [setShowPoster, hasPoster, onReadyForDisplay]); const _onPictureInPictureStatusChanged = useCallback( (e: NativeSyntheticEvent) => { @@ -567,7 +570,7 @@ const Video = forwardRef( _onReceiveAdEvent as (e: NativeSyntheticEvent) => void } /> - {showPoster ? ( + {hasPoster && showPoster ? ( ) : null} diff --git a/src/types/video.ts b/src/types/video.ts index 77c0f4db36..2e703d5d4f 100644 --- a/src/types/video.ts +++ b/src/types/video.ts @@ -186,7 +186,6 @@ export interface ReactVideoProps extends ReactVideoEvents, ViewProps { drm?: Drm; style?: StyleProp; adTagUrl?: string; - audioOnly?: boolean; audioOutput?: AudioOutput; // Mobile automaticallyWaitsToMinimizeStalling?: boolean; // iOS bufferConfig?: BufferConfig; // Android From 07f71c2fc446b43aa9565659983f7acb36d95f0c Mon Sep 17 00:00:00 2001 From: Krzysztof Moch Date: Fri, 5 Apr 2024 10:37:00 +0200 Subject: [PATCH 06/12] fix(ios): apply PictureInPicture state on start (#3655) --- examples/basic/index.js | 2 + examples/basic/ios/Podfile.lock | 46 ++++++++++++++++-- ios/Video/Features/RCTPictureInPicture.swift | 4 ++ ios/Video/RCTVideo.swift | 51 ++++++++++++++++---- 4 files changed, 89 insertions(+), 14 deletions(-) create mode 100644 examples/basic/index.js diff --git a/examples/basic/index.js b/examples/basic/index.js new file mode 100644 index 0000000000..cbc5a71fb3 --- /dev/null +++ b/examples/basic/index.js @@ -0,0 +1,2 @@ +// Without this file, the example will not build on physical devices +import './src/index'; diff --git a/examples/basic/ios/Podfile.lock b/examples/basic/ios/Podfile.lock index c54c33e930..8d2f45c9ef 100644 --- a/examples/basic/ios/Podfile.lock +++ b/examples/basic/ios/Podfile.lock @@ -935,11 +935,49 @@ PODS: - React-Mapbuffer (0.74.0-rc.4): - glog - React-debug - - react-native-video (6.0.0-beta.6): + - react-native-video (6.0.0-beta.8): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Codegen - React-Core - - react-native-video/Video (= 6.0.0-beta.6) - - react-native-video/Video (6.0.0-beta.6): + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - react-native-video/Video (= 6.0.0-beta.8) + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - react-native-video/Video (6.0.0-beta.8): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Codegen - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - React-nativeconfig (0.74.0-rc.4) - React-NativeModulesApple (0.74.0-rc.4): - glog @@ -1383,7 +1421,7 @@ SPEC CHECKSUMS: React-jsitracing: 50e3ea936a199a2a7fcab922f156507c97f0b88c React-logger: 6004e0cf41b7e9714ca26b1648e5d76fcfd638b5 React-Mapbuffer: 9b163fa28e549d5f36f89a39a1145fcaf262d0d0 - react-native-video: d340c162bf7974c2935fbeec0c5dea362f9dd74a + react-native-video: 64df5d2bc3bbc028cb97d87b53e42583127a9b9e React-nativeconfig: 3948d6fb6acfec364625cffbb1cf420346fb37c0 React-NativeModulesApple: 46745aba687c1019983d56b6d5fa39265152f64f React-perflogger: 0d62c0261b6fd3920605850de91abc8135dd3ee9 diff --git a/ios/Video/Features/RCTPictureInPicture.swift b/ios/Video/Features/RCTPictureInPicture.swift index 6f5ef79ece..0d745d7c8d 100644 --- a/ios/Video/Features/RCTPictureInPicture.swift +++ b/ios/Video/Features/RCTPictureInPicture.swift @@ -63,6 +63,10 @@ import React _pipController?.delegate = self } + func deinitPipController() { + _pipController = nil + } + func setPictureInPicture(_ isActive: Bool) { if _isActive == isActive { return diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index 571cc8c0e2..fb6e9b965e 100644 --- a/ios/Video/RCTVideo.swift +++ b/ios/Video/RCTVideo.swift @@ -64,8 +64,20 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH private var _filterName: String! private var _filterEnabled = false private var _presentingViewController: UIViewController? - private var _pictureInPictureEnabled = false private var _startPosition: Float64 = -1 + private var _pictureInPictureEnabled = false { + didSet { + #if os(iOS) + if _pictureInPictureEnabled { + initPictureinPicture() + _playerViewController?.allowsPictureInPicturePlayback = true + } else { + _pip?.deinitPipController() + _playerViewController?.allowsPictureInPicturePlayback = false + } + #endif + } + } /* IMA Ads */ private var _adTagUrl: String? @@ -144,6 +156,24 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH return _pictureInPictureEnabled } + func initPictureinPicture() { + #if os(iOS) + _pip = RCTPictureInPicture({ [weak self] in + self?._onPictureInPictureEnter() + }, { [weak self] in + self?._onPictureInPictureExit() + }, { [weak self] in + self?.onRestoreUserInterfaceForPictureInPictureStop?([:]) + }) + + if _playerLayer != nil && !_controls { + _pip?.setupPipController(_playerLayer) + } + #else + DebugLog("Picture in Picture is not supported on this platform") + #endif + } + init(eventDispatcher: RCTEventDispatcher!) { super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) #if USE_GOOGLE_IMA @@ -153,13 +183,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _eventDispatcher = eventDispatcher #if os(iOS) - _pip = RCTPictureInPicture({ [weak self] in - self?._onPictureInPictureEnter() - }, { [weak self] in - self?._onPictureInPictureExit() - }, { [weak self] in - self?.onRestoreUserInterfaceForPictureInPictureStop?([:]) - }) + if _pictureInPictureEnabled { + initPictureinPicture() + _playerViewController?.allowsPictureInPicturePlayback = true + } else { + _playerViewController?.allowsPictureInPicturePlayback = false + } #endif NotificationCenter.default.addObserver( @@ -970,7 +999,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH viewController.view.frame = self.bounds viewController.player = player if #available(tvOS 14.0, *) { - viewController.allowsPictureInPicturePlayback = true + viewController.allowsPictureInPicturePlayback = _pictureInPictureEnabled } return viewController } @@ -991,7 +1020,9 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } self.layer.needsDisplayOnBoundsChange = true #if os(iOS) - _pip?.setupPipController(_playerLayer) + if _pictureInPictureEnabled { + _pip?.setupPipController(_playerLayer) + } #endif } } From e992243305af0915442c3400f6ef105c4d5cd44c Mon Sep 17 00:00:00 2001 From: Olivier Bouillet <62574056+freeboub@users.noreply.github.com> Date: Fri, 5 Apr 2024 10:37:21 +0200 Subject: [PATCH 07/12] fix(ios): do not save pause state before seeking (#3650) * fix(ios): do not save pause state before seeking From 453907483db18addee74ca42bc01e53b6a717594 Mon Sep 17 00:00:00 2001 From: Olivier Bouillet <62574056+freeboub@users.noreply.github.com> Date: Sun, 7 Apr 2024 19:01:42 +0200 Subject: [PATCH 08/12] docs: describe project and sample build (#3656) * fix(ts): onPlaybackRateChangeData was not correctly typed * doc: describe project and sample build --- docs/pages/other/debug.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/pages/other/debug.md b/docs/pages/other/debug.md index 6b54df7584..bdc365617f 100644 --- a/docs/pages/other/debug.md +++ b/docs/pages/other/debug.md @@ -1,5 +1,18 @@ # Debugging +This page describe usefull tips for debugging and investigating issue in the package or in your application. + +## Using the sample app +This repository contains multiple a sample implementation in example folder. +It is always preferable to test behavior on a sample app than in a full app implementation. +The basic sample allow to test a lot of feature. +To use the sample you will need to do steps: +- Clone this repository: ``` git clone git@github.com:react-native-video/react-native-video.git``` +- Go to root folder and build it. It will generate a transpiled version of the package in lib folder: ```cd react-native-video && yarn && yarn build``` +- Go to the sample and install it: ```cd example/basic && yarn install``` +- Build it ! for android ```yarn android``` for ios ```cd ios && pod install && cd .. && yarn ios``` + + ## HTTP playback doesn't work or Black Screen on Release build (Android) If your video work on Debug mode, but on Release you see only black screen, please, check the link to your video. If you use 'http' protocol there, you will need to add next string to your AndroidManifest.xml file. [Details here](https://developer.android.com/guide/topics/manifest/application-element#usesCleartextTraffic) From 4c7719a3f537509426c366d2176895661933c63c Mon Sep 17 00:00:00 2001 From: Olivier Bouillet <62574056+freeboub@users.noreply.github.com> Date: Sun, 7 Apr 2024 19:02:39 +0200 Subject: [PATCH 09/12] fix: ensure tracks are available in sample (#3660) * fix(ts): onPlaybackRateChangeData was not correctly typed * fix: ensure tracks are well displayed in the sample --- examples/basic/src/VideoPlayer.tsx | 29 +++++++++-- ios/Video/Features/RCTPlayerOperations.swift | 51 -------------------- ios/Video/RCTVideo.swift | 2 +- 3 files changed, 27 insertions(+), 55 deletions(-) diff --git a/examples/basic/src/VideoPlayer.tsx b/examples/basic/src/VideoPlayer.tsx index 1e12d4c4e2..cb92c5fc69 100644 --- a/examples/basic/src/VideoPlayer.tsx +++ b/examples/basic/src/VideoPlayer.tsx @@ -735,10 +735,11 @@ class VideoPlayer extends Component { AudioTrack {this.state.audioTracks?.length <= 0 ? ( - empty + empty ) : ( { console.log('on audio value change ' + itemValue); @@ -765,10 +766,11 @@ class VideoPlayer extends Component { )} TextTrack {this.state.textTracks?.length <= 0 ? ( - empty + empty ) : ( { console.log('on value change ' + itemValue); @@ -960,6 +962,13 @@ const styles = StyleSheet.create({ paddingRight: 2, lineHeight: 12, }, + pickerContainer: { + width: 100, + alignSelf: 'center', + color: 'white', + borderWidth: 1, + borderColor: 'red', + }, IndicatorStyle: { flex: 1, justifyContent: 'center', @@ -997,10 +1006,24 @@ const styles = StyleSheet.create({ width: 12, }, picker: { - color: 'white', flex: 1, flexDirection: 'row', justifyContent: 'center', + width: 100, + height: 40, + }, + pickerItem: { + color: 'white', + width: 100, + height: 40, + }, + emptyPickerItem: { + color: 'white', + marginTop: 20, + marginLeft: 20, + flex: 1, + width: 100, + height: 40, }, }); diff --git a/ios/Video/Features/RCTPlayerOperations.swift b/ios/Video/Features/RCTPlayerOperations.swift index 265ab23529..3f25515923 100644 --- a/ios/Video/Features/RCTPlayerOperations.swift +++ b/ios/Video/Features/RCTPlayerOperations.swift @@ -78,57 +78,6 @@ enum RCTPlayerOperations { } } - // UNUSED - static func setStreamingText(player: AVPlayer?, criteria: SelectedTrackCriteria?) async { - let type = criteria?.type - var mediaOption: AVMediaSelectionOption! - - guard let group = await RCTVideoAssetsUtils.getMediaSelectionGroup(asset: player?.currentItem?.asset, for: .legible) else { - return - } - - if type == "disabled" { - // Do nothing. We want to ensure option is nil - } else if (type == "language") || (type == "title") { - let value = criteria?.value as? String - for i in 0 ..< group.options.count { - let currentOption: AVMediaSelectionOption! = group.options[i] - var optionValue: String! - if type == "language" { - optionValue = currentOption.extendedLanguageTag - } else { - optionValue = currentOption.commonMetadata.map(\.value)[0] as! String - } - if value == optionValue { - mediaOption = currentOption - break - } - } - // } else if ([type isEqualToString:@"default"]) { - // option = group.defaultOption; */ - } else if type == "index" { - if let value = criteria?.value, let index = value as? Int { - if group.options.count > index { - mediaOption = group.options[index] - } - } - } else { // default. invalid type or "system" - #if os(tvOS) - // Do noting. Fix for tvOS native audio menu language selector - #else - await player?.currentItem?.selectMediaOptionAutomatically(in: group) - return - #endif - } - - #if os(tvOS) - // Do noting. Fix for tvOS native audio menu language selector - #else - // If a match isn't found, option will be nil and text tracks will be disabled - await player?.currentItem?.select(mediaOption, in: group) - #endif - } - static func setMediaSelectionTrackForCharacteristic(player: AVPlayer?, characteristic: AVMediaCharacteristic, criteria: SelectedTrackCriteria?) async { let type = criteria?.type var mediaOption: AVMediaSelectionOption! diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index fb6e9b965e..bde3917bfa 100644 --- a/ios/Video/RCTVideo.swift +++ b/ios/Video/RCTVideo.swift @@ -1499,7 +1499,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH let audioTracks = await RCTVideoUtils.getAudioTrackInfo(self._player) let textTracks = await RCTVideoUtils.getTextTrackInfo(self._player) - self.onTextTracks?(["textTracks": textTracks]) + self.onTextTracks?(["textTracks": self._textTracks?.compactMap { $0.json } ?? textTracks.compactMap(\.json)]) self.onAudioTracks?(["audioTracks": audioTracks]) } } From e82f9dc24b6a4a09786f5425153b53494d72b05d Mon Sep 17 00:00:00 2001 From: YangJH Date: Mon, 8 Apr 2024 02:03:37 +0900 Subject: [PATCH 10/12] fix: prevents crash from occurring when using the selected video track with resolution type (#3664) * fix: fix video resolution track native crash error * fix: fix type error --- src/Video.tsx | 5 ++++- src/specs/VideoNativeComponent.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Video.tsx b/src/Video.tsx index f357ee1385..84e2e88399 100644 --- a/src/Video.tsx +++ b/src/Video.tsx @@ -221,10 +221,13 @@ const Video = forwardRef( if (!selectedVideoTrack) { return; } + const value = selectedVideoTrack?.value + ? `${selectedVideoTrack.value}` + : undefined; return { type: selectedVideoTrack?.type, - value: selectedVideoTrack?.value, + value, }; }, [selectedVideoTrack]); diff --git a/src/specs/VideoNativeComponent.ts b/src/specs/VideoNativeComponent.ts index 8f8d09a12d..d654ad7875 100644 --- a/src/specs/VideoNativeComponent.ts +++ b/src/specs/VideoNativeComponent.ts @@ -81,7 +81,7 @@ type SelectedVideoTrackType = WithDefault; type SelectedVideoTrack = Readonly<{ type?: SelectedVideoTrackType; - value?: Int32; + value?: string; }>; export type Seek = Readonly<{ From e26afac403e0b3675138f18569b08b4c9000cd81 Mon Sep 17 00:00:00 2001 From: Olivier Bouillet <62574056+freeboub@users.noreply.github.com> Date: Sun, 7 Apr 2024 19:04:43 +0200 Subject: [PATCH 11/12] fix(ios): workaround for rate change (#3657) * fix(ts): onPlaybackRateChangeData was not correctly typed * fix(ios): add a workaround for smooth rate change management --- ios/Video/RCTVideo.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index bde3917bfa..5d3700b4af 100644 --- a/ios/Video/RCTVideo.swift +++ b/ios/Video/RCTVideo.swift @@ -732,8 +732,20 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH @objc func setRate(_ rate: Float) { - _rate = rate - applyModifiers() + if _rate != 1 { + // This is a workaround + // when player change from rate != 1 to another rate != 1 we see some video blocking + // To bypass it we shall force the rate to 1 and apply real valut afterward + _player?.rate = 1 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self._rate = rate + self.applyModifiers() + } + } else { + // apply it directly + self._rate = rate + self.applyModifiers() + } } @objc From 60c7a5e57e4a9bbd4fdd81469dcde21edd211567 Mon Sep 17 00:00:00 2001 From: Krzysztof Moch Date: Wed, 10 Apr 2024 08:09:52 +0200 Subject: [PATCH 12/12] docs: update readme (#3669) --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 40324bd22f..99ab1685b6 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,11 @@ var styles = StyleSheet.create({ }); ``` -## Supported by +## Enterprise Support

- 📱 TWG provides both free and commercial support for this project. Feel free to contact us 🤝 to build something awesome together! 🚀 + 📱 react-native-video is provided as it is. For enterprise support or other business inquiries, please contact us 🤝. 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! 🚀

- +