@granite-js/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/CHANGELOG.md +7 -0
- package/GraniteVideo.podspec +72 -0
- package/android/README.md +232 -0
- package/android/build.gradle +117 -0
- package/android/gradle.properties +8 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/run/granite/video/GraniteVideoModule.kt +70 -0
- package/android/src/main/java/run/granite/video/GraniteVideoPackage.kt +43 -0
- package/android/src/main/java/run/granite/video/GraniteVideoView.kt +384 -0
- package/android/src/main/java/run/granite/video/GraniteVideoViewManager.kt +318 -0
- package/android/src/main/java/run/granite/video/event/GraniteVideoEvents.kt +273 -0
- package/android/src/main/java/run/granite/video/event/VideoEventDispatcher.kt +66 -0
- package/android/src/main/java/run/granite/video/event/VideoEventListenerAdapter.kt +157 -0
- package/android/src/main/java/run/granite/video/provider/GraniteVideoProvider.kt +346 -0
- package/android/src/media3/AndroidManifest.xml +9 -0
- package/android/src/media3/java/run/granite/video/provider/media3/ExoPlayerProvider.kt +386 -0
- package/android/src/media3/java/run/granite/video/provider/media3/Media3ContentProvider.kt +29 -0
- package/android/src/media3/java/run/granite/video/provider/media3/Media3Initializer.kt +25 -0
- package/android/src/media3/java/run/granite/video/provider/media3/factory/ExoPlayerFactory.kt +32 -0
- package/android/src/media3/java/run/granite/video/provider/media3/factory/MediaSourceFactory.kt +61 -0
- package/android/src/media3/java/run/granite/video/provider/media3/factory/TrackSelectorFactory.kt +26 -0
- package/android/src/media3/java/run/granite/video/provider/media3/factory/VideoSurfaceFactory.kt +62 -0
- package/android/src/media3/java/run/granite/video/provider/media3/listener/ExoPlayerEventListener.kt +104 -0
- package/android/src/media3/java/run/granite/video/provider/media3/scheduler/ProgressScheduler.kt +56 -0
- package/android/src/test/java/run/granite/video/GraniteVideoViewRobolectricTest.kt +598 -0
- package/android/src/test/java/run/granite/video/event/VideoEventListenerAdapterTest.kt +319 -0
- package/android/src/test/java/run/granite/video/helpers/FakeGraniteVideoProvider.kt +161 -0
- package/android/src/test/java/run/granite/video/helpers/TestProgressScheduler.kt +42 -0
- package/android/src/test/java/run/granite/video/provider/GraniteVideoRegistryTest.kt +232 -0
- package/android/src/test/java/run/granite/video/provider/ProviderContractTest.kt +174 -0
- package/android/src/test/java/run/granite/video/provider/media3/listener/ExoPlayerEventListenerTest.kt +243 -0
- package/android/src/test/resources/kotest.properties +2 -0
- package/dist/module/GraniteVideo.js +458 -0
- package/dist/module/GraniteVideo.js.map +1 -0
- package/dist/module/GraniteVideoNativeComponent.ts +265 -0
- package/dist/module/index.js +7 -0
- package/dist/module/index.js.map +1 -0
- package/dist/module/package.json +1 -0
- package/dist/module/types.js +4 -0
- package/dist/module/types.js.map +1 -0
- package/dist/typescript/GraniteVideo.d.ts +12 -0
- package/dist/typescript/GraniteVideoNativeComponent.d.ts +189 -0
- package/dist/typescript/index.d.ts +5 -0
- package/dist/typescript/types.d.ts +328 -0
- package/ios/GraniteVideoComponentsProvider.h +10 -0
- package/ios/GraniteVideoProvider.swift +280 -0
- package/ios/GraniteVideoView.h +15 -0
- package/ios/GraniteVideoView.mm +661 -0
- package/ios/Providers/AVPlayerProvider.swift +541 -0
- package/package.json +106 -0
- package/src/GraniteVideo.tsx +575 -0
- package/src/GraniteVideoNativeComponent.ts +265 -0
- package/src/index.ts +8 -0
- package/src/types.ts +464 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import AVKit
|
|
4
|
+
import Combine
|
|
5
|
+
|
|
6
|
+
// MARK: - Player Container View
|
|
7
|
+
|
|
8
|
+
private class AVPlayerContainerView: UIView {
|
|
9
|
+
var playerLayer: AVPlayerLayer? {
|
|
10
|
+
didSet {
|
|
11
|
+
if let layer = playerLayer {
|
|
12
|
+
self.layer.addSublayer(layer)
|
|
13
|
+
layer.frame = self.bounds
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
override func layoutSubviews() {
|
|
19
|
+
super.layoutSubviews()
|
|
20
|
+
playerLayer?.frame = self.bounds
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// MARK: - AVPlayerProvider
|
|
25
|
+
|
|
26
|
+
@objc public class AVPlayerProvider: NSObject, GraniteVideoProvidable {
|
|
27
|
+
|
|
28
|
+
// MARK: - Properties
|
|
29
|
+
|
|
30
|
+
@objc public weak var delegate: GraniteVideoDelegate?
|
|
31
|
+
|
|
32
|
+
private var player: AVPlayer
|
|
33
|
+
private var playerLayer: AVPlayerLayer
|
|
34
|
+
private var playerView: AVPlayerContainerView?
|
|
35
|
+
private var playerItem: AVPlayerItem?
|
|
36
|
+
private var timeObserver: Any?
|
|
37
|
+
private var pipController: AVPictureInPictureController?
|
|
38
|
+
|
|
39
|
+
private var shouldRepeat: Bool = false
|
|
40
|
+
private var isMuted: Bool = false
|
|
41
|
+
private var playerVolume: Float = 1.0
|
|
42
|
+
private var playerRate: Float = 1.0
|
|
43
|
+
private var playInBackgroundEnabled: Bool = false
|
|
44
|
+
private var playWhenInactiveEnabled: Bool = false
|
|
45
|
+
private var isFullscreen: Bool = false
|
|
46
|
+
private var pipEnabled: Bool = false
|
|
47
|
+
private var preferredForwardBuffer: Double = 0
|
|
48
|
+
private var automaticallyWaits: Bool = true
|
|
49
|
+
private var allowsExternalPlaybackEnabled: Bool = true
|
|
50
|
+
private var preventsDisplaySleep: Bool = true
|
|
51
|
+
private var maxBitRateValue: Int = 0
|
|
52
|
+
|
|
53
|
+
private var currentUri: String?
|
|
54
|
+
private var hasLoadedData: Bool = false
|
|
55
|
+
private var isSeekingFlag: Bool = false
|
|
56
|
+
|
|
57
|
+
// Combine subscriptions
|
|
58
|
+
private var cancellables = Set<AnyCancellable>()
|
|
59
|
+
private var itemCancellables = Set<AnyCancellable>()
|
|
60
|
+
|
|
61
|
+
// MARK: - Required Protocol Properties
|
|
62
|
+
|
|
63
|
+
@objc public var currentTime: Double {
|
|
64
|
+
guard playerItem != nil else { return 0 }
|
|
65
|
+
return CMTimeGetSeconds(player.currentTime())
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@objc public var duration: Double {
|
|
69
|
+
guard let item = playerItem else { return 0 }
|
|
70
|
+
let duration = item.duration
|
|
71
|
+
if !duration.isValid || duration.isIndefinite {
|
|
72
|
+
return 0
|
|
73
|
+
}
|
|
74
|
+
return CMTimeGetSeconds(duration)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@objc public var isPlaying: Bool {
|
|
78
|
+
return player.rate != 0 && player.error == nil
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// MARK: - Initialization
|
|
82
|
+
|
|
83
|
+
@objc public override init() {
|
|
84
|
+
player = AVPlayer()
|
|
85
|
+
playerLayer = AVPlayerLayer(player: player)
|
|
86
|
+
playerLayer.videoGravity = .resizeAspect
|
|
87
|
+
|
|
88
|
+
super.init()
|
|
89
|
+
|
|
90
|
+
player.allowsExternalPlayback = true
|
|
91
|
+
|
|
92
|
+
setupPlayerObservers()
|
|
93
|
+
setupNotificationObservers()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
deinit {
|
|
97
|
+
unload()
|
|
98
|
+
cancellables.removeAll()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// MARK: - Combine Observers Setup
|
|
102
|
+
|
|
103
|
+
private func setupPlayerObservers() {
|
|
104
|
+
// Player rate changes (play/pause state)
|
|
105
|
+
player.publisher(for: \.rate)
|
|
106
|
+
.receive(on: DispatchQueue.main)
|
|
107
|
+
.sink { [weak self] rate in
|
|
108
|
+
guard let self = self else { return }
|
|
109
|
+
let isPlaying = rate != 0
|
|
110
|
+
self.delegate?.videoPlaybackStateChanged?(isPlaying: isPlaying, isSeeking: self.isSeekingFlag, isLooping: self.shouldRepeat)
|
|
111
|
+
self.delegate?.videoPlaybackRateChanged?(rate: rate)
|
|
112
|
+
}
|
|
113
|
+
.store(in: &cancellables)
|
|
114
|
+
|
|
115
|
+
// Player time control status (buffering state)
|
|
116
|
+
player.publisher(for: \.timeControlStatus)
|
|
117
|
+
.receive(on: DispatchQueue.main)
|
|
118
|
+
.sink { [weak self] status in
|
|
119
|
+
let isBuffering = (status == .waitingToPlayAtSpecifiedRate)
|
|
120
|
+
self?.delegate?.videoBufferingStateChanged?(isBuffering: isBuffering)
|
|
121
|
+
}
|
|
122
|
+
.store(in: &cancellables)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private func setupNotificationObservers() {
|
|
126
|
+
// App lifecycle notifications
|
|
127
|
+
NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
|
|
128
|
+
.receive(on: DispatchQueue.main)
|
|
129
|
+
.sink { [weak self] _ in
|
|
130
|
+
self?.handleAppDidEnterBackground()
|
|
131
|
+
}
|
|
132
|
+
.store(in: &cancellables)
|
|
133
|
+
|
|
134
|
+
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
|
|
135
|
+
.receive(on: DispatchQueue.main)
|
|
136
|
+
.sink { [weak self] _ in
|
|
137
|
+
self?.handleAppWillEnterForeground()
|
|
138
|
+
}
|
|
139
|
+
.store(in: &cancellables)
|
|
140
|
+
|
|
141
|
+
NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification)
|
|
142
|
+
.receive(on: DispatchQueue.main)
|
|
143
|
+
.sink { [weak self] _ in
|
|
144
|
+
self?.handleAudioSessionInterruption()
|
|
145
|
+
}
|
|
146
|
+
.store(in: &cancellables)
|
|
147
|
+
|
|
148
|
+
NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification)
|
|
149
|
+
.receive(on: DispatchQueue.main)
|
|
150
|
+
.sink { [weak self] notification in
|
|
151
|
+
self?.handleAudioRouteChange(notification)
|
|
152
|
+
}
|
|
153
|
+
.store(in: &cancellables)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private func setupPlayerItemObservers(for item: AVPlayerItem) {
|
|
157
|
+
// Clear previous item observers
|
|
158
|
+
itemCancellables.removeAll()
|
|
159
|
+
|
|
160
|
+
// Player item status
|
|
161
|
+
item.publisher(for: \.status)
|
|
162
|
+
.receive(on: DispatchQueue.main)
|
|
163
|
+
.sink { [weak self] status in
|
|
164
|
+
self?.handlePlayerItemStatusChange(status)
|
|
165
|
+
}
|
|
166
|
+
.store(in: &itemCancellables)
|
|
167
|
+
|
|
168
|
+
// Playback end notification
|
|
169
|
+
NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: item)
|
|
170
|
+
.receive(on: DispatchQueue.main)
|
|
171
|
+
.sink { [weak self] _ in
|
|
172
|
+
self?.handlePlayerItemDidPlayToEndTime()
|
|
173
|
+
}
|
|
174
|
+
.store(in: &itemCancellables)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// MARK: - GraniteVideoProvidable Required
|
|
178
|
+
|
|
179
|
+
@objc public func createPlayerView() -> UIView {
|
|
180
|
+
let view = AVPlayerContainerView()
|
|
181
|
+
view.backgroundColor = .black
|
|
182
|
+
view.playerLayer = playerLayer
|
|
183
|
+
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
184
|
+
playerView = view
|
|
185
|
+
return view
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@objc public func loadSource(_ source: GraniteVideoSource) {
|
|
189
|
+
guard let uri = source.uri, !uri.isEmpty else { return }
|
|
190
|
+
|
|
191
|
+
// Cleanup previous item
|
|
192
|
+
unloadPlayerItem()
|
|
193
|
+
|
|
194
|
+
currentUri = uri
|
|
195
|
+
hasLoadedData = false
|
|
196
|
+
|
|
197
|
+
// Notify load start
|
|
198
|
+
let isNetwork = uri.hasPrefix("http://") || uri.hasPrefix("https://")
|
|
199
|
+
let type = source.type ?? ""
|
|
200
|
+
delegate?.videoDidLoadStart?(isNetwork: isNetwork, type: type, uri: uri)
|
|
201
|
+
|
|
202
|
+
// Create URL
|
|
203
|
+
let url: URL?
|
|
204
|
+
if isNetwork {
|
|
205
|
+
url = URL(string: uri)
|
|
206
|
+
} else {
|
|
207
|
+
url = URL(fileURLWithPath: uri)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
guard let validUrl = url else {
|
|
211
|
+
let errorData = GraniteVideoErrorData()
|
|
212
|
+
errorData.code = -1
|
|
213
|
+
errorData.domain = "GraniteVideo"
|
|
214
|
+
errorData.localizedDescription_ = "Invalid URL"
|
|
215
|
+
delegate?.videoDidFail?(error: errorData)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Create player item
|
|
220
|
+
let asset = AVURLAsset(url: validUrl)
|
|
221
|
+
let item = AVPlayerItem(asset: asset)
|
|
222
|
+
playerItem = item
|
|
223
|
+
|
|
224
|
+
// Configure buffer
|
|
225
|
+
if preferredForwardBuffer > 0 {
|
|
226
|
+
item.preferredForwardBufferDuration = preferredForwardBuffer
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if maxBitRateValue > 0 {
|
|
230
|
+
item.preferredPeakBitRate = Double(maxBitRateValue)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Setup Combine observers for this item
|
|
234
|
+
setupPlayerItemObservers(for: item)
|
|
235
|
+
|
|
236
|
+
// Replace current item
|
|
237
|
+
player.replaceCurrentItem(with: item)
|
|
238
|
+
|
|
239
|
+
// Set start position if specified
|
|
240
|
+
if source.startPosition > 0 {
|
|
241
|
+
let seekTime = CMTime(seconds: source.startPosition, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
|
242
|
+
player.seek(to: seekTime)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Add time observer for progress
|
|
246
|
+
let interval = CMTime(seconds: 0.25, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
|
247
|
+
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] _ in
|
|
248
|
+
self?.handleProgressUpdate()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Apply settings
|
|
252
|
+
player.isMuted = isMuted
|
|
253
|
+
player.volume = playerVolume
|
|
254
|
+
player.automaticallyWaitsToMinimizeStalling = automaticallyWaits
|
|
255
|
+
player.allowsExternalPlayback = allowsExternalPlaybackEnabled
|
|
256
|
+
|
|
257
|
+
// Setup PiP if available and enabled
|
|
258
|
+
if pipEnabled {
|
|
259
|
+
setupPictureInPicture()
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
@objc public func unload() {
|
|
264
|
+
unloadPlayerItem()
|
|
265
|
+
currentUri = nil
|
|
266
|
+
hasLoadedData = false
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
@objc public func play() {
|
|
270
|
+
player.rate = playerRate
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
@objc public func pause() {
|
|
274
|
+
player.pause()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
@objc public func seek(to time: Double, toleranceBefore: Double, toleranceAfter: Double) {
|
|
278
|
+
guard playerItem != nil else { return }
|
|
279
|
+
|
|
280
|
+
isSeekingFlag = true
|
|
281
|
+
|
|
282
|
+
let seekTime = CMTime(seconds: time, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
|
283
|
+
let toleranceBeforeTime = CMTime(seconds: toleranceBefore, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
|
284
|
+
let toleranceAfterTime = CMTime(seconds: toleranceAfter, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
|
285
|
+
|
|
286
|
+
let currentTimeBeforeSeek = CMTimeGetSeconds(player.currentTime())
|
|
287
|
+
|
|
288
|
+
player.seek(to: seekTime, toleranceBefore: toleranceBeforeTime, toleranceAfter: toleranceAfterTime) { [weak self] finished in
|
|
289
|
+
DispatchQueue.main.async {
|
|
290
|
+
guard let self = self else { return }
|
|
291
|
+
self.isSeekingFlag = false
|
|
292
|
+
if finished {
|
|
293
|
+
self.delegate?.videoDidSeek?(currentTime: currentTimeBeforeSeek, seekTime: time)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// MARK: - GraniteVideoProvidable Optional
|
|
300
|
+
|
|
301
|
+
@objc public func setVolume(_ volume: Float) {
|
|
302
|
+
playerVolume = volume
|
|
303
|
+
player.volume = volume
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
@objc public func setMuted(_ muted: Bool) {
|
|
307
|
+
isMuted = muted
|
|
308
|
+
player.isMuted = muted
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
@objc public func setRate(_ rate: Float) {
|
|
312
|
+
playerRate = rate
|
|
313
|
+
if player.rate != 0 {
|
|
314
|
+
player.rate = rate
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
@objc public func setRepeat(_ shouldRepeat: Bool) {
|
|
319
|
+
self.shouldRepeat = shouldRepeat
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
@objc public func setResizeMode(_ mode: GraniteVideoResizeMode) {
|
|
323
|
+
switch mode {
|
|
324
|
+
case .contain:
|
|
325
|
+
playerLayer.videoGravity = .resizeAspect
|
|
326
|
+
case .cover:
|
|
327
|
+
playerLayer.videoGravity = .resizeAspectFill
|
|
328
|
+
case .stretch:
|
|
329
|
+
playerLayer.videoGravity = .resize
|
|
330
|
+
case .none:
|
|
331
|
+
playerLayer.videoGravity = .resizeAspect
|
|
332
|
+
@unknown default:
|
|
333
|
+
playerLayer.videoGravity = .resizeAspect
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
@objc public func setPlayInBackground(_ enabled: Bool) {
|
|
338
|
+
playInBackgroundEnabled = enabled
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
@objc public func setPlayWhenInactive(_ enabled: Bool) {
|
|
342
|
+
playWhenInactiveEnabled = enabled
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
@objc public func setPictureInPictureEnabled(_ enabled: Bool) {
|
|
346
|
+
pipEnabled = enabled
|
|
347
|
+
if enabled && playerItem != nil {
|
|
348
|
+
setupPictureInPicture()
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
@objc public func enterPictureInPicture() {
|
|
353
|
+
if let pipController = pipController, pipController.isPictureInPicturePossible {
|
|
354
|
+
pipController.startPictureInPicture()
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
@objc public func exitPictureInPicture() {
|
|
359
|
+
if let pipController = pipController, pipController.isPictureInPictureActive {
|
|
360
|
+
pipController.stopPictureInPicture()
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
@objc public func setFullscreen(_ fullscreen: Bool, animated: Bool) {
|
|
365
|
+
isFullscreen = fullscreen
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
@objc public func setMaxBitRate(_ bitRate: Int) {
|
|
369
|
+
maxBitRateValue = bitRate
|
|
370
|
+
playerItem?.preferredPeakBitRate = Double(bitRate)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
@objc public func setPreferredForwardBufferDuration(_ duration: Double) {
|
|
374
|
+
preferredForwardBuffer = duration
|
|
375
|
+
playerItem?.preferredForwardBufferDuration = duration
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
@objc public func setAutomaticallyWaitsToMinimizeStalling(_ waits: Bool) {
|
|
379
|
+
automaticallyWaits = waits
|
|
380
|
+
player.automaticallyWaitsToMinimizeStalling = waits
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
@objc public func setAllowsExternalPlayback(_ allows: Bool) {
|
|
384
|
+
allowsExternalPlaybackEnabled = allows
|
|
385
|
+
player.allowsExternalPlayback = allows
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
@objc public func setPreventsDisplaySleepDuringVideoPlayback(_ prevents: Bool) {
|
|
389
|
+
preventsDisplaySleep = prevents
|
|
390
|
+
player.preventsDisplaySleepDuringVideoPlayback = prevents
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
@objc public func setControlsEnabled(_ enabled: Bool) {
|
|
394
|
+
// Native controls handled by AVPlayerViewController if needed
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
@objc public func onTransferEnd(uri: String, bytesTransferred: Int) {
|
|
398
|
+
delegate?.videoTransferEnd?(uri: uri, bytesTransferred: Double(bytesTransferred))
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// MARK: - Private Methods
|
|
402
|
+
|
|
403
|
+
private func unloadPlayerItem() {
|
|
404
|
+
if let observer = timeObserver {
|
|
405
|
+
player.removeTimeObserver(observer)
|
|
406
|
+
timeObserver = nil
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Cancel all item-specific Combine subscriptions
|
|
410
|
+
itemCancellables.removeAll()
|
|
411
|
+
playerItem = nil
|
|
412
|
+
|
|
413
|
+
player.replaceCurrentItem(with: nil)
|
|
414
|
+
pipController = nil
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private func setupPictureInPicture() {
|
|
418
|
+
if AVPictureInPictureController.isPictureInPictureSupported() {
|
|
419
|
+
pipController = AVPictureInPictureController(playerLayer: playerLayer)
|
|
420
|
+
pipController?.delegate = self
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private func handleProgressUpdate() {
|
|
425
|
+
guard playerItem != nil, hasLoadedData else { return }
|
|
426
|
+
|
|
427
|
+
let currentTime = CMTimeGetSeconds(player.currentTime())
|
|
428
|
+
let duration = self.duration
|
|
429
|
+
|
|
430
|
+
var playableDuration: Double = 0
|
|
431
|
+
if let loadedRanges = playerItem?.loadedTimeRanges, let firstRange = loadedRanges.first {
|
|
432
|
+
let range = firstRange.timeRangeValue
|
|
433
|
+
let start = CMTimeGetSeconds(range.start)
|
|
434
|
+
let rangeDuration = CMTimeGetSeconds(range.duration)
|
|
435
|
+
playableDuration = start + rangeDuration
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
let progressData = GraniteVideoProgressData()
|
|
439
|
+
progressData.currentTime = currentTime
|
|
440
|
+
progressData.playableDuration = playableDuration
|
|
441
|
+
progressData.seekableDuration = duration
|
|
442
|
+
|
|
443
|
+
delegate?.videoDidUpdateProgress?(data: progressData)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// MARK: - Event Handlers
|
|
447
|
+
|
|
448
|
+
private func handlePlayerItemStatusChange(_ status: AVPlayerItem.Status) {
|
|
449
|
+
guard let item = playerItem else { return }
|
|
450
|
+
|
|
451
|
+
switch status {
|
|
452
|
+
case .readyToPlay:
|
|
453
|
+
if !hasLoadedData {
|
|
454
|
+
hasLoadedData = true
|
|
455
|
+
|
|
456
|
+
var naturalSize = CGSize.zero
|
|
457
|
+
if let tracks = item.asset.tracks(withMediaType: .video).first {
|
|
458
|
+
naturalSize = tracks.naturalSize
|
|
459
|
+
let transform = tracks.preferredTransform
|
|
460
|
+
if transform.a == 0 && transform.d == 0 {
|
|
461
|
+
naturalSize = CGSize(width: naturalSize.height, height: naturalSize.width)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
let loadData = GraniteVideoLoadData()
|
|
466
|
+
loadData.currentTime = CMTimeGetSeconds(player.currentTime())
|
|
467
|
+
loadData.duration = duration
|
|
468
|
+
loadData.naturalWidth = naturalSize.width
|
|
469
|
+
loadData.naturalHeight = naturalSize.height
|
|
470
|
+
loadData.orientation = naturalSize.width >= naturalSize.height ? "landscape" : "portrait"
|
|
471
|
+
|
|
472
|
+
delegate?.videoDidLoad?(data: loadData)
|
|
473
|
+
delegate?.videoReadyForDisplay?()
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
case .failed:
|
|
477
|
+
let nsError = item.error ?? NSError(domain: "GraniteVideo", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unknown error"])
|
|
478
|
+
let errorData = GraniteVideoErrorData(error: nsError)
|
|
479
|
+
delegate?.videoDidFail?(error: errorData)
|
|
480
|
+
|
|
481
|
+
default:
|
|
482
|
+
break
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private func handlePlayerItemDidPlayToEndTime() {
|
|
487
|
+
delegate?.videoDidEnd?()
|
|
488
|
+
|
|
489
|
+
if shouldRepeat {
|
|
490
|
+
player.seek(to: .zero)
|
|
491
|
+
player.play()
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private func handleAppDidEnterBackground() {
|
|
496
|
+
if !playInBackgroundEnabled {
|
|
497
|
+
playerLayer.player = nil
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private func handleAppWillEnterForeground() {
|
|
502
|
+
if playerLayer.player == nil {
|
|
503
|
+
playerLayer.player = player
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private func handleAudioSessionInterruption() {
|
|
508
|
+
// Handle audio interruption
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private func handleAudioRouteChange(_ notification: NotificationCenter.Publisher.Output) {
|
|
512
|
+
guard let userInfo = notification.userInfo,
|
|
513
|
+
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
|
514
|
+
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if reason == .oldDeviceUnavailable {
|
|
519
|
+
delegate?.videoAudioBecomingNoisy?()
|
|
520
|
+
player.pause()
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// MARK: - AVPictureInPictureControllerDelegate
|
|
526
|
+
|
|
527
|
+
extension AVPlayerProvider: AVPictureInPictureControllerDelegate {
|
|
528
|
+
public func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
|
529
|
+
delegate?.videoPictureInPictureStatusChanged?(isActive: true)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
public func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
|
533
|
+
delegate?.videoPictureInPictureStatusChanged?(isActive: false)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController,
|
|
537
|
+
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
|
|
538
|
+
delegate?.videoRestoreUserInterfaceForPictureInPictureStop?()
|
|
539
|
+
completionHandler(true)
|
|
540
|
+
}
|
|
541
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@granite-js/video",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Pluggable video component for React Native with provider selection",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/module/index.js",
|
|
7
|
+
"types": "./dist/typescript/index.d.ts",
|
|
8
|
+
"react-native": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/typescript/index.d.ts",
|
|
12
|
+
"default": "./dist/module/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"dist",
|
|
19
|
+
"android",
|
|
20
|
+
"ios",
|
|
21
|
+
"*.podspec",
|
|
22
|
+
"react-native.config.js",
|
|
23
|
+
"!example/**",
|
|
24
|
+
"!ios/build",
|
|
25
|
+
"!android/build",
|
|
26
|
+
"!android/gradle",
|
|
27
|
+
"!android/gradlew",
|
|
28
|
+
"!android/gradlew.bat",
|
|
29
|
+
"!android/local.properties",
|
|
30
|
+
"!**/__tests__",
|
|
31
|
+
"!**/__fixtures__",
|
|
32
|
+
"!**/__mocks__",
|
|
33
|
+
"!**/.*"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"prepack": "yarn build",
|
|
37
|
+
"example": "yarn workspace @granite-js/video-example",
|
|
38
|
+
"clean": "del-cli android/build example/android/build example/android/app/build example/ios/build",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"lint": "eslint .",
|
|
41
|
+
"build": "bob build && tsc --project tsconfig.build.json"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"react-native",
|
|
45
|
+
"ios",
|
|
46
|
+
"android",
|
|
47
|
+
"video",
|
|
48
|
+
"player",
|
|
49
|
+
"exoplayer",
|
|
50
|
+
"avplayer",
|
|
51
|
+
"vlc",
|
|
52
|
+
"streaming",
|
|
53
|
+
"hls",
|
|
54
|
+
"dash",
|
|
55
|
+
"drm",
|
|
56
|
+
"pluggable",
|
|
57
|
+
"provider"
|
|
58
|
+
],
|
|
59
|
+
"repository": {
|
|
60
|
+
"type": "git",
|
|
61
|
+
"url": "git+https://github.com/toss/granite.git",
|
|
62
|
+
"directory": "packages/video"
|
|
63
|
+
},
|
|
64
|
+
"author": "Toss <platform@toss.im>",
|
|
65
|
+
"homepage": "https://github.com/toss/granite/tree/main/packages/video#readme",
|
|
66
|
+
"license": "MIT",
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@types/react": "19.2.0",
|
|
69
|
+
"del-cli": "^6.0.0",
|
|
70
|
+
"eslint": "^9.7.0",
|
|
71
|
+
"react": "19.2.3",
|
|
72
|
+
"react-native": "0.84.0-rc.5",
|
|
73
|
+
"react-native-builder-bob": "0.40.17",
|
|
74
|
+
"typescript": "5.9.3"
|
|
75
|
+
},
|
|
76
|
+
"peerDependencies": {
|
|
77
|
+
"react": "*",
|
|
78
|
+
"react-native": "*"
|
|
79
|
+
},
|
|
80
|
+
"codegenConfig": {
|
|
81
|
+
"name": "GraniteVideoViewSpec",
|
|
82
|
+
"type": "all",
|
|
83
|
+
"jsSrcsDir": "src",
|
|
84
|
+
"android": {
|
|
85
|
+
"javaPackageName": "run.granite.video"
|
|
86
|
+
},
|
|
87
|
+
"ios": {
|
|
88
|
+
"componentProvider": {
|
|
89
|
+
"GraniteVideoView": "GraniteVideoView"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"sideEffects": false,
|
|
94
|
+
"react-native-builder-bob": {
|
|
95
|
+
"source": "src",
|
|
96
|
+
"output": "dist",
|
|
97
|
+
"targets": [
|
|
98
|
+
[
|
|
99
|
+
"module",
|
|
100
|
+
{
|
|
101
|
+
"esm": true
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
}
|