@stepincto/expo-video 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +45 -0
  2. package/android/build.gradle +32 -0
  3. package/android/src/main/AndroidManifest.xml +20 -0
  4. package/android/src/main/java/expo/modules/video/AudioFocusManager.kt +241 -0
  5. package/android/src/main/java/expo/modules/video/FullscreenPlayerActivity.kt +145 -0
  6. package/android/src/main/java/expo/modules/video/IntervalUpdateClock.kt +54 -0
  7. package/android/src/main/java/expo/modules/video/MediaMetadataRetriever.kt +89 -0
  8. package/android/src/main/java/expo/modules/video/PictureInPictureHelperFragment.kt +26 -0
  9. package/android/src/main/java/expo/modules/video/PlayerViewExtension.kt +36 -0
  10. package/android/src/main/java/expo/modules/video/VideoCache.kt +104 -0
  11. package/android/src/main/java/expo/modules/video/VideoExceptions.kt +34 -0
  12. package/android/src/main/java/expo/modules/video/VideoManager.kt +133 -0
  13. package/android/src/main/java/expo/modules/video/VideoModule.kt +414 -0
  14. package/android/src/main/java/expo/modules/video/VideoThumbnail.kt +20 -0
  15. package/android/src/main/java/expo/modules/video/VideoView.kt +367 -0
  16. package/android/src/main/java/expo/modules/video/delegates/IgnoreSameSet.kt +24 -0
  17. package/android/src/main/java/expo/modules/video/drawing/OutlineProvider.kt +217 -0
  18. package/android/src/main/java/expo/modules/video/enums/AudioMixingMode.kt +20 -0
  19. package/android/src/main/java/expo/modules/video/enums/ContentFit.kt +19 -0
  20. package/android/src/main/java/expo/modules/video/enums/ContentType.kt +22 -0
  21. package/android/src/main/java/expo/modules/video/enums/DRMType.kt +26 -0
  22. package/android/src/main/java/expo/modules/video/enums/PlayerStatus.kt +10 -0
  23. package/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt +184 -0
  24. package/android/src/main/java/expo/modules/video/playbackService/PlaybackServiceConnection.kt +39 -0
  25. package/android/src/main/java/expo/modules/video/playbackService/VideoMediaSessionCallback.kt +47 -0
  26. package/android/src/main/java/expo/modules/video/player/FirstFrameEventGenerator.kt +93 -0
  27. package/android/src/main/java/expo/modules/video/player/PlayerEvent.kt +164 -0
  28. package/android/src/main/java/expo/modules/video/player/VideoPlayer.kt +460 -0
  29. package/android/src/main/java/expo/modules/video/player/VideoPlayerAudioTracks.kt +125 -0
  30. package/android/src/main/java/expo/modules/video/player/VideoPlayerListener.kt +32 -0
  31. package/android/src/main/java/expo/modules/video/player/VideoPlayerLoadControl.kt +525 -0
  32. package/android/src/main/java/expo/modules/video/player/VideoPlayerSubtitles.kt +125 -0
  33. package/android/src/main/java/expo/modules/video/records/BufferOptions.kt +15 -0
  34. package/android/src/main/java/expo/modules/video/records/DRMOptions.kt +25 -0
  35. package/android/src/main/java/expo/modules/video/records/PlaybackError.kt +19 -0
  36. package/android/src/main/java/expo/modules/video/records/Tracks.kt +81 -0
  37. package/android/src/main/java/expo/modules/video/records/VideoEventPayloads.kt +79 -0
  38. package/android/src/main/java/expo/modules/video/records/VideoMetadata.kt +12 -0
  39. package/android/src/main/java/expo/modules/video/records/VideoSize.kt +14 -0
  40. package/android/src/main/java/expo/modules/video/records/VideoSource.kt +104 -0
  41. package/android/src/main/java/expo/modules/video/records/VideoThumbnailOptions.kt +24 -0
  42. package/android/src/main/java/expo/modules/video/utils/DataSourceUtils.kt +75 -0
  43. package/android/src/main/java/expo/modules/video/utils/EventDispatcherUtils.kt +43 -0
  44. package/android/src/main/java/expo/modules/video/utils/MutableWeakReference.kt +15 -0
  45. package/android/src/main/java/expo/modules/video/utils/PictureInPictureUtils.kt +96 -0
  46. package/android/src/main/java/expo/modules/video/utils/YogaUtils.kt +20 -0
  47. package/android/src/main/res/drawable/seek_backwards_10s.xml +25 -0
  48. package/android/src/main/res/drawable/seek_backwards_15s.xml +25 -0
  49. package/android/src/main/res/drawable/seek_backwards_5s.xml +25 -0
  50. package/android/src/main/res/drawable/seek_forwards_10s.xml +30 -0
  51. package/android/src/main/res/drawable/seek_forwards_15s.xml +31 -0
  52. package/android/src/main/res/drawable/seek_forwards_5s.xml +30 -0
  53. package/android/src/main/res/layout/fullscreen_player_activity.xml +16 -0
  54. package/android/src/main/res/layout/surface_player_view.xml +7 -0
  55. package/android/src/main/res/layout/texture_player_view.xml +7 -0
  56. package/android/src/main/res/values/styles.xml +9 -0
  57. package/app.plugin.js +1 -0
  58. package/build/NativeVideoModule.d.ts +16 -0
  59. package/build/NativeVideoModule.d.ts.map +1 -0
  60. package/build/NativeVideoModule.js +3 -0
  61. package/build/NativeVideoModule.js.map +1 -0
  62. package/build/NativeVideoModule.web.d.ts +3 -0
  63. package/build/NativeVideoModule.web.d.ts.map +1 -0
  64. package/build/NativeVideoModule.web.js +2 -0
  65. package/build/NativeVideoModule.web.js.map +1 -0
  66. package/build/NativeVideoView.d.ts +4 -0
  67. package/build/NativeVideoView.d.ts.map +1 -0
  68. package/build/NativeVideoView.js +6 -0
  69. package/build/NativeVideoView.js.map +1 -0
  70. package/build/VideoModule.d.ts +38 -0
  71. package/build/VideoModule.d.ts.map +1 -0
  72. package/build/VideoModule.js +53 -0
  73. package/build/VideoModule.js.map +1 -0
  74. package/build/VideoPlayer.d.ts +15 -0
  75. package/build/VideoPlayer.d.ts.map +1 -0
  76. package/build/VideoPlayer.js +52 -0
  77. package/build/VideoPlayer.js.map +1 -0
  78. package/build/VideoPlayer.types.d.ts +532 -0
  79. package/build/VideoPlayer.types.d.ts.map +1 -0
  80. package/build/VideoPlayer.types.js +2 -0
  81. package/build/VideoPlayer.types.js.map +1 -0
  82. package/build/VideoPlayer.web.d.ts +75 -0
  83. package/build/VideoPlayer.web.d.ts.map +1 -0
  84. package/build/VideoPlayer.web.js +376 -0
  85. package/build/VideoPlayer.web.js.map +1 -0
  86. package/build/VideoPlayerEvents.types.d.ts +262 -0
  87. package/build/VideoPlayerEvents.types.d.ts.map +1 -0
  88. package/build/VideoPlayerEvents.types.js +2 -0
  89. package/build/VideoPlayerEvents.types.js.map +1 -0
  90. package/build/VideoThumbnail.d.ts +29 -0
  91. package/build/VideoThumbnail.d.ts.map +1 -0
  92. package/build/VideoThumbnail.js +3 -0
  93. package/build/VideoThumbnail.js.map +1 -0
  94. package/build/VideoView.d.ts +44 -0
  95. package/build/VideoView.d.ts.map +1 -0
  96. package/build/VideoView.js +76 -0
  97. package/build/VideoView.js.map +1 -0
  98. package/build/VideoView.types.d.ts +147 -0
  99. package/build/VideoView.types.d.ts.map +1 -0
  100. package/build/VideoView.types.js +2 -0
  101. package/build/VideoView.types.js.map +1 -0
  102. package/build/VideoView.web.d.ts +9 -0
  103. package/build/VideoView.web.d.ts.map +1 -0
  104. package/build/VideoView.web.js +180 -0
  105. package/build/VideoView.web.js.map +1 -0
  106. package/build/index.d.ts +9 -0
  107. package/build/index.d.ts.map +1 -0
  108. package/build/index.js +7 -0
  109. package/build/index.js.map +1 -0
  110. package/build/resolveAssetSource.d.ts +3 -0
  111. package/build/resolveAssetSource.d.ts.map +1 -0
  112. package/build/resolveAssetSource.js +3 -0
  113. package/build/resolveAssetSource.js.map +1 -0
  114. package/build/resolveAssetSource.web.d.ts +4 -0
  115. package/build/resolveAssetSource.web.d.ts.map +1 -0
  116. package/build/resolveAssetSource.web.js +16 -0
  117. package/build/resolveAssetSource.web.js.map +1 -0
  118. package/expo-module.config.json +9 -0
  119. package/ios/Cache/CachableRequest.swift +44 -0
  120. package/ios/Cache/CachedResource.swift +97 -0
  121. package/ios/Cache/CachingHelpers.swift +92 -0
  122. package/ios/Cache/MediaFileHandle.swift +94 -0
  123. package/ios/Cache/MediaInfo.swift +147 -0
  124. package/ios/Cache/ResourceLoaderDelegate.swift +274 -0
  125. package/ios/Cache/SynchronizedHashTable.swift +23 -0
  126. package/ios/Cache/VideoCacheManager.swift +338 -0
  127. package/ios/ContentKeyDelegate.swift +214 -0
  128. package/ios/ContentKeyManager.swift +21 -0
  129. package/ios/Enums/AudioMixingMode.swift +37 -0
  130. package/ios/Enums/ContentType.swift +12 -0
  131. package/ios/Enums/DRMType.swift +20 -0
  132. package/ios/Enums/PlayerStatus.swift +10 -0
  133. package/ios/Enums/VideoContentFit.swift +39 -0
  134. package/ios/ExpoVideo.podspec +29 -0
  135. package/ios/NowPlayingManager.swift +296 -0
  136. package/ios/Records/BufferOptions.swift +12 -0
  137. package/ios/Records/DRMOptions.swift +24 -0
  138. package/ios/Records/PlaybackError.swift +10 -0
  139. package/ios/Records/Tracks.swift +176 -0
  140. package/ios/Records/VideoEventPayloads.swift +76 -0
  141. package/ios/Records/VideoMetadata.swift +16 -0
  142. package/ios/Records/VideoSize.swift +15 -0
  143. package/ios/Records/VideoSource.swift +25 -0
  144. package/ios/Thumbnails/VideoThumbnail.swift +27 -0
  145. package/ios/Thumbnails/VideoThumbnailGenerator.swift +68 -0
  146. package/ios/Thumbnails/VideoThumbnailOptions.swift +15 -0
  147. package/ios/VideoAsset.swift +123 -0
  148. package/ios/VideoExceptions.swift +53 -0
  149. package/ios/VideoItem.swift +11 -0
  150. package/ios/VideoManager.swift +140 -0
  151. package/ios/VideoModule.swift +383 -0
  152. package/ios/VideoPlayer/DangerousPropertiesStore.swift +19 -0
  153. package/ios/VideoPlayer.swift +435 -0
  154. package/ios/VideoPlayerAudioTracks.swift +72 -0
  155. package/ios/VideoPlayerItem.swift +97 -0
  156. package/ios/VideoPlayerObserver.swift +523 -0
  157. package/ios/VideoPlayerSubtitles.swift +71 -0
  158. package/ios/VideoSourceLoader.swift +89 -0
  159. package/ios/VideoSourceLoaderListener.swift +34 -0
  160. package/ios/VideoView.swift +224 -0
  161. package/package.json +59 -0
  162. package/plugin/build/tsconfig.tsbuildinfo +1 -0
  163. package/plugin/build/withExpoVideo.d.ts +7 -0
  164. package/plugin/build/withExpoVideo.js +38 -0
  165. package/src/NativeVideoModule.ts +20 -0
  166. package/src/NativeVideoModule.web.ts +1 -0
  167. package/src/NativeVideoView.ts +8 -0
  168. package/src/VideoModule.ts +59 -0
  169. package/src/VideoPlayer.tsx +67 -0
  170. package/src/VideoPlayer.types.ts +613 -0
  171. package/src/VideoPlayer.web.tsx +451 -0
  172. package/src/VideoPlayerEvents.types.ts +313 -0
  173. package/src/VideoThumbnail.ts +31 -0
  174. package/src/VideoView.tsx +86 -0
  175. package/src/VideoView.types.ts +165 -0
  176. package/src/VideoView.web.tsx +214 -0
  177. package/src/index.ts +46 -0
  178. package/src/resolveAssetSource.ts +2 -0
  179. package/src/resolveAssetSource.web.ts +17 -0
  180. package/src/ts-declarations/react-native-assets.d.ts +1 -0
@@ -0,0 +1,435 @@
1
+ // Copyright 2023-present 650 Industries. All rights reserved.
2
+
3
+ import AVFoundation
4
+ import MediaPlayer
5
+ import ExpoModulesCore
6
+
7
+ internal final class VideoPlayer: SharedRef<AVPlayer>, Hashable, VideoPlayerObserverDelegate {
8
+ let videoSourceLoader = VideoSourceLoader()
9
+ lazy var contentKeyManager = ContentKeyManager()
10
+ var observer: VideoPlayerObserver?
11
+ lazy var subtitles: VideoPlayerSubtitles = VideoPlayerSubtitles(owner: self)
12
+ private var dangerousPropertiesStore = DangerousPropertiesStore()
13
+ lazy var audioTracks: VideoPlayerAudioTracks = VideoPlayerAudioTracks(owner: self)
14
+
15
+ var loop = false
16
+ var audioMixingMode: AudioMixingMode = .doNotMix {
17
+ didSet {
18
+ if oldValue != audioMixingMode {
19
+ VideoManager.shared.setAppropriateAudioSessionOrWarn()
20
+ }
21
+ }
22
+ }
23
+ private(set) var isPlaying = false
24
+ private(set) var status: PlayerStatus = .idle
25
+
26
+ var playbackRate: Float = 1.0 {
27
+ didSet {
28
+ if oldValue != playbackRate {
29
+ let payload = PlaybackRateChangedEventPayload(playbackRate: playbackRate, oldPlaybackRate: oldValue)
30
+ safeEmit(event: "playbackRateChange", payload: payload)
31
+ }
32
+ if #available(iOS 16.0, tvOS 16.0, *) {
33
+ ref.defaultRate = playbackRate
34
+ }
35
+ ref.rate = playbackRate
36
+ }
37
+ }
38
+
39
+ var currentTime: Double {
40
+ get {
41
+ let currentTime = ref.currentTime().seconds
42
+ return currentTime.isNaN ? 0 : currentTime
43
+ }
44
+ set {
45
+ // Only clamp the lower limit, AVPlayer automatically clamps the upper limit.
46
+ let clampedTime = max(0, newValue)
47
+ let timeToSeek = CMTimeMakeWithSeconds(clampedTime, preferredTimescale: .max)
48
+
49
+ // AVPlayer can't apply the currentTime while the resource is loading. We will re-apply it after loading
50
+ if dangerousPropertiesStore.ownerIsReplacing {
51
+ dangerousPropertiesStore.currentTime = clampedTime
52
+ return
53
+ }
54
+
55
+ ref.seek(to: timeToSeek)
56
+ }
57
+ }
58
+
59
+ var staysActiveInBackground = false {
60
+ didSet {
61
+ if staysActiveInBackground {
62
+ VideoManager.shared.setAppropriateAudioSessionOrWarn()
63
+ }
64
+ }
65
+ }
66
+
67
+ var preservesPitch = true {
68
+ didSet {
69
+ ref.currentItem?.audioTimePitchAlgorithm = preservesPitch ? .spectral : .varispeed
70
+ }
71
+ }
72
+
73
+ var volume: Float = 1.0 {
74
+ didSet {
75
+ if oldValue != volume {
76
+ let payload = VolumeChangedEventPayload(volume: volume, oldVolume: oldValue)
77
+ safeEmit(event: "volumeChange", payload: payload)
78
+ }
79
+ ref.volume = volume
80
+ }
81
+ }
82
+
83
+ var isMuted: Bool = false {
84
+ didSet {
85
+ if oldValue != isMuted {
86
+ let payload = MutedChangedEventPayload(muted: isMuted, oldMuted: oldValue)
87
+ safeEmit(event: "mutedChange", payload: payload)
88
+ }
89
+ ref.isMuted = isMuted
90
+ VideoManager.shared.setAppropriateAudioSessionOrWarn()
91
+ }
92
+ }
93
+
94
+ var showNowPlayingNotification = false {
95
+ didSet {
96
+ // The audio session needs to be appropriate before displaying the notfication
97
+ VideoManager.shared.setAppropriateAudioSessionOrWarn()
98
+
99
+ if showNowPlayingNotification {
100
+ NowPlayingManager.shared.registerPlayer(self)
101
+ } else {
102
+ NowPlayingManager.shared.unregisterPlayer(self)
103
+ }
104
+ }
105
+ }
106
+
107
+ // TODO: @behenate - Once the Player instance is available in OnStartObserving we can automatically start/stop the interval.
108
+ var timeUpdateEventInterval: Double = 0 {
109
+ didSet {
110
+ if timeUpdateEventInterval <= 0 {
111
+ observer?.stopTimeUpdates()
112
+ return
113
+ }
114
+ observer?.startOrUpdateTimeUpdates(forInterval: timeUpdateEventInterval)
115
+ }
116
+ }
117
+
118
+ var currentLiveTimestamp: Double? {
119
+ guard let currentDate = ref.currentItem?.currentDate() else {
120
+ return nil
121
+ }
122
+ let timeIntervalSince = currentDate.timeIntervalSince1970
123
+ return Double(timeIntervalSince * 1000)
124
+ }
125
+
126
+ var currentOffsetFromLive: Double? {
127
+ guard let currentDate = ref.currentItem?.currentDate() else {
128
+ return nil
129
+ }
130
+ let timeIntervalSince = currentDate.timeIntervalSince1970
131
+ let unixTime = Date().timeIntervalSince1970
132
+ return unixTime - timeIntervalSince
133
+ }
134
+
135
+ var bufferOptions = BufferOptions() {
136
+ didSet {
137
+ ref.currentItem?.preferredForwardBufferDuration = bufferOptions.preferredForwardBufferDuration
138
+ ref.automaticallyWaitsToMinimizeStalling = bufferOptions.waitsToMinimizeStalling
139
+ }
140
+ }
141
+
142
+ var bufferedPosition: Double {
143
+ return getBufferedPosition()
144
+ }
145
+
146
+ private(set) var availableVideoTracks: [VideoTrack] = []
147
+ private(set) var currentVideoTrack: VideoTrack? {
148
+ didSet {
149
+ let payload = VideoTrackChangedEventPayload(videoTrack: currentVideoTrack, oldVideoTrack: oldValue)
150
+ safeEmit(event: "videoTrackChange", payload: payload)
151
+ }
152
+ }
153
+
154
+ convenience init(_ ref: AVPlayer, initialSource: VideoSource?, useSynchronousReplace: Bool = false) throws {
155
+ self.init(ref)
156
+
157
+ // While the replace task below is being created, the properties from the JS constructor will start getting applied
158
+ // Therefore we have to set the state as `loading` before the task is created to ensure that we don't lose any dangerous properties
159
+ dangerousPropertiesStore.ownerIsReplacing = initialSource != nil
160
+
161
+ if useSynchronousReplace {
162
+ try replaceCurrentItem(with: initialSource)
163
+ } else {
164
+ Task {
165
+ try await replaceCurrentItem(with: initialSource)
166
+ }
167
+ }
168
+ }
169
+
170
+ private override init(_ ref: AVPlayer) {
171
+ super.init(ref)
172
+ observer = VideoPlayerObserver(owner: self, videoSourceLoader: videoSourceLoader)
173
+ observer?.registerDelegate(delegate: self)
174
+ VideoManager.shared.register(videoPlayer: self)
175
+
176
+ // Disable automatic subtitle selection
177
+ let selectionCriteria = AVPlayerMediaSelectionCriteria(preferredLanguages: [], preferredMediaCharacteristics: [.legible])
178
+ ref.setMediaSelectionCriteria(selectionCriteria, forMediaCharacteristic: .legible)
179
+ }
180
+
181
+ deinit {
182
+ observer?.cleanup()
183
+ NowPlayingManager.shared.unregisterPlayer(self)
184
+ VideoManager.shared.unregister(videoPlayer: self)
185
+
186
+ videoSourceLoader.cancelCurrentTask()
187
+
188
+ // We have to replace from the main thread because of KVOs (see comment in VideoSourceLoader).
189
+ // Moreover, in this case we have to keep a strong reference to AVPlayer and remove its item
190
+ // If we don't do this AVPlayer doesn't get deallocated
191
+ DispatchQueue.main.async { [ref] in
192
+ ref.replaceCurrentItem(with: nil)
193
+ }
194
+ }
195
+
196
+ func replaceCurrentItem(with videoSource: VideoSource?) throws {
197
+ dangerousPropertiesStore.ownerIsReplacing = true
198
+ videoSourceLoader.cancelCurrentTask()
199
+ guard
200
+ let videoSource = videoSource,
201
+ let playerItem = VideoPlayerItem(videoSource: videoSource)
202
+ else {
203
+ clearCurrentItem()
204
+ return
205
+ }
206
+
207
+ if let drm = videoSource.drm {
208
+ try drm.type.assertIsSupported()
209
+ contentKeyManager.addContentKeyRequest(videoSource: videoSource, asset: playerItem.urlAsset)
210
+ }
211
+
212
+ playerItem.audioTimePitchAlgorithm = preservesPitch ? .spectral : .varispeed
213
+ playerItem.preferredForwardBufferDuration = bufferOptions.preferredForwardBufferDuration
214
+
215
+ // The current item has to be replaced from the main thread. When replacing from other queues
216
+ // sometimes the KVOs will try to deliver updates after the item has been changed or player deallocated,
217
+ // which causes crashes.
218
+ DispatchQueue.main.async { [weak self] in
219
+ guard let self else {
220
+ return
221
+ }
222
+ self.ref.replaceCurrentItem(with: playerItem)
223
+ self.dangerousPropertiesStore.ownerIsReplacing = false
224
+ self.dangerousPropertiesStore.applyProperties(to: self)
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Replaces the current item, while loading the AVAsset on a different thread. The synchronous version can lock the main thread for extended periods of time.
230
+ */
231
+ func replaceCurrentItem(with videoSource: VideoSource?) async throws {
232
+ guard let videoSource, videoSource.uri != nil else {
233
+ clearCurrentItem()
234
+ return
235
+ }
236
+
237
+ dangerousPropertiesStore.ownerIsReplacing = true
238
+ guard let playerItem = try await videoSourceLoader.load(videoSource: videoSource) else {
239
+ // Resolve the promise without applying the source. The loading task has been cancelled.
240
+ // The caller that cancelled this task should handle dangerousPropertiesStore
241
+ return
242
+ }
243
+
244
+ if let drm = videoSource.drm {
245
+ try drm.type.assertIsSupported()
246
+ self.contentKeyManager.addContentKeyRequest(videoSource: videoSource, asset: playerItem.urlAsset)
247
+ }
248
+
249
+ playerItem.audioTimePitchAlgorithm = self.preservesPitch ? .spectral : .varispeed
250
+ playerItem.preferredForwardBufferDuration = self.bufferOptions.preferredForwardBufferDuration
251
+
252
+ // The current item has to be replaced from the main thread. When replacing from other queues
253
+ // sometimes the KVOs will try to deliver updates after the item has been changed or player deallocated,
254
+ // which causes crashes.
255
+ DispatchQueue.main.async { [weak self] in
256
+ guard let self else {
257
+ return
258
+ }
259
+ self.ref.replaceCurrentItem(with: playerItem)
260
+ dangerousPropertiesStore.ownerIsReplacing = false
261
+ dangerousPropertiesStore.applyProperties(to: self)
262
+ }
263
+ }
264
+
265
+ private func clearCurrentItem() {
266
+ DispatchQueue.main.async { [ref, videoSourceLoader, dangerousPropertiesStore] in
267
+ ref.replaceCurrentItem(with: nil)
268
+ videoSourceLoader.cancelCurrentTask()
269
+ dangerousPropertiesStore.reset()
270
+ dangerousPropertiesStore.ownerIsReplacing = false
271
+ }
272
+ return
273
+ }
274
+
275
+ /**
276
+ * iOS automatically pauses videos when the app enters the background. Only way to avoid this is to detach the player from the playerLayer.
277
+ * Typical way of doing this for `AVPlayerViewController` is setting `playerViewController.player = nil`, but that makes the
278
+ * video invisible for around a second after foregrounding, disabling the tracks requires more code, but works a lot faster.
279
+ */
280
+ func setTracksEnabled(_ enabled: Bool) {
281
+ ref.currentItem?.tracks.forEach({ track in
282
+ guard let assetTrack = track.assetTrack else {
283
+ return
284
+ }
285
+
286
+ if assetTrack.hasMediaCharacteristic(AVMediaCharacteristic.visual) {
287
+ track.isEnabled = enabled
288
+ }
289
+ })
290
+ }
291
+
292
+ private func getBufferedPosition() -> Double {
293
+ guard let currentItem = ref.currentItem else {
294
+ return -1
295
+ }
296
+ let currentTime = ref.currentTime().seconds
297
+
298
+ for timeRange in currentItem.loadedTimeRanges {
299
+ let start = CMTimeGetSeconds(timeRange.timeRangeValue.start)
300
+ let end = CMTimeGetSeconds(timeRange.timeRangeValue.end)
301
+ if start <= currentTime && end >= currentTime {
302
+ return end
303
+ }
304
+ }
305
+ return 0
306
+ }
307
+
308
+ // MARK: - VideoPlayerObserverDelegate
309
+
310
+ func onStatusChanged(player: AVPlayer, oldStatus: PlayerStatus?, newStatus: PlayerStatus, error: Exception?) {
311
+ let errorRecord = error != nil ? PlaybackError(message: error?.description) : nil
312
+ let payload = StatusChangedEventPayload(status: newStatus, oldStatus: oldStatus, error: errorRecord)
313
+ safeEmit(event: "statusChange", payload: payload)
314
+ status = newStatus
315
+ }
316
+
317
+ func onIsPlayingChanged(player: AVPlayer, oldIsPlaying: Bool?, newIsPlaying: Bool) {
318
+ let payload = IsPlayingEventPayload(isPlaying: newIsPlaying, oldIsPlaying: oldIsPlaying)
319
+ safeEmit(event: "playingChange", payload: payload)
320
+ isPlaying = newIsPlaying
321
+
322
+ VideoManager.shared.setAppropriateAudioSessionOrWarn()
323
+ }
324
+
325
+ func onRateChanged(player: AVPlayer, oldRate: Float?, newRate: Float) {
326
+ if #available(iOS 16.0, tvOS 16.0, *) {
327
+ if player.defaultRate != playbackRate {
328
+ // User changed the playback speed in the native controls. Update the desiredRate variable
329
+ playbackRate = player.defaultRate
330
+ }
331
+ } else if newRate != 0 && newRate != playbackRate {
332
+ // On iOS < 16 play() method always returns the rate to 1.0, we have to keep resetting it back to desiredRate
333
+ // iOS < 16 uses an older player UI, so we don't have to worry about changes to the rate that come from the player UI
334
+ ref.rate = playbackRate
335
+ }
336
+ }
337
+
338
+ func onVolumeChanged(player: AVPlayer, oldVolume: Float?, newVolume: Float) {
339
+ volume = newVolume
340
+ }
341
+
342
+ func onIsMutedChanged(player: AVPlayer, oldIsMuted: Bool?, newIsMuted: Bool) {
343
+ isMuted = newIsMuted
344
+ }
345
+
346
+ func onPlayedToEnd(player: AVPlayer) {
347
+ safeEmit(event: "playToEnd")
348
+ if loop {
349
+ self.ref.seek(to: .zero)
350
+ self.ref.play()
351
+ }
352
+ }
353
+
354
+ func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?) {
355
+ let payload = SourceChangedEventPayload(
356
+ source: newVideoPlayerItem?.videoSource,
357
+ oldSource: oldVideoPlayerItem?.videoSource
358
+ )
359
+ safeEmit(event: "sourceChange", payload: payload)
360
+ newVideoPlayerItem?.preferredForwardBufferDuration = bufferOptions.preferredForwardBufferDuration
361
+ }
362
+
363
+ func onTimeUpdate(player: AVPlayer, timeUpdate: TimeUpdate) {
364
+ safeEmit(event: "timeUpdate", payload: timeUpdate)
365
+ }
366
+
367
+ func onLoadedPlayerItem(player: AVPlayer, playerItem: AVPlayerItem?) {
368
+ // This event means that a new player item has been loaded so the subtitle tracks should change
369
+ let oldTracks = subtitles.availableSubtitleTracks
370
+ self.subtitles.onNewPlayerItemLoaded(playerItem: playerItem)
371
+ let payload = SubtitleTracksChangedEventPayload(
372
+ availableSubtitleTracks: subtitles.availableSubtitleTracks,
373
+ oldAvailableSubtitleTracks: oldTracks
374
+ )
375
+ safeEmit(event: "availableSubtitleTracksChange", payload: payload)
376
+
377
+ // Handle audio tracks
378
+ let oldAudioTracks = audioTracks.availableAudioTracks
379
+ self.audioTracks.onNewPlayerItemLoaded(playerItem: playerItem)
380
+ let audioPayload = AudioTracksChangedEventPayload(
381
+ availableAudioTracks: audioTracks.availableAudioTracks,
382
+ oldAvailableAudioTracks: oldAudioTracks
383
+ )
384
+ safeEmit(event: "availableAudioTracksChange", payload: audioPayload)
385
+
386
+ Task {
387
+ let videoPlayerItem: VideoPlayerItem? = playerItem as? VideoPlayerItem
388
+ // Those properties will be already loaded 99.9% of time, so the event delay should be almost 0
389
+ availableVideoTracks = await videoPlayerItem?.videoTracks ?? []
390
+
391
+ let videoSourceLoadedPayload = VideoSourceLoadedEventPayload(
392
+ videoSource: videoPlayerItem?.videoSource,
393
+ duration: playerItem?.duration.seconds,
394
+ availableVideoTracks: availableVideoTracks,
395
+ availableSubtitleTracks: subtitles.availableSubtitleTracks,
396
+ availableAudioTracks: audioTracks.availableAudioTracks
397
+ )
398
+ safeEmit(event: "sourceLoad", payload: videoSourceLoadedPayload)
399
+ }
400
+ }
401
+
402
+ func onSubtitleSelectionChanged(player: AVPlayer, playerItem: AVPlayerItem?, subtitleTrack: SubtitleTrack?) {
403
+ let oldTrack = subtitles.currentSubtitleTrack
404
+ subtitles.onNewSubtitleTrackSelected(subtitleTrack: subtitleTrack)
405
+ let payload = SubtitleTrackChangedEventPayload(subtitleTrack: subtitles.currentSubtitleTrack, oldSubtitleTrack: oldTrack)
406
+ safeEmit(event: "subtitleTrackChange", payload: payload)
407
+ }
408
+
409
+ func onAudioTrackSelectionChanged(player: AVPlayer, playerItem: AVPlayerItem?, audioTrack: AudioTrack?) {
410
+ let oldTrack = audioTracks.currentAudioTrack
411
+ audioTracks.onNewAudioTrackSelected(audioTrack: audioTrack)
412
+ let payload = AudioTrackChangedEventPayload(audioTrack: audioTracks.currentAudioTrack, oldAudioTrack: oldTrack)
413
+ safeEmit(event: "audioTrackChange", payload: payload)
414
+ }
415
+
416
+ func onVideoTrackChanged(player: AVPlayer, oldVideoTrack: VideoTrack?, newVideoTrack: VideoTrack?) {
417
+ currentVideoTrack = newVideoTrack
418
+ }
419
+
420
+ func safeEmit(event: String, payload: Record? = nil) {
421
+ if self.appContext != nil {
422
+ self.emit(event: event, arguments: payload?.toDictionary(appContext: appContext))
423
+ }
424
+ }
425
+
426
+ // MARK: - Hashable
427
+
428
+ func hash(into hasher: inout Hasher) {
429
+ hasher.combine(ObjectIdentifier(self))
430
+ }
431
+
432
+ static func == (lhs: VideoPlayer, rhs: VideoPlayer) -> Bool {
433
+ return ObjectIdentifier(lhs) == ObjectIdentifier(rhs)
434
+ }
435
+ }
@@ -0,0 +1,72 @@
1
+ import Foundation
2
+ import AVFoundation
3
+
4
+ class VideoPlayerAudioTracks {
5
+ weak var owner: VideoPlayer?
6
+ private(set) var availableAudioTracks: [AudioTrack] = []
7
+ private(set) var currentAudioTrack: AudioTrack?
8
+
9
+ init (owner: VideoPlayer) {
10
+ self.owner = owner
11
+ }
12
+
13
+ func onNewAudioTrackSelected(audioTrack: AudioTrack?) {
14
+ currentAudioTrack = audioTrack
15
+ }
16
+
17
+ func onNewPlayerItemLoaded(playerItem: AVPlayerItem?) {
18
+ availableAudioTracks = Self.findAvailableAudioTracks(for: playerItem)
19
+ }
20
+
21
+ func selectAudioTrack(audioTrack: AudioTrack?) {
22
+ guard let currentItem = self.owner?.ref.currentItem else {
23
+ return
24
+ }
25
+
26
+ if let group = currentItem.asset.mediaSelectionGroup(forMediaCharacteristic: .audible) {
27
+ let option = group.options.first {
28
+ $0.displayName == audioTrack?.label && $0.locale?.identifier == audioTrack?.language
29
+ }
30
+ currentItem.select(option, in: group)
31
+ }
32
+ }
33
+
34
+ static func findAvailableAudioTracks(for playerItem: AVPlayerItem?) -> [AudioTrack] {
35
+ var availableAudioTracks: [AudioTrack] = []
36
+
37
+ guard let asset = playerItem?.asset else {
38
+ return availableAudioTracks
39
+ }
40
+
41
+ let mediaSelectionCharacteristics = asset.availableMediaCharacteristicsWithMediaSelectionOptions
42
+
43
+ for characteristic in mediaSelectionCharacteristics {
44
+ guard characteristic == .audible else {
45
+ continue
46
+ }
47
+
48
+ if let group = asset.mediaSelectionGroup(forMediaCharacteristic: characteristic) {
49
+ for option in group.options {
50
+ guard let audioTrack = AudioTrack.from(mediaSelectionOption: option) else {
51
+ continue
52
+ }
53
+
54
+ availableAudioTracks.append(audioTrack)
55
+ }
56
+ }
57
+ }
58
+ return availableAudioTracks
59
+ }
60
+
61
+ static func findCurrentAudioTrack(for playerItem: AVPlayerItem?) -> AudioTrack? {
62
+ guard
63
+ let currentItem = playerItem,
64
+ let mediaSelectionGroup = currentItem.asset.mediaSelectionGroup(forMediaCharacteristic: .audible),
65
+ let selectedMediaOption = currentItem.currentMediaSelection.selectedMediaOption(in: mediaSelectionGroup)
66
+ else {
67
+ return nil
68
+ }
69
+
70
+ return AudioTrack.from(mediaSelectionOption: selectedMediaOption)
71
+ }
72
+ }
@@ -0,0 +1,97 @@
1
+ // Copyright 2024-present 650 Industries. All rights reserved.
2
+
3
+ import ExpoModulesCore
4
+ import AVKit
5
+
6
+ class VideoPlayerItem: AVPlayerItem {
7
+ let urlAsset: VideoAsset
8
+ let videoSource: VideoSource
9
+ let isHls: Bool
10
+ var videoTracks: [VideoTrack] {
11
+ get async {
12
+ return await tracksLoadingTask?.value ?? []
13
+ }
14
+ }
15
+
16
+ private var tracksLoadingTask: Task<[VideoTrack], Never>?
17
+
18
+ init?(videoSource: VideoSource) {
19
+ guard let url = videoSource.uri else {
20
+ return nil
21
+ }
22
+ self.videoSource = videoSource
23
+ self.isHls = videoSource.uri?.pathExtension == "m3u8" || videoSource.contentType == .hls
24
+
25
+ let asset = VideoAsset(url: url, videoSource: videoSource)
26
+ self.urlAsset = asset
27
+ super.init(asset: urlAsset, automaticallyLoadedAssetKeys: nil)
28
+ self.createTracksLoadingTask()
29
+ }
30
+
31
+ init?(videoSource: VideoSource) async throws {
32
+ guard let url = videoSource.uri else {
33
+ return nil
34
+ }
35
+ self.videoSource = videoSource
36
+ self.isHls = videoSource.uri?.pathExtension == "m3u8" || videoSource.contentType == .hls
37
+
38
+ let asset = VideoAsset(url: url, videoSource: videoSource)
39
+ self.urlAsset = asset
40
+ // We can ignore any exceptions thrown during the load. The asset will be assigned to the `VideoPlayer` anyways
41
+ // and cause it to go into .error state trigerring the `onStatusChange` event.
42
+ _ = try? await asset.load(.duration, .preferredTransform, .isPlayable)
43
+
44
+ super.init(asset: urlAsset, automaticallyLoadedAssetKeys: nil)
45
+ self.createTracksLoadingTask()
46
+ }
47
+
48
+ func createTracksLoadingTask() {
49
+ tracksLoadingTask = Task { [weak self] in
50
+ var tracks: [VideoTrack] = []
51
+ guard let self else {
52
+ return []
53
+ }
54
+
55
+ if isHls {
56
+ do {
57
+ tracks = try await self.fetchHlsVideoTracks()
58
+ } catch {
59
+ tracks = []
60
+ log.warn("Failed to fetch HLS video tracks, this is not required for playback, but `expo-video` will have no knowledge of the available tracks: \(error.localizedDescription)")
61
+ }
62
+ } else {
63
+ let avAssetTracks = asset.tracks(withMediaType: .video)
64
+ for avAssetTrack in avAssetTracks {
65
+ tracks.append(await VideoTrack.from(assetTrack: avAssetTrack))
66
+ }
67
+ }
68
+ return tracks
69
+ }
70
+ }
71
+
72
+ // AVKit API doesn't provide us with a list of available tracks for a HLS source. We can download the playlist file and parse it ourselves
73
+ // it's usually very small (1-2 kB), so we won't add too much overhead
74
+ private func fetchHlsVideoTracks() async throws -> [VideoTrack] {
75
+ guard let uri = videoSource.uri else {
76
+ throw URLError(.badURL)
77
+ }
78
+
79
+ var request = URLRequest(url: uri)
80
+ if let headers = videoSource.headers {
81
+ for (key, value) in headers {
82
+ request.addValue(value, forHTTPHeaderField: key)
83
+ }
84
+ }
85
+
86
+ let (data, _) = try await URLSession.shared.data(for: request)
87
+ let content = String(data: data, encoding: .utf8) ?? ""
88
+ return parseM3U8(content)
89
+ }
90
+
91
+ private func parseM3U8(_ content: String) -> [VideoTrack] {
92
+ let lines = content.components(separatedBy: "\n")
93
+ return zip(lines, lines.dropFirst()).compactMap { line, nextLine in
94
+ VideoTrack.from(hlsHeaderLine: line, idLine: nextLine)
95
+ }
96
+ }
97
+ }