๐ŸŒ KO

โ–ถ๏ธ AVKit

โญ Difficulty: โญโญ โฑ๏ธ Est. Time: 1h ๐Ÿ“‚ Graphics & Media

An advanced framework for video player UI and controls

iOS 8+SwiftUI Supported

โœจ AVKit is?

AVKit is a framework that provides a complete user interface for video playback. Built on top of AVFoundation, it makes it easy to implement professional video player features like playback controls, Picture-in-Picture, AirPlay, subtitles, and chapter navigation. It supports both UIKit's AVPlayerViewController and SwiftUI's VideoPlayer.

๐Ÿ’ก Key Features: Playback Controls ยท Picture-in-Picture ยท AirPlay ยท Subtitles ยท Chapters ยท Speed Control ยท Full Screen ยท Gestures ยท System Integration

๐ŸŽฌ 1. ๊ธฐ๋ณธ ๋น„๋””์˜ค ํ”Œ๋ ˆ์ด์–ด (SwiftUI)

Play video easily with SwiftUI's VideoPlayer.

VideoPlayerView.swift โ€” SwiftUI Video Player
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)

Build a full-featured player using AVPlayerViewController in UIKit.

PlayerViewController.swift โ€” UIKit Player
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

Implement Picture-in-Picture to play video in a floating window.

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

Stream video to other devices via AirPlay.

AirPlayView.swift โ€” AirPlay Supported
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 โ€” Custom Controls
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
        }
    }
}

๐Ÿ“ฑ Complete Example

VideoApp.swift โ€” App
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 Guidelines

๐ŸŽฏ Practical Usage

๐Ÿ“š Learn More

โšก๏ธ Performance Tips: Using AVPlayerViewController lets the system automatically provide an optimized playback experience. When building custom controls, addPeriodicTimeObserver โ€” don't set the interval too short. About 0.5 seconds is appropriate.

๐Ÿ“Ž Apple Official Resources

๐Ÿ“˜ Documentation ๐ŸŽฌ WWDC Sessions