@movementinfra/expo-twostep-video 0.1.8 → 0.1.10
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/build/ExpoTwoStepVideo.types.d.ts +16 -0
- package/build/ExpoTwoStepVideo.types.d.ts.map +1 -1
- package/build/ExpoTwoStepVideo.types.js.map +1 -1
- package/build/TwoStepPlayerControllerView.d.ts +30 -0
- package/build/TwoStepPlayerControllerView.d.ts.map +1 -0
- package/build/TwoStepPlayerControllerView.js +54 -0
- package/build/TwoStepPlayerControllerView.js.map +1 -0
- package/build/VideoScrubber.d.ts +22 -0
- package/build/VideoScrubber.d.ts.map +1 -0
- package/build/VideoScrubber.js +489 -0
- package/build/VideoScrubber.js.map +1 -0
- package/build/VideoScrubber.types.d.ts +78 -0
- package/build/VideoScrubber.types.d.ts.map +1 -0
- package/build/VideoScrubber.types.js +2 -0
- package/build/VideoScrubber.types.js.map +1 -0
- package/build/hooks/useVideoScrubber.d.ts +86 -0
- package/build/hooks/useVideoScrubber.d.ts.map +1 -0
- package/build/hooks/useVideoScrubber.js +114 -0
- package/build/hooks/useVideoScrubber.js.map +1 -0
- package/build/index.d.ts +5 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -0
- package/build/index.js.map +1 -1
- package/ios/ExpoTwoStepVideoModule.swift +72 -8
- package/ios/ExpoTwoStepVideoView.swift +103 -1
- package/ios/ExpoTwostepPlayerControllerView.swift +297 -0
- package/package.json +1 -1
|
@@ -622,12 +622,12 @@ public class ExpoTwoStepVideoModule: Module {
|
|
|
622
622
|
|
|
623
623
|
// MARK: - Video Player View
|
|
624
624
|
|
|
625
|
-
View(
|
|
625
|
+
View(ExpoTwoStepVideoView.self) {
|
|
626
626
|
// Events emitted by the view
|
|
627
627
|
Events("onPlaybackStatusChange", "onProgress", "onEnd", "onError")
|
|
628
628
|
|
|
629
629
|
// Prop to set composition ID - view will load it
|
|
630
|
-
Prop("compositionId") { (view:
|
|
630
|
+
Prop("compositionId") { (view: ExpoTwoStepVideoView, compositionId: String?) in
|
|
631
631
|
guard let compositionId = compositionId else { return }
|
|
632
632
|
guard let composition = self.activeCompositions[compositionId] else {
|
|
633
633
|
view.onError(["error": "Composition not found: \(compositionId)"])
|
|
@@ -638,7 +638,7 @@ public class ExpoTwoStepVideoModule: Module {
|
|
|
638
638
|
}
|
|
639
639
|
|
|
640
640
|
// Prop to set asset ID - view will load it for preview
|
|
641
|
-
Prop("assetId") { (view:
|
|
641
|
+
Prop("assetId") { (view: ExpoTwoStepVideoView, assetId: String?) in
|
|
642
642
|
guard let assetId = assetId else { return }
|
|
643
643
|
guard let asset = self.activeAssets[assetId] else {
|
|
644
644
|
view.onError(["error": "Asset not found: \(assetId)"])
|
|
@@ -648,27 +648,91 @@ public class ExpoTwoStepVideoModule: Module {
|
|
|
648
648
|
}
|
|
649
649
|
|
|
650
650
|
// Prop to enable continuous looping
|
|
651
|
-
Prop("loop") { (view:
|
|
651
|
+
Prop("loop") { (view: ExpoTwoStepVideoView, loop: Bool?) in
|
|
652
652
|
view.shouldLoop = loop ?? false
|
|
653
653
|
}
|
|
654
654
|
|
|
655
655
|
// Playback control functions
|
|
656
|
-
AsyncFunction("play") { (view:
|
|
656
|
+
AsyncFunction("play") { (view: ExpoTwoStepVideoView) in
|
|
657
657
|
view.play()
|
|
658
658
|
}
|
|
659
659
|
|
|
660
|
-
AsyncFunction("pause") { (view:
|
|
660
|
+
AsyncFunction("pause") { (view: ExpoTwoStepVideoView) in
|
|
661
661
|
view.pause()
|
|
662
662
|
}
|
|
663
663
|
|
|
664
|
-
AsyncFunction("seek") { (view:
|
|
664
|
+
AsyncFunction("seek") { (view: ExpoTwoStepVideoView, time: Double) in
|
|
665
665
|
view.seek(to: time)
|
|
666
666
|
}
|
|
667
667
|
|
|
668
|
-
AsyncFunction("replay") { (view:
|
|
668
|
+
AsyncFunction("replay") { (view: ExpoTwoStepVideoView) in
|
|
669
669
|
view.replay()
|
|
670
670
|
}
|
|
671
671
|
}
|
|
672
|
+
|
|
673
|
+
// MARK: - Video Player Controller View (with native controls)
|
|
674
|
+
|
|
675
|
+
View(ExpoTwoStepPlayerControllerView.self) {
|
|
676
|
+
// Events emitted by the view
|
|
677
|
+
Events("onPlaybackStatusChange", "onProgress", "onEnd", "onError")
|
|
678
|
+
|
|
679
|
+
// Prop to set composition ID - view will load it
|
|
680
|
+
Prop("compositionId") { (view: ExpoTwoStepPlayerControllerView, compositionId: String?) in
|
|
681
|
+
guard let compositionId = compositionId else { return }
|
|
682
|
+
guard let composition = self.activeCompositions[compositionId] else {
|
|
683
|
+
view.onError(["error": "Composition not found: \(compositionId)"])
|
|
684
|
+
return
|
|
685
|
+
}
|
|
686
|
+
let videoComposition = self.activeVideoCompositions[compositionId]
|
|
687
|
+
view.loadComposition(compositionId: compositionId, composition: composition, videoComposition: videoComposition)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Prop to set asset ID - view will load it for preview
|
|
691
|
+
Prop("assetId") { (view: ExpoTwoStepPlayerControllerView, assetId: String?) in
|
|
692
|
+
guard let assetId = assetId else { return }
|
|
693
|
+
guard let asset = self.activeAssets[assetId] else {
|
|
694
|
+
view.onError(["error": "Asset not found: \(assetId)"])
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
view.loadAsset(assetId: assetId, asset: asset.avAsset)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Prop to enable continuous looping
|
|
701
|
+
Prop("loop") { (view: ExpoTwoStepPlayerControllerView, loop: Bool?) in
|
|
702
|
+
view.shouldLoop = loop ?? false
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Prop to show/hide native playback controls
|
|
706
|
+
Prop("showsPlaybackControls") { (view: ExpoTwoStepPlayerControllerView, show: Bool?) in
|
|
707
|
+
view.showsPlaybackControls = show ?? true
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Playback control functions
|
|
711
|
+
AsyncFunction("play") { (view: ExpoTwoStepPlayerControllerView) in
|
|
712
|
+
view.play()
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
AsyncFunction("pause") { (view: ExpoTwoStepPlayerControllerView) in
|
|
716
|
+
view.pause()
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
AsyncFunction("seek") { (view: ExpoTwoStepPlayerControllerView, time: Double) in
|
|
720
|
+
view.seek(to: time)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
AsyncFunction("replay") { (view: ExpoTwoStepPlayerControllerView) in
|
|
724
|
+
view.replay()
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Fullscreen functions
|
|
728
|
+
AsyncFunction("enterFullscreen") { (view: ExpoTwoStepPlayerControllerView) in
|
|
729
|
+
view.enterFullscreen()
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
AsyncFunction("exitFullscreen") { (view: ExpoTwoStepPlayerControllerView) in
|
|
733
|
+
view.exitFullscreen()
|
|
734
|
+
}
|
|
735
|
+
}
|
|
672
736
|
}
|
|
673
737
|
|
|
674
738
|
// MARK: - Private Helper Methods
|
|
@@ -3,7 +3,7 @@ import AVFoundation
|
|
|
3
3
|
import UIKit
|
|
4
4
|
|
|
5
5
|
/// Native video player view that can play compositions directly
|
|
6
|
-
class
|
|
6
|
+
class ExpoTwoStepVideoView: ExpoView {
|
|
7
7
|
|
|
8
8
|
// MARK: - Properties
|
|
9
9
|
|
|
@@ -11,6 +11,7 @@ class ExpoTwostepVideoView: ExpoView {
|
|
|
11
11
|
private var playerLayer: AVPlayerLayer?
|
|
12
12
|
private var timeObserver: Any?
|
|
13
13
|
private var isObservingStatus: Bool = false
|
|
14
|
+
private var isAudioSessionConfigured: Bool = false
|
|
14
15
|
|
|
15
16
|
/// Event dispatchers
|
|
16
17
|
let onPlaybackStatusChange = EventDispatcher()
|
|
@@ -32,6 +33,8 @@ class ExpoTwostepVideoView: ExpoView {
|
|
|
32
33
|
clipsToBounds = true
|
|
33
34
|
backgroundColor = .black
|
|
34
35
|
setupPlayerLayer()
|
|
36
|
+
setupAudioSession()
|
|
37
|
+
setupAudioSessionObservers()
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
private func setupPlayerLayer() {
|
|
@@ -43,6 +46,87 @@ class ExpoTwostepVideoView: ExpoView {
|
|
|
43
46
|
}
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
/// Configure audio session for video playback
|
|
50
|
+
/// Uses .playback category to ensure audio plays even when silent switch is on
|
|
51
|
+
private func setupAudioSession() {
|
|
52
|
+
do {
|
|
53
|
+
let audioSession = AVAudioSession.sharedInstance()
|
|
54
|
+
// .playback category: audio plays even with silent switch, stops other audio
|
|
55
|
+
// .defaultToSpeaker: routes audio to speaker by default (not earpiece)
|
|
56
|
+
try audioSession.setCategory(.playback, mode: .moviePlayback, options: [.defaultToSpeaker])
|
|
57
|
+
try audioSession.setActive(true, options: [.notifyOthersOnDeactivation])
|
|
58
|
+
isAudioSessionConfigured = true
|
|
59
|
+
} catch {
|
|
60
|
+
print("ExpoTwoStepVideoView: Failed to configure audio session: \(error)")
|
|
61
|
+
// Try a simpler configuration as fallback
|
|
62
|
+
do {
|
|
63
|
+
try AVAudioSession.sharedInstance().setCategory(.playback)
|
|
64
|
+
try AVAudioSession.sharedInstance().setActive(true)
|
|
65
|
+
isAudioSessionConfigured = true
|
|
66
|
+
} catch {
|
|
67
|
+
print("ExpoTwoStepVideoView: Fallback audio session also failed: \(error)")
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Listen for audio session interruptions (phone calls, other apps)
|
|
73
|
+
private func setupAudioSessionObservers() {
|
|
74
|
+
NotificationCenter.default.addObserver(
|
|
75
|
+
self,
|
|
76
|
+
selector: #selector(handleAudioSessionInterruption),
|
|
77
|
+
name: AVAudioSession.interruptionNotification,
|
|
78
|
+
object: AVAudioSession.sharedInstance()
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
NotificationCenter.default.addObserver(
|
|
82
|
+
self,
|
|
83
|
+
selector: #selector(handleAudioSessionRouteChange),
|
|
84
|
+
name: AVAudioSession.routeChangeNotification,
|
|
85
|
+
object: AVAudioSession.sharedInstance()
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@objc private func handleAudioSessionInterruption(notification: Notification) {
|
|
90
|
+
guard let userInfo = notification.userInfo,
|
|
91
|
+
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
92
|
+
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
switch type {
|
|
97
|
+
case .began:
|
|
98
|
+
// Interruption began - pause playback
|
|
99
|
+
player?.pause()
|
|
100
|
+
onPlaybackStatusChange(["status": "interrupted"])
|
|
101
|
+
case .ended:
|
|
102
|
+
// Interruption ended - check if we should resume
|
|
103
|
+
guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
|
|
104
|
+
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
|
105
|
+
if options.contains(.shouldResume) {
|
|
106
|
+
// Re-activate audio session and resume
|
|
107
|
+
setupAudioSession()
|
|
108
|
+
player?.play()
|
|
109
|
+
onPlaybackStatusChange(["status": "playing"])
|
|
110
|
+
}
|
|
111
|
+
@unknown default:
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@objc private func handleAudioSessionRouteChange(notification: Notification) {
|
|
117
|
+
guard let userInfo = notification.userInfo,
|
|
118
|
+
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
|
119
|
+
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Pause when headphones are unplugged
|
|
124
|
+
if reason == .oldDeviceUnavailable {
|
|
125
|
+
player?.pause()
|
|
126
|
+
onPlaybackStatusChange(["status": "paused"])
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
46
130
|
override func layoutSubviews() {
|
|
47
131
|
super.layoutSubviews()
|
|
48
132
|
playerLayer?.frame = bounds
|
|
@@ -117,6 +201,21 @@ class ExpoTwostepVideoView: ExpoView {
|
|
|
117
201
|
}
|
|
118
202
|
|
|
119
203
|
func play() {
|
|
204
|
+
// Ensure audio session is properly configured before playing
|
|
205
|
+
// This handles cases where another component may have changed the audio session
|
|
206
|
+
if !isAudioSessionConfigured {
|
|
207
|
+
setupAudioSession()
|
|
208
|
+
} else {
|
|
209
|
+
// Re-activate in case it was deactivated
|
|
210
|
+
do {
|
|
211
|
+
try AVAudioSession.sharedInstance().setActive(true, options: [.notifyOthersOnDeactivation])
|
|
212
|
+
} catch {
|
|
213
|
+
print("ExpoTwoStepVideoView: Failed to activate audio session: \(error)")
|
|
214
|
+
// Try full reconfiguration
|
|
215
|
+
setupAudioSession()
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
120
219
|
player?.play()
|
|
121
220
|
onPlaybackStatusChange(["status": "playing"])
|
|
122
221
|
}
|
|
@@ -216,6 +315,9 @@ class ExpoTwostepVideoView: ExpoView {
|
|
|
216
315
|
|
|
217
316
|
deinit {
|
|
218
317
|
cleanup()
|
|
318
|
+
// Remove audio session observers
|
|
319
|
+
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
|
|
320
|
+
NotificationCenter.default.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: nil)
|
|
219
321
|
// Also remove the layer to break any potential retain cycles
|
|
220
322
|
playerLayer?.removeFromSuperlayer()
|
|
221
323
|
playerLayer = nil
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import AVKit
|
|
4
|
+
import UIKit
|
|
5
|
+
|
|
6
|
+
/// Native video player view using AVPlayerViewController with built-in controls and fullscreen support
|
|
7
|
+
class ExpoTwoStepPlayerControllerView: ExpoView {
|
|
8
|
+
|
|
9
|
+
// MARK: - Properties
|
|
10
|
+
|
|
11
|
+
private var playerViewController: AVPlayerViewController?
|
|
12
|
+
private var player: AVPlayer?
|
|
13
|
+
private var timeObserver: Any?
|
|
14
|
+
private var isObservingStatus: Bool = false
|
|
15
|
+
private var hostingViewController: UIViewController?
|
|
16
|
+
|
|
17
|
+
/// Event dispatchers
|
|
18
|
+
let onPlaybackStatusChange = EventDispatcher()
|
|
19
|
+
let onProgress = EventDispatcher()
|
|
20
|
+
let onEnd = EventDispatcher()
|
|
21
|
+
let onError = EventDispatcher()
|
|
22
|
+
|
|
23
|
+
/// Current composition/asset IDs being played
|
|
24
|
+
private var currentCompositionId: String?
|
|
25
|
+
private var currentAssetId: String?
|
|
26
|
+
|
|
27
|
+
/// Whether to loop playback continuously
|
|
28
|
+
var shouldLoop: Bool = false
|
|
29
|
+
|
|
30
|
+
/// Whether to show native playback controls
|
|
31
|
+
var showsPlaybackControls: Bool = true {
|
|
32
|
+
didSet {
|
|
33
|
+
playerViewController?.showsPlaybackControls = showsPlaybackControls
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// MARK: - Initialization
|
|
38
|
+
|
|
39
|
+
required init(appContext: AppContext? = nil) {
|
|
40
|
+
super.init(appContext: appContext)
|
|
41
|
+
clipsToBounds = true
|
|
42
|
+
backgroundColor = .black
|
|
43
|
+
setupPlayerViewController()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private func setupPlayerViewController() {
|
|
47
|
+
let pvc = AVPlayerViewController()
|
|
48
|
+
pvc.showsPlaybackControls = showsPlaybackControls
|
|
49
|
+
pvc.videoGravity = .resizeAspect
|
|
50
|
+
playerViewController = pvc
|
|
51
|
+
|
|
52
|
+
// Add the view - defer view controller containment until we have a window
|
|
53
|
+
pvc.view.frame = bounds
|
|
54
|
+
pvc.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
55
|
+
addSubview(pvc.view)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
override func layoutSubviews() {
|
|
59
|
+
super.layoutSubviews()
|
|
60
|
+
playerViewController?.view.frame = bounds
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
override func didMoveToWindow() {
|
|
64
|
+
super.didMoveToWindow()
|
|
65
|
+
|
|
66
|
+
// When added to window, find hosting view controller for proper containment
|
|
67
|
+
if window != nil, hostingViewController == nil {
|
|
68
|
+
if let vc = findViewController() {
|
|
69
|
+
hostingViewController = vc
|
|
70
|
+
if let pvc = playerViewController {
|
|
71
|
+
vc.addChild(pvc)
|
|
72
|
+
pvc.didMove(toParent: vc)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private func findViewController() -> UIViewController? {
|
|
79
|
+
var responder: UIResponder? = self
|
|
80
|
+
while let next = responder?.next {
|
|
81
|
+
if let vc = next as? UIViewController {
|
|
82
|
+
return vc
|
|
83
|
+
}
|
|
84
|
+
responder = next
|
|
85
|
+
}
|
|
86
|
+
return nil
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// MARK: - Public Methods (called from module)
|
|
90
|
+
|
|
91
|
+
func loadComposition(compositionId: String, composition: AVMutableComposition, videoComposition: AVMutableVideoComposition?) {
|
|
92
|
+
// Clean up previous player
|
|
93
|
+
cleanup()
|
|
94
|
+
|
|
95
|
+
currentCompositionId = compositionId
|
|
96
|
+
currentAssetId = nil
|
|
97
|
+
|
|
98
|
+
// Create player item from composition
|
|
99
|
+
let playerItem = AVPlayerItem(asset: composition)
|
|
100
|
+
|
|
101
|
+
// Apply video composition for transforms (mirror, etc.)
|
|
102
|
+
if let videoComposition = videoComposition {
|
|
103
|
+
playerItem.videoComposition = videoComposition
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
setupPlayer(with: playerItem)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func loadAsset(assetId: String, asset: AVAsset) {
|
|
110
|
+
// Clean up previous player
|
|
111
|
+
cleanup()
|
|
112
|
+
|
|
113
|
+
currentAssetId = assetId
|
|
114
|
+
currentCompositionId = nil
|
|
115
|
+
|
|
116
|
+
let playerItem = AVPlayerItem(asset: asset)
|
|
117
|
+
setupPlayer(with: playerItem)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private func setupPlayer(with playerItem: AVPlayerItem) {
|
|
121
|
+
// Create player
|
|
122
|
+
player = AVPlayer(playerItem: playerItem)
|
|
123
|
+
playerViewController?.player = player
|
|
124
|
+
|
|
125
|
+
// Observe playback status
|
|
126
|
+
playerItem.addObserver(self, forKeyPath: "status", options: [.new, .initial], context: nil)
|
|
127
|
+
isObservingStatus = true
|
|
128
|
+
|
|
129
|
+
// Observe when playback ends
|
|
130
|
+
NotificationCenter.default.addObserver(
|
|
131
|
+
self,
|
|
132
|
+
selector: #selector(playerDidFinishPlaying),
|
|
133
|
+
name: .AVPlayerItemDidPlayToEndTime,
|
|
134
|
+
object: playerItem
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
// Add periodic time observer for progress
|
|
138
|
+
let interval = CMTime(seconds: 0.25, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
|
139
|
+
timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
|
140
|
+
guard let self = self,
|
|
141
|
+
let duration = self.player?.currentItem?.duration,
|
|
142
|
+
duration.isNumeric && !duration.isIndefinite else { return }
|
|
143
|
+
|
|
144
|
+
let currentTime = CMTimeGetSeconds(time)
|
|
145
|
+
let totalDuration = CMTimeGetSeconds(duration)
|
|
146
|
+
|
|
147
|
+
self.onProgress([
|
|
148
|
+
"currentTime": currentTime,
|
|
149
|
+
"duration": totalDuration,
|
|
150
|
+
"progress": totalDuration > 0 ? currentTime / totalDuration : 0
|
|
151
|
+
])
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
onPlaybackStatusChange(["status": "ready"])
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func play() {
|
|
158
|
+
player?.play()
|
|
159
|
+
onPlaybackStatusChange(["status": "playing"])
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func pause() {
|
|
163
|
+
player?.pause()
|
|
164
|
+
onPlaybackStatusChange(["status": "paused"])
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func seek(to time: Double) {
|
|
168
|
+
let cmTime = CMTime(seconds: time, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
|
169
|
+
player?.seek(to: cmTime, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] finished in
|
|
170
|
+
if finished {
|
|
171
|
+
self?.onPlaybackStatusChange(["status": "seeked", "time": time])
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
func replay() {
|
|
177
|
+
seek(to: 0)
|
|
178
|
+
play()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func enterFullscreen() {
|
|
182
|
+
// Present player view controller modally for fullscreen experience
|
|
183
|
+
guard let hostVC = hostingViewController ?? findViewController(),
|
|
184
|
+
let player = player else { return }
|
|
185
|
+
|
|
186
|
+
let fullscreenVC = AVPlayerViewController()
|
|
187
|
+
fullscreenVC.player = player
|
|
188
|
+
fullscreenVC.showsPlaybackControls = true
|
|
189
|
+
fullscreenVC.modalPresentationStyle = .fullScreen
|
|
190
|
+
|
|
191
|
+
// Track playback position and state before presenting
|
|
192
|
+
let currentTime = player.currentTime()
|
|
193
|
+
let wasPlaying = player.rate != 0
|
|
194
|
+
|
|
195
|
+
hostVC.present(fullscreenVC, animated: true) {
|
|
196
|
+
// Restore playback position and state
|
|
197
|
+
fullscreenVC.player?.seek(to: currentTime)
|
|
198
|
+
if wasPlaying {
|
|
199
|
+
fullscreenVC.player?.play()
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
func exitFullscreen() {
|
|
205
|
+
// The fullscreen presentation is dismissed by the user via the player controls
|
|
206
|
+
// or by swiping down. We can also dismiss programmatically if needed.
|
|
207
|
+
guard let hostVC = hostingViewController ?? findViewController(),
|
|
208
|
+
hostVC.presentedViewController is AVPlayerViewController else { return }
|
|
209
|
+
|
|
210
|
+
hostVC.dismiss(animated: true)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
var isPlaying: Bool {
|
|
214
|
+
return player?.rate != 0 && player?.error == nil
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
var currentTime: Double {
|
|
218
|
+
guard let time = player?.currentTime() else { return 0 }
|
|
219
|
+
return CMTimeGetSeconds(time)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
var duration: Double {
|
|
223
|
+
guard let duration = player?.currentItem?.duration,
|
|
224
|
+
duration.isNumeric && !duration.isIndefinite else { return 0 }
|
|
225
|
+
return CMTimeGetSeconds(duration)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// MARK: - Observers
|
|
229
|
+
|
|
230
|
+
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
|
231
|
+
if keyPath == "status" {
|
|
232
|
+
if let playerItem = object as? AVPlayerItem {
|
|
233
|
+
switch playerItem.status {
|
|
234
|
+
case .readyToPlay:
|
|
235
|
+
onPlaybackStatusChange(["status": "ready"])
|
|
236
|
+
case .failed:
|
|
237
|
+
onError(["error": playerItem.error?.localizedDescription ?? "Unknown error"])
|
|
238
|
+
default:
|
|
239
|
+
break
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
@objc private func playerDidFinishPlaying() {
|
|
246
|
+
if shouldLoop {
|
|
247
|
+
player?.seek(to: .zero) { [weak self] _ in
|
|
248
|
+
self?.player?.play()
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
onEnd([:])
|
|
252
|
+
onPlaybackStatusChange(["status": "ended"])
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// MARK: - Cleanup
|
|
257
|
+
|
|
258
|
+
private func cleanup() {
|
|
259
|
+
// Remove time observer first
|
|
260
|
+
if let observer = timeObserver, let player = player {
|
|
261
|
+
player.removeTimeObserver(observer)
|
|
262
|
+
}
|
|
263
|
+
timeObserver = nil
|
|
264
|
+
|
|
265
|
+
// Remove KVO observer only if we added it
|
|
266
|
+
if isObservingStatus, let playerItem = player?.currentItem {
|
|
267
|
+
playerItem.removeObserver(self, forKeyPath: "status")
|
|
268
|
+
isObservingStatus = false
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Remove notification observer
|
|
272
|
+
if let playerItem = player?.currentItem {
|
|
273
|
+
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Stop and clear player
|
|
277
|
+
player?.pause()
|
|
278
|
+
player?.replaceCurrentItem(with: nil)
|
|
279
|
+
player = nil
|
|
280
|
+
playerViewController?.player = nil
|
|
281
|
+
|
|
282
|
+
currentCompositionId = nil
|
|
283
|
+
currentAssetId = nil
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
deinit {
|
|
287
|
+
cleanup()
|
|
288
|
+
|
|
289
|
+
// Remove from parent view controller
|
|
290
|
+
if let pvc = playerViewController {
|
|
291
|
+
pvc.willMove(toParent: nil)
|
|
292
|
+
pvc.view.removeFromSuperview()
|
|
293
|
+
pvc.removeFromParent()
|
|
294
|
+
}
|
|
295
|
+
playerViewController = nil
|
|
296
|
+
}
|
|
297
|
+
}
|