Create BearWave macOS radio-first app
This commit is contained in:
466
Sources/BearWaveCore/Services/PlaybackController.swift
Normal file
466
Sources/BearWaveCore/Services/PlaybackController.swift
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user