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? private var retryAttempts = 0 private var lastURL: URL? private var lastStation: RadioStation? private var cancellables: Set = [] private var itemCancellables: Set = [] 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 }