TheNoiseClock/AudioPlaybackKit/Sources/AudioPlaybackKit/Services/SoundPlayer.swift

302 lines
11 KiB
Swift

//
// SoundPlayer.swift
// AudioPlaybackKit
//
// Created by Matt Bruce on 9/8/25.
//
import AVFoundation
import Observation
/// Audio playback service for sounds and ambient audio
@available(iOS 17.0, tvOS 17.0, *)
@Observable
public class SoundPlayer {
// MARK: - Singleton
public static let shared = SoundPlayer()
// MARK: - Properties
private var players: [String: AVAudioPlayer] = [:]
private var currentPlayer: AVAudioPlayer?
public private(set) var currentSound: Sound?
private var shouldResumeAfterInterruption = false
private let wakeLockService = WakeLockService.shared
private let soundConfigurationService = SoundConfigurationService.shared
// MARK: - Initialization
private init() {
setupAudioSession()
preloadSounds()
setupAudioInterruptionHandling()
}
deinit {
stopAllSounds()
}
// MARK: - Public Interface
public var isPlaying: Bool {
return currentPlayer?.isPlaying ?? false
}
public func playSound(_ sound: Sound) {
playSound(sound, volumeOverride: nil)
}
public func playSound(_ sound: Sound, volume: Float) {
playSound(sound, volumeOverride: volume)
}
private func playSound(_ sound: Sound, volumeOverride: Float?) {
print("🎵 Attempting to play: \(sound.name)")
// Stop current sound if playing
stopSound()
// Store current sound for interruption handling
currentSound = sound
// Get or create player for this sound
guard let player = players[sound.fileName] else {
print("❌ Sound not preloaded: \(sound.fileName)")
print("📁 Available sounds: \(players.keys)")
// Try to load the sound dynamically as fallback
guard let fileUrl = getURL(for: sound) else {
print("❌ Sound file not found: \(sound.fileName)")
return
}
do {
let newPlayer = try AVAudioPlayer(contentsOf: fileUrl)
newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops
newPlayer.volume = volumeOverride ?? AudioConstants.Volume.default
newPlayer.prepareToPlay()
players[sound.fileName] = newPlayer
currentPlayer = newPlayer
let success = newPlayer.play()
print("🎵 Fallback play result: \(success ? "SUCCESS" : "FAILED")")
return
} catch {
print("❌ Error creating fallback player: \(error)")
return
}
}
currentPlayer = player
if let volumeOverride {
player.volume = volumeOverride
}
let success = player.play()
print("🎵 Play result: \(success ? "SUCCESS" : "FAILED")")
print("🔊 Player isPlaying: \(player.isPlaying)")
print("🔊 Player volume: \(player.volume)")
// Enable wake lock when playing audio to prevent device sleep
if success {
wakeLockService.enableWakeLock()
}
}
public func stopSound() {
currentPlayer?.stop()
currentPlayer = nil
shouldResumeAfterInterruption = false
// Disable wake lock when stopping audio
wakeLockService.disableWakeLock()
}
public func clearCurrentSound() {
stopSound()
currentSound = nil
}
// MARK: - Private Methods
/// Helper method to get URL for sound file, handling bundles and direct paths
private func getURL(for sound: Sound) -> URL? {
// If sound has a bundle name, look in that bundle first
if let bundleName = sound.bundleName {
if let bundleURL = Bundle.main.url(forResource: bundleName, withExtension: "bundle"),
let bundle = Bundle(url: bundleURL) {
return bundle.url(forResource: sound.fileName, withExtension: nil)
}
}
// Fallback to direct file path
if sound.fileName.contains("/") {
// Path includes subfolder (e.g., "Sounds/white-noise.mp3")
let components = sound.fileName.components(separatedBy: "/")
let fileName = components.last!
let subfolder = components.dropLast().joined(separator: "/")
return Bundle.main.url(forResource: fileName, withExtension: nil, subdirectory: subfolder)
} else {
// Direct file path (fallback)
if let url = Bundle.main.url(forResource: sound.fileName, withExtension: nil) {
return url
}
// Alarm sounds live in a subdirectory; try that next
return Bundle.main.url(forResource: sound.fileName, withExtension: nil, subdirectory: "AlarmSounds")
}
}
private func setupAudioSession() {
do {
let settings = soundConfigurationService.getAudioSettings()
// Use configuration settings or fall back to constants
let category = settings.audioSessionCategory == "playback" ?
AVAudioSession.Category.playback : AudioConstants.AudioSession.category
let mode = settings.audioSessionMode == "default" ?
AVAudioSession.Mode.default : AudioConstants.AudioSession.mode
let options: AVAudioSession.CategoryOptions = settings.audioSessionOptions.contains("mixWithOthers") == true ?
[.mixWithOthers] : AudioConstants.AudioSession.options
try AVAudioSession.sharedInstance().setCategory(category, mode: mode, options: options)
try AVAudioSession.sharedInstance().setActive(true)
// Configure for background audio playback
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers])
try AVAudioSession.sharedInstance().setActive(true)
print("🔊 Audio session configured for background playback")
} catch {
print("Error setting up audio session: \(error)")
}
}
private func preloadSounds() {
print("📁 Preloading audio files...")
// Get sound configuration
let sounds = soundConfigurationService.getAvailableSounds()
let settings = soundConfigurationService.getAudioSettings()
for sound in sounds {
guard let fileUrl = getURL(for: sound) else {
print("❌ Sound file not found: \(sound.fileName)")
continue
}
do {
let player = try AVAudioPlayer(contentsOf: fileUrl)
player.numberOfLoops = settings.defaultLoopCount
player.volume = settings.defaultVolume
if settings.preloadSounds {
player.prepareToPlay()
}
players[sound.fileName] = player
print("✅ Loaded: \(sound.name) (\(sound.fileName))")
} catch {
print("❌ Error preloading sound \(sound.fileName): \(error)")
}
}
print("📁 Preloading complete. Loaded \(players.count) sounds.")
}
private func stopAllSounds() {
for player in players.values {
player.stop()
}
players.removeAll()
currentPlayer = nil
currentSound = nil
shouldResumeAfterInterruption = false
}
/// Set up audio interruption handling to maintain playback
private func setupAudioInterruptionHandling() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAudioInterruption),
name: AVAudioSession.interruptionNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRouteChange),
name: AVAudioSession.routeChangeNotification,
object: nil
)
}
@objc private func handleAudioInterruption(notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}
switch type {
case .began:
// Audio was interrupted (e.g., phone call)
shouldResumeAfterInterruption = isPlaying
if isPlaying {
currentPlayer?.pause()
print("🔇 Audio interrupted - will resume after interruption ends")
}
case .ended:
// Audio interruption ended
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) && shouldResumeAfterInterruption {
// Resume playback
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.resumePlayback()
}
}
}
@unknown default:
break
}
}
@objc private func handleRouteChange(notification: Notification) {
guard let userInfo = notification.userInfo,
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
return
}
switch reason {
case .oldDeviceUnavailable:
// Headphones were unplugged, etc.
if isPlaying {
shouldResumeAfterInterruption = true
currentPlayer?.pause()
print("🔇 Audio route changed - will resume when new route available")
}
case .newDeviceAvailable:
// New audio device available
if shouldResumeAfterInterruption {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.resumePlayback()
}
}
default:
break
}
}
/// Resume playback after interruption
private func resumePlayback() {
guard shouldResumeAfterInterruption, let sound = currentSound else { return }
do {
try AVAudioSession.sharedInstance().setActive(true)
playSound(sound)
shouldResumeAfterInterruption = false
print("🔊 Audio playback resumed after interruption")
} catch {
print("❌ Error resuming audio playback: \(error)")
}
}
}