NavidromeApp/watchOS/Audio/WatchAudioPlayer.swift
Dallas Groot 97f2f9ab76 Watch: large volume buttons, remove Digital Crown volume
Volume controls moved to dedicated row with large tap targets
(36pt height, full-width, dark background, percentage readout).
Digital Crown binding removed — buttons are the primary control.
Applied to both local playback and iPhone remote views.
2026-04-30 23:13:40 -07:00

399 lines
16 KiB
Swift

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 {
// Always use .longFormAudio this is the key to background runtime.
// On watchOS 11+, .longFormAudio falls through to the built-in speaker
// when no Bluetooth device is connected. The system handles routing:
// - Bluetooth connected routes to Bluetooth
// - No Bluetooth routes to speaker (Watch Ultra)
// Activation MUST use activate(options:completionHandler:), not setActive(true).
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
print("[WatchAudio] Session: .playback / .longFormAudio")
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
/// Activate the audio session. Must be called before playback.
/// Uses activate(options:completionHandler:) which is required for .longFormAudio.
/// On watchOS 11+, this routes to speaker if no Bluetooth is available.
func activateSession() async -> Bool {
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:
// Bluetooth disconnected. If speaker is available (Watch Ultra),
// audio will automatically route to speaker don't pause.
// Only pause if no speaker (standard Watch models).
if !self.isSpeakerAvailable {
print("[WatchAudio] Bluetooth disconnected, no speaker — pausing")
self.pause()
} else {
print("[WatchAudio] Bluetooth disconnected — continuing on speaker")
}
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
}
// Activate session before playback. .longFormAudio provides background
// runtime. On watchOS 11+, routes to speaker if no BT connected.
if isSessionActive && (isBluetoothConnected || useSpeakerMode) {
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)
}
}