Files
bearwave-mac/Sources/BearWaveCore/Services/PlaybackController.swift
2026-06-25 09:21:42 +02:00

467 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}