ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • AVPlayer in SwiftUI (feat. PIP)
    SwiftUI 2023. 3. 23. 15:15

    안녕하세요. 그린입니다🍏

    이번 포스팅에서는 SwiftUI에서 커스텀한 AVPlayer를 통해 동영상 플레이어 구축을 해보겠습니다🙌

     

    우선 바닐라 SwiftUI 환경에서 구축을 진행하였습니다.

     

    VideoPlayer의 단점

    iOS 14부터 SwiftUI에서 기본적으로 제공하는 VideoPlayer라는것이 있습니다.

    물론 간단한 구현정도는 해당 API를 통해 구축할 수 있지만, 세부적인 조작이 필요한 경우 UIKit에서 제공하는 AVPlayer를 직접 커스텀하게 구성해야 됩니다🥹

     

    그럼 이번 포스팅에서는 큰 설명보다는 코드로 같이 보겠습니다!

     

    아참! 우선 해당 프로젝트 타겟에서 아래 설정을 켜줘야합니다.

    🙋🏻 Signing & Capabilites > Background Modes > Audio, Airplay, and Picture in Picture

     

    AVPlayer in SwiftUI

    우선적으로 해아할건 핵심적인 VideoPlayer의 뷰와 코어를 만드는것입니다.

     

    View 코드

    import AVKit
    import SwiftUI
    
    public struct GreenVideoPlayer: UIViewControllerRepresentable {
      @ObservedObject var viewModel: GreenVideoPlayerViewModel
      
      /// 영상 재생 관련 컨트롤러 노출 및 동작 여부
      var isDisplayPlaybackControls: Bool
      /// 선형 재생 여부 (선형 재생 시 되감기/넘어가기 동작 불가)
      var isRequireLinearPlayback: Bool
      /// 비디오 화면 리사이징
      var videoGravity: AVLayerVideoGravity
      /// PIP 재생 모드 허용 여부
      var isAllowPIP: Bool
      /// 화면 회전 지원
      var autorotate: Bool
      /// 화면 모드
      var interfaceMode: InterfaceOrientationMode
      /// 외부 디바이스 재생 연동
      var isAllowExternalPlayback: Bool
      
      public init(
        viewModel: GreenVideoPlayerViewModel,
        isDisplayPlaybackControls: Bool = true,
        isRequireLinearPlayback: Bool = false,
        videoGravity: AVLayerVideoGravity = .resizeAspect,
        isAllowPIP: Bool = true,
        autorotate: Bool = true,
        interfaceMode: InterfaceOrientationMode = .all,
        isAllowExternalPlayback: Bool = false
      ) {
        self.viewModel = viewModel
        self.isDisplayPlaybackControls = isDisplayPlaybackControls
        self.isRequireLinearPlayback = isRequireLinearPlayback
        self.videoGravity = videoGravity
        self.isAllowPIP = isAllowPIP
        self.autorotate = autorotate
        self.interfaceMode = interfaceMode
        self.isAllowExternalPlayback = isAllowExternalPlayback
      }
      
      public func makeUIViewController(context: Context) -> AVPlayerViewController {
        let controller = CustomAVPlayerViewController(
          autorotate: autorotate,
          interfaceMode: interfaceMode
        )
        
        controller.player = viewModel.player
        controller.delegate = context.coordinator
        controller.showsPlaybackControls = isDisplayPlaybackControls
        controller.requiresLinearPlayback = isRequireLinearPlayback
        controller.videoGravity = videoGravity
        controller.allowsPictureInPicturePlayback = isAllowPIP
        controller.player?.allowsExternalPlayback = isAllowExternalPlayback
       
        return controller
      }
      
      public func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
        uiViewController.player = viewModel.player
        uiViewController.allowsPictureInPicturePlayback = isAllowPIP
      }
      
      public func makeCoordinator() -> Coordinator {
        return Coordinator(self)
      }
      
      public class Coordinator: NSObject, AVPlayerViewControllerDelegate {
        let parent: GreenVideoPlayer
        
        public init(_ parent: GreenVideoPlayer) {
          self.parent = parent
        }
        
        public func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
          parent.viewModel.pipStatus = .willStart
        }
        
        public func playerViewControllerDidStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
          parent.viewModel.pipStatus = .didStart
        }
        
        public func playerViewControllerWillStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
          parent.viewModel.pipStatus = .willStop
        }
        
        public func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
          parent.viewModel.pipStatus = .didStop
        }
      }
    }
    
    // MARK: - 화면 모드
    public enum InterfaceOrientationMode {
      case portrait
      case all
    }
    
    // MARK: - 커스텀 AVPlayerController
    class CustomAVPlayerViewController: AVPlayerViewController {
      var autorotate: Bool
      var interfaceMode: InterfaceOrientationMode
      
      required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
      }
      
      init(
        autorotate: Bool = true,
        interfaceMode: InterfaceOrientationMode = .all
      ) {
        self.autorotate = autorotate
        self.interfaceMode = interfaceMode
        
        super.init(nibName: nil, bundle: nil)
      }
      
      // 회전 지원
      override var shouldAutorotate: Bool {
        return autorotate
      }
      
      // 세로, 전체 모드
      override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return interfaceMode == .portrait ? .portrait : .all
      }
    }
    
    // MARK: - PIP 모드 상태
    public enum PipStatus: String {
      case willStart
      case didStart
      case willStop
      case didStop
      case unowned
    }

    요렇게 핵심적인 뷰를 만들 수 있어요.

    하고싶은 기능들을 해당 뷰를 이닛할때 프로퍼티로 넣어줍니다.

    영상 컨트롤러 노출 여부나 선형 재생(되감기/넘어가기가 불가한 쇼츠같은 재생), PIP 모드 지원, 화면 회전 지원 등등 말이죠.

    이러한 프로퍼티를 가지고 AVPlayer를 설정해줄거에요!

     

    makeUIViewController 메서드 내에서 우리는 화면 회전 지원등을 위해 커스텀한 AVPlayerVC를 구축해야 합니다.

    이를 위해, AVPlayerViewController를 상속받는 CustomAVPlayerViewController를 구현해주고 이 내부에 화면 회전 및 모드에 대한 설정을 담아줍니다.

     

    만들어진 CustomAVPlayerVC를 선언하고 해당 컨트롤러에 이제 프로퍼티에서 받은 설정들을 입혀줍니다👍

     

    그 다음으로, PIP 모드까지 지원해주기 위해 Coordinator를 통해 AVPlayerVCDelegate를 채택하여 내부 액션을 구성해줄 수 있어요.

    PIP 모드 시작, 정지 등과 같은 상태의 기본 메서드를 구축하여 이 상태일때 원하는 동작들을 자유롭게 구성해줄 수 있습니다.

     

    자 그럼 뷰단이 간단히 만들어 졌으니 코어를 구축해볼까요?

     

    Core(ViewModel) 코드

    관심사 분리를 위해 뷰모델에 해당하는 로직 코드들을 뷰모델로 별도로 생성해줍니다.

    import AVKit
    import Combine
    
    final public class GreenVideoPlayerViewModel: ObservableObject {
      @Published var pipStatus: PipStatus = .unowned
      @Published var media: Media?
      
      let player = AVPlayer()
      private var cancellable: AnyCancellable?
      
      public init() {
        setAudioSessionCategory(to: .playback)
        cancellable = $media
          .compactMap({ $0 })
          .compactMap({ URL(string: $0.url) })
          .sink(receiveValue: { [weak self] in
            guard let self = self else { return }
            self.player.replaceCurrentItem(with: AVPlayerItem(url: $0))
          })
      }
      
      // 영상 재생
      func play() {
        player.play()
      }
      
      // 영상 정지
      func pause() {
        player.pause()
      }
      
      // 영상 음소거
      func mute(_ isMuted: Bool) {
        player.isMuted = isMuted
      }
      
      func setAudioSessionCategory(to value: AVAudioSession.Category) {
        let audioSession = AVAudioSession.sharedInstance()
        do {
          try audioSession.setCategory(value)
        } catch {
          print("Setting category to AVAudioSessionCategoryPlayback failed.")
        }
      }
    }
    
    // 제공받는 미디어 타입
    public struct Media {
      let url: String
    }

    pip 상태와 들어올 영상 미디어를 프로퍼티로 갖습니다.

    그 외 설정들은 View 단에서 처리했기에 라이트하게 뷰모델을 가져갈 수 있습니다.

    내부에서 AVPlayer 프로퍼티를 생성하고 각 재생/정지/음소거에 대한 액션 로직을 넣어줍니다.

    처음 초기화 시 setAudioSessionCategory 메서드를 통해 재생을 관장해줄 수 있습니다.

     

    요러면 코어단도 가볍게 끝났습니다!

     

    이제 그럼 이 만들어진 GreenVideoPlayer를 SwiftUI 코드에 입혀볼까요?🙋🏻

     

    SwiftUI View 코드

    우선적으로 View와 Core(ViewModel)로 동일하게 만들어줍니다.

    뷰 부터~⭐️

    import SwiftUI
    
    public struct VideoPlayerView: View {
      @StateObject var viewModel: VideoPlayerViewModel
      @StateObject private var greenVideoPlayerViewModel = GreenVideoPlayerViewModel()
      
      public var body: some View {
        GreenVideoPlayer(viewModel: greenVideoPlayerViewModel)
          .onAppear {
            greenVideoPlayerViewModel.media = Media(
              url: viewModel.contentURL
            )
          }
          .overlay(
            RetryView(
              title: "This video file could not be played",
              remainingRetries: viewModel.remainingRetries,
              perform: {
                Task {
                  let canRetryPlay = await viewModel.retryPlayVideo()
                  if canRetryPlay {
                    greenVideoPlayerViewModel.play()
                  }
                }
              }
            )
            .opacity(viewModel.canPlay ? 0 : 1)
          )
      }
    }
    
    // MARK: - 재시도 뷰
    private struct RetryView: View {
      var title: String
      var remainingRetries: Int
      var perform: () -> Void
      
      public init(
        title: String,
        remainingRetries: Int,
        perform: @escaping () -> Void
      ) {
        self.title = title
        self.remainingRetries = remainingRetries
        self.perform = perform
      }
      
      public var body: some View {
        VStack(alignment: .center, spacing: 10) {
          Spacer()
          
          Text(title)
            .foregroundColor(.white)
          
          Button(
            action: remainingRetries != 0 ? perform : {},
            label: {
              Text(
                remainingRetries != 0
                ? "Please try again"
                : "Please try again later"
              )
            }
          )
          .padding(.horizontal, 20)
          .padding(.vertical, 10)
          .foregroundColor(.white)
          .background(remainingRetries != 0 ? Color.blue : Color.red)
          .cornerRadius(10)
          .disabled(remainingRetries == 0)
          .padding(.bottom, 300)
        }
      }
    }

    해당 뷰에서는 뷰모델을 두개를 갖습니다.

    하나는 해당 뷰의 뷰모델이고 또 하나는 위에서 만들어준 GreenVideoPlayerVM 타입의 뷰모델 프로퍼티입니다.

    재시도 뷰라는 것을 구현했기에 해당 뷰에 해당하는 VM이 존재합니다.

    재시도 뷰는 통신이 늦거나 정상적이지 않은 URL로 영상을 띄워주려할 때 아예 빈 화면보다는 영상 로드를 다시 시켜줄 수 있도록 하기 위한 뷰 입니다.

     

    뷰는 아주 간단하죠?

    커스텀하게 만든 비디오 플레이어를 얹고 오버레이로 재시도 뷰를 입히면 끝!

     

    그럼 코어 단 코드를 볼까요?

     

    SwiftUI Core(ViewModel) 코드

    import AVKit
    
    final public class VideoPlayerViewModel: ObservableObject {
      enum ContentType {
        case video
        case unknown
      }
      
      @Published var contentURL: String
      @Published var remainingRetries: Int
      var canPlay: Bool {
        let type = filterByExtension(url: contentURL)
        return type == .video
      }
      
      public init(
        contentURL: String,
        remainingRetries: Int = 5
      ) {
        self.contentURL = contentURL
        self.remainingRetries = remainingRetries
      }
    }
    
    // MARK: - Business Logic
    extension VideoPlayerViewModel {
      // 동영상 확장자 체크
      private func filterByExtension(url: String) -> ContentType {
        let videoExtensions: [String] = [
          "caf", "ttml", "au", "ts", "mqv", "pls",
          "flac", "dv", "amr", "mp1", "mp3", "ac3",
          "loas", "3gp", "aifc", "m2v", "m2t", "m4b",
          "m2a", "m4r", "aa", "webvtt", "aiff", "m4a",
          "scc", "mp4", "m4p", "mp2", "eac3", "mpa", "vob",
          "scc", "aax", "mpg", "wav", "mov", "itt", "xhe",
          "m3u", "mts", "mod", "vtt", "m4v", "3g2", "sc2",
          "aac", "mp4", "vtt", "m1a", "mp2", "avi", "m3u8",
        ]
        guard let fileExtension = URL(string: url)?.pathExtension.lowercased() else {
          return .unknown
        }
        
        if videoExtensions.contains(fileExtension) {
          return .video
        } else {
          return .unknown
        }
      }
      
      // 영상 재생 재시도
      @MainActor
      func retryPlayVideo() async -> Bool {
        remainingRetries -= 1
        return remainingRetries > 0
      }
    }

    여기선 재시도에 대한 로직들이 담겨 있습니다.

    우선, 해당 뷰를 띄워줄 때 contentURL 즉, 영상 소스 URL이 존재해야하고 재시도 횟수도 정해주도록 프로퍼티를 갖습니다.

    canPlay로 이제 재시도 뷰를 띄워줄지 말지를 결정할 수 있습니다.

     

    로직에는 우선 contentURL이 들어오면 정상적인 비디오 타입인지 확장자를 체크할 수 있습니다.

    또한 retryPlayVideo 메서드로 실제 재시도 버튼이 눌리면 횟수를 차감하며 조건을 비교합니다.

     

    자 아주 간단히 SwiftUI에서 커스텀한 동영상 플레이어를 구축해봤는데 실제 시연을 해볼까요?

     

    정상 동작

     

    PIP 모드

     

    재시도 동작

     

    재시도 끝내 실패😭

     

    마무리

    해당 예제 샘플 소스 코드는 제 깃헙에서 확인하실 수 있습니다🙋🏻

    https://github.com/GREENOVER/GreenVideoPlayer

     

    GitHub - GREENOVER/GreenVideoPlayer

    Contribute to GREENOVER/GreenVideoPlayer development by creating an account on GitHub.

    github.com

     

    [참고자료]

    https://www.createwithswift.com/implementing-picture-in-picture-with-avkit-and-swiftui/

     

    Implementing Picture-In-Picture with AVKit and SwiftUI

    Learn in this tutorial to implement Picture-in-Picture (PiP) with AVKit and SwiftUI using UIViewRepresentable & UIViewControllerRepresentable.

    www.createwithswift.com

Designed by Tistory.