@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.
- package/README.md +45 -0
- package/android/build.gradle +32 -0
- package/android/src/main/AndroidManifest.xml +20 -0
- package/android/src/main/java/expo/modules/video/AudioFocusManager.kt +241 -0
- package/android/src/main/java/expo/modules/video/FullscreenPlayerActivity.kt +145 -0
- package/android/src/main/java/expo/modules/video/IntervalUpdateClock.kt +54 -0
- package/android/src/main/java/expo/modules/video/MediaMetadataRetriever.kt +89 -0
- package/android/src/main/java/expo/modules/video/PictureInPictureHelperFragment.kt +26 -0
- package/android/src/main/java/expo/modules/video/PlayerViewExtension.kt +36 -0
- package/android/src/main/java/expo/modules/video/VideoCache.kt +104 -0
- package/android/src/main/java/expo/modules/video/VideoExceptions.kt +34 -0
- package/android/src/main/java/expo/modules/video/VideoManager.kt +133 -0
- package/android/src/main/java/expo/modules/video/VideoModule.kt +414 -0
- package/android/src/main/java/expo/modules/video/VideoThumbnail.kt +20 -0
- package/android/src/main/java/expo/modules/video/VideoView.kt +367 -0
- package/android/src/main/java/expo/modules/video/delegates/IgnoreSameSet.kt +24 -0
- package/android/src/main/java/expo/modules/video/drawing/OutlineProvider.kt +217 -0
- package/android/src/main/java/expo/modules/video/enums/AudioMixingMode.kt +20 -0
- package/android/src/main/java/expo/modules/video/enums/ContentFit.kt +19 -0
- package/android/src/main/java/expo/modules/video/enums/ContentType.kt +22 -0
- package/android/src/main/java/expo/modules/video/enums/DRMType.kt +26 -0
- package/android/src/main/java/expo/modules/video/enums/PlayerStatus.kt +10 -0
- package/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt +184 -0
- package/android/src/main/java/expo/modules/video/playbackService/PlaybackServiceConnection.kt +39 -0
- package/android/src/main/java/expo/modules/video/playbackService/VideoMediaSessionCallback.kt +47 -0
- package/android/src/main/java/expo/modules/video/player/FirstFrameEventGenerator.kt +93 -0
- package/android/src/main/java/expo/modules/video/player/PlayerEvent.kt +164 -0
- package/android/src/main/java/expo/modules/video/player/VideoPlayer.kt +460 -0
- package/android/src/main/java/expo/modules/video/player/VideoPlayerAudioTracks.kt +125 -0
- package/android/src/main/java/expo/modules/video/player/VideoPlayerListener.kt +32 -0
- package/android/src/main/java/expo/modules/video/player/VideoPlayerLoadControl.kt +525 -0
- package/android/src/main/java/expo/modules/video/player/VideoPlayerSubtitles.kt +125 -0
- package/android/src/main/java/expo/modules/video/records/BufferOptions.kt +15 -0
- package/android/src/main/java/expo/modules/video/records/DRMOptions.kt +25 -0
- package/android/src/main/java/expo/modules/video/records/PlaybackError.kt +19 -0
- package/android/src/main/java/expo/modules/video/records/Tracks.kt +81 -0
- package/android/src/main/java/expo/modules/video/records/VideoEventPayloads.kt +79 -0
- package/android/src/main/java/expo/modules/video/records/VideoMetadata.kt +12 -0
- package/android/src/main/java/expo/modules/video/records/VideoSize.kt +14 -0
- package/android/src/main/java/expo/modules/video/records/VideoSource.kt +104 -0
- package/android/src/main/java/expo/modules/video/records/VideoThumbnailOptions.kt +24 -0
- package/android/src/main/java/expo/modules/video/utils/DataSourceUtils.kt +75 -0
- package/android/src/main/java/expo/modules/video/utils/EventDispatcherUtils.kt +43 -0
- package/android/src/main/java/expo/modules/video/utils/MutableWeakReference.kt +15 -0
- package/android/src/main/java/expo/modules/video/utils/PictureInPictureUtils.kt +96 -0
- package/android/src/main/java/expo/modules/video/utils/YogaUtils.kt +20 -0
- package/android/src/main/res/drawable/seek_backwards_10s.xml +25 -0
- package/android/src/main/res/drawable/seek_backwards_15s.xml +25 -0
- package/android/src/main/res/drawable/seek_backwards_5s.xml +25 -0
- package/android/src/main/res/drawable/seek_forwards_10s.xml +30 -0
- package/android/src/main/res/drawable/seek_forwards_15s.xml +31 -0
- package/android/src/main/res/drawable/seek_forwards_5s.xml +30 -0
- package/android/src/main/res/layout/fullscreen_player_activity.xml +16 -0
- package/android/src/main/res/layout/surface_player_view.xml +7 -0
- package/android/src/main/res/layout/texture_player_view.xml +7 -0
- package/android/src/main/res/values/styles.xml +9 -0
- package/app.plugin.js +1 -0
- package/build/NativeVideoModule.d.ts +16 -0
- package/build/NativeVideoModule.d.ts.map +1 -0
- package/build/NativeVideoModule.js +3 -0
- package/build/NativeVideoModule.js.map +1 -0
- package/build/NativeVideoModule.web.d.ts +3 -0
- package/build/NativeVideoModule.web.d.ts.map +1 -0
- package/build/NativeVideoModule.web.js +2 -0
- package/build/NativeVideoModule.web.js.map +1 -0
- package/build/NativeVideoView.d.ts +4 -0
- package/build/NativeVideoView.d.ts.map +1 -0
- package/build/NativeVideoView.js +6 -0
- package/build/NativeVideoView.js.map +1 -0
- package/build/VideoModule.d.ts +38 -0
- package/build/VideoModule.d.ts.map +1 -0
- package/build/VideoModule.js +53 -0
- package/build/VideoModule.js.map +1 -0
- package/build/VideoPlayer.d.ts +15 -0
- package/build/VideoPlayer.d.ts.map +1 -0
- package/build/VideoPlayer.js +52 -0
- package/build/VideoPlayer.js.map +1 -0
- package/build/VideoPlayer.types.d.ts +532 -0
- package/build/VideoPlayer.types.d.ts.map +1 -0
- package/build/VideoPlayer.types.js +2 -0
- package/build/VideoPlayer.types.js.map +1 -0
- package/build/VideoPlayer.web.d.ts +75 -0
- package/build/VideoPlayer.web.d.ts.map +1 -0
- package/build/VideoPlayer.web.js +376 -0
- package/build/VideoPlayer.web.js.map +1 -0
- package/build/VideoPlayerEvents.types.d.ts +262 -0
- package/build/VideoPlayerEvents.types.d.ts.map +1 -0
- package/build/VideoPlayerEvents.types.js +2 -0
- package/build/VideoPlayerEvents.types.js.map +1 -0
- package/build/VideoThumbnail.d.ts +29 -0
- package/build/VideoThumbnail.d.ts.map +1 -0
- package/build/VideoThumbnail.js +3 -0
- package/build/VideoThumbnail.js.map +1 -0
- package/build/VideoView.d.ts +44 -0
- package/build/VideoView.d.ts.map +1 -0
- package/build/VideoView.js +76 -0
- package/build/VideoView.js.map +1 -0
- package/build/VideoView.types.d.ts +147 -0
- package/build/VideoView.types.d.ts.map +1 -0
- package/build/VideoView.types.js +2 -0
- package/build/VideoView.types.js.map +1 -0
- package/build/VideoView.web.d.ts +9 -0
- package/build/VideoView.web.d.ts.map +1 -0
- package/build/VideoView.web.js +180 -0
- package/build/VideoView.web.js.map +1 -0
- package/build/index.d.ts +9 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +7 -0
- package/build/index.js.map +1 -0
- package/build/resolveAssetSource.d.ts +3 -0
- package/build/resolveAssetSource.d.ts.map +1 -0
- package/build/resolveAssetSource.js +3 -0
- package/build/resolveAssetSource.js.map +1 -0
- package/build/resolveAssetSource.web.d.ts +4 -0
- package/build/resolveAssetSource.web.d.ts.map +1 -0
- package/build/resolveAssetSource.web.js +16 -0
- package/build/resolveAssetSource.web.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Cache/CachableRequest.swift +44 -0
- package/ios/Cache/CachedResource.swift +97 -0
- package/ios/Cache/CachingHelpers.swift +92 -0
- package/ios/Cache/MediaFileHandle.swift +94 -0
- package/ios/Cache/MediaInfo.swift +147 -0
- package/ios/Cache/ResourceLoaderDelegate.swift +274 -0
- package/ios/Cache/SynchronizedHashTable.swift +23 -0
- package/ios/Cache/VideoCacheManager.swift +338 -0
- package/ios/ContentKeyDelegate.swift +214 -0
- package/ios/ContentKeyManager.swift +21 -0
- package/ios/Enums/AudioMixingMode.swift +37 -0
- package/ios/Enums/ContentType.swift +12 -0
- package/ios/Enums/DRMType.swift +20 -0
- package/ios/Enums/PlayerStatus.swift +10 -0
- package/ios/Enums/VideoContentFit.swift +39 -0
- package/ios/ExpoVideo.podspec +29 -0
- package/ios/NowPlayingManager.swift +296 -0
- package/ios/Records/BufferOptions.swift +12 -0
- package/ios/Records/DRMOptions.swift +24 -0
- package/ios/Records/PlaybackError.swift +10 -0
- package/ios/Records/Tracks.swift +176 -0
- package/ios/Records/VideoEventPayloads.swift +76 -0
- package/ios/Records/VideoMetadata.swift +16 -0
- package/ios/Records/VideoSize.swift +15 -0
- package/ios/Records/VideoSource.swift +25 -0
- package/ios/Thumbnails/VideoThumbnail.swift +27 -0
- package/ios/Thumbnails/VideoThumbnailGenerator.swift +68 -0
- package/ios/Thumbnails/VideoThumbnailOptions.swift +15 -0
- package/ios/VideoAsset.swift +123 -0
- package/ios/VideoExceptions.swift +53 -0
- package/ios/VideoItem.swift +11 -0
- package/ios/VideoManager.swift +140 -0
- package/ios/VideoModule.swift +383 -0
- package/ios/VideoPlayer/DangerousPropertiesStore.swift +19 -0
- package/ios/VideoPlayer.swift +435 -0
- package/ios/VideoPlayerAudioTracks.swift +72 -0
- package/ios/VideoPlayerItem.swift +97 -0
- package/ios/VideoPlayerObserver.swift +523 -0
- package/ios/VideoPlayerSubtitles.swift +71 -0
- package/ios/VideoSourceLoader.swift +89 -0
- package/ios/VideoSourceLoaderListener.swift +34 -0
- package/ios/VideoView.swift +224 -0
- package/package.json +59 -0
- package/plugin/build/tsconfig.tsbuildinfo +1 -0
- package/plugin/build/withExpoVideo.d.ts +7 -0
- package/plugin/build/withExpoVideo.js +38 -0
- package/src/NativeVideoModule.ts +20 -0
- package/src/NativeVideoModule.web.ts +1 -0
- package/src/NativeVideoView.ts +8 -0
- package/src/VideoModule.ts +59 -0
- package/src/VideoPlayer.tsx +67 -0
- package/src/VideoPlayer.types.ts +613 -0
- package/src/VideoPlayer.web.tsx +451 -0
- package/src/VideoPlayerEvents.types.ts +313 -0
- package/src/VideoThumbnail.ts +31 -0
- package/src/VideoView.tsx +86 -0
- package/src/VideoView.types.ts +165 -0
- package/src/VideoView.web.tsx +214 -0
- package/src/index.ts +46 -0
- package/src/resolveAssetSource.ts +2 -0
- package/src/resolveAssetSource.web.ts +17 -0
- 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
|
+
}
|