swiftswiftuiavfoundationapple-watch

Converting AVPlayerView for Apple Watch


I have an app on the store for at least six months that comes with a target for iOS and another for Apple Watch. Both targets play videos using AVFoundation.

The code I was using for playing the videos was giving me problems. I found some code on the web, modified it and managed to make it work for iOS. Now I am trying to do it for the watch.

This is the code:

#CustomVideoPlayer

import SwiftUI
import Combine
import AVKit

public struct CustomVideoPlayer: UIViewRepresentable {
  @ObservedObject private var playerVM: PlayerViewModel
  
  public init(_ playerVM: PlayerViewModel) {
    self.playerVM = playerVM
  }
  
  public func makeUIView(context: Context) -> PlayerView {
    let view = PlayerView()
    view.player = playerVM.player
    context.coordinator.setController(view.playerLayer)
    return view
  }
  
  public func updateUIView(_ uiView: PlayerView, context: Context) { }
  
  public func makeCoordinator() -> Coordinator {
    return Coordinator(self)
  }
  
  public class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
    private let parent: CustomVideoPlayer
    private var controller: AVPictureInPictureController?
    private var cancellable: AnyCancellable?
    
    init(_ parent: CustomVideoPlayer) {
      self.parent = parent
      super.init()
      
      cancellable = parent.playerVM.$isInPipMode
        .sink { [weak self] in
          guard let self = self,
                let controller = self.controller else { return }
          if $0 {
            if controller.isPictureInPictureActive == false {
              controller.startPictureInPicture()
            }
          } else if controller.isPictureInPictureActive {
            controller.stopPictureInPicture()
          }
        }
    }
    
    public func setController(_ playerLayer: AVPlayerLayer) {
      controller = AVPictureInPictureController(playerLayer: playerLayer)
      controller?.canStartPictureInPictureAutomaticallyFromInline = true
      controller?.delegate = self
    }
    
    public func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
      parent.playerVM.isInPipMode = true
    }
    
    public func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
      parent.playerVM.isInPipMode = false
    }
  }
}

#CustomControlsView

import SwiftUI

public struct CustomControlsView: View {
  @ObservedObject private var playerVM: PlayerViewModel
  
  public init(playerVM: PlayerViewModel) {
    self.playerVM = playerVM
  }
  
  
  public var body: some View {
    HStack {
      if playerVM.isPlaying == false {
        Button(action: {
          playerVM.player.play()
        }, label: {
          Image(systemName: "play.circle")
            .renderingMode(.template)
            .font(.system(size: 25))
            .foregroundColor(.black)
          
        })
      } else {
        Button(action: {
          playerVM.player.pause()
        }, label: {
          Image(systemName: "pause.circle")
            .renderingMode(.template)
            .font(.system(size: 25))
            .foregroundColor(.black)
        })
      }
      
      if let duration = playerVM.duration {
        Slider(value: $playerVM.currentTime, in: 0...duration, onEditingChanged: { isEditing in
          playerVM.isEditingCurrentTime = isEditing
        })
      } else {
        Spacer()
      }
    }
    .padding()
    .background(.thinMaterial)
  }
}

#PlayerViewModel

import AVFoundation
import Combine

final public class PlayerViewModel: ObservableObject {
  public let player = AVPlayer()
  @Published var isInPipMode: Bool = false
  @Published var isPlaying = false
  
  @Published var isEditingCurrentTime = false
  @Published var currentTime: Double = .zero
  @Published var duration: Double?
  
  private var subscriptions: Set<AnyCancellable> = []
  private var timeObserver: Any?
  
  deinit {
    if let timeObserver = timeObserver {
      player.removeTimeObserver(timeObserver)
    }
  }
  
  public init() {
    $isEditingCurrentTime
      .dropFirst()
      .filter({ $0 == false })
      .sink(receiveValue: { [weak self] _ in
        guard let self = self else { return }
        self.player.seek(to: CMTime(seconds: self.currentTime, preferredTimescale: 1), toleranceBefore: .zero, toleranceAfter: .zero)
        if self.player.rate != 0 {
          self.player.play()
        }
      })
      .store(in: &subscriptions)
    
    player.publisher(for: \.timeControlStatus)
      .sink { [weak self] status in
        switch status {
        case .playing:
          self?.isPlaying = true
        case .paused:
          self?.isPlaying = false
        case .waitingToPlayAtSpecifiedRate:
          break
        @unknown default:
          break
        }
      }
      .store(in: &subscriptions)
    
    timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 1, preferredTimescale: 600), queue: .main) { [weak self] time in
      guard let self = self else { return }
      if self.isEditingCurrentTime == false {
        self.currentTime = time.seconds
      }
    }
  }
  
  private var videoName = ""
  
  
  private func videoURL(_ filename:String) -> URL? {
    guard let fileURL = Bundle.main.url(forResource:filename,
                                        withExtension: "mp4")
    else { return nil }
    return fileURL
  }
  
  
  public func setCurrentItem(_ filename:String){
    currentTime = .zero
    duration = nil
    
    guard let videoURL = videoURL(filename) else { return }
    
    let avPlayerItem =  AVPlayerItem(url:videoURL)
    
    player.replaceCurrentItem(with: avPlayerItem)
    
    avPlayerItem.publisher(for: \.status)
      .filter({ $0 == .readyToPlay })
      .sink(receiveValue: { [weak self] _ in
        self?.duration = avPlayerItem.asset.duration.seconds
      })
      .store(in: &subscriptions)
  }
}

#PlayerView

import AVFoundation
import UIKit

final public class PlayerView: UIView {
  public override static var layerClass: AnyClass {
        return AVPlayerLayer.self
    }
    
    var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
    
    var player: AVPlayer? {
        get {
            playerLayer.player
        }
        set {
            playerLayer.videoGravity = .resizeAspectFill
            playerLayer.player = newValue
        }
    }
}

How to use it:

CustomVideoPlayer(playerVM)
        .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
        .onAppear {
          if let filename = item.filename {
            playerVM.setCurrentItem("beach") // load 'beach.mp4' from the bundle
            playerVM.player.play()
          }
        }

My problem is that PlayerView uses UIKit, and Apple Watch does not have that framework.

How can I modify this code to work with Apple Watch?

Thanks in advance?


Solution

  • You're trying to use views and types on watchOS that aren't available on watchOS.

    Just to name a few:

    You can't do that. You either need to find equivalents for them which are available on watchOS or make some of their provided functionality iOS-only (such as Picture in Picture - that doesn't really make sense on watchOS).

    If you want to be able to keep your existing video player features on iOS, you'll have to use a different view on watchOS for playing videos - this approach makes sense anyways as the completely different screen sizes mean users don't expect the same video features on both platforms.

    You can keep using the current, iOS only component on iOS and use a separate video player view on watchOS. You have multiple options: