YouTubePlayerKit을 활용한 쇼츠 구현하기
안녕하세요. 그린입니다 🍏
이번 포스팅에서는 YouTubePlayerKit이라는 오픈소스를 이용해 유투브의 쇼츠 기능을 구현해보려 합니다 🙋🏻
어디까지나 이번에는 학습 목적보다는 순수 오픈 소스를 활용해서 기능 구현에 초점이 있습니다 🚨
그렇기에, 크게 같이 학습한다는 느낌보다 이런 코드로 쇼츠를 구현할 수 있구나하고 너그럽게 봐주시면 좋을것 같아요 ㅎㅎ
YouTubePlayerKit?
우선 YouTubePlayerKit이라는 라이브러리가 어떤것이고 왜 써보는지 중요하겠죠?
유투브의 영상이나 쇼츠를 iOS 앱에서 띄워야 한다면 어떻게 해보실것 같나요?
자체 AVPlayer를 활용한다? WKWebView를 활용한다?
보통 많이들 영상 구현을 위해서는 AVPlayer 자체 플레이어를 생각하실텐데요.
이 방법으론 한계가 있습니다.
우선, 비디오 데이터 소스가 있어야 하는데, 직접 유투브 URL로 넣어보시면 안될거에요.
유투브는 스트리밍 서비스이기에 해당 비디오는 특별하게 인코딩 되어 있어서 AVPlayer에서 직접 재생할 수 없어요.
만약 그래도 재생하고 싶다면 Youtube Data API를 통해서 비디오 정보를 가져오고 추출해서 사용한다면 될 수 있을지 모르겠지만,
Youtube 이용약관 관련하여 문제의 소지가 있을 수 있습니다.
(즉, 구글의 제재를 받을 수 있다는 점 🥲)
그렇기에, AVPlayer를 쓰는건 불가능!
두번째로, WKWebView를 사용해볼 수 있죠.
쉽게 웹뷰를 띄워서 해당 비디오를 앱 내에서 재생할 수 있어요.
이 방법으로 할 수 도 있지만, 웹뷰는 단순히 유투브 페이지를 표시하는거여서 비디오 플레이어에 대한 세밀한 제어가 어려울 수 있어요.
즉, 재생/일시정지/탐색 등 기능을 직접 구현하기 어려울 수 있으며 네이티브와 연동성에서 다소 사용성이 떨어집니다.
성능적으로도 마찬가지일거구요 🥲
또한 가장 큰 문제는 광고 제어가 안되지 않을까 싶어요!
그래서 WKWebView도 뭐 사용은 할 수 있지만? 굳이 쇼츠를 구현하는데 적절하지 않을까란 생각입니다.
특히, 광고때문에 아주 적절하지 않겠죠.
그래서 어떻게 해야 하냐?
우리는, 결국 YouTube에서 제공하는 IFrame Player API를 사용할수밖에 없습니다.
iFrame API를 통해서 유투브의 플레이어를 사용해서 구현할 수 밖에 없죠.
해당 공식문서를 살펴보면 사용을 어떻게 하는지 확인해볼 수 있어요.
모바일에선 안드로이드와 iOS 모두 지원하고 친절히 문서까지 있는데요, 문서가 잘 관리되고 계속 발전하고 있다곤 하기 어려워요.
youtube-ios-player-helper는 iOS 앱에 Youtube iframe 플레이어를 추가하는데 도움을 주는 오픈소스 라이브러리인데요.
결국 해당 라이브러리도 WebView 및 앱의 Objective-C 그리고 YouTube 플레이어의 자바스크립트 코드 간의 브릿지를 만들어서 iOS 앱이 YouTube 플레이어를 제어할 수 있도록 해줍니다.
개요만 들어도 어렵지 않나요?
즉, 결국 내장되어서 WKWebView로 구현되어 있긴하지만, 이걸 그대로 날것으로 가져다 쓰는 러닝커브가 상당할것 같습니다.
문서도 그닥 친절하지 않구요.
또한, 코코아팟만 지원되며 예시 코드들도 거의 Objective-C입니다.
저는 특히, SwiftUI로 써보고 싶어서 이걸 잘 말아놓은 라이브러리를 찾게 되었습니다 🙋🏻
그게 바로, YouTubePlayerKit이라는 오픈 소스였어요!
물론, 완벽히 제어되지 않고 허점도 있지만 가볍게 올려서 사용해보기 좋아서 채택해봤습니다.
이것말고도 다양하진 않지만, 몇가지 래핑된 오픈 소스들이 있으니 입맛에 맞는걸 채택해보셔도 좋을거라 생각됩니다.
YouTubePlayerKit는 SPM도 지원되고 특히 Swift 현재 최신 버전까지도 잘 대응하고 있고 무엇보다 SwiftUI에서 쉽게 사용할 수 있도록 래핑되어 있어요.
사용법도 간단하여, 아래처럼 단순하게 구현해줄 수 있습니다.
import SwiftUI
import YouTubePlayerKit
struct ContentView: View {
@StateObject
var youTubePlayer: YouTubePlayer = "https://youtube.com/watch?v=psL_5RIBqnY"
var body: some View {
YouTubePlayerView(self.youTubePlayer) { state in
// Overlay ViewBuilder closure to place an overlay View
// for the current `YouTubePlayer.State`
switch state {
case .idle:
ProgressView()
case .ready:
EmptyView()
case .error(let error):
Text(verbatim: "YouTube player couldn't be loaded")
}
}
}
}
물론, UIKit과 AppKit에서도 사용할 수 있어요!
import UIKit
import YouTubePlayerKit
// Initialize a YouTubePlayerViewController
let youTubePlayerViewController = YouTubePlayerViewController(
player: "https://youtube.com/watch?v=psL_5RIBqnY"
)
// Example: Access the underlying iFrame API via the `YouTubePlayer` instance
youTubePlayerViewController.player.showStatsForNerds()
// Present YouTubePlayerViewController
self.present(youTubePlayerViewController, animated: true)
youTubePlayer.source = .video(id: "0TD96VTf0Xs")
youTubePlayer.configuration = .init(
autoPlay: true
)
이런식으로 YouTubePlayerViewController를 만들어서 비디오 소스를 주고 설정도 심어주며 커스텀하게 사용할 수 있죠.
특히, YouTubePlayer는 ObservableObject를 채택하고 있어서 언제든 설정이나 소스들의 업데이트가 가능합니다.
youTubePlayer
.objectWillChange
.sink { }
소스로는 비디오나 플레이리스트 그리고 채널등의 인자값만 넣어주면 됩니다.
// YouTubePlayer Video Source
let videoSource: YouTubePlayer.Source = .video(id: "psL_5RIBqnY")
// YouTubePlayer Playlist Source
let playlistSource: YouTubePlayer.Source = .playlist(id: "PLHFlHpPjgk72Si7r1kLGt1_aD3aJDu092")
// YouTubePlayer Channel Source
let channelSource: YouTubePlayer.Source = .channel(name: "iJustine")
그리고 비디오 설정으론 정말 다양한 인자들이 있는데요.
해당 유투브 공식문서에 나온 다양한 파라미터를 활용할 수 있습니다.
let configuration = YouTubePlayer.Configuration(
// Define which fullscreen mode should be used (system or web)
fullscreenMode: .system,
// Custom action to perform when a URL gets opened
openURLAction: { url in
// ...
},
// Enable auto play
autoPlay: true,
// Hide controls
showControls: false,
// Enable loop
loopEnabled: true
)
let youTubePlayer = YouTubePlayer(
source: .url("https://youtube.com/watch?v=psL_5RIBqnY"),
configuration: configuration
)
또한, YouTubePlayer는 iFrame API에 접근하여 현재 재생중인 비디오의 재생 퀄리티나 제목 등 정보들을 재생/일시정지 등의 기능을 구현해줄 수 있습니다.
// Async/Await: Retrieve the current PlaybackMetadata
let playbackMetadata = try await youTubePlayer.getPlaybackMetadata()
// Completion-Closure: Retrieve the current PlaybackMetadata
youTubePlayer.getPlaybackMetadata { result in
switch result {
case .success(let playbackMetadata):
print(playbackMetadata)
case .failure(let error):
print(error)
}
}
Concurrency하게 다뤄줄 수 있죠.
비디오의 재생과 일시정지 탐색 등도 쉽게 구현해낼 수 있습니다.
// Play video
youTubePlayer.play()
// Pause video
youTubePlayer.pause()
// Stop video
youTubePlayer.stop()
// Seek to 60 seconds
youTubePlayer.seek(to: 60, allowSeekAhead: false)
// Closes any current picture-in-picture video and fullscreen video
await youTubePlayer.closeAllMediaPresentations()
그 외에도 이벤트나 재생 상태에 대해 다뤄줄 수 있으며 영상에 관련된 다양한 기능들을 해줄 수 있죠.
너무 많아서 공식문서를 참고해주시면 좋을것 같아요!
자... 이제 YouTubePlayerKit을 왜 써야하고 어떤 녀석인지 살펴봤으니 코드로 직접 구현하여 쇼츠 기능을 만들어 보시죠!
쇼츠 기능 구현하기
우선 저는 적절히 SwiftUI와 UIKit을 섞었습니다.
스크롤뷰의 단점을 보완하고자 페이징 스크롤 뷰라는 UIKit으로 구현된 커스텀한 스크롤 뷰를 만들었어요.
PagingScrollView
import SwiftUI
public struct PagingScrollView<Content: View>: UIViewRepresentable {
private var count: Int
private var content: () -> Content
public init(
count: Int,
@ViewBuilder content: @escaping () -> Content
) {
self.count = count
self.content = content
}
public func makeCoordinator() -> Coordinator {
Coordinator(self)
}
public func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.isPagingEnabled = true
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
scrollView.contentInsetAdjustmentBehavior = .never
scrollView.bounces = false
scrollView.delegate = context.coordinator
let hostingController = context.coordinator.hostingController
hostingController.rootView = content()
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
hostingController.view.backgroundColor = .clear
scrollView.addSubview(hostingController.view)
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
hostingController.view.topAnchor.constraint(equalTo: scrollView.topAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
hostingController.view.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
hostingController.view.heightAnchor.constraint(equalToConstant: UIScreen.main.bounds.height * CGFloat(count))
])
return scrollView
}
public func updateUIView(_ uiView: UIScrollView, context: Context) {
if let hostingView = uiView.subviews.first {
hostingView.setNeedsLayout()
hostingView.layoutIfNeeded()
}
}
public class Coordinator: NSObject, UIScrollViewDelegate {
var parent: PagingScrollView
var hostingController = UIHostingController<Content?>(rootView: nil)
init(_ parent: PagingScrollView) {
self.parent = parent
}
}
}
해당 스크롤 뷰에 이제 컨텐츠를 넣어줘야겠죠?
우선, YouTubePlayer를 활용해 쇼츠 뷰를 만들고 볼께요.
ShortViewModel & ShortView
import SwiftUI
import YouTubePlayerKit
class ShortsViewModel: ObservableObject {
@Published var contentURL: String
@Published var youtubePlayer: YouTubePlayer
private static let cache = YouTubePlayerCache()
init(contentURL: String) {
self.contentURL = contentURL
self.youtubePlayer = ShortsViewModel.cache.getPlayer(
for: contentURL,
configuration: ShortsViewModel.playerConfiguration
)
}
static func preloadPlayer(for url: String) {
cache.preloadPlayer(
for: url,
configuration: ShortsViewModel.playerConfiguration
)
}
private static var playerConfiguration: YouTubePlayer.Configuration {
return .init(
autoPlay: true,
showControls: false,
loopEnabled: true,
showRelatedVideos: false
)
}
}
class YouTubePlayerCache {
private var cache: [String: YouTubePlayer] = [:]
private let maxCacheSize = 5
func getPlayer(for url: String, configuration: YouTubePlayer.Configuration) -> YouTubePlayer {
if let cachedPlayer = cache[url] {
return cachedPlayer
}
let newPlayer = YouTubePlayer(source: .url(url), configuration: configuration)
cache[url] = newPlayer
if cache.count > maxCacheSize {
cache.removeValue(forKey: cache.keys.first!)
}
return newPlayer
}
func preloadPlayer(for url: String, configuration: YouTubePlayer.Configuration) {
_ = getPlayer(for: url, configuration: configuration)
}
}
public struct ShortsView: View {
@StateObject var viewModel: ShortsViewModel
@Binding var isPlaying: Bool
@Environment(\.scenePhase) private var scenePhase
public var body: some View {
GeometryReader { geometry in
let frame = geometry.frame(in: .global)
let screenHeight = UIScreen.main.bounds.height
let midY = frame.midY
YouTubePlayerView(viewModel.youtubePlayer)
.onChange(of: midY) { newValue in
let wasPlaying = isPlaying
isPlaying = (midY > 0 && midY < screenHeight)
if isPlaying {
if !wasPlaying {
viewModel.youtubePlayer.seek(to: Measurement(value: 0, unit: UnitDuration.seconds))
viewModel.youtubePlayer.play()
} else {
viewModel.youtubePlayer.play()
}
} else {
viewModel.youtubePlayer.pause()
}
}
.onChange(of: scenePhase) { newPhase in
switch newPhase {
case .active:
if isPlaying {
viewModel.youtubePlayer.play()
}
default:
break
}
}
.onDisappear {
viewModel.youtubePlayer.pause()
}
}
}
}
코드가 길지만 쪼개보면 쉽습니다.
우선, ShortsViewModel을 통해 YouTubePlayer를 관리하고 설정도 해줍니다.
저는 자동 및 반복 재생이 되고 영상 컨트롤러를 숨겨주고 연관 비디오를 나타나지 않도록 설정해줬어요.
그리고 성능 향상을 위해 YouTubePlayerCache라는걸 통해 캐싱을 해줍니다.
즉, 프리로드를 해주는 것이죠.
그리고 이제 뷰에서 해당 YouTubePlayerView를 사용하여 적절히 플레이와 정지를 구현해줬습니다.
마지막으로, 이 여러 쇼츠 뷰들을 컨텐츠에 담아 스크롤 되도록 구현해볼께요.
ContentView
struct ContentView: View {
let videoURLs: [String] = [
"https://www.youtube.com/shorts/q3ZOdrWTbl8",
"https://www.youtube.com/shorts/sj_BoRg7pS8",
"https://www.youtube.com/shorts/Rp5GI1wdHMs"
]
@State private var currentVisibleIndex: Int = 0
var body: some View {
PagingScrollView(count: videoURLs.count) {
LazyVStack(spacing: 0) {
ForEach(videoURLs.indices, id: \.self) { index in
GeometryReader { geometry in
if isViewVisible(geometry: geometry) {
ShortsView(
viewModel: ShortsViewModel(contentURL: videoURLs[index]),
isPlaying: Binding<Bool>(
get: { self.currentVisibleIndex == index },
set: { newValue in
if newValue {
self.currentVisibleIndex = index
preloadAdjacentVideos(currentIndex: index)
}
}
)
)
.allowsHitTesting(false)
.frame(height: UIScreen.main.bounds.height)
} else {
Color.clear.frame(height: UIScreen.main.bounds.height)
}
}
.frame(height: UIScreen.main.bounds.height)
}
}
.ignoresSafeArea()
}
.ignoresSafeArea()
.onAppear {
preloadAdjacentVideos(currentIndex: 0)
}
}
private func isViewVisible(geometry: GeometryProxy) -> Bool {
let frame = geometry.frame(in: .global)
return frame.intersects(UIScreen.main.bounds)
}
private func preloadAdjacentVideos(currentIndex: Int) {
let prevIndex = max(0, currentIndex - 1)
let nextIndex = min(videoURLs.count - 1, currentIndex + 1)
if prevIndex != currentIndex {
ShortsViewModel.preloadPlayer(for: videoURLs[prevIndex])
}
if nextIndex != currentIndex {
ShortsViewModel.preloadPlayer(for: videoURLs[nextIndex])
}
}
}
이렇게 커스텀하게 만든 스크롤 뷰에 해당 쇼츠 뷰들을 여러개 담아줍니다.
그리고, 적절히 현재 인덱스를 계산하고 재생과 스크롤 그리고 프리로드가 될 수 있도록 구현했습니다.
여기서 중요한 부분은, 해당 쇼츠 뷰에 allowsHitTesting을 꺼뒀는데요.
꺼주지 않으면 스크롤이 안됩니다.
왜냐면, 해당 YouTubePlayerView도 결국 WKWebView라, 드래그 및 탭 동작을 가지고 있는데, 이걸 우선순위를 위로 가져와서 하는 방법이 해당 오픈 소스를 사용하는데 한계가 있더라구요.
그래서, 현재 쇼츠 기능에서는 드래그로 다음 영상들을 넘어갈 수 있지만, 정지 등의 자체 기능은 사용할 수 없어요.
둘 중 택해야하죠.
물론, 정지 및 탐색 컨트롤러등을 오버레이로 올려서 커스텀하게 만들면 그만이긴 합니다 😀
그럼 한번 돌려볼까요?
어떤가요?
나름 유투브 쇼츠나 릴스랑 비슷하지 않나요?ㅎㅎ
"완벽한 오픈 소스다" 라고 말하긴 허점이 좀 있지만, 쉽게 써볼 수 있는 오픈 소스라고 생각됩니다.
좀 더 개선하고 싶은 부분들이 있는데, 해결되면 또 다른 포스팅으로 찾아오겠습니다 🫡
레퍼런스