1import AVKit
2import UIKit
3
4final class T3NativeVideoPresentation: NSObject, AVPlayerViewControllerDelegate,
5 UIAdaptivePresentationControllerDelegate {
6 let identifier: String
7 private let controller = AVPlayerViewController()
8 private let completion: (Error?) -> Void
9 private var itemObservation: NSKeyValueObservation?
10 private var backgroundObserver: NSObjectProtocol?
11 private var playbackError: Error?
12 private var presented = false
13 private var dismissRequested = false
14 private var finished = false
15 private struct AudioSessionConfiguration {
16 let category: AVAudioSession.Category
17 let mode: AVAudioSession.Mode
18 let options: AVAudioSession.CategoryOptions
19
20 init(_ session: AVAudioSession) {
21 category = session.category
22 mode = session.mode
23 options = session.categoryOptions
24 }
25 }
26 private var previousAudioSession: AudioSessionConfiguration?
27 private weak var fullScreenController: UIViewController?
28 private var embedded = false
29
30 init(identifier: String, url: URL, title: String, completion: @escaping (Error?) -> Void) {
31 self.identifier = identifier
32 self.completion = completion
33 super.init()
34
35 let item = AVPlayerItem(url: url)
36 let metadata = AVMutableMetadataItem()
37 metadata.identifier = .commonIdentifierTitle
38 metadata.value = title as NSString
39 item.externalMetadata = [metadata]
40 controller.player = AVPlayer(playerItem: item)
41 controller.delegate = self
42 controller.overrideUserInterfaceStyle = .dark
43 controller.allowsPictureInPicturePlayback = false
44
45 itemObservation = item.observe(\.status, options: [.initial, .new]) { [weak self] item, _ in
46 guard item.status == .failed else { return }
47 DispatchQueue.main.async {
48 guard let self else { return }
49 self.playbackError = item.error ?? NSError(
50 domain: "T3NativeVideo",
51 code: 1,
52 userInfo: [NSLocalizedDescriptionKey: "This video couldn't be played on this device."]
53 )
54 self.dismiss()
55 }
56 }
57 backgroundObserver = NotificationCenter.default.addObserver(
58 forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main
59 ) { [weak self] _ in self?.controller.player?.pause() }
60 }
61
62 func present(from presenter: UIViewController, sources: T3PresentationSources, sourceIdentifier: String) {
63 let audioSession = AVAudioSession.sharedInstance()
64 previousAudioSession = AudioSessionConfiguration(audioSession)
65 do {
66 try audioSession.setCategory(.playback, mode: .moviePlayback)
67 } catch {
68 NSLog("T3 video audio session: %@", error.localizedDescription)
69 }
70 // AVKit exposes programmatic inline-to-full-screen entry through this selector.
71 // This is the same guarded entry point used by expo-video's enterFullscreen().
72 let enterFullScreen = NSSelectorFromString("enterFullScreenAnimated:completionHandler:")
73 if let source = sources.view(for: sourceIdentifier), source.window != nil,
74 controller.responds(to: enterFullScreen) {
75 // AVKit owns the transition from its inline view to full screen. Using a
76 // separate UIKit zoom transition prevents its native Close action from exiting.
77 var responder: UIResponder? = source
78 while let current = responder, !(current is UIViewController) { responder = current.next }
79 let parent = responder as? UIViewController ?? presenter
80 embedded = true
81 parent.addChild(controller)
82 controller.view.frame = source.bounds
83 controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
84 source.addSubview(controller.view)
85 controller.didMove(toParent: parent)
86 controller.view.layoutIfNeeded()
87 controller.perform(enterFullScreen, with: true, with: nil)
88 controller.player?.play()
89 } else {
90 presenter.present(controller, animated: true) { [self] in
91 presented = true
92 if dismissRequested {
93 dismiss()
94 } else if UIApplication.shared.applicationState == .active {
95 controller.player?.play()
96 }
97 }
98 controller.presentationController?.delegate = self
99 }
100 }
101
102 func dismiss() {
103 dismissRequested = true
104 guard !finished else { return }
105 guard presented else {
106 if embedded && fullScreenController == nil { finish() }
107 return
108 }
109 (fullScreenController ?? controller).dismiss(animated: true) { [self] in finish() }
110 }
111
112 func playerViewController(
113 _ playerViewController: AVPlayerViewController,
114 willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
115 ) {
116 fullScreenController = coordinator.viewController(forKey: .to)
117 coordinator.animate(alongsideTransition: nil) { [weak self] context in
118 guard let self else { return }
119 if context.isCancelled {
120 finish()
121 } else {
122 presented = true
123 if dismissRequested { dismiss() }
124 }
125 }
126 }
127
128 func playerViewController(
129 _ playerViewController: AVPlayerViewController,
130 willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
131 ) {
132 coordinator.animate(alongsideTransition: nil) { [weak self] context in
133 if !context.isCancelled { self?.finish() }
134 }
135 }
136
137 func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
138 finish()
139 }
140
141 private func finish() {
142 guard !finished else { return }
143 finished = true
144 controller.player?.pause()
145 if embedded {
146 controller.willMove(toParent: nil)
147 controller.view.removeFromSuperview()
148 controller.removeFromParent()
149 }
150 itemObservation = nil
151 controller.player = nil
152 if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) }
153 backgroundObserver = nil
154 let audioSession = AVAudioSession.sharedInstance()
155 if let previousAudioSession, audioSession.category == .playback,
156 audioSession.mode == .moviePlayback, audioSession.categoryOptions.isEmpty {
157 // AVPlayer owns activation. Deactivating the shared session here could
158 // stop another player or recorder that was active before this preview.
159 try? audioSession.setCategory(
160 previousAudioSession.category,
161 mode: previousAudioSession.mode,
162 options: previousAudioSession.options
163 )
164 }
165 completion(playbackError)
166 }
167}