Skip to content
Source
Sign inSign up

T3NativeVideoPresentation.swift

180 lines6.9 KB

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