DamusVideoPlayer.swift (9637B)
1 // 2 // DamusVideoPlayer.swift 3 // damus 4 // 5 // Created by Bryan Montz on 9/5/23. 6 // 7 8 import AVFoundation 9 import AVKit 10 import Combine 11 import Foundation 12 import SwiftUI 13 14 /// DamusVideoPlayer has the function of wrapping `AVPlayer` and exposing a control interface that integrates seamlessly with SwiftUI views 15 /// 16 /// This is **NOT** a video player view. This is a headless video object concerned about the video and its playback. To display a video, you need `DamusVideoPlayerView` 17 /// This is also **NOT** a control view. Please see `DamusVideoControlsView` for that. 18 /// 19 /// **Implementation notes:** 20 /// - `@MainActor` is needed because `@Published` properties need to be updated on the main thread to avoid SwiftUI mutations within a single render pass 21 /// - `@Published` variables are the chosen interface because they integrate very seamlessly with SwiftUI views. Avoid the use of procedural functions to avoid SwiftUI state desync. 22 @MainActor final class DamusVideoPlayer: ObservableObject { 23 24 // MARK: Immutable foundational instance members 25 26 /// The URL of the video 27 let url: URL 28 /// The underlying AVPlayer that we are wrapping. 29 /// This is not public because we don't want any callers of this class controlling the `AVPlayer` directly, we want them to go through our interface 30 /// This measure helps avoid state inconsistencies and other flakiness. DO NOT USE THIS OUTSIDE `DamusVideoPlayer` 31 private let player: AVPlayer 32 33 34 // MARK: SwiftUI-friendly interface 35 36 /// Indicates whether the video has audio at all 37 @Published private(set) var has_audio = false 38 /// Whether whether this is a live video 39 @Published private(set) var is_live = false 40 /// The video size 41 @Published private(set) var video_size: CGSize? 42 /// Whether or not to mute the video 43 @Published var is_muted = true { 44 didSet { 45 if oldValue == is_muted { return } 46 player.isMuted = is_muted 47 } 48 } 49 /// Whether the video is loading 50 @Published private(set) var is_loading = true 51 /// The current time of playback, in seconds 52 /// Usage note: If editing (such as in a slider), make sure to set `is_editing_current_time` to `true` to detach this value from the current playback 53 @Published var current_time: TimeInterval = .zero 54 /// Whether video is playing or not 55 @Published var is_playing = false { 56 didSet { 57 if oldValue == is_playing { return } 58 // When scrubbing, the playback control is temporarily decoupled, so don't play/pause our `AVPlayer` 59 // When scrubbing stops, the `is_editing_current_time` handler will automatically play/pause depending on `is_playing` 60 if is_editing_current_time { return } 61 if is_playing { 62 player.play() 63 } 64 else { 65 player.pause() 66 } 67 } 68 } 69 /// Whether the current time is being manually edited (e.g. when user is scrubbing through the video) 70 /// **Implementation note:** When set to `true`, this decouples the `current_time` from the video playback observer — in a way analogous to a clutch on a standard transmission car, if you are into Automotive engineering. 71 @Published var is_editing_current_time = false { 72 didSet { 73 if oldValue == is_editing_current_time { return } 74 if !is_editing_current_time { 75 Task { 76 await self.player.seek(to: CMTime(seconds: current_time, preferredTimescale: 60)) 77 // Start playing video again, if we were playing before scrubbing 78 if self.is_playing { 79 self.player.play() 80 } 81 } 82 } 83 else { 84 // Pause playing video, if we were playing before we started scrubbing 85 if self.is_playing { self.player.pause() } 86 } 87 } 88 } 89 /// The duration of the video, in seconds. 90 var duration: TimeInterval? { 91 return player.currentItem?.duration.seconds 92 } 93 94 // MARK: Internal instance members 95 96 private var cancellables = Set<AnyCancellable>() 97 private var videoSizeObserver: NSKeyValueObservation? 98 private var videoDurationObserver: NSKeyValueObservation? 99 private var videoCurrentTimeObserver: Any? 100 private var videoIsPlayingObserver: NSKeyValueObservation? 101 102 103 // MARK: - Initialization 104 105 public init(url: URL) { 106 self.url = url 107 self.player = AVPlayer(playerItem: AVPlayerItem(url: url)) 108 self.video_size = nil 109 110 Task { 111 await load() 112 } 113 114 player.isMuted = is_muted 115 116 NotificationCenter.default.addObserver( 117 self, 118 selector: #selector(did_play_to_end), 119 name: Notification.Name.AVPlayerItemDidPlayToEndTime, 120 object: player.currentItem 121 ) 122 123 observeVideoSize() 124 observeDuration() 125 observeCurrentTime() 126 observeVideoIsPlaying() 127 } 128 129 // MARK: - Observers 130 // Functions that allow us to observe certain variables and publish their changes for view updates 131 // These are all private because they are part of the internal logic 132 133 private func observeVideoSize() { 134 videoSizeObserver = player.currentItem?.observe(\.presentationSize, options: [.new], changeHandler: { [weak self] (playerItem, change) in 135 guard let self else { return } 136 if let newSize = change.newValue, newSize != .zero { 137 DispatchQueue.main.async { 138 self.video_size = newSize // Update the bound value 139 } 140 } 141 }) 142 } 143 144 private func observeDuration() { 145 videoDurationObserver = player.currentItem?.observe(\.duration, options: [.new], changeHandler: { [weak self] (playerItem, change) in 146 guard let self else { return } 147 if let newDuration = change.newValue, newDuration != .zero { 148 DispatchQueue.main.async { 149 self.is_live = newDuration == .indefinite 150 } 151 } 152 }) 153 } 154 155 private func observeCurrentTime() { 156 videoCurrentTimeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 1, preferredTimescale: 600), queue: .main) { [weak self] time in 157 guard let self else { return } 158 DispatchQueue.main.async { // Must use main thread to update @Published properties 159 if self.is_editing_current_time == false { 160 self.current_time = time.seconds 161 } 162 } 163 } 164 } 165 166 private func observeVideoIsPlaying() { 167 videoIsPlayingObserver = player.observe(\.rate, changeHandler: { [weak self] (player, change) in 168 guard let self else { return } 169 guard let new_rate = change.newValue else { return } 170 DispatchQueue.main.async { 171 self.is_playing = new_rate > 0 172 } 173 }) 174 } 175 176 // MARK: - Other internal logic functions 177 178 private func load() async { 179 has_audio = await self.video_has_audio() 180 is_loading = false 181 } 182 183 private func video_has_audio() async -> Bool { 184 do { 185 let hasAudibleTracks = ((try await player.currentItem?.asset.loadMediaSelectionGroup(for: .audible)) != nil) 186 let tracks = try? await player.currentItem?.asset.load(.tracks) 187 let hasAudioTrack = tracks?.filter({ t in t.mediaType == .audio }).first != nil // Deal with odd cases of audio only MOV 188 return hasAudibleTracks || hasAudioTrack 189 } catch { 190 return false 191 } 192 } 193 194 @objc private func did_play_to_end() { 195 player.seek(to: CMTime.zero) 196 player.play() 197 } 198 199 // MARK: - Deinit 200 201 deinit { 202 videoSizeObserver?.invalidate() 203 videoDurationObserver?.invalidate() 204 videoIsPlayingObserver?.invalidate() 205 } 206 207 // MARK: - Convenience interface functions 208 209 func play() { 210 self.is_playing = true 211 } 212 213 func pause() { 214 self.is_playing = false 215 } 216 } 217 218 extension DamusVideoPlayer { 219 /// The simplest view for a `DamusVideoPlayer` object. 220 /// 221 /// Other views with more features should use this as a base. 222 /// 223 /// ## Implementation notes: 224 /// 225 /// 1. This is defined inside `DamusVideoPlayer` to allow it to access the private `AVPlayer` instance required to initialize it, which is otherwise hidden away from every other class. 226 /// 2. DO NOT write any `AVPlayer` control/manipulation code, the `AVPlayer` instance is owned by `DamusVideoPlayer` and only managed there to keep things sane. 227 struct BaseView: UIViewControllerRepresentable { 228 229 let player: DamusVideoPlayer 230 let show_playback_controls: Bool 231 232 func makeUIViewController(context: Context) -> AVPlayerViewController { 233 let controller = AVPlayerViewController() 234 controller.showsPlaybackControls = show_playback_controls 235 return controller 236 } 237 238 func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) { 239 if uiViewController.player == nil { 240 uiViewController.player = player.player 241 } 242 } 243 244 static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: ()) { 245 uiViewController.player = nil 246 } 247 } 248 }