diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..a3e2aee --- /dev/null +++ b/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "AudioPlaybackKit", + platforms: [ + .iOS(.v17), + .tvOS(.v17) + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "AudioPlaybackKit", + targets: ["AudioPlaybackKit"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "AudioPlaybackKit", + dependencies: [] + ), + .testTarget( + name: "AudioPlaybackKitTests", + dependencies: ["AudioPlaybackKit"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..71bcdd3 --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# AudioPlaybackKit + +A Swift package for audio playback functionality, specifically designed for white noise and ambient sound applications. + +## Features + +- **Audio Playback**: High-quality audio playback with background support +- **Sound Management**: Load and manage audio files from bundles +- **Wake Lock**: Prevent device from sleeping during audio playback +- **Interruption Handling**: Automatic handling of audio interruptions (calls, etc.) +- **Configuration**: JSON-based sound configuration system +- **Preview Support**: Built-in audio preview functionality + +## Requirements + +- iOS 16.0+ / macOS 13.0+ +- Swift 5.9+ + +## Installation + +### Swift Package Manager + +Add the following to your `Package.swift` file: + +```swift +dependencies: [ + .package(url: "https://github.com/yourusername/AudioPlaybackKit.git", from: "1.0.0") +] +``` + +Or add it through Xcode: +1. File → Add Package Dependencies +2. Enter the repository URL +3. Select the version and add to your target + +## Usage + +### Basic Audio Playback + +```swift +import AudioPlaybackKit + +// Get the shared player instance +let player = NoisePlayer.shared + +// Create a sound +let sound = Sound( + name: "White Noise", + fileName: "white-noise.mp3", + category: "ambient", + description: "Classic white noise" +) + +// Play the sound +player.playSound(sound) + +// Stop the sound +player.stopSound() +``` + +### Using the ViewModel + +```swift +import AudioPlaybackKit + +// Create a view model +let viewModel = NoiseViewModel() + +// Get available sounds +let sounds = viewModel.availableSounds + +// Play a sound +viewModel.playSound(sounds.first!) + +// Preview a sound (3 seconds) +viewModel.previewSound(sounds.first!) + +// Stop playback +viewModel.stopSound() +``` + +### Sound Configuration + +Create a `sounds.json` file in your app bundle: + +```json +{ + "sounds": [ + { + "id": "white-noise", + "name": "White Noise", + "fileName": "white-noise.mp3", + "category": "ambient", + "description": "Classic white noise for focus and relaxation", + "bundleName": "Ambient" + } + ], + "categories": [ + { + "id": "ambient", + "name": "Ambient", + "description": "General ambient sounds", + "bundleName": "Ambient" + } + ], + "settings": { + "defaultVolume": 0.8, + "defaultLoopCount": -1, + "preloadSounds": true, + "preloadStrategy": "category", + "audioSessionCategory": "playback", + "audioSessionMode": "default", + "audioSessionOptions": ["mixWithOthers"] + } +} +``` + +### Wake Lock Service + +```swift +import AudioPlaybackKit + +let wakeLock = WakeLockService.shared + +// Enable wake lock (prevents device from sleeping) +wakeLock.enableWakeLock() + +// Disable wake lock +wakeLock.disableWakeLock() + +// Check if active +if wakeLock.isActive { + print("Wake lock is active") +} +``` + +## Architecture + +### Core Components + +- **NoisePlayer**: Main audio playback service +- **SoundConfigurationService**: Manages sound configuration and loading +- **WakeLockService**: Prevents device from sleeping during playback +- **NoiseViewModel**: SwiftUI-friendly view model for audio playback + +### Models + +- **Sound**: Represents an audio file with metadata +- **SoundConfiguration**: JSON configuration structure +- **AudioSettings**: Audio session and playback settings + +## License + +This package is available under the MIT license. See the LICENSE file for more info. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/Sources/AudioPlaybackKit/Constants/AudioConstants.swift b/Sources/AudioPlaybackKit/Constants/AudioConstants.swift new file mode 100644 index 0000000..d687a55 --- /dev/null +++ b/Sources/AudioPlaybackKit/Constants/AudioConstants.swift @@ -0,0 +1,33 @@ +// +// AudioConstants.swift +// AudioPlaybackKit +// +// Created by Matt Bruce on 9/8/25. +// + +import Foundation +import AVFAudio + +/// Audio-related constants and configuration +public enum AudioConstants { + + // MARK: - Audio Session Configuration + public enum AudioSession { + public static let category = AVAudioSession.Category.playback + public static let mode = AVAudioSession.Mode.default + public static let options: AVAudioSession.CategoryOptions = [.mixWithOthers] + } + + // MARK: - Playback Settings + public enum Playback { + public static let numberOfLoops = -1 // Infinite loop + public static let prepareToPlay = true + } + + // MARK: - Volume + public enum Volume { + public static let min: Float = 0.0 + public static let max: Float = 1.0 + public static let `default`: Float = 0.8 + } +} diff --git a/Sources/AudioPlaybackKit/Models/Sound.swift b/Sources/AudioPlaybackKit/Models/Sound.swift new file mode 100644 index 0000000..9568ca2 --- /dev/null +++ b/Sources/AudioPlaybackKit/Models/Sound.swift @@ -0,0 +1,67 @@ +// +// Sound.swift +// AudioPlaybackKit +// +// Created by Matt Bruce on 9/8/25. +// + +import Foundation + +/// Sound data model for audio files +public struct Sound: Identifiable, Hashable, Codable { + public let id: String + public let name: String + public let fileName: String + public let category: String + public let description: String + public let bundleName: String? // Optional bundle name for organization + public let isDefault: Bool? // Optional - used for alarm sounds to mark default + + // MARK: - Initialization + public init(id: String? = nil, name: String, fileName: String, category: String, description: String, bundleName: String? = nil, isDefault: Bool? = nil) { + self.id = id ?? UUID().uuidString // Use provided id or generate GUID + self.name = name + self.fileName = fileName + self.category = category + self.description = description + self.bundleName = bundleName + self.isDefault = isDefault + } + + // MARK: - Codable Custom Implementation + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Generate a new GUID for each sound loaded from JSON + self.id = UUID().uuidString + + self.name = try container.decode(String.self, forKey: .name) + self.fileName = try container.decode(String.self, forKey: .fileName) + self.category = try container.decode(String.self, forKey: .category) + self.description = try container.decode(String.self, forKey: .description) + self.bundleName = try container.decodeIfPresent(String.self, forKey: .bundleName) + self.isDefault = try container.decodeIfPresent(Bool.self, forKey: .isDefault) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(fileName, forKey: .fileName) + try container.encode(category, forKey: .category) + try container.encode(description, forKey: .description) + try container.encodeIfPresent(bundleName, forKey: .bundleName) + try container.encodeIfPresent(isDefault, forKey: .isDefault) + } + + private enum CodingKeys: String, CodingKey { + case id, name, fileName, category, description, bundleName, isDefault + } + + // MARK: - Hashable + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + +} diff --git a/Sources/AudioPlaybackKit/Models/SoundConfiguration.swift b/Sources/AudioPlaybackKit/Models/SoundConfiguration.swift new file mode 100644 index 0000000..71759f4 --- /dev/null +++ b/Sources/AudioPlaybackKit/Models/SoundConfiguration.swift @@ -0,0 +1,155 @@ +// +// SoundConfiguration.swift +// AudioPlaybackKit +// +// Created by Matt Bruce on 9/8/25. +// + +import Foundation + +/// Configuration model for sound system loaded from JSON +public struct SoundConfiguration: Codable { + public let sounds: [Sound] + public let settings: AudioSettings + + public init(sounds: [Sound], settings: AudioSettings) { + self.sounds = sounds + self.settings = settings + } +} + +/// Simple struct for loading just the sounds array from category JSON files +public struct SoundsOnly: Codable { + public let sounds: [Sound] + + public init(sounds: [Sound]) { + self.sounds = sounds + } +} + + + +/// Audio settings configuration +public struct AudioSettings: Codable { + public let defaultVolume: Float + public let defaultLoopCount: Int + public let preloadSounds: Bool + public let preloadStrategy: String // "all", "category", "none" + public let audioSessionCategory: String + public let audioSessionMode: String + public let audioSessionOptions: [String] + + public init(defaultVolume: Float, defaultLoopCount: Int, preloadSounds: Bool, preloadStrategy: String, audioSessionCategory: String, audioSessionMode: String, audioSessionOptions: [String]) { + self.defaultVolume = defaultVolume + self.defaultLoopCount = defaultLoopCount + self.preloadSounds = preloadSounds + self.preloadStrategy = preloadStrategy + self.audioSessionCategory = audioSessionCategory + self.audioSessionMode = audioSessionMode + self.audioSessionOptions = audioSessionOptions + } +} + +/// Service for loading and managing sound configuration +public class SoundConfigurationService { + public static let shared = SoundConfigurationService() + + private var configuration: SoundConfiguration? + + private init() {} + + + /// Load audio settings from SoundsSettings.json + private func loadAudioSettings(from bundle: Bundle = .main) -> AudioSettings { + guard let url = bundle.url(forResource: "SoundsSettings", withExtension: "json") else { + print("⚠️ Warning: SoundsSettings.json not found, using default settings") + return AudioSettings( + defaultVolume: 0.8, + defaultLoopCount: -1, + preloadSounds: true, + preloadStrategy: "category", + audioSessionCategory: "playback", + audioSessionMode: "default", + audioSessionOptions: ["mixWithOthers"] + ) + } + + do { + let data = try Data(contentsOf: url) + let settings = try JSONDecoder().decode(AudioSettings.self, from: data) + print("✅ Loaded audio settings from SoundsSettings.json") + return settings + } catch { + print("⚠️ Warning: Error loading audio settings, using defaults: \(error)") + return AudioSettings( + defaultVolume: 0.8, + defaultLoopCount: -1, + preloadSounds: true, + preloadStrategy: "category", + audioSessionCategory: "playback", + audioSessionMode: "default", + audioSessionOptions: ["mixWithOthers"] + ) + } + } + + /// Load sound configuration from multiple category-specific JSON files + public func loadConfigurationFromBundles(from bundle: Bundle = .main) -> SoundConfiguration { + // Include AlarmSounds bundle for alarm sound preview functionality + let bundleNames = ["Colored", "Nature", "Mechanical", "Ambient", "AlarmSounds"] + var allSounds: [Sound] = [] + + for bundleName in bundleNames { + guard let bundleURL = bundle.url(forResource: bundleName, withExtension: "bundle"), + let categoryBundle = Bundle(url: bundleURL), + let url = categoryBundle.url(forResource: "sounds", withExtension: "json") else { + print("⚠️ Warning: Could not find sounds.json in \(bundleName).bundle") + continue + } + + do { + let data = try Data(contentsOf: url) + let soundsOnly = try JSONDecoder().decode(SoundsOnly.self, from: data) + allSounds.append(contentsOf: soundsOnly.sounds) + + print("✅ Loaded \(soundsOnly.sounds.count) sounds from \(bundleName).bundle") + } catch { + print("⚠️ Warning: Error loading sounds from \(bundleName).bundle: \(error)") + } + } + + // Load settings from separate file + let settings = loadAudioSettings(from: bundle) + + let config = SoundConfiguration(sounds: allSounds, settings: settings) + self.configuration = config + print("✅ Loaded combined sound configuration with \(allSounds.count) sounds from \(bundleNames.count) bundles") + return config + } + + /// Get current configuration + public func getConfiguration() -> SoundConfiguration { + if configuration == nil { + return loadConfigurationFromBundles() + } + return configuration! + } + + /// Get all available sounds + public func getAvailableSounds() -> [Sound] { + return getConfiguration().sounds + } + + /// Get sounds by category + public func getSoundsByCategory(_ categoryId: String) -> [Sound] { + return getConfiguration().sounds + .filter { $0.category == categoryId } + } + + + /// Get audio settings + public func getAudioSettings() -> AudioSettings { + return getConfiguration().settings + } + +} diff --git a/Sources/AudioPlaybackKit/Services/SoundPlayer.swift b/Sources/AudioPlaybackKit/Services/SoundPlayer.swift new file mode 100644 index 0000000..9ab4820 --- /dev/null +++ b/Sources/AudioPlaybackKit/Services/SoundPlayer.swift @@ -0,0 +1,321 @@ +// +// 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 let playersLock = NSLock() + 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() + setupAudioInterruptionHandling() + // Preload sounds off the main thread to avoid blocking UI during app launch + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.preloadSounds() + } + } + + 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 + playersLock.lock() + let player = players[sound.fileName] + playersLock.unlock() + + guard let player else { + playersLock.lock() + let availableKeys = Array(players.keys) + playersLock.unlock() + print("❌ Sound not preloaded: \(sound.fileName)") + print("📁 Available sounds: \(availableKeys)") + + // 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() + playersLock.lock() + players[sound.fileName] = newPlayer + playersLock.unlock() + 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() + } + playersLock.lock() + players[sound.fileName] = player + playersLock.unlock() + print("✅ Loaded: \(sound.name) (\(sound.fileName))") + } catch { + print("❌ Error preloading sound \(sound.fileName): \(error)") + } + } + playersLock.lock() + let count = players.count + playersLock.unlock() + print("📁 Preloading complete. Loaded \(count) sounds.") + } + + private func stopAllSounds() { + playersLock.lock() + for player in players.values { + player.stop() + } + players.removeAll() + playersLock.unlock() + 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)") + } + } +} diff --git a/Sources/AudioPlaybackKit/Services/WakeLockService.swift b/Sources/AudioPlaybackKit/Services/WakeLockService.swift new file mode 100644 index 0000000..0e45408 --- /dev/null +++ b/Sources/AudioPlaybackKit/Services/WakeLockService.swift @@ -0,0 +1,86 @@ +// +// WakeLockService.swift +// AudioPlaybackKit +// +// Created by Matt Bruce on 9/8/25. +// + +#if canImport(UIKit) +import UIKit +#endif +import Observation + +/// Service to manage screen wake lock and prevent device from sleeping +@available(iOS 17.0, tvOS 17.0, *) +@Observable +public class WakeLockService { + + // MARK: - Singleton + public static let shared = WakeLockService() + + // MARK: - Properties + public private(set) var isWakeLockActive = false + private var wakeLockTimer: Timer? + + // MARK: - Initialization + private init() {} + + deinit { + disableWakeLock() + } + + // MARK: - Public Interface + + /// Enable wake lock to prevent device from sleeping + public func enableWakeLock() { + guard !isWakeLockActive else { return } + + #if canImport(UIKit) + // Prevent device from sleeping + UIApplication.shared.isIdleTimerDisabled = true + + // Set up periodic timer to maintain wake lock + wakeLockTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { _ in + // Keep the app active by briefly enabling/disabling idle timer + UIApplication.shared.isIdleTimerDisabled = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + UIApplication.shared.isIdleTimerDisabled = true + } + } + #endif + + isWakeLockActive = true + print("🔒 Wake lock enabled - device will not sleep") + } + + /// Disable wake lock and allow device to sleep normally + public func disableWakeLock() { + guard isWakeLockActive else { return } + + #if canImport(UIKit) + // Allow device to sleep normally + UIApplication.shared.isIdleTimerDisabled = false + #endif + + // Stop the maintenance timer + wakeLockTimer?.invalidate() + wakeLockTimer = nil + + isWakeLockActive = false + print("🔓 Wake lock disabled - device can sleep normally") + } + + /// Toggle wake lock state + public func toggleWakeLock() { + if isWakeLockActive { + disableWakeLock() + } else { + enableWakeLock() + } + } + + /// Check if wake lock is currently active + public var isActive: Bool { + return isWakeLockActive + } +} diff --git a/Sources/AudioPlaybackKit/ViewModels/SoundViewModel.swift b/Sources/AudioPlaybackKit/ViewModels/SoundViewModel.swift new file mode 100644 index 0000000..1c1528e --- /dev/null +++ b/Sources/AudioPlaybackKit/ViewModels/SoundViewModel.swift @@ -0,0 +1,83 @@ +// +// SoundViewModel.swift +// AudioPlaybackKit +// +// Created by Matt Bruce on 9/8/25. +// + +import Foundation +import Observation + +/// ViewModel for sound/audio playback +@available(iOS 17.0, tvOS 17.0, *) +@Observable +public class SoundViewModel { + + // MARK: - Properties + private let soundPlayer: SoundPlayer + private let soundConfigurationService: SoundConfigurationService + public var isPreviewing: Bool = false + public var previewSound: Sound? + + public var selectedSound: Sound? + + public var isPlaying: Bool { + soundPlayer.isPlaying + } + + public var availableSounds: [Sound] { + return soundConfigurationService.getAvailableSounds() + } + + // MARK: - Initialization + public init(soundPlayer: SoundPlayer = SoundPlayer.shared, soundConfigurationService: SoundConfigurationService = SoundConfigurationService.shared) { + self.soundPlayer = soundPlayer + self.soundConfigurationService = soundConfigurationService + } + + // MARK: - Public Interface + public func playSound(_ sound: Sound) { + soundPlayer.playSound(sound) + } + + public func stopSound() { + soundPlayer.stopSound() + } + + public func selectSound(_ sound: Sound) { + // Stop any current playback when selecting a new sound + if isPlaying { + stopSound() + } + // Stop any preview + stopPreview() + + selectedSound = sound + } + + // MARK: - Preview Functionality + public func previewSound(_ sound: Sound) { + // Stop any current preview + stopPreview() + + // Set preview state + previewSound = sound + isPreviewing = true + + // Play preview (3 seconds) + soundPlayer.playSound(sound) + + // Auto-stop preview after 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + self.stopPreview() + } + } + + public func stopPreview() { + if isPreviewing { + soundPlayer.stopSound() + isPreviewing = false + previewSound = nil + } + } +} diff --git a/Tests/AudioPlaybackKitTests/AudioPlaybackKitTests.swift b/Tests/AudioPlaybackKitTests/AudioPlaybackKitTests.swift new file mode 100644 index 0000000..8411a81 --- /dev/null +++ b/Tests/AudioPlaybackKitTests/AudioPlaybackKitTests.swift @@ -0,0 +1,48 @@ +// +// AudioPlaybackKitTests.swift +// AudioPlaybackKitTests +// +// Created by Matt Bruce on 9/8/25. +// + +import XCTest +@testable import AudioPlaybackKit + +final class AudioPlaybackKitTests: XCTestCase { + + func testSoundModel() throws { + let sound = Sound( + name: "Test Sound", + fileName: "test.mp3", + category: "test", + description: "Test description" + ) + + XCTAssertEqual(sound.name, "Test Sound") + XCTAssertEqual(sound.fileName, "test.mp3") + XCTAssertEqual(sound.category, "test") + } + + func testSoundConfigurationService() throws { + let service = SoundConfigurationService.shared + let sounds = service.getAvailableSounds() + + // Should have fallback sounds if no configuration is loaded + XCTAssertFalse(sounds.isEmpty) + } + + func testWakeLockService() throws { + let service = WakeLockService.shared + + // Initially should not be active + XCTAssertFalse(service.isActive) + + // Enable wake lock + service.enableWakeLock() + XCTAssertTrue(service.isActive) + + // Disable wake lock + service.disableWakeLock() + XCTAssertFalse(service.isActive) + } +}