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.
303 lines
10 KiB
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 {}
|
|
}
|
|
|