283 lines
10 KiB
Swift
283 lines
10 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?
|
|
private 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) {
|
|
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 = 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
|
|
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
|
|
currentSound = nil
|
|
shouldResumeAfterInterruption = false
|
|
|
|
// Disable wake lock when stopping audio
|
|
wakeLockService.disableWakeLock()
|
|
}
|
|
|
|
// 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)
|
|
return Bundle.main.url(forResource: sound.fileName, withExtension: nil)
|
|
}
|
|
}
|
|
|
|
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)")
|
|
}
|
|
}
|
|
}
|