▶️ AVKit

비디오 플레이어 UI와 컨트롤을 위한 고급 프레임워크

iOS 8+SwiftUI 지원

✨ AVKit이란?

AVKit은 비디오 재생을 위한 완전한 사용자 인터페이스를 제공하는 프레임워크입니다. AVFoundation 위에 구축되어 재생 컨트롤, Picture-in-Picture, AirPlay, 자막, 챕터 네비게이션 등 프로페셔널한 비디오 플레이어 기능을 손쉽게 구현할 수 있습니다. UIKit의 AVPlayerViewController와 SwiftUI의 VideoPlayer를 모두 지원합니다.

💡 핵심 기능: 재생 컨트롤 · Picture-in-Picture · AirPlay · 자막 · 챕터 · 속도 조절 · 전체화면 · 제스처 · 시스템 통합

🎬 1. 기본 비디오 플레이어 (SwiftUI)

SwiftUI의 VideoPlayer를 사용하여 간단하게 비디오를 재생합니다.

VideoPlayerView.swift — SwiftUI 비디오 플레이어
import SwiftUI
import AVKit

struct SimpleVideoPlayer: View {
    let url: URL

    var body: some View {
        VideoPlayer(player: AVPlayer(url: url))
            .frame(height: 300)
    }
}

// 사용 예제
struct ContentView: View {
    var body: some View {
        VStack {
            if let url = URL(string: "https://example.com/video.mp4") {
                SimpleVideoPlayer(url: url)
            }
        }
    }
}

// 커스텀 컨트롤이 있는 VideoPlayer
struct VideoPlayerWithOverlay: View {
    @State private var player: AVPlayer
    @State private var isPlaying = false

    init(url: URL) {
        _player = State(initialValue: AVPlayer(url: url))
    }

    var body: some View {
        VideoPlayer(player: player) {
            // 커스텀 오버레이
            VStack {
                Text("나만의 비디오")
                    .font(.title)
                    .foregroundStyle(.white)
                    .padding()

                Spacer()

                HStack {
                    Button {
                        if isPlaying {
                            player.pause()
                        } else {
                            player.play()
                        }
                        isPlaying.toggle()
                    } label: {
                        Image(systemName: isPlaying ? "pause.fill" : "play.fill")
                            .font(.largeTitle)
                            .foregroundStyle(.white)
                    }
                }
                .padding()
            }
        }
        .frame(height: 300)
    }
}

📱 2. AVPlayerViewController (UIKit)

UIKit에서 AVPlayerViewController를 사용하여 전체 기능을 갖춘 플레이어를 만듭니다.

PlayerViewController.swift — UIKit 플레이어
import SwiftUI
import AVKit

struct PlayerView: UIViewControllerRepresentable {
    let player: AVPlayer

    func makeUIViewController(context: Context) -> AVPlayerViewController {
        let controller = AVPlayerViewController()
        controller.player = player

        // 재생 컨트롤 설정
        controller.showsPlaybackControls = true

        // Picture-in-Picture 허용
        controller.allowsPictureInPicturePlayback = true

        // 비디오 중력 설정
        controller.videoGravity = .resizeAspect

        // 자막 표시
        controller.canStartPictureInPictureAutomaticallyFromInline = true

        return controller
    }

    func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {}
}

// 사용 예제
struct VideoPlayerScreen: View {
    @State private var player: AVPlayer?

    var body: some View {
        VStack {
            if let player = player {
                PlayerView(player: player)
                    .frame(height: 300)
            } else {
                ProgressView()
            }
        }
        .onAppear {
            loadVideo()
        }
    }

    func loadVideo() {
        guard let url = URL(string: "https://example.com/video.mp4") else { return }
        player = AVPlayer(url: url)
    }
}

📺 3. Picture-in-Picture

Picture-in-Picture 기능을 구현하여 비디오를 떠있는 창에서 재생합니다.

PiPPlayerView.swift — Picture-in-Picture
import SwiftUI
import AVKit

@Observable
class PiPManager: NSObject {
    var pipController: AVPictureInPictureController?
    var isPiPActive = false
    var isPiPSupported = false

    func setupPiP(with playerLayer: AVPlayerLayer) {
        // PiP 지원 확인
        isPiPSupported = AVPictureInPictureController.isPictureInPictureSupported()

        guard isPiPSupported else { return }

        // PiP 컨트롤러 생성
        pipController = AVPictureInPictureController(playerLayer: playerLayer)
        pipController?.delegate = self

        // 오디오 세션 설정
        try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
        try? AVAudioSession.sharedInstance().setActive(true)
    }

    func startPiP() {
        pipController?.startPictureInPicture()
    }

    func stopPiP() {
        pipController?.stopPictureInPicture()
    }
}

extension PiPManager: AVPictureInPictureControllerDelegate {
    func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
        isPiPActive = true
    }

    func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
        isPiPActive = false
    }

    func pictureInPictureController(
        _ pictureInPictureController: AVPictureInPictureController,
        failedToStartPictureInPictureWithError error: Error
    ) {
        print("PiP 시작 실패: \(error.localizedDescription)")
    }
}

struct PiPPlayerView: View {
    @State private var player: AVPlayer
    @State private var pipManager = PiPManager()

    init(url: URL) {
        _player = State(initialValue: AVPlayer(url: url))
    }

    var body: some View {
        VStack {
            PiPVideoView(player: player, pipManager: pipManager)
                .frame(height: 300)

            Button {
                if pipManager.isPiPActive {
                    pipManager.stopPiP()
                } else {
                    pipManager.startPiP()
                }
            } label: {
                Label(
                    pipManager.isPiPActive ? "PiP 종료" : "PiP 시작",
                    systemImage: "pip"
                )
            }
            .disabled(!pipManager.isPiPSupported)
            .buttonStyle(.borderedProminent)
            .padding()
        }
    }
}

struct PiPVideoView: UIViewRepresentable {
    let player: AVPlayer
    let pipManager: PiPManager

    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        let playerLayer = AVPlayerLayer(player: player)
        playerLayer.videoGravity = .resizeAspect
        view.layer.addSublayer(playerLayer)

        // PiP 설정
        pipManager.setupPiP(with: playerLayer)

        DispatchQueue.main.async {
            playerLayer.frame = view.bounds
        }

        return view
    }

    func updateUIView(_ uiView: UIView, context: Context) {
        if let layer = uiView.layer.sublayers?.first as? AVPlayerLayer {
            layer.frame = uiView.bounds
        }
    }
}

📡 4. AirPlay

AirPlay를 통해 다른 기기로 비디오를 스트리밍합니다.

AirPlayView.swift — AirPlay 지원
import SwiftUI
import AVKit

struct AirPlayButton: UIViewRepresentable {
    func makeUIView(context: Context) -> AVRoutePickerView {
        let routePickerView = AVRoutePickerView()
        routePickerView.tintColor = .UIColor.systemBlue
        routePickerView.activeTintColor = .UIColor.systemGreen
        routePickerView.prioritizesVideoDevices = true

        return routePickerView
    }

    func updateUIView(_ uiView: AVRoutePickerView, context: Context) {}
}

struct VideoPlayerWithAirPlay: View {
    let url: URL
    @State private var player: AVPlayer?

    var body: some View {
        VStack {
            if let player = player {
                VideoPlayer(player: player)
                    .frame(height: 300)
            }

            HStack {
                Text("AirPlay로 재생")
                    .font(.headline)

                Spacer()

                AirPlayButton()
                    .frame(width: 40, height: 40)
            }
            .padding()
        }
        .onAppear {
            setupPlayer()
        }
    }

    func setupPlayer() {
        player = AVPlayer(url: url)

        // AirPlay를 위한 오디오 세션
        let audioSession = AVAudioSession.sharedInstance()
        try? audioSession.setCategory(.playback, mode: .moviePlayback)
        try? audioSession.setActive(true)

        // 외부 재생 허용
        player?.allowsExternalPlayback = true
        player?.usesExternalPlaybackWhileExternalScreenIsActive = true
    }
}

⚙️ 5. 커스텀 컨트롤

완전히 커스터마이즈된 재생 컨트롤을 만듭니다.

CustomPlayerView.swift — 커스텀 컨트롤
import SwiftUI
import AVKit
import Combine

@Observable
class CustomPlayerManager {
    var player: AVPlayer
    var isPlaying = false
    var currentTime: Double = 0
    var duration: Double = 0
    var playbackRate: Float = 1.0
    var volume: Float = 1.0

    private var timeObserver: Any?

    init(url: URL) {
        self.player = AVPlayer(url: url)
        setupObservers()
    }

    func setupObservers() {
        // 재생 시간 관찰
        timeObserver = player.addPeriodicTimeObserver(
            forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
            queue: .main
        ) { [weak self] time in
            self?.currentTime = time.seconds
        }

        // 재생 완료 감지
        NotificationCenter.default.addObserver(
            forName: .AVPlayerItemDidPlayToEndTime,
            object: player.currentItem,
            queue: .main
        ) { [weak self] _ in
            self?.isPlaying = false
            self?.player.seek(to: .zero)
        }

        // 영상 길이 로드
        Task {
            if let asset = player.currentItem?.asset,
               let duration = try? await asset.load(.duration) {
                self.duration = duration.seconds
            }
        }
    }

    func togglePlayPause() {
        if isPlaying {
            player.pause()
        } else {
            player.play()
        }
        isPlaying.toggle()
    }

    func seek(to time: Double) {
        let cmTime = CMTime(seconds: time, preferredTimescale: 600)
        player.seek(to: cmTime)
    }

    func skipForward(_ seconds: Double = 10) {
        seek(to: currentTime + seconds)
    }

    func skipBackward(_ seconds: Double = 10) {
        seek(to: max(0, currentTime - seconds))
    }

    func changePlaybackRate(to rate: Float) {
        playbackRate = rate
        player.rate = rate
    }

    func changeVolume(to volume: Float) {
        self.volume = volume
        player.volume = volume
    }
}

struct CustomPlayerView: View {
    @State private var playerManager: CustomPlayerManager
    @State private var showControls = true

    init(url: URL) {
        _playerManager = State(initialValue: CustomPlayerManager(url: url))
    }

    var body: some View {
        ZStack {
            // 비디오 레이어
            VideoLayer(player: playerManager.player)
                .onTapGesture {
                    withAnimation {
                        showControls.toggle()
                    }
                }

            // 커스텀 컨트롤
            if showControls {
                VStack {
                    Spacer()

                    // 진행 바
                    VStack(spacing: 8) {
                        Slider(
                            value: Binding(
                                get: { playerManager.currentTime },
                                set: { playerManager.seek(to: $0) }
                            ),
                            in: 0...max(playerManager.duration, 1)
                        )
                        .tint(.white)

                        HStack {
                            Text(formatTime(playerManager.currentTime))
                            Spacer()
                            Text(formatTime(playerManager.duration))
                        }
                        .font(.caption)
                        .foregroundStyle(.white)
                    }
                    .padding(.horizontal)

                    // 재생 컨트롤
                    HStack(spacing: 40) {
                        Button {
                            playerManager.skipBackward()
                        } label: {
                            Image(systemName: "gobackward.10")
                                .font(.title)
                        }

                        Button {
                            playerManager.togglePlayPause()
                        } label: {
                            Image(systemName: playerManager.isPlaying ? "pause.fill" : "play.fill")
                                .font(.title)
                        }

                        Button {
                            playerManager.skipForward()
                        } label: {
                            Image(systemName: "goforward.10")
                                .font(.title)
                        }

                        Menu {
                            Button("0.5x") { playerManager.changePlaybackRate(to: 0.5) }
                            Button("1.0x") { playerManager.changePlaybackRate(to: 1.0) }
                            Button("1.5x") { playerManager.changePlaybackRate(to: 1.5) }
                            Button("2.0x") { playerManager.changePlaybackRate(to: 2.0) }
                        } label: {
                            Image(systemName: "gauge")
                                .font(.title)
                        }
                    }
                    .foregroundStyle(.white)
                    .padding()
                }
                .background(
                    LinearGradient(
                        colors: [.Color.clear, .Color.black.opacity(0.7)],
                        startPoint: .top,
                        endPoint: .bottom
                    )
                )
                .transition(.opacity)
            }
        }
        .frame(height: 300)
        .background(Color.black)
    }

    func formatTime(_ time: Double) -> String {
        let minutes = Int(time) / 60
        let seconds = Int(time) % 60
        return String(format: "%d:%02d", minutes, seconds)
    }
}

struct VideoLayer: UIViewRepresentable {
    let player: AVPlayer

    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        let playerLayer = AVPlayerLayer(player: player)
        playerLayer.videoGravity = .resizeAspect
        view.layer.addSublayer(playerLayer)

        DispatchQueue.main.async {
            playerLayer.frame = view.bounds
        }

        return view
    }

    func updateUIView(_ uiView: UIView, context: Context) {
        if let layer = uiView.layer.sublayers?.first as? AVPlayerLayer {
            layer.frame = uiView.bounds
        }
    }
}

📱 종합 예제

VideoApp.swift — 비디오 앱
import SwiftUI
import AVKit

struct VideoApp: View {
    @State private var selectedVideo: VideoItem?

    let videos = [
        VideoItem(title: "샘플 비디오 1", url: URL(string: "https://example.com/video1.mp4")!),
        VideoItem(title: "샘플 비디오 2", url: URL(string: "https://example.com/video2.mp4")!),
    ]

    var body: some View {
        NavigationStack {
            List(videos) { video in
                Button {
                    selectedVideo = video
                } label: {
                    HStack {
                        Image(systemName: "play.rectangle.fill")
                            .font(.largeTitle)
                            .foregroundStyle(.blue)

                        Text(video.title)
                            .font(.headline)
                    }
                }
            }
            .navigationTitle("비디오")
            .sheet(item: $selectedVideo) { video in
                VideoPlayerSheet(video: video)
            }
        }
    }
}

struct VideoPlayerSheet: View {
    let video: VideoItem
    @Environment(\.dismiss) var dismiss

    var body: some View {
        NavigationStack {
            CustomPlayerView(url: video.url)
                .navigationTitle(video.title)
                .navigationBarTitleDisplayMode(.inline)
                .toolbar {
                    ToolbarItem(placement: .cancellationAction) {
                        Button("닫기") {
                            dismiss()
                        }
                    }
                }
        }
    }
}

struct VideoItem: Identifiable {
    let id = UUID()
    let title: String
    let url: URL
}

💡 HIG 가이드라인

🎯 실전 활용

📚 더 알아보기

⚡️ 성능 팁: AVPlayerViewController를 사용하면 시스템이 자동으로 최적화된 재생 경험을 제공합니다. 커스텀 컨트롤을 만들 때는 addPeriodicTimeObserver의 간격을 너무 짧게 설정하지 마세요. 0.5초 정도가 적당합니다.