import Foundation import AVFoundation import Combine import WatchKit import MediaPlayer import HealthKit /// watchOS audio player with dual-mode output: /// /// **Bluetooth Mode** (default): /// Uses `.longFormAudio` policy. Requires Bluetooth/AirPlay. Background playback is /// handled by the audio session itself. /// /// **Speaker Mode** (Watch Ultra): /// Uses `.default` policy which routes to the built-in speaker. Background runtime is /// maintained by a silent `HKWorkoutSession`. watchOS keeps workout apps alive indefinitely /// even when the screen sleeps. A green workout indicator appears on the watch face. /// /// 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 @Published 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? // HealthKit workout session for speaker background runtime private let healthStore = HKHealthStore() private var workoutSession: HKWorkoutSession? private var workoutBuilder: HKLiveWorkoutBuilder? 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 // Check model name heuristic 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 try session.setCategory(.playback, mode: .default, policy: .default) 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 useSpeakerMode { startWorkoutSession() } else { stopWorkoutSession() } if wasPlaying { player?.play() } updateRouteInfo() } // MARK: - HKWorkoutSession (keeps app alive for speaker mode) private func startWorkoutSession() { guard workoutSession == nil else { return } guard HKHealthStore.isHealthDataAvailable() else { print("[WatchAudio] HealthKit not available") return } let config = HKWorkoutConfiguration() config.activityType = .mindAndBody // Least intrusive workout type config.locationType = .indoor do { let session = try HKWorkoutSession(healthStore: healthStore, configuration: config) let builder = session.associatedWorkoutBuilder() builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: config) session.delegate = self builder.delegate = self session.startActivity(with: Date()) builder.beginCollection(withStart: Date()) { success, error in if let error = error { print("[WatchAudio] Workout builder start failed: \(error.localizedDescription)") } else { print("[WatchAudio] Workout session started (speaker background mode)") } } workoutSession = session workoutBuilder = builder } catch { print("[WatchAudio] Failed to start workout session: \(error.localizedDescription)") } } private func stopWorkoutSession() { guard let session = workoutSession else { return } session.end() workoutBuilder?.endCollection(withEnd: Date()) { [weak self] success, error in self?.workoutBuilder?.finishWorkout { workout, error in print("[WatchAudio] Workout session ended") } } workoutSession = nil workoutBuilder = nil } // MARK: - Session Activation (Bluetooth mode only) func activateSession() async -> Bool { if useSpeakerMode { // Speaker mode doesn't need activation — just start workout await MainActor.run { startWorkoutSession() isSessionActive = true updateRouteInfo() } return true } 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: start workout if needed, then play startWorkoutSession() 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 } self.updateNowPlayingInfo() } 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() { if useSpeakerMode { startWorkoutSession() } 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 } } @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 } if useSpeakerMode { stopWorkoutSession() } } // 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) } } // MARK: - HKWorkoutSession Delegate extension WatchAudioPlayer: HKWorkoutSessionDelegate { func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) { print("[WatchAudio] Workout state: \(fromState.rawValue) → \(toState.rawValue)") if toState == .ended { DispatchQueue.main.async { self.workoutSession = nil self.workoutBuilder = nil } } } func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) { print("[WatchAudio] Workout session failed: \(error.localizedDescription)") } } // MARK: - HKLiveWorkoutBuilder Delegate extension WatchAudioPlayer: HKLiveWorkoutBuilderDelegate { func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {} func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set) {} }