StreamingPlayer.swift 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. import AVFoundation
  2. import Foundation
  3. import Observation
  4. /// AVPlayer-based streaming player for cloud tracks.
  5. /// Separate from AudioEngine (which uses AVAudioEngine for local files with EQ/BPM).
  6. /// In Phase 3, cached cloud tracks will switch to AudioEngine for EQ support.
  7. @MainActor
  8. @Observable
  9. final class StreamingPlayer {
  10. // MARK: - State
  11. var isPlaying = false
  12. var currentTime: TimeInterval = 0
  13. var duration: TimeInterval = 0
  14. var isBuffering = false
  15. /// The cloud track currently loaded (nil = nothing loaded).
  16. var currentCloudTrack: ChadTrack?
  17. // MARK: - Callbacks
  18. @ObservationIgnored var onPlaybackFinished: (() -> Void)?
  19. @ObservationIgnored var onPlaybackError: ((String) -> Void)?
  20. // MARK: - Private
  21. @ObservationIgnored private var player: AVPlayer?
  22. @ObservationIgnored private var timeObserver: Any?
  23. @ObservationIgnored private var statusObservation: NSKeyValueObservation?
  24. @ObservationIgnored private var bufferObservation: NSKeyValueObservation?
  25. @ObservationIgnored private var didEndObserver: NSObjectProtocol?
  26. // MARK: - Load & Play
  27. func loadAndPlay(track: ChadTrack, streamURL: URL, authHeaders: [String: String]) {
  28. cleanup()
  29. let asset = AVURLAsset(url: streamURL, options: [
  30. "AVURLAssetHTTPHeaderFieldsKey": authHeaders
  31. ])
  32. let playerItem = AVPlayerItem(asset: asset)
  33. let avPlayer = AVPlayer(playerItem: playerItem)
  34. self.player = avPlayer
  35. self.currentCloudTrack = track
  36. self.isBuffering = true
  37. // Observe status to know when ready to play
  38. statusObservation = playerItem.observe(\.status, options: [.new]) { [weak self] item, _ in
  39. Task { @MainActor in
  40. guard let self else { return }
  41. switch item.status {
  42. case .readyToPlay:
  43. self.isBuffering = false
  44. let cmDuration = item.duration
  45. if cmDuration.isNumeric {
  46. self.duration = CMTimeGetSeconds(cmDuration)
  47. } else if let trackDuration = track.duration, trackDuration > 0 {
  48. self.duration = trackDuration
  49. } else {
  50. self.duration = 1.0 // safe fallback to avoid division by zero
  51. }
  52. avPlayer.play()
  53. self.isPlaying = true
  54. case .failed:
  55. self.isBuffering = false
  56. let msg = item.error?.localizedDescription ?? "unknown error"
  57. print("StreamingPlayer: Playback failed: \(msg)")
  58. self.onPlaybackError?(msg)
  59. default:
  60. break
  61. }
  62. }
  63. }
  64. // Observe buffering state
  65. bufferObservation = playerItem.observe(\.isPlaybackBufferEmpty, options: [.new]) { [weak self] _, change in
  66. Task { @MainActor in
  67. self?.isBuffering = change.newValue ?? false
  68. }
  69. }
  70. // Periodic time updates (~30fps to match AudioEngine sync rate)
  71. let interval = CMTime(seconds: 1.0 / 30.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
  72. timeObserver = avPlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
  73. Task { @MainActor in
  74. guard let self, self.player != nil else { return }
  75. self.currentTime = CMTimeGetSeconds(time)
  76. }
  77. }
  78. // End-of-track notification
  79. didEndObserver = NotificationCenter.default.addObserver(
  80. forName: .AVPlayerItemDidPlayToEndTime,
  81. object: playerItem,
  82. queue: .main
  83. ) { [weak self] _ in
  84. Task { @MainActor in
  85. guard let self else { return }
  86. self.isPlaying = false
  87. self.currentTime = self.duration
  88. self.onPlaybackFinished?()
  89. }
  90. }
  91. }
  92. // MARK: - Transport
  93. func play() {
  94. player?.play()
  95. isPlaying = true
  96. }
  97. func pause() {
  98. player?.pause()
  99. isPlaying = false
  100. }
  101. func togglePlayPause() {
  102. if isPlaying { pause() } else { play() }
  103. }
  104. func stop() {
  105. cleanup()
  106. isPlaying = false
  107. currentTime = 0
  108. duration = 0
  109. currentCloudTrack = nil
  110. }
  111. func seek(to time: TimeInterval) {
  112. let cmTime = CMTime(seconds: time, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
  113. player?.seek(to: cmTime, toleranceBefore: .zero, toleranceAfter: .zero)
  114. currentTime = time
  115. }
  116. func seek(by delta: TimeInterval) {
  117. let newTime = max(0, min(currentTime + delta, duration))
  118. seek(to: newTime)
  119. }
  120. var volume: Float {
  121. get { player?.volume ?? 0.8 }
  122. set { player?.volume = newValue }
  123. }
  124. // MARK: - Cleanup
  125. private func cleanup() {
  126. if let observer = timeObserver, let player {
  127. player.removeTimeObserver(observer)
  128. }
  129. timeObserver = nil
  130. statusObservation?.invalidate()
  131. statusObservation = nil
  132. bufferObservation?.invalidate()
  133. bufferObservation = nil
  134. if let observer = didEndObserver {
  135. NotificationCenter.default.removeObserver(observer)
  136. }
  137. didEndObserver = nil
  138. player?.pause()
  139. player = nil
  140. }
  141. }