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.
399 lines
16 KiB
Swift
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)
|
|
}
|
|
}
|