damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

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 }