재생목록 플레이어 추가
This commit is contained in:
326
SodaLive/Sources/Content/Player/ContentPlayerPlayManager.swift
Normal file
326
SodaLive/Sources/Content/Player/ContentPlayerPlayManager.swift
Normal file
@@ -0,0 +1,326 @@
|
||||
//
|
||||
// ContentPlayerPlayManager.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 12/16/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVKit
|
||||
import MediaPlayer
|
||||
import Combine
|
||||
|
||||
final class ContentPlayerPlayManager: NSObject, ObservableObject {
|
||||
static let shared = ContentPlayerPlayManager()
|
||||
|
||||
private let repository = ContentGenerateUrlRepository()
|
||||
|
||||
@Published var title = ""
|
||||
@Published var nickname = ""
|
||||
@Published var coverImageUrl = ""
|
||||
@Published var creatorProfileUrl = ""
|
||||
|
||||
@Published private (set) var isShowingMiniPlayer = false
|
||||
@Published private (set) var isPlaying = false
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage = ""
|
||||
@Published var isShowPopup = false
|
||||
@Published var isShowPlaylist = false
|
||||
@Published var playlist: [AudioContentPlaylistContent] = []
|
||||
@Published var currentTime: Double = 0.0
|
||||
@Published private (set) var duration: Double = 0.0
|
||||
@Published var isEditing = false
|
||||
|
||||
var player: AVPlayer?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@Published var bufferedTime: Double = 0 // 현재 버퍼링된 시간
|
||||
@Published var isPlaybackLikelyToKeepUp: Bool = false // 재생 가능 상태
|
||||
|
||||
let minimumBufferedTime: Double = 5.0
|
||||
|
||||
var playlistManager: AudioContentPlaylistManager? = nil
|
||||
|
||||
override init() {
|
||||
self.player = AVPlayer()
|
||||
super.init()
|
||||
}
|
||||
|
||||
func setPlaylist(playlist: [AudioContentPlaylistContent]) {
|
||||
resetPlayer()
|
||||
self.playlist = playlist
|
||||
playlistManager = AudioContentPlaylistManager(playlist: playlist)
|
||||
playNextContent()
|
||||
isShowingMiniPlayer = true
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
}
|
||||
|
||||
private func setupPlayer(with url: URL) {
|
||||
// 기존 playerItem 관련 Combine 구독 해제
|
||||
cancellables.removeAll()
|
||||
|
||||
// 새로운 AVPlayerItem 생성
|
||||
let playerItem = AVPlayerItem(url: url)
|
||||
self.player?.replaceCurrentItem(with: playerItem)
|
||||
self.player?
|
||||
.periodicTimePublisher(interval: CMTime(seconds: 0.5, preferredTimescale: 600))
|
||||
.sink { [weak self] currentTime in
|
||||
if !(self?.isEditing ?? false) {
|
||||
self?.currentTime = CMTimeGetSeconds(currentTime)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
playerItem.publisher(for: \.duration)
|
||||
.map { CMTimeGetSeconds($0) }
|
||||
.filter { !$0.isNaN } // NaN 방지
|
||||
.sink { [weak self] duration in
|
||||
self?.duration = duration
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
playerItem.publisher(for: \.loadedTimeRanges)
|
||||
.compactMap { $0.first?.timeRangeValue }
|
||||
.map { CMTimeGetSeconds($0.start) + CMTimeGetSeconds($0.duration) }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$bufferedTime)
|
||||
|
||||
playerItem.publisher(for: \.isPlaybackLikelyToKeepUp)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$isPlaybackLikelyToKeepUp)
|
||||
|
||||
// CombineLatest로 버퍼링 및 재생 조건 확인
|
||||
Publishers.CombineLatest($bufferedTime, $isPlaybackLikelyToKeepUp)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] bufferedTime, isLikelyToKeepUp in
|
||||
self?.checkPlaybackStart(bufferedTime: bufferedTime, isLikelyToKeepUp: isLikelyToKeepUp)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Combine으로 재생 완료 이벤트 감지
|
||||
NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: playerItem)
|
||||
.sink { [weak self] _ in
|
||||
DispatchQueue.main.async {
|
||||
self?.handlePlaybackEnded()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func checkPlaybackStart(bufferedTime: Double, isLikelyToKeepUp: Bool) {
|
||||
if bufferedTime >= minimumBufferedTime && isLikelyToKeepUp {
|
||||
if !isPlaying {
|
||||
player?.play()
|
||||
isPlaying = true
|
||||
isLoading = false
|
||||
DEBUG_LOG("재생 시작: \(bufferedTime)초 버퍼링")
|
||||
}
|
||||
} else {
|
||||
if isPlaying {
|
||||
player?.pause()
|
||||
isPlaying = false
|
||||
isLoading = true
|
||||
DEBUG_LOG("재생 중단: 버퍼링 부족 (\(bufferedTime)초)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePlaybackEnded() {
|
||||
playNextContent()
|
||||
}
|
||||
|
||||
func playNextContent() {
|
||||
stop()
|
||||
|
||||
if let content = playlistManager?.moveToNext() {
|
||||
generateUrl(contentId: content.id) { [unowned self] url in
|
||||
self.urlGenerateSuccess(content: content, url: url)
|
||||
} onFailure: {
|
||||
if let hasNextContent = self.playlistManager?.hasNextContent(), hasNextContent {
|
||||
self.playNextContent()
|
||||
} else {
|
||||
self.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func playPreviousContent() {
|
||||
stop()
|
||||
|
||||
if let content = playlistManager?.moveToPrevious() {
|
||||
generateUrl(contentId: content.id) { [unowned self] url in
|
||||
self.urlGenerateSuccess(content: content, url: url)
|
||||
} onFailure: {
|
||||
if let hasNextContent = self.playlistManager?.hasNextContent(), hasNextContent {
|
||||
self.playNextContent()
|
||||
} else {
|
||||
self.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func urlGenerateSuccess(content: AudioContentPlaylistContent, url: String) {
|
||||
title = content.title
|
||||
nickname = content.creatorNickname
|
||||
coverImageUrl = content.coverUrl
|
||||
creatorProfileUrl = content.creatorProfileUrl
|
||||
setupPlayer(with: URL(string: url)!)
|
||||
}
|
||||
|
||||
func playOrPause() {
|
||||
if player?.timeControlStatus == .paused {
|
||||
player?.play()
|
||||
isPlaying = true
|
||||
} else {
|
||||
player?.pause()
|
||||
isPlaying = false
|
||||
}
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
isPlaying = false
|
||||
isLoading = false
|
||||
player?.pause()
|
||||
player?.seek(to: .zero)
|
||||
duration = 0
|
||||
currentTime = 0
|
||||
}
|
||||
|
||||
func resetPlayer() {
|
||||
stop()
|
||||
isShowingMiniPlayer = false
|
||||
|
||||
title = ""
|
||||
nickname = ""
|
||||
coverImageUrl = ""
|
||||
creatorProfileUrl = ""
|
||||
|
||||
cancellables.removeAll()
|
||||
playlistManager = nil
|
||||
unRegisterRemoteControlEvents()
|
||||
}
|
||||
|
||||
func seek(to time: Double) {
|
||||
let cmTime = CMTime(seconds: time, preferredTimescale: 600)
|
||||
player?.seek(to: cmTime)
|
||||
}
|
||||
|
||||
private func generateUrl(contentId: Int, onSuccess: @escaping (String) -> Void, onFailure: @escaping () -> Void) {
|
||||
if contentId < 0 {
|
||||
onFailure()
|
||||
}
|
||||
|
||||
if !isLoading {
|
||||
isLoading = true
|
||||
|
||||
repository.generateUrl(contentId: contentId)
|
||||
.sink { result in
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
}
|
||||
} receiveValue: { [unowned self] response in
|
||||
self.isLoading = false
|
||||
let responseData = response.data
|
||||
|
||||
do {
|
||||
let jsonDecoder = JSONDecoder()
|
||||
let decoded = try jsonDecoder.decode(ApiResponse<GenerateUrlResponse>.self, from: responseData)
|
||||
|
||||
if let data = decoded.data, decoded.success {
|
||||
onSuccess(data.contentUrl)
|
||||
} else {
|
||||
self.isLoading = false
|
||||
onFailure()
|
||||
}
|
||||
} catch {
|
||||
self.isLoading = false
|
||||
onFailure()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
private func registerNowPlayingInfoCenter() {
|
||||
let center = MPNowPlayingInfoCenter.default()
|
||||
var nowPlayingInfo = center.nowPlayingInfo ?? [String: Any]()
|
||||
|
||||
nowPlayingInfo[MPMediaItemPropertyTitle] = title
|
||||
nowPlayingInfo[MPMediaItemPropertyArtist] = nickname
|
||||
if let artworkURL = URL(string: coverImageUrl), let imageData = try? Data(contentsOf: artworkURL), let artworkImage = UIImage(data: imageData) {
|
||||
let artwork = MPMediaItemArtwork(boundsSize: artworkImage.size) { size in
|
||||
return artworkImage
|
||||
}
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
|
||||
}
|
||||
|
||||
if let player = player {
|
||||
// 콘텐츠 총 길이
|
||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = player.currentItem?.duration ?? .zero
|
||||
// 콘텐츠 재생 시간에 따른 progressBar 초기화
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate
|
||||
// 콘텐츠 현재 재생시간
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime
|
||||
}
|
||||
|
||||
center.nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
|
||||
private func registerRemoteControlEvents() {
|
||||
let center = MPRemoteCommandCenter.shared()
|
||||
|
||||
center.playCommand.addTarget { [unowned self] (commandEvent) -> MPRemoteCommandHandlerStatus in
|
||||
self.playOrPause()
|
||||
return .success
|
||||
}
|
||||
|
||||
center.pauseCommand.addTarget { [unowned self] (commandEvent) -> MPRemoteCommandHandlerStatus in
|
||||
self.playOrPause()
|
||||
return .success
|
||||
}
|
||||
|
||||
center.skipForwardCommand.addTarget { [unowned self] (commandEvent) -> MPRemoteCommandHandlerStatus in
|
||||
self.playNextContent()
|
||||
return .success
|
||||
}
|
||||
|
||||
center.skipBackwardCommand.addTarget { [unowned self] (commandEvent) -> MPRemoteCommandHandlerStatus in
|
||||
self.playPreviousContent()
|
||||
return .success
|
||||
}
|
||||
}
|
||||
|
||||
private func unRegisterRemoteControlEvents() {
|
||||
let center = MPRemoteCommandCenter.shared()
|
||||
center.playCommand.removeTarget(nil)
|
||||
center.pauseCommand.removeTarget(nil)
|
||||
center.skipForwardCommand.removeTarget(nil)
|
||||
center.skipBackwardCommand.removeTarget(nil)
|
||||
UIApplication.shared.endReceivingRemoteControlEvents()
|
||||
}
|
||||
}
|
||||
|
||||
extension AVPlayer {
|
||||
/// AVPlayer의 addPeriodicTimeObserver를 Combine Publisher로 변환
|
||||
func periodicTimePublisher(interval: CMTime, queue: DispatchQueue = .main) -> AnyPublisher<CMTime, Never> {
|
||||
let subject = PassthroughSubject<CMTime, Never>()
|
||||
|
||||
// Periodic Time Observer 추가
|
||||
let timeObserverToken = self.addPeriodicTimeObserver(forInterval: interval, queue: queue) { time in
|
||||
subject.send(time) // 현재 시간을 스트림으로 보냄
|
||||
}
|
||||
|
||||
// Subscription이 끝나면 Observer 제거
|
||||
return subject
|
||||
.handleEvents(receiveCancel: { [weak self] in
|
||||
self?.removeTimeObserver(timeObserverToken)
|
||||
})
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user