381 lines
13 KiB
Swift
381 lines
13 KiB
Swift
//
|
|
// ContentPlayerPlayManager.swift
|
|
// SodaLive
|
|
//
|
|
// Created by klaus on 12/16/24.
|
|
//
|
|
|
|
import Foundation
|
|
import AVKit
|
|
import MediaPlayer
|
|
import Combine
|
|
|
|
import Kingfisher
|
|
|
|
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()
|
|
|
|
do {
|
|
let audioSession = AVAudioSession.sharedInstance()
|
|
try audioSession.setCategory(.playback, mode: .moviePlayback)
|
|
try audioSession.setActive(true)
|
|
} catch {
|
|
DEBUG_LOG("Audio Session 설정 실패: \(error.localizedDescription)")
|
|
}
|
|
|
|
super.init()
|
|
}
|
|
|
|
func setPlaylist(playlist: [AudioContentPlaylistContent]) {
|
|
resetPlayer()
|
|
self.registerRemoteControlEvents()
|
|
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)
|
|
|
|
self.fetchAlbumArtAndUpdateNowPlayingInfo()
|
|
}
|
|
|
|
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)초)")
|
|
}
|
|
}
|
|
updateNowPlayingInfo()
|
|
}
|
|
|
|
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 && !isPlaying {
|
|
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<GenerateUrlResponse>.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
|
|
}
|
|
|
|
let processor = DownsamplingImageProcessor(size: CGSize(width: 240, height: 240))
|
|
|
|
KingfisherManager.shared.retrieveImage(
|
|
with: url,
|
|
options: [
|
|
.processor(processor),
|
|
.scaleFactor(UIScreen.main.scale),
|
|
.cacheOriginalImage
|
|
]
|
|
) { [weak self] result in
|
|
switch result {
|
|
case .success(let value):
|
|
self?.registerNowPlayingInfoCenter(with: value.image)
|
|
case .failure(_):
|
|
self?.registerNowPlayingInfoCenter(with: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateNowPlayingInfo() {
|
|
guard var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo else { return }
|
|
|
|
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player?.currentTime().seconds
|
|
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
|
|
|
|
let duration = CMTimeGetSeconds(player?.currentItem?.duration ?? CMTime.zero)
|
|
if duration.isFinite {
|
|
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
|
|
}
|
|
|
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
|
}
|
|
|
|
private func registerNowPlayingInfoCenter(with albumArtImage: UIImage?) {
|
|
guard let currentItem = player?.currentItem else { return }
|
|
|
|
var 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[MPNowPlayingInfoPropertyPlaybackRate] = player.rate
|
|
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime().seconds
|
|
|
|
// CMTimeGetSeconds를 사용해 duration을 가져옴
|
|
let duration = CMTimeGetSeconds(currentItem.duration)
|
|
if duration.isFinite {
|
|
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
|
|
}
|
|
}
|
|
|
|
MPNowPlayingInfoCenter.default().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.nextTrackCommand.isEnabled = true
|
|
center.nextTrackCommand.addTarget { [unowned self] (commandEvent) -> MPRemoteCommandHandlerStatus in
|
|
self.playNextContent()
|
|
return .success
|
|
}
|
|
|
|
center.previousTrackCommand.isEnabled = true
|
|
center.previousTrackCommand.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.nextTrackCommand.removeTarget(nil)
|
|
center.previousTrackCommand.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()
|
|
}
|
|
}
|