โถ๏ธ AVKit
๋น๋์ค ํ๋ ์ด์ด UI์ ์ปจํธ๋กค์ ์ํ ๊ณ ๊ธ ํ๋ ์์ํฌ
โจ AVKit์ด๋?
AVKit์ ๋น๋์ค ์ฌ์์ ์ํ ์์ ํ ์ฌ์ฉ์ ์ธํฐํ์ด์ค๋ฅผ ์ ๊ณตํ๋ ํ๋ ์์ํฌ์ ๋๋ค. AVFoundation ์์ ๊ตฌ์ถ๋์ด ์ฌ์ ์ปจํธ๋กค, Picture-in-Picture, AirPlay, ์๋ง, ์ฑํฐ ๋ค๋น๊ฒ์ด์ ๋ฑ ํ๋กํ์ ๋ํ ๋น๋์ค ํ๋ ์ด์ด ๊ธฐ๋ฅ์ ์์ฝ๊ฒ ๊ตฌํํ ์ ์์ต๋๋ค. UIKit์ AVPlayerViewController์ SwiftUI์ VideoPlayer๋ฅผ ๋ชจ๋ ์ง์ํฉ๋๋ค.
๐ฌ 1. ๊ธฐ๋ณธ ๋น๋์ค ํ๋ ์ด์ด (SwiftUI)
SwiftUI์ VideoPlayer๋ฅผ ์ฌ์ฉํ์ฌ ๊ฐ๋จํ๊ฒ ๋น๋์ค๋ฅผ ์ฌ์ํฉ๋๋ค.
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๋ฅผ ์ฌ์ฉํ์ฌ ์ ์ฒด ๊ธฐ๋ฅ์ ๊ฐ์ถ ํ๋ ์ด์ด๋ฅผ ๋ง๋ญ๋๋ค.
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 ๊ธฐ๋ฅ์ ๊ตฌํํ์ฌ ๋น๋์ค๋ฅผ ๋ ์๋ ์ฐฝ์์ ์ฌ์ํฉ๋๋ค.
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๋ฅผ ํตํด ๋ค๋ฅธ ๊ธฐ๊ธฐ๋ก ๋น๋์ค๋ฅผ ์คํธ๋ฆฌ๋ฐํฉ๋๋ค.
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. ์ปค์คํ ์ปจํธ๋กค
์์ ํ ์ปค์คํฐ๋ง์ด์ฆ๋ ์ฌ์ ์ปจํธ๋กค์ ๋ง๋ญ๋๋ค.
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 } } }
๐ฑ ์ข ํฉ ์์
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์ ๊ธฐ๋ณธ ์ปจํธ๋กค ์ฌ์ฉ
- ์ ์ค์ฒ: ํญ์ผ๋ก ์ปจํธ๋กค ํ์/์จ๊น, ์ค์์ดํ๋ก ํ์
- Picture-in-Picture: ๋ฉํฐํ์คํน์ ์ํด PiP ์ง์
- AirPlay: ์ธ๋ถ ๋์คํ๋ ์ด ์ฌ์ ํ์ฉ
- ๋ฐฑ๊ทธ๋ผ์ด๋: Info.plist์ ๋ฐฑ๊ทธ๋ผ์ด๋ ๋ชจ๋ ์ถ๊ฐ
- ์ ๊ทผ์ฑ: ์๋ง ๋ฐ ์ค๋์ค ์ค๋ช ์ง์
๐ฏ ์ค์ ํ์ฉ
- ์คํธ๋ฆฌ๋ฐ ์ฑ: Netflix, YouTube ์คํ์ผ ํ๋ ์ด์ด
- ๊ต์ก ํ๋ซํผ: ๊ฐ์ ๋น๋์ค ์ฌ์
- ์์ ๋ฏธ๋์ด: ์ธํผ๋ ๋น๋์ค ์ฌ์
- ๋ผ์ด๋ธ ๋ฐฉ์ก: ์ค์๊ฐ ์คํธ๋ฆฌ๋ฐ ์์ฒญ
- ๋น๋์ค ํธ์ง: ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ๋ ์ด์ด
๐ ๋ ์์๋ณด๊ธฐ
addPeriodicTimeObserver์ ๊ฐ๊ฒฉ์ ๋๋ฌด ์งง๊ฒ ์ค์ ํ์ง ๋ง์ธ์. 0.5์ด ์ ๋๊ฐ ์ ๋นํฉ๋๋ค.