467 lines
16 KiB
Swift
467 lines
16 KiB
Swift
import AppKit
|
||
import AVFoundation
|
||
import Combine
|
||
@preconcurrency import MediaPlayer
|
||
import Observation
|
||
import os
|
||
|
||
@Observable
|
||
@MainActor
|
||
public final class PlaybackController {
|
||
private let logger = Logger(subsystem: "com.spalencsar.BearWave", category: "Playback")
|
||
private let player = AVPlayer()
|
||
private let artworkProvider: (RadioStation?, String?, String?) -> NSImage
|
||
var onMetadataChanged: ((String?, String?, RadioStation?) -> Void)?
|
||
private var retryTask: Task<Void, Never>?
|
||
private var retryAttempts = 0
|
||
private var lastURL: URL?
|
||
private var lastStation: RadioStation?
|
||
private var cancellables: Set<AnyCancellable> = []
|
||
private var itemCancellables: Set<AnyCancellable> = []
|
||
private var metadataHandler: MetadataHandler?
|
||
|
||
var state: PlaybackStatus = .stopped
|
||
public var isPlaying: Bool { state.isPlaying }
|
||
var currentStation: RadioStation?
|
||
var currentTrackTitle = ""
|
||
var currentTrackArtist = ""
|
||
var currentArtwork: NSImage = BearWaveAssets.image(named: "bearwave") ?? NSImage()
|
||
var currentTime: TimeInterval = 0
|
||
var duration: TimeInterval = 0
|
||
var volume: Double = 0.55 {
|
||
didSet {
|
||
player.volume = Float(min(max(volume, 0), 1))
|
||
updateNowPlaying()
|
||
}
|
||
}
|
||
|
||
init(artworkProvider: @escaping (RadioStation?, String?, String?) -> NSImage = { station, _, _ in
|
||
BearWaveAssets.image(named: "bearwave") ?? NSImage()
|
||
}) {
|
||
self.artworkProvider = artworkProvider
|
||
player.volume = Float(volume)
|
||
configureRemoteCommands()
|
||
observePlaybackNotifications()
|
||
observePlayer()
|
||
configureTimeObserver()
|
||
}
|
||
|
||
func play(_ station: RadioStation) {
|
||
guard let url = URL(string: station.playbackURLString) else { return }
|
||
retryTask?.cancel()
|
||
retryAttempts = 0
|
||
lastURL = url
|
||
lastStation = station
|
||
currentStation = station
|
||
currentTrackTitle = ""
|
||
currentTrackArtist = ""
|
||
state = .connecting
|
||
itemCancellables.removeAll()
|
||
|
||
let item = AVPlayerItem(url: url)
|
||
|
||
let metadataOutput = AVPlayerItemMetadataOutput(identifiers: nil)
|
||
let handler = MetadataHandler { [weak self] artist, title in
|
||
Task { @MainActor [weak self] in
|
||
guard let self else { return }
|
||
var changed = false
|
||
if let title, title != self.currentTrackTitle {
|
||
self.currentTrackTitle = title
|
||
changed = true
|
||
}
|
||
if let artist, artist != self.currentTrackArtist {
|
||
self.currentTrackArtist = artist
|
||
changed = true
|
||
}
|
||
if changed {
|
||
self.onMetadataChanged?(self.currentTrackArtist, self.currentTrackTitle, self.currentStation)
|
||
self.updateNowPlaying()
|
||
}
|
||
}
|
||
}
|
||
metadataOutput.setDelegate(handler, queue: .main)
|
||
item.add(metadataOutput)
|
||
metadataHandler = handler
|
||
|
||
observeItem(item)
|
||
player.replaceCurrentItem(with: item)
|
||
player.play()
|
||
setInitialArtwork()
|
||
updateNowPlaying()
|
||
}
|
||
|
||
func resume() {
|
||
if let station = lastStation {
|
||
play(station)
|
||
} else {
|
||
player.play()
|
||
state = .connecting
|
||
updateNowPlaying()
|
||
}
|
||
}
|
||
|
||
func pause() {
|
||
player.pause()
|
||
state = .paused
|
||
updateNowPlaying()
|
||
}
|
||
|
||
func stop() {
|
||
retryTask?.cancel()
|
||
player.pause()
|
||
player.replaceCurrentItem(with: nil)
|
||
state = .stopped
|
||
currentStation = nil
|
||
currentTrackTitle = ""
|
||
currentTrackArtist = ""
|
||
currentArtwork = BearWaveAssets.image(named: "bearwave") ?? NSImage()
|
||
currentTime = 0
|
||
duration = 0
|
||
itemCancellables.removeAll()
|
||
metadataHandler = nil
|
||
onMetadataChanged?(nil, nil, nil)
|
||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||
MPNowPlayingInfoCenter.default().playbackState = .stopped
|
||
}
|
||
|
||
public func togglePlayPause() {
|
||
isPlaying ? pause() : resume()
|
||
}
|
||
|
||
private var timeObserverToken: Any?
|
||
|
||
func configureTimeObserver() {
|
||
let interval = CMTime(seconds: 1, preferredTimescale: 1)
|
||
timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||
Task { @MainActor in
|
||
guard let self else { return }
|
||
let currentSeconds = time.seconds
|
||
guard currentSeconds.isFinite, currentSeconds >= 0 else { return }
|
||
|
||
self.currentTime = currentSeconds
|
||
let itemDuration = self.player.currentItem?.duration
|
||
if let itemDuration, itemDuration.timescale > 0, itemDuration.value > 0 {
|
||
let dur = CMTimeGetSeconds(itemDuration)
|
||
if dur.isFinite, dur > 0 {
|
||
self.duration = dur
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func observePlaybackNotifications() {
|
||
NotificationCenter.default.addObserver(
|
||
forName: .AVPlayerItemFailedToPlayToEndTime,
|
||
object: nil,
|
||
queue: .main
|
||
) { [weak self] notification in
|
||
let error = (notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError)
|
||
Task { @MainActor in
|
||
guard let self else { return }
|
||
let resolvedError = error ?? self.player.currentItem?.error as NSError?
|
||
let playbackError = Self.classifyError(resolvedError)
|
||
self.logger.info("playback failed: \(playbackError.title) – \(playbackError.detail)")
|
||
self.state = .failed(playbackError)
|
||
if playbackError.isRetryable {
|
||
self.scheduleRetry()
|
||
}
|
||
}
|
||
}
|
||
|
||
NotificationCenter.default.addObserver(
|
||
forName: .AVPlayerItemPlaybackStalled,
|
||
object: nil,
|
||
queue: .main
|
||
) { [weak self] _ in
|
||
Task { @MainActor in
|
||
guard let self else { return }
|
||
self.state = .buffering
|
||
self.scheduleRetry()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func observePlayer() {
|
||
player.publisher(for: \.timeControlStatus)
|
||
.receive(on: DispatchQueue.main)
|
||
.sink { [weak self] status in
|
||
Task { @MainActor in self?.handleTimeControlStatus(status) }
|
||
}
|
||
.store(in: &cancellables)
|
||
}
|
||
|
||
private func observeItem(_ item: AVPlayerItem) {
|
||
item.publisher(for: \.status)
|
||
.receive(on: DispatchQueue.main)
|
||
.sink { [weak self, weak item] status in
|
||
Task { @MainActor in self?.handleItemStatus(status, item: item) }
|
||
}
|
||
.store(in: &itemCancellables)
|
||
}
|
||
|
||
private func handleItemStatus(_ status: AVPlayerItem.Status, item: AVPlayerItem?) {
|
||
switch status {
|
||
case .unknown:
|
||
state = .connecting
|
||
case .readyToPlay:
|
||
if player.timeControlStatus == .playing {
|
||
state = .playing
|
||
} else {
|
||
state = .buffering
|
||
}
|
||
case .failed:
|
||
let error = Self.classifyError(item?.error as NSError?)
|
||
logger.info("item failed: \(error.title) – \(error.detail)")
|
||
state = .failed(error)
|
||
if error.isRetryable {
|
||
scheduleRetry()
|
||
}
|
||
@unknown default:
|
||
state = .failed(.unknown(String(localized: "Unsupported stream state")))
|
||
}
|
||
updateNowPlaying()
|
||
}
|
||
|
||
private func handleTimeControlStatus(_ status: AVPlayer.TimeControlStatus) {
|
||
guard currentStation != nil else { return }
|
||
switch status {
|
||
case .paused:
|
||
if state != .stopped, !isFailed {
|
||
state = .paused
|
||
}
|
||
case .waitingToPlayAtSpecifiedRate:
|
||
if !isFailed {
|
||
state = player.currentItem?.status == .unknown ? .connecting : .buffering
|
||
}
|
||
case .playing:
|
||
state = .playing
|
||
@unknown default:
|
||
break
|
||
}
|
||
updateNowPlaying()
|
||
}
|
||
|
||
private func scheduleRetry() {
|
||
guard retryAttempts < 2, let url = lastURL else { return }
|
||
if case let .failed(error) = state, !error.isRetryable {
|
||
logger.info("skipping retry: error is not retryable")
|
||
return
|
||
}
|
||
retryAttempts += 1
|
||
logger.info("scheduling retry attempt \(self.retryAttempts)")
|
||
retryTask?.cancel()
|
||
retryTask = Task { [weak self] in
|
||
try? await Task.sleep(for: .milliseconds(1200))
|
||
await MainActor.run {
|
||
guard let self else { return }
|
||
let item = AVPlayerItem(url: url)
|
||
self.itemCancellables.removeAll()
|
||
self.observeItem(item)
|
||
self.state = .connecting
|
||
self.player.replaceCurrentItem(with: item)
|
||
self.player.play()
|
||
self.updateNowPlaying()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func configureRemoteCommands() {
|
||
let commandCenter = MPRemoteCommandCenter.shared()
|
||
commandCenter.playCommand.addTarget { [weak self] _ in
|
||
Task { @MainActor in self?.resume() }
|
||
return .success
|
||
}
|
||
commandCenter.pauseCommand.addTarget { [weak self] _ in
|
||
Task { @MainActor in self?.pause() }
|
||
return .success
|
||
}
|
||
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||
Task { @MainActor in self?.togglePlayPause() }
|
||
return .success
|
||
}
|
||
commandCenter.stopCommand.addTarget { [weak self] _ in
|
||
Task { @MainActor in self?.stop() }
|
||
return .success
|
||
}
|
||
}
|
||
|
||
func refreshNowPlaying() {
|
||
logger.info("refreshNowPlaying called")
|
||
currentArtwork = artworkProvider(currentStation, currentTrackArtist.isEmpty ? nil : currentTrackArtist, currentTrackTitle.isEmpty ? nil : currentTrackTitle)
|
||
updateNowPlaying()
|
||
}
|
||
|
||
public func updateNowPlayingExternally() {
|
||
updateNowPlaying()
|
||
}
|
||
|
||
private func setInitialArtwork() {
|
||
currentArtwork = artworkProvider(currentStation, nil, nil)
|
||
}
|
||
|
||
private func updateNowPlaying() {
|
||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo()
|
||
MPNowPlayingInfoCenter.default().playbackState = state == .playing ? .playing : .paused
|
||
}
|
||
|
||
private func nowPlayingInfo() -> [String: Any] {
|
||
var info: [String: Any] = [
|
||
MPMediaItemPropertyTitle: currentTrackTitle.isEmpty ? currentStation?.name ?? "BearWave" : currentTrackTitle,
|
||
MPMediaItemPropertyAlbumTitle: currentStation?.name ?? "BearWave",
|
||
MPNowPlayingInfoPropertyPlaybackRate: state == .playing ? 1.0 : 0.0
|
||
]
|
||
if !currentTrackArtist.isEmpty {
|
||
info[MPMediaItemPropertyArtist] = currentTrackArtist
|
||
}
|
||
if let artwork = nowPlayingArtwork() {
|
||
info[MPMediaItemPropertyArtwork] = artwork
|
||
}
|
||
return info
|
||
}
|
||
|
||
private func nowPlayingArtwork() -> MPMediaItemArtwork? {
|
||
let image = artworkProvider(currentStation, currentTrackArtist.isEmpty ? nil : currentTrackArtist, currentTrackTitle.isEmpty ? nil : currentTrackTitle)
|
||
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
|
||
let size = image.size
|
||
return MPMediaItemArtwork(boundsSize: size, requestHandler: { @Sendable requestedSize in
|
||
NSImage(cgImage: cgImage, size: requestedSize)
|
||
})
|
||
}
|
||
|
||
private var isFailed: Bool {
|
||
if case .failed = state { return true }
|
||
return false
|
||
}
|
||
|
||
private static func classifyError(_ error: NSError?) -> PlaybackError {
|
||
guard let error else { return .unknown(String(localized: "Stream failed")) }
|
||
|
||
let domain = error.domain
|
||
let code = error.code
|
||
|
||
if domain == NSURLErrorDomain {
|
||
switch code {
|
||
case NSURLErrorTimedOut:
|
||
return .connectionTimeout
|
||
case NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed:
|
||
return .dnsResolutionFailed
|
||
case NSURLErrorCannotConnectToHost, NSURLErrorNetworkConnectionLost:
|
||
return .stationOffline
|
||
case NSURLErrorUnsupportedURL, NSURLErrorFileDoesNotExist:
|
||
return .codecNotSupported
|
||
default:
|
||
if (500...599).contains(code) {
|
||
return .httpError(code)
|
||
}
|
||
return .unknown(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
if domain == "CoreMediaErrorDomain" {
|
||
switch code {
|
||
case -12785:
|
||
return .streamInterrupted
|
||
case -12935, -16042, -15697:
|
||
return .codecNotSupported
|
||
default:
|
||
return .unknown(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
if domain == "AVFoundationErrorDomain" {
|
||
switch code {
|
||
case -11800, -11828, -11838:
|
||
return .codecNotSupported
|
||
default:
|
||
return .unknown(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
if (400...599).contains(code) {
|
||
return .httpError(code)
|
||
}
|
||
|
||
return .unknown(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
final class MetadataHandler: NSObject, AVPlayerItemMetadataOutputPushDelegate {
|
||
private let onMetadata: @Sendable (String?, String?) -> Void
|
||
|
||
init(onMetadata: @escaping @Sendable (String?, String?) -> Void) {
|
||
self.onMetadata = onMetadata
|
||
}
|
||
|
||
func metadataOutput(_ output: AVPlayerItemMetadataOutput, didOutputTimedMetadataGroups groups: [AVTimedMetadataGroup], from track: AVPlayerItemTrack?) {
|
||
guard let items = groups.first?.items else { return }
|
||
let parsed = parseMetadataItems(items)
|
||
onMetadata(parsed.artist, parsed.title)
|
||
}
|
||
}
|
||
|
||
private func parseMetadataItems(_ items: [AVMetadataItem]) -> (artist: String?, title: String?) {
|
||
var artist: String?
|
||
var title: String?
|
||
|
||
for item in items {
|
||
let key = metadataKey(for: item)
|
||
let value = metadataStringValue(for: item)
|
||
guard let value, !value.isEmpty else { continue }
|
||
|
||
if key.contains("artist") || key.contains("author") {
|
||
artist = value
|
||
} else if key.contains("title") || key.contains("song") {
|
||
if let split = splitArtistTitle(value) {
|
||
artist = artist ?? split.artist
|
||
title = split.title
|
||
} else {
|
||
title = value
|
||
}
|
||
} else if artist == nil && title == nil, let split = splitArtistTitle(value) {
|
||
artist = split.artist
|
||
title = split.title
|
||
}
|
||
}
|
||
|
||
return (artist, title)
|
||
}
|
||
|
||
private func metadataKey(for item: AVMetadataItem) -> String {
|
||
let common = item.commonKey?.rawValue ?? ""
|
||
let identifier = item.identifier?.rawValue ?? ""
|
||
let key = item.key as? String ?? ""
|
||
return [common, identifier, key].joined(separator: " ").lowercased()
|
||
}
|
||
|
||
private func metadataStringValue(for item: AVMetadataItem) -> String? {
|
||
if let stringValue = item.stringValue {
|
||
return cleanMetadataString(stringValue)
|
||
}
|
||
if let value = item.value as? String {
|
||
return cleanMetadataString(value)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private func cleanMetadataString(_ value: String) -> String {
|
||
value
|
||
.replacingOccurrences(of: "StreamTitle=", with: "")
|
||
.trimmingCharacters(in: CharacterSet(charactersIn: " ';\""))
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
|
||
private func splitArtistTitle(_ value: String) -> (artist: String, title: String)? {
|
||
let separators = [" - ", " – ", " — "]
|
||
for separator in separators {
|
||
let parts = value.components(separatedBy: separator)
|
||
guard parts.count >= 2 else { continue }
|
||
let artist = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let title = parts.dropFirst().joined(separator: separator).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if !artist.isEmpty, !title.isEmpty {
|
||
return (artist, title)
|
||
}
|
||
}
|
||
return nil
|
||
}
|