import AVFoundation import Foundation import Observation /// AVPlayer-based streaming player for cloud tracks. /// Separate from AudioEngine (which uses AVAudioEngine for local files with EQ/BPM). @MainActor @Observable final class StreamingPlayer { // MARK: - State var isPlaying = false var currentTime: TimeInterval = 0 var duration: TimeInterval = 0 var isBuffering = false var currentCloudTrack: ChadTrack? // MARK: - Callbacks @ObservationIgnored var onPlaybackFinished: (() -> Void)? @ObservationIgnored var onPlaybackError: ((String) -> Void)? // MARK: - Private @ObservationIgnored private var player: AVPlayer? @ObservationIgnored private var timeObserver: Any? @ObservationIgnored private var statusObservation: NSKeyValueObservation? @ObservationIgnored private var bufferObservation: NSKeyValueObservation? @ObservationIgnored private var didEndObserver: NSObjectProtocol? // MARK: - Audio Session (iOS) // Configure audio session for playback. We set .playback category but do NOT // deactivate on stop — the session stays active when switching between // StreamingPlayer and AudioEngine to avoid session thrashing. private func configureAudioSession() { do { let session = AVAudioSession.sharedInstance() if session.category != .playback { try session.setCategory(.playback, mode: .default, options: []) } if !session.isOtherAudioPlaying { try session.setActive(true) } } catch { print("StreamingPlayer: Failed to configure audio session: \(error)") } } // MARK: - Load & Play func loadAndPlay(track: ChadTrack, streamURL: URL, authHeaders: [String: String]) { cleanup() configureAudioSession() let asset = AVURLAsset(url: streamURL, options: [ "AVURLAssetHTTPHeaderFieldsKey": authHeaders ]) let playerItem = AVPlayerItem(asset: asset) let avPlayer = AVPlayer(playerItem: playerItem) self.player = avPlayer self.currentCloudTrack = track self.isBuffering = true statusObservation = playerItem.observe(\.status, options: [.new]) { [weak self] item, _ in Task { @MainActor in guard let self else { return } switch item.status { case .readyToPlay: self.isBuffering = false let cmDuration = item.duration if cmDuration.isNumeric { self.duration = CMTimeGetSeconds(cmDuration) } else if let trackDuration = track.duration, trackDuration > 0 { self.duration = trackDuration } else { self.duration = 1.0 } avPlayer.play() self.isPlaying = true case .failed: self.isBuffering = false let msg = item.error?.localizedDescription ?? "unknown error" print("StreamingPlayer: Playback failed: \(msg)") self.onPlaybackError?(msg) default: break } } } bufferObservation = playerItem.observe(\.isPlaybackBufferEmpty, options: [.new]) { [weak self] _, change in Task { @MainActor in self?.isBuffering = change.newValue ?? false } } let interval = CMTime(seconds: 1.0 / 30.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) timeObserver = avPlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in Task { @MainActor in guard let self, self.player != nil else { return } self.currentTime = CMTimeGetSeconds(time) } } didEndObserver = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: playerItem, queue: .main ) { [weak self] _ in Task { @MainActor in guard let self else { return } self.isPlaying = false self.currentTime = self.duration self.onPlaybackFinished?() } } } // MARK: - Transport func play() { player?.play() isPlaying = true } func pause() { player?.pause() isPlaying = false } func togglePlayPause() { if isPlaying { pause() } else { play() } } func stop() { cleanup() isPlaying = false currentTime = 0 duration = 0 currentCloudTrack = nil } func seek(to time: TimeInterval) { let cmTime = CMTime(seconds: time, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) player?.seek(to: cmTime, toleranceBefore: .zero, toleranceAfter: .zero) currentTime = time } func seek(by delta: TimeInterval) { let newTime = max(0, min(currentTime + delta, duration)) seek(to: newTime) } var volume: Float { get { player?.volume ?? 0.8 } set { player?.volume = newValue } } // MARK: - Cleanup private func cleanup() { if let observer = timeObserver, let player { player.removeTimeObserver(observer) } timeObserver = nil statusObservation?.invalidate() statusObservation = nil bufferObservation?.invalidate() bufferObservation = nil if let observer = didEndObserver { NotificationCenter.default.removeObserver(observer) } didEndObserver = nil player?.pause() player = nil } }