Create BearWave macOS radio-first app

This commit is contained in:
Sebastian Palencsar
2026-06-25 09:21:42 +02:00
commit b47b591e03
42 changed files with 3814 additions and 0 deletions

View File

@@ -0,0 +1,466 @@
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
}