// // 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() @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) do { let audioSession = AVAudioSession.sharedInstance() try audioSession.setCategory(.playback, mode: .moviePlayback) try audioSession.setActive(true) self.registerRemoteControlEvents() self.fetchAlbumArtAndUpdateNowPlayingInfo() } catch { DEBUG_LOG("Audio Session 설정 실패: \(error.localizedDescription)") } } 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() { if let hasNextContent = self.playlistManager?.hasNextContent(), hasNextContent { self.playNextContent() } else { self.stop() } } func playNextContent() { stop() if let content = playlistManager?.moveToNext() { generateUrl(contentId: content.id) { [unowned self] url in self.urlGenerateSuccess(content: content, url: url) } onFailure: { self.playNextContent() } } } func playPreviousContent() { stop() if let content = playlistManager?.moveToPrevious() { generateUrl(contentId: content.id) { [unowned self] url in self.urlGenerateSuccess(content: content, url: url) } onFailure: { self.playPreviousContent() } } } 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) currentTime = 0 } func resetPlayer() { stop() duration = 0 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.self, from: responseData) if let data = decoded.data, !data.contentUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, decoded.success { onSuccess(data.contentUrl) } else { self.isLoading = false onFailure() } } catch { self.isLoading = false onFailure() } } .store(in: &cancellables) } } private func fetchAlbumArtAndUpdateNowPlayingInfo() { guard let url = URL(string: coverImageUrl) else { print("잘못된 이미지 URL") registerNowPlayingInfoCenter(with: nil) // 앨범 아트 없이 업데이트 return } // URLSession을 사용하여 비동기적으로 이미지 다운로드 URLSession.shared.dataTask(with: url) { [weak self] data, _, error in if let error = error { print("앨범 아트 다운로드 실패: \(error.localizedDescription)") DispatchQueue.main.async { self?.registerNowPlayingInfoCenter(with: nil) // 앨범 아트 없이 업데이트 } return } guard let data = data, let image = UIImage(data: data) else { print("이미지 데이터를 가져오지 못했습니다.") DispatchQueue.main.async { self?.registerNowPlayingInfoCenter(with: nil) // 앨범 아트 없이 업데이트 } return } // 성공적으로 다운로드된 이미지를 사용 DispatchQueue.main.async { self?.registerNowPlayingInfoCenter(with: image) } }.resume() } private func registerNowPlayingInfoCenter(with albumArtImage: UIImage?) { let center = MPNowPlayingInfoCenter.default() var nowPlayingInfo = center.nowPlayingInfo ?? [String: Any]() nowPlayingInfo[MPMediaItemPropertyTitle] = title nowPlayingInfo[MPMediaItemPropertyArtist] = nickname if let image = albumArtImage { let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } 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.isEnabled = true center.playCommand.addTarget { [unowned self] (commandEvent) -> MPRemoteCommandHandlerStatus in self.playOrPause() return .success } center.pauseCommand.isEnabled = true center.pauseCommand.addTarget { [unowned self] (commandEvent) -> MPRemoteCommandHandlerStatus in self.playOrPause() return .success } center.skipForwardCommand.isEnabled = true center.skipForwardCommand.addTarget { [unowned self] (commandEvent) -> MPRemoteCommandHandlerStatus in self.playNextContent() return .success } center.skipBackwardCommand.isEnabled = true 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 { let subject = PassthroughSubject() // 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() } }