import Foundation import AVFoundation import Combine import WatchKit import MediaPlayer /// watchOS audio player with dual-mode output: /// /// **Bluetooth Mode** (default): /// Uses `.longFormAudio` policy. Requires Bluetooth/AirPlay. Session must be /// activated via `activate(options:completionHandler:)`. /// /// **Speaker Mode** (Watch Ultra): /// Uses `.default` policy which routes to the built-in speaker. Background /// runtime is provided by the `audio` UIBackgroundModes entitlement — the /// system keeps the app alive while audio is actively playing. No HealthKit /// or HKWorkoutSession required. /// /// Toggle between modes in the Now Playing route indicator. class WatchAudioPlayer: NSObject, ObservableObject { static let shared = WatchAudioPlayer() // MARK: - Published State @Published var currentSong: Song? @Published var queue: [Song] = [] @Published var queueIndex: Int = 0 @Published var isPlaying = false @Published var currentTime: TimeInterval = 0 @Published var duration: TimeInterval = 0 @Published var volume: Float = 1.0 @Published var repeatMode: RepeatMode = .off @Published var shuffleEnabled = false // audioLevels is NOT @Published — avoids 30fps objectWillChange on WatchAudioPlayer // which forces every observing view to re-evaluate at 30fps, draining the battery. // Views that need levels should use a dedicated TimelineView polling approach instead. var audioLevels: [Float] = Array(repeating: 0, count: 8) // Audio route state @Published var isSessionActive = false @Published var isBluetoothConnected = false @Published var currentRouteName: String = "No Output" @Published var activationError: String? @Published var useSpeakerMode = false { didSet { UserDefaults.standard.set(useSpeakerMode, forKey: "watch_speaker_mode") reconfigureAudioSession() } } @Published var isSpeakerAvailable = false enum RepeatMode { case off, all, one } // MARK: - Private private var player: AVPlayer? private var playerItem: AVPlayerItem? private var timeObserver: Any? private var levelTimer: Timer? private var pendingPlayURL: URL? private var pendingSong: Song? private override init() { super.init() useSpeakerMode = UserDefaults.standard.bool(forKey: "watch_speaker_mode") isSpeakerAvailable = checkSpeakerAvailable() configureAudioSession() setupRemoteControls() observeRouteChanges() } // MARK: - Speaker Detection private func checkSpeakerAvailable() -> Bool { // Check if device has a speaker by looking at available outputs let route = AVAudioSession.sharedInstance().currentRoute let hasBuiltIn = route.outputs.contains { $0.portType == .builtInSpeaker } if hasBuiltIn { return true } // Watch Ultra always has a speaker even if not currently routed let model = WKInterfaceDevice.current().model.lowercased() return model.contains("ultra") } // MARK: - Audio Session Configuration private func configureAudioSession() { let session = AVAudioSession.sharedInstance() do { if useSpeakerMode { // Speaker mode: .default policy allows speaker output. // No .longFormAudio — that policy requires Bluetooth and blocks // speaker routing on pre-watchOS 11. The `audio` background mode // in Info.plist keeps the app alive while audio plays. try session.setCategory(.playback, mode: .default, policy: .default) try session.setActive(true) print("[WatchAudio] Session: .playback / .default (speaker mode)") } else { // Bluetooth mode: .longFormAudio requires BT/AirPlay try session.setCategory(.playback, mode: .default, policy: .longFormAudio) print("[WatchAudio] Session: .playback / .longFormAudio (bluetooth mode)") } updateRouteInfo() } catch { print("[WatchAudio] Session config FAILED: \(error.localizedDescription)") } } private func reconfigureAudioSession() { let wasPlaying = isPlaying if wasPlaying { player?.pause() } configureAudioSession() if wasPlaying { player?.play() } updateRouteInfo() } // MARK: - Session Activation func activateSession() async -> Bool { if useSpeakerMode { // Speaker mode: session is already active from configureAudioSession. // No Bluetooth route picker needed. await MainActor.run { isSessionActive = true updateRouteInfo() } return true } // Bluetooth mode: must use activate(options:completionHandler:) — NOT setActive(true). // setActive(true) throws OSStatus 561145203 with .longFormAudio policy. let session = AVAudioSession.sharedInstance() let result = await withCheckedContinuation { (continuation: CheckedContinuation<(Bool, String?), Never>) in session.activate(options: []) { success, error in continuation.resume(returning: (success, error?.localizedDescription)) } } await MainActor.run { if result.0 { self.isSessionActive = true self.activationError = nil self.updateRouteInfo() print("[WatchAudio] Session activated OK. Route: \(self.currentRouteName)") } else { self.isSessionActive = false self.activationError = result.1 ?? "Unknown error" print("[WatchAudio] Session activation FAILED: \(result.1 ?? "?")") } } return result.0 } // MARK: - Route Monitoring private func observeRouteChanges() { NotificationCenter.default.addObserver( self, selector: #selector(handleRouteChange), name: AVAudioSession.routeChangeNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(handleInterruption), name: AVAudioSession.interruptionNotification, object: nil ) } @objc private func handleRouteChange(_ notification: Notification) { guard let info = notification.userInfo, let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt, let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return } DispatchQueue.main.async { self.updateRouteInfo() switch reason { case .oldDeviceUnavailable: if !self.useSpeakerMode { print("[WatchAudio] Bluetooth disconnected — pausing") self.pause() } case .newDeviceAvailable: print("[WatchAudio] New route: \(self.currentRouteName)") default: print("[WatchAudio] Route changed: \(self.currentRouteName)") } } } @objc private func handleInterruption(_ notification: Notification) { guard let info = notification.userInfo, let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } DispatchQueue.main.async { switch type { case .began: self.pause() case .ended: if let opts = info[AVAudioSessionInterruptionOptionKey] as? UInt { if AVAudioSession.InterruptionOptions(rawValue: opts).contains(.shouldResume) { self.resume() } } @unknown default: break } } } private func updateRouteInfo() { let route = AVAudioSession.sharedInstance().currentRoute if let output = route.outputs.first { currentRouteName = output.portName isBluetoothConnected = [.bluetoothA2DP, .bluetoothLE, .bluetoothHFP, .airPlay].contains(output.portType) } else { currentRouteName = useSpeakerMode ? "Speaker" : "No Output" isBluetoothConnected = false } } // MARK: - Playback func play(song: Song, fromQueue: [Song]? = nil, at index: Int = 0) { if let q = fromQueue { queue = shuffleEnabled ? q.shuffled() : q queueIndex = index } currentSong = song let url: URL? if let localURL = WatchOfflineStore.shared.localURL(for: song.id) { url = localURL } else { url = WatchSessionManager.shared.streamURL(songId: song.id, maxBitRate: 192) } guard let playURL = url else { print("[WatchAudio] No URL for: \(song.title)") return } if useSpeakerMode { // Speaker mode: session already configured with .default policy. // setActive(true) was called in configureAudioSession. isSessionActive = true playFromURL(playURL) return } // Bluetooth mode: activate session if needed if isSessionActive && isBluetoothConnected { playFromURL(playURL) return } pendingPlayURL = playURL pendingSong = song Task { _ = await activateSession() await MainActor.run { if let url = self.pendingPlayURL { self.playFromURL(url) } self.pendingPlayURL = nil self.pendingSong = nil } } } private func playFromURL(_ url: URL) { levelTimer?.invalidate() if let observer = timeObserver { player?.removeTimeObserver(observer); timeObserver = nil } let asset = AVURLAsset(url: url) playerItem = AVPlayerItem(asset: asset) if player == nil { player = AVPlayer(playerItem: playerItem) } else { player?.replaceCurrentItem(with: playerItem) } player?.volume = volume player?.play() isPlaying = true let interval = CMTime(seconds: 1.0, preferredTimescale: 1) timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in guard let self = self else { return } self.currentTime = time.seconds if let dur = self.playerItem?.duration.seconds, !dur.isNaN { self.duration = dur } // MPNowPlayingInfoCenter update removed from here — writing the full // dictionary via IPC every second is excessive (AUDIT-046). It is now // called only when actual state changes (play/pause/seek/song change). } levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in guard let self = self, self.isPlaying else { self?.audioLevels = Array(repeating: 0, count: 8) return } self.audioLevels = (0..<8).map { i in let target = Float.random(in: 0.1...0.85) let current = self.audioLevels.count > i ? self.audioLevels[i] : 0.1 return current + (target - current) * 0.2 } } NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(playerDidFinish), name: .AVPlayerItemDidPlayToEndTime, object: playerItem) updateNowPlayingInfo() print("[WatchAudio] Playing: \(currentSong?.title ?? "?") via \(currentRouteName) (\(useSpeakerMode ? "speaker" : "bluetooth"))") } // MARK: - Transport func togglePlayPause() { if isPlaying { pause() } else { resume() } } func pause() { player?.pause() isPlaying = false updateNowPlayingInfo() } func resume() { player?.play() isPlaying = true updateNowPlayingInfo() } func next() { guard !queue.isEmpty else { return } if queueIndex < queue.count - 1 { queueIndex += 1 } else if repeatMode == .all { queueIndex = 0 } else { pause(); return } play(song: queue[queueIndex]) } func previous() { if currentTime > 3 { seek(to: 0); return } guard !queue.isEmpty else { return } if queueIndex > 0 { queueIndex -= 1 } else if repeatMode == .all { queueIndex = queue.count - 1 } play(song: queue[queueIndex]) } func seek(to time: TimeInterval) { player?.seek(to: CMTime(seconds: time, preferredTimescale: 600)) { [weak self] _ in self?.updateNowPlayingInfo() } currentTime = time } func setVolume(_ vol: Float) { volume = vol; player?.volume = vol } func toggleShuffle() { shuffleEnabled.toggle() } func cycleRepeat() { switch repeatMode { case .off: repeatMode = .all; case .all: repeatMode = .one; case .one: repeatMode = .off } } func playNext(_ song: Song) { let insertAt = min(queueIndex + 1, queue.count) queue.insert(song, at: insertAt) } func playLater(_ song: Song) { queue.append(song) } @objc private func playerDidFinish() { if repeatMode == .one { seek(to: 0); player?.play() } else { next() } } func stop() { player?.pause() isPlaying = false currentSong = nil levelTimer?.invalidate(); levelTimer = nil audioLevels = Array(repeating: 0, count: 8) if let observer = timeObserver { player?.removeTimeObserver(observer); timeObserver = nil } } // MARK: - Now Playing private func updateNowPlayingInfo() { var info: [String: Any] = [:] info[MPMediaItemPropertyTitle] = currentSong?.title ?? "Unknown" info[MPMediaItemPropertyArtist] = currentSong?.artist ?? "Unknown" info[MPMediaItemPropertyAlbumTitle] = currentSong?.album ?? "" info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime info[MPMediaItemPropertyPlaybackDuration] = duration info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0 MPNowPlayingInfoCenter.default().nowPlayingInfo = info } // MARK: - Remote Controls private func setupRemoteControls() { let c = MPRemoteCommandCenter.shared() c.playCommand.addTarget { [weak self] _ in self?.resume(); return .success } c.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success } c.togglePlayPauseCommand.addTarget { [weak self] _ in self?.togglePlayPause(); return .success } c.nextTrackCommand.addTarget { [weak self] _ in self?.next(); return .success } c.previousTrackCommand.addTarget { [weak self] _ in self?.previous(); return .success } c.changePlaybackPositionCommand.addTarget { [weak self] event in guard let e = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } self?.seek(to: e.positionTime); return .success } } // MARK: - Helpers var progressPercent: Double { guard duration > 0 else { return 0 }; return currentTime / duration } var currentTimeFormatted: String { formatTime(currentTime) } var remainingTimeFormatted: String { "-" + formatTime(max(0, duration - currentTime)) } private func formatTime(_ seconds: TimeInterval) -> String { let s = Int(max(0, seconds)); return String(format: "%d:%02d", s / 60, s % 60) } }