sodalive-ios/SodaLive/Sources/Content/Player/ContentPlayerPlayManager.swift

406 lines
14 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 id = 0
@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()
}
}
}
func playSelectedContent(content: AudioContentPlaylistContent) {
if content.id != id {
generateUrl(contentId: content.id) { [unowned self] url in
self.urlGenerateSuccess(content: content, url: url)
} onFailure: {
self.playPreviousContent()
}
}
}
private func urlGenerateSuccess(content: AudioContentPlaylistContent, url: String) {
id = content.id
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)
}
func seekBackward(seconds: Double) {
guard let currentTime = player?.currentTime() else { return }
let newTimeInSeconds = CMTimeGetSeconds(currentTime) - seconds
seek(to: max(newTimeInSeconds, 0))
}
func seekForward(seconds: Double) {
guard let currentTime = player?.currentTime(), let duration = player?.currentItem?.duration else { return }
let newTimeInSeconds = CMTimeGetSeconds(currentTime) + seconds
let durationInSeconds = CMTimeGetSeconds(duration)
seek(to: min(newTimeInSeconds, durationInSeconds))
}
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()
}
}