Features: - Dual-AVPlayer Smart DJ crossfade with LUFS normalization - Mitsuha-style FFT visualizer (real-time + offline pre-computed) - Companion API integration (Smart DJ, tag editing, vis frames) - Offline-first SyncEngine with delta sync and album detail pre-caching - Audio pre-fetcher for gapless queue playback - Optimistic action queue (star/unstar with background retry) - ShazamKit recognition with MusicKit preview playback - Radio streaming with HLS/PLS/M3U support and buffer seek - Watch app with Crown Sequencer and Ultra speaker support - Batch metadata editing with album_artist fix for split albums - Cache-first UI pattern across all views - NWPathMonitor offline detection with reactive song greying
482 lines
18 KiB
Swift
482 lines
18 KiB
Swift
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<HKSampleType>) {}
|
|
}
|