StreamingPlayer.swift 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  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. @MainActor
  7. @Observable
  8. final class StreamingPlayer {
  9. // MARK: - State
  10. var isPlaying = false
  11. var currentTime: TimeInterval = 0
  12. var duration: TimeInterval = 0
  13. var isBuffering = false
  14. var currentCloudTrack: ChadTrack?
  15. // MARK: - Callbacks
  16. @ObservationIgnored var onPlaybackFinished: (() -> Void)?
  17. @ObservationIgnored var onPlaybackError: ((String) -> Void)?
  18. // MARK: - Private
  19. @ObservationIgnored private var player: AVPlayer?
  20. @ObservationIgnored private var timeObserver: Any?
  21. @ObservationIgnored private var statusObservation: NSKeyValueObservation?
  22. @ObservationIgnored private var bufferObservation: NSKeyValueObservation?
  23. @ObservationIgnored private var didEndObserver: NSObjectProtocol?
  24. // MARK: - Audio Session (iOS)
  25. // Configure audio session for playback. We set .playback category but do NOT
  26. // deactivate on stop — the session stays active when switching between
  27. // StreamingPlayer and AudioEngine to avoid session thrashing.
  28. private func configureAudioSession() {
  29. do {
  30. let session = AVAudioSession.sharedInstance()
  31. if session.category != .playback {
  32. try session.setCategory(.playback, mode: .default, options: [])
  33. }
  34. if !session.isOtherAudioPlaying {
  35. try session.setActive(true)
  36. }
  37. } catch {
  38. print("StreamingPlayer: Failed to configure audio session: \(error)")
  39. }
  40. }
  41. // MARK: - Load & Play
  42. func loadAndPlay(track: ChadTrack, streamURL: URL, authHeaders: [String: String]) {
  43. cleanup()
  44. configureAudioSession()
  45. let asset = AVURLAsset(url: streamURL, options: [
  46. "AVURLAssetHTTPHeaderFieldsKey": authHeaders
  47. ])
  48. let playerItem = AVPlayerItem(asset: asset)
  49. let avPlayer = AVPlayer(playerItem: playerItem)
  50. self.player = avPlayer
  51. self.currentCloudTrack = track
  52. self.isBuffering = true
  53. statusObservation = playerItem.observe(\.status, options: [.new]) { [weak self] item, _ in
  54. Task { @MainActor in
  55. guard let self else { return }
  56. switch item.status {
  57. case .readyToPlay:
  58. self.isBuffering = false
  59. let cmDuration = item.duration
  60. if cmDuration.isNumeric {
  61. self.duration = CMTimeGetSeconds(cmDuration)
  62. } else if let trackDuration = track.duration, trackDuration > 0 {
  63. self.duration = trackDuration
  64. } else {
  65. self.duration = 1.0
  66. }
  67. avPlayer.play()
  68. self.isPlaying = true
  69. case .failed:
  70. self.isBuffering = false
  71. let msg = item.error?.localizedDescription ?? "unknown error"
  72. print("StreamingPlayer: Playback failed: \(msg)")
  73. self.onPlaybackError?(msg)
  74. default:
  75. break
  76. }
  77. }
  78. }
  79. bufferObservation = playerItem.observe(\.isPlaybackBufferEmpty, options: [.new]) { [weak self] _, change in
  80. Task { @MainActor in
  81. self?.isBuffering = change.newValue ?? false
  82. }
  83. }
  84. let interval = CMTime(seconds: 1.0 / 30.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
  85. timeObserver = avPlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
  86. Task { @MainActor in
  87. guard let self, self.player != nil else { return }
  88. self.currentTime = CMTimeGetSeconds(time)
  89. }
  90. }
  91. didEndObserver = NotificationCenter.default.addObserver(
  92. forName: .AVPlayerItemDidPlayToEndTime,
  93. object: playerItem,
  94. queue: .main
  95. ) { [weak self] _ in
  96. Task { @MainActor in
  97. guard let self else { return }
  98. self.isPlaying = false
  99. self.currentTime = self.duration
  100. self.onPlaybackFinished?()
  101. }
  102. }
  103. }
  104. // MARK: - Transport
  105. func play() {
  106. player?.play()
  107. isPlaying = true
  108. }
  109. func pause() {
  110. player?.pause()
  111. isPlaying = false
  112. }
  113. func togglePlayPause() {
  114. if isPlaying { pause() } else { play() }
  115. }
  116. func stop() {
  117. cleanup()
  118. isPlaying = false
  119. currentTime = 0
  120. duration = 0
  121. currentCloudTrack = nil
  122. }
  123. func seek(to time: TimeInterval) {
  124. let cmTime = CMTime(seconds: time, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
  125. player?.seek(to: cmTime, toleranceBefore: .zero, toleranceAfter: .zero)
  126. currentTime = time
  127. }
  128. func seek(by delta: TimeInterval) {
  129. let newTime = max(0, min(currentTime + delta, duration))
  130. seek(to: newTime)
  131. }
  132. var volume: Float {
  133. get { player?.volume ?? 0.8 }
  134. set { player?.volume = newValue }
  135. }
  136. // MARK: - Cleanup
  137. private func cleanup() {
  138. if let observer = timeObserver, let player {
  139. player.removeTimeObserver(observer)
  140. }
  141. timeObserver = nil
  142. statusObservation?.invalidate()
  143. statusObservation = nil
  144. bufferObservation?.invalidate()
  145. bufferObservation = nil
  146. if let observer = didEndObserver {
  147. NotificationCenter.default.removeObserver(observer)
  148. }
  149. didEndObserver = nil
  150. player?.pause()
  151. player = nil
  152. }
  153. }