470 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			470 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
//
 | 
						|
//  ContentPlayerPlayManager.swift
 | 
						|
//  SodaLive
 | 
						|
//
 | 
						|
//  Created by klaus on 12/16/24.
 | 
						|
//
 | 
						|
 | 
						|
import Foundation
 | 
						|
import AVKit
 | 
						|
import MediaPlayer
 | 
						|
import Combine
 | 
						|
 | 
						|
import Kingfisher
 | 
						|
import SwiftUI
 | 
						|
 | 
						|
final class ContentPlayerPlayManager: NSObject, ObservableObject {
 | 
						|
    enum LoopState {
 | 
						|
        case idle
 | 
						|
        case waitingForEnd(start: CMTime)
 | 
						|
        case looping(start: CMTime, end: CMTime)
 | 
						|
    }
 | 
						|
    
 | 
						|
    static let shared = ContentPlayerPlayManager()
 | 
						|
    
 | 
						|
    private let repository = ContentGenerateUrlRepository()
 | 
						|
    
 | 
						|
    @StateObject var recentContentViewModel = RecentContentViewModel()
 | 
						|
    
 | 
						|
    @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
 | 
						|
    
 | 
						|
    @Published private(set) var loopState: LoopState = .idle
 | 
						|
    @Published private var timeObserver: Any?
 | 
						|
    
 | 
						|
    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) {
 | 
						|
        stopLooping()
 | 
						|
        loopState = .idle
 | 
						|
        // 기존 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()
 | 
						|
        
 | 
						|
        recentContentViewModel.insertRecentContent(
 | 
						|
            contentId: Int64(id),
 | 
						|
            coverImageUrl: coverImageUrl,
 | 
						|
            title: title,
 | 
						|
            creatorNickname: nickname
 | 
						|
        )
 | 
						|
    }
 | 
						|
    
 | 
						|
    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()
 | 
						|
        stopLooping()
 | 
						|
        loopState = .idle
 | 
						|
        
 | 
						|
        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()
 | 
						|
        stopLooping()
 | 
						|
        loopState = .idle
 | 
						|
        
 | 
						|
        if let content = playlistManager?.moveToPrevious() {
 | 
						|
            generateUrl(contentId: content.id) { [unowned self] url in
 | 
						|
                self.urlGenerateSuccess(content: content, url: url)
 | 
						|
            } onFailure: {
 | 
						|
                self.playPreviousContent()
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
    
 | 
						|
    func playSelectedContent(contentId: Int) {
 | 
						|
        if let content = playlistManager?.findByContentId(contentId: contentId) {
 | 
						|
            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()
 | 
						|
        stopLooping()
 | 
						|
        loopState = .idle
 | 
						|
        duration = 0
 | 
						|
        isShowingMiniPlayer = false
 | 
						|
        
 | 
						|
        title = ""
 | 
						|
        nickname = ""
 | 
						|
        coverImageUrl = ""
 | 
						|
        creatorProfileUrl = ""
 | 
						|
        
 | 
						|
        cancellables.removeAll()
 | 
						|
        playlistManager = nil
 | 
						|
        unRegisterRemoteControlEvents()
 | 
						|
    }
 | 
						|
    
 | 
						|
    func seek(to time: Double) {
 | 
						|
        stopLooping()
 | 
						|
        loopState = .idle
 | 
						|
        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))
 | 
						|
    }
 | 
						|
    
 | 
						|
    func toggleLoop() {
 | 
						|
        guard let currentTime = player?.currentTime() else { return }
 | 
						|
        
 | 
						|
        switch loopState {
 | 
						|
        case .idle:
 | 
						|
            loopState = .waitingForEnd(start: currentTime)
 | 
						|
            
 | 
						|
        case .waitingForEnd(let start):
 | 
						|
            let end = currentTime
 | 
						|
            loopState = .looping(start: start, end: end)
 | 
						|
            startLooping(from: start, to: end)
 | 
						|
            
 | 
						|
        case .looping:
 | 
						|
            stopLooping()
 | 
						|
            loopState = .idle
 | 
						|
        }
 | 
						|
    }
 | 
						|
    
 | 
						|
    private func startLooping(from start: CMTime, to end: CMTime) {
 | 
						|
        let interval = CMTime(seconds: 0.1, preferredTimescale: 600)
 | 
						|
        
 | 
						|
        timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] currentTime in
 | 
						|
            if currentTime >= end {
 | 
						|
                self?.player?.seek(to: start, toleranceBefore: .zero, toleranceAfter: .zero)
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
    
 | 
						|
    private func stopLooping() {
 | 
						|
        if let observer = timeObserver {
 | 
						|
            player?.removeTimeObserver(observer)
 | 
						|
            timeObserver = nil
 | 
						|
        }
 | 
						|
    }
 | 
						|
    
 | 
						|
    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()
 | 
						|
    }
 | 
						|
}
 |