| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180 |
- 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
- }
- }
|