| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166 |
- 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).
- /// In Phase 3, cached cloud tracks will switch to AudioEngine for EQ support.
- @MainActor
- @Observable
- final class StreamingPlayer {
- // MARK: - State
- var isPlaying = false
- var currentTime: TimeInterval = 0
- var duration: TimeInterval = 0
- var isBuffering = false
- /// The cloud track currently loaded (nil = nothing loaded).
- 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: - Load & Play
- func loadAndPlay(track: ChadTrack, streamURL: URL, authHeaders: [String: String]) {
- cleanup()
- 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
- // Observe status to know when ready to play
- 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 // safe fallback to avoid division by zero
- }
- 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
- }
- }
- }
- // Observe buffering state
- bufferObservation = playerItem.observe(\.isPlaybackBufferEmpty, options: [.new]) { [weak self] _, change in
- Task { @MainActor in
- self?.isBuffering = change.newValue ?? false
- }
- }
- // Periodic time updates (~30fps to match AudioEngine sync rate)
- 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)
- }
- }
- // End-of-track notification
- 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
- }
- }
|