You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
Music/Music/Providers/StreamingPlaybackProvider.s...

303 lines
10 KiB

import AVFoundation
import Foundation
import Observation
import MusicShared
import os
@Observable
final class StreamingPlaybackProvider: PlaybackProvider {
var isPlaying = false
var currentTime: Double = 0
var duration: Double = 0
var volume: Float = 0.65 {
didSet { player?.volume = volume }
}
private(set) var isScrubbing = false
var playbackError: String?
var isBuffering = false
var onTrackFinished: (() -> Void)?
var onPlaybackStateChanged: (() -> Void)?
private(set) var player: AVPlayer?
private var timeObserver: Any?
private var endObserver: NSObjectProtocol?
private var failedObserver: NSObjectProtocol?
private var statusObservation: NSKeyValueObservation?
private var timeControlObservation: NSKeyValueObservation?
private var seekInProgress = false
private var pendingSeekTime: Double?
private var playTask: Task<Void, Never>?
private let hostURL: String
private let apiKey: String
private let logger = Logger(subsystem: "com.staxriver.mu", category: "StreamingPlayback")
init(hostURL: String, apiKey: String) {
self.hostURL = hostURL.hasSuffix("/") ? String(hostURL.dropLast()) : hostURL
self.apiKey = apiKey
}
func urlForTrack(_ track: Track) -> URL? {
guard let trackId = track.id else { return nil }
// StreamingRoutes.trackFile already includes ?id=TRACKID
return URL(string: "\(hostURL)\(StreamingRoutes.trackFile(trackId: trackId))&token=\(apiKey)")
}
func play(url: URL) {
cleanup()
playbackError = nil
isBuffering = true
isPlaying = true
onPlaybackStateChanged?()
playTask = Task { [weak self] in
guard let self else { return }
// Pre-flight: verify the URL is reachable before handing to AVPlayer
do {
var request = URLRequest(url: url)
request.httpMethod = "GET"
// Only fetch first byte to avoid downloading the whole file
request.setValue("bytes=0-0", forHTTPHeaderField: "Range")
let (data, response) = try await URLSession.shared.data(for: request)
guard !Task.isCancelled else { return }
if let http = response as? HTTPURLResponse, http.statusCode != 200 && http.statusCode != 206 {
let body = String(data: data.prefix(500), encoding: .utf8) ?? ""
let msg: String
switch http.statusCode {
case 401: msg = "Server rejected authentication"
case 404: msg = "Route not found (HTTP 404)"
case 500...599: msg = "Server error (\(http.statusCode))"
default: msg = "HTTP \(http.statusCode)"
}
self.logger.error("\(msg, privacy: .public) — body: \(body, privacy: .public)")
self.playbackError = msg
self.isPlaying = false
self.isBuffering = false
self.onPlaybackStateChanged?()
return
}
} catch {
guard !Task.isCancelled else { return }
self.logger.error("Network error: \(error.localizedDescription, privacy: .public)")
self.playbackError = "Network: \(error.localizedDescription)"
self.isPlaying = false
self.isBuffering = false
self.onPlaybackStateChanged?()
return
}
guard !Task.isCancelled else { return }
self.logger.info("Pre-flight OK, starting AVPlayer for \(url.absoluteString, privacy: .public)")
self.startAVPlayer(url: url)
}
}
func startAVPlayer(url: URL) {
let asset = AVURLAsset(url: url)
let item = AVPlayerItem(asset: asset)
player = AVPlayer(playerItem: item)
player?.volume = volume
statusObservation = item.observe(\.status, options: [.new]) { [weak self] (playerItem: AVPlayerItem, _) in
DispatchQueue.main.async {
guard let self else { return }
switch playerItem.status {
case .failed:
let msg = playerItem.error?.localizedDescription ?? "Unknown playback error"
self.logger.error("AVPlayer failed: \(msg, privacy: .public)")
self.playbackError = msg
self.isPlaying = false
self.isBuffering = false
self.onPlaybackStateChanged?()
case .readyToPlay:
self.logger.info("Stream ready")
self.playbackError = nil
self.isBuffering = false
self.onPlaybackStateChanged?()
default:
break
}
}
}
timeControlObservation = player?.observe(\.timeControlStatus, options: [.new]) { [weak self] (avPlayer: AVPlayer, _) in
DispatchQueue.main.async {
guard let self else { return }
switch avPlayer.timeControlStatus {
case .waitingToPlayAtSpecifiedRate:
self.isBuffering = true
case .playing:
self.isBuffering = false
case .paused:
self.isBuffering = false
@unknown default:
break
}
self.onPlaybackStateChanged?()
}
}
timeObserver = player?.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
queue: .main
) { [weak self] time in
guard let self, !self.isScrubbing else { return }
self.currentTime = time.seconds
if let dur = self.player?.currentItem?.duration, dur.isValid, !dur.isIndefinite {
self.duration = dur.seconds
}
self.onPlaybackStateChanged?()
}
endObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: item,
queue: .main
) { [weak self] _ in
self?.isPlaying = false
self?.currentTime = 0
self?.onPlaybackStateChanged?()
self?.onTrackFinished?()
}
failedObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemFailedToPlayToEndTime,
object: item,
queue: .main
) { [weak self] notification in
let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error
let msg = error?.localizedDescription ?? "Playback interrupted"
self?.playbackError = msg
self?.isPlaying = false
self?.isBuffering = false
self?.onPlaybackStateChanged?()
}
player?.play()
}
func pause() {
player?.pause()
isPlaying = false
onPlaybackStateChanged?()
}
func resume() {
player?.play()
isPlaying = true
onPlaybackStateChanged?()
}
func togglePlayPause() {
if isPlaying { pause() } else { resume() }
}
func seek(to time: Double) {
let clamped = max(0, min(time, duration))
currentTime = clamped
player?.seek(
to: CMTime(seconds: clamped, preferredTimescale: 600),
toleranceBefore: .zero,
toleranceAfter: .zero
)
}
func setVolume(_ level: Float) {
volume = level
}
func beginScrubbing() {
isScrubbing = true
}
func scrub(to time: Double) {
let clamped = max(0, min(time, duration))
currentTime = clamped
pendingSeekTime = clamped
guard !seekInProgress else { return }
performPendingSeek()
}
func endScrubbing(at time: Double) {
let clamped = max(0, min(time, duration))
currentTime = clamped
pendingSeekTime = nil
seekInProgress = false
player?.seek(
to: CMTime(seconds: clamped, preferredTimescale: 600),
toleranceBefore: .zero,
toleranceAfter: .zero
) { [weak self] _ in
DispatchQueue.main.async {
self?.isScrubbing = false
}
}
}
func stop() {
cleanup()
isPlaying = false
isBuffering = false
playbackError = nil
currentTime = 0
duration = 0
onPlaybackStateChanged?()
}
private func performPendingSeek() {
guard let time = pendingSeekTime else { return }
pendingSeekTime = nil
seekInProgress = true
player?.seek(
to: CMTime(seconds: time, preferredTimescale: 600),
toleranceBefore: CMTime(seconds: 0.1, preferredTimescale: 600),
toleranceAfter: CMTime(seconds: 0.1, preferredTimescale: 600)
) { [weak self] _ in
DispatchQueue.main.async {
guard let self else { return }
self.seekInProgress = false
if self.pendingSeekTime != nil {
self.performPendingSeek()
}
}
}
}
private func cleanup() {
playTask?.cancel()
playTask = nil
statusObservation?.invalidate()
statusObservation = nil
timeControlObservation?.invalidate()
timeControlObservation = nil
if let obs = timeObserver {
player?.removeTimeObserver(obs)
timeObserver = nil
}
if let obs = endObserver {
NotificationCenter.default.removeObserver(obs)
endObserver = nil
}
if let obs = failedObserver {
NotificationCenter.default.removeObserver(obs)
failedObserver = nil
}
player?.pause()
// Dissociate the item from the player to release its decode/render pipeline.
// Setting `player = nil` alone does NOT free the pipeline (ARC tears it down
// asynchronously); without this, pipelines accumulate across track switches
// until a new player can't acquire a decode session and fails with -16044.
player?.replaceCurrentItem(with: nil)
player = nil
}
nonisolated deinit {}
}