From bec30920015db95c1d5ba01cfe1d256182de7093 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 8 Sep 2025 13:18:46 -0500 Subject: [PATCH] Signed-off-by: Matt Bruce --- PRD.md | 32 ++++- TheNoiseClock.xcodeproj/project.pbxproj | 15 +++ TheNoiseClock/Info.plist | 10 ++ TheNoiseClock/Models/ClockStyle.swift | 9 +- TheNoiseClock/Services/NoisePlayer.swift | 118 ++++++++++++++++++ TheNoiseClock/Services/WakeLockService.swift | 79 ++++++++++++ TheNoiseClock/ViewModels/ClockViewModel.swift | 17 +++ .../Views/Clock/ClockSettingsView.swift | 10 ++ 8 files changed, 283 insertions(+), 7 deletions(-) create mode 100644 TheNoiseClock/Info.plist create mode 100644 TheNoiseClock/Services/WakeLockService.swift diff --git a/PRD.md b/PRD.md index dc4a928..7914918 100644 --- a/PRD.md +++ b/PRD.md @@ -46,6 +46,8 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di - **Safe area expansion**: Clock expands into tab bar area when hidden - **Dynamic Island awareness**: Proper spacing to avoid Dynamic Island overlap - **Orientation handling**: Full-screen mode works in both portrait and landscape +- **Keep awake functionality**: Optional screen wake lock to prevent device sleep in display mode +- **Battery optimization**: Wake lock automatically disabled when exiting display mode ### 4. Information Overlays - **Battery level display**: Real-time battery percentage with icon @@ -64,6 +66,10 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di - **JSON-based configuration**: Sound definitions loaded from external configuration - **Bundle organization**: Sounds organized in category-based bundles - **Shared audio player**: Singleton pattern prevents audio conflicts +- **Background audio support**: Continues playing when app is backgrounded +- **Audio interruption handling**: Automatically resumes after phone calls or route changes +- **Wake lock integration**: Prevents device sleep while audio is playing +- **Bluetooth audio support**: Works with AirPods and other Bluetooth audio devices ### 6. Advanced Alarm System - **Multiple alarms**: Create and manage unlimited alarms @@ -200,9 +206,12 @@ These principles are fundamental to the project's long-term success and must be - **AVFoundation**: AVAudioPlayer for noise playback - **@Observable NoisePlayer**: Modern state management with preloading - **Looping playback**: Infinite loop for ambient sounds -- **Audio session management**: Proper audio session configuration +- **Audio session management**: Proper audio session configuration with background support - **Error handling**: Graceful handling of missing audio files - **AlarmTonePlayer**: Dedicated player for alarm sound previews +- **Background audio**: Continues playback when app is backgrounded +- **Interruption handling**: Automatic resume after phone calls and route changes +- **Wake lock integration**: Prevents device sleep during audio playback ### Notification System - **UserNotifications**: iOS notification framework @@ -212,6 +221,14 @@ These principles are fundamental to the project's long-term success and must be - **Sound customization**: System sound selection with volume control - **Multiple notifications**: Support for repeating alarms with unique identifiers +### Wake Lock System +- **WakeLockService**: Singleton service for managing screen wake lock +- **Display mode integration**: Automatically enables wake lock in full-screen display mode +- **Audio integration**: Enables wake lock during audio playback to prevent device sleep +- **Battery optimization**: Automatic wake lock management with proper cleanup +- **Timer-based maintenance**: Periodic wake lock refresh to ensure continuous operation +- **State management**: Tracks wake lock status and provides toggle functionality + ## User Interface Design ### Navigation @@ -292,9 +309,10 @@ TheNoiseClock/ │ ├── SoundPickerView.swift # Sound selection component │ └── SoundControlView.swift # Playback controls component ├── Services/ -│ ├── NoisePlayer.swift # Audio playback service +│ ├── NoisePlayer.swift # Audio playback service with background support │ ├── AlarmService.swift # Alarm management service -│ └── NotificationService.swift # Notification handling service +│ ├── NotificationService.swift # Notification handling service +│ └── WakeLockService.swift # Screen wake lock management service └── Resources/ ├── sounds.json # Sound configuration and definitions ├── Ambient.bundle/ # Ambient sound category @@ -370,8 +388,9 @@ The following changes **automatically require** PRD updates: ### Settings 1. **Time format**: Toggle 24-hour, seconds, AM/PM display 2. **Appearance**: Adjust colors, glow, size, opacity -3. **Overlays**: Control battery and date display -4. **Background**: Set background color and use presets +3. **Display**: Control keep awake functionality for display mode +4. **Overlays**: Control battery and date display +5. **Background**: Set background color and use presets ### Alarms Tab 1. **View alarms**: List of all created alarms with labels and repeat schedules @@ -411,12 +430,13 @@ The following changes **automatically require** PRD updates: ### Dependencies - **SwiftUI**: Native iOS UI framework with latest features -- **AVFoundation**: Audio playback with modern async patterns +- **AVFoundation**: Audio playback with modern async patterns and background support - **UserNotifications**: Alarm notifications with rich content support - **Combine**: Timer publishers and reactive programming - **Observation**: Modern state management with @Observable - **Foundation**: Core system frameworks and utilities - **UIKit**: UIFont integration for precise text measurement and font customization +- **UIApplication**: Screen wake lock management and idle timer control ### Font and Typography Utilities - **FontUtils.optimalFontSize()**: Calculates optimal font size for portrait orientation diff --git a/TheNoiseClock.xcodeproj/project.pbxproj b/TheNoiseClock.xcodeproj/project.pbxproj index fb2a37c..d2e8f1e 100644 --- a/TheNoiseClock.xcodeproj/project.pbxproj +++ b/TheNoiseClock.xcodeproj/project.pbxproj @@ -29,9 +29,22 @@ EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + EA384D3B2E6F554D00CA7D50 /* Exceptions for "TheNoiseClock" folder in "TheNoiseClock" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = EA384AFA2E6E6B6000CA7D50 /* TheNoiseClock */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + EA384D3B2E6F554D00CA7D50 /* Exceptions for "TheNoiseClock" folder in "TheNoiseClock" target */, + ); path = TheNoiseClock; sourceTree = ""; }; @@ -400,6 +413,7 @@ DEVELOPMENT_TEAM = 6R7KLBPBLZ; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TheNoiseClock/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -431,6 +445,7 @@ DEVELOPMENT_TEAM = 6R7KLBPBLZ; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TheNoiseClock/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/TheNoiseClock/Info.plist b/TheNoiseClock/Info.plist new file mode 100644 index 0000000..f753731 --- /dev/null +++ b/TheNoiseClock/Info.plist @@ -0,0 +1,10 @@ + + + + + UIBackgroundModes + + audio + + + diff --git a/TheNoiseClock/Models/ClockStyle.swift b/TheNoiseClock/Models/ClockStyle.swift index 9bfd51c..e53473a 100644 --- a/TheNoiseClock/Models/ClockStyle.swift +++ b/TheNoiseClock/Models/ClockStyle.swift @@ -36,6 +36,9 @@ class ClockStyle: Codable, Equatable { var clockOpacity: Double = AppConstants.Defaults.clockOpacity var overlayOpacity: Double = AppConstants.Defaults.overlayOpacity + // MARK: - Display Settings + var keepAwake: Bool = false // Keep screen awake in display mode + // MARK: - Cached Colors private var _cachedDigitColor: Color? private var _cachedBackgroundColor: Color? @@ -58,6 +61,7 @@ class ClockStyle: Codable, Equatable { case dateFormat case clockOpacity case overlayOpacity + case keepAwake } // MARK: - Initialization @@ -85,6 +89,7 @@ class ClockStyle: Codable, Equatable { self.dateFormat = try container.decodeIfPresent(String.self, forKey: .dateFormat) ?? self.dateFormat self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity + self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake clearColorCache() } @@ -107,6 +112,7 @@ class ClockStyle: Codable, Equatable { try container.encode(dateFormat, forKey: .dateFormat) try container.encode(clockOpacity, forKey: .clockOpacity) try container.encode(overlayOpacity, forKey: .overlayOpacity) + try container.encode(keepAwake, forKey: .keepAwake) } // MARK: - Computed Properties @@ -151,7 +157,8 @@ class ClockStyle: Codable, Equatable { lhs.showDate == rhs.showDate && lhs.dateFormat == rhs.dateFormat && lhs.clockOpacity == rhs.clockOpacity && - lhs.overlayOpacity == rhs.overlayOpacity + lhs.overlayOpacity == rhs.overlayOpacity && + lhs.keepAwake == rhs.keepAwake } } diff --git a/TheNoiseClock/Services/NoisePlayer.swift b/TheNoiseClock/Services/NoisePlayer.swift index c819965..e7ce1d5 100644 --- a/TheNoiseClock/Services/NoisePlayer.swift +++ b/TheNoiseClock/Services/NoisePlayer.swift @@ -18,11 +18,15 @@ class NoisePlayer { // MARK: - Properties private var players: [String: AVAudioPlayer] = [:] private var currentPlayer: AVAudioPlayer? + private var currentSound: Sound? + private var shouldResumeAfterInterruption = false + private let wakeLockService = WakeLockService.shared // MARK: - Initialization private init() { setupAudioSession() preloadSounds() + setupAudioInterruptionHandling() } deinit { @@ -39,6 +43,9 @@ class NoisePlayer { // 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)") @@ -71,11 +78,21 @@ class NoisePlayer { 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() + } } func stopSound() { currentPlayer?.stop() currentPlayer = nil + currentSound = nil + shouldResumeAfterInterruption = false + + // Disable wake lock when stopping audio + wakeLockService.disableWakeLock() } // MARK: - Private Methods @@ -117,6 +134,12 @@ class NoisePlayer { 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, .allowBluetooth, .allowBluetoothA2DP]) + try AVAudioSession.sharedInstance().setActive(true) + + print("🔊 Audio session configured for background playback") } catch { print("Error setting up audio session: \(error)") } @@ -157,5 +180,100 @@ class NoisePlayer { } 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)") + } } } diff --git a/TheNoiseClock/Services/WakeLockService.swift b/TheNoiseClock/Services/WakeLockService.swift new file mode 100644 index 0000000..b0da6a3 --- /dev/null +++ b/TheNoiseClock/Services/WakeLockService.swift @@ -0,0 +1,79 @@ +// +// WakeLockService.swift +// TheNoiseClock +// +// Created by Matt Bruce on 9/7/25. +// + +import UIKit +import Observation + +/// Service to manage screen wake lock and prevent device from sleeping +@Observable +class WakeLockService { + + // MARK: - Singleton + static let shared = WakeLockService() + + // MARK: - Properties + 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 + func enableWakeLock() { + guard !isWakeLockActive else { return } + + // 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 + } + } + + isWakeLockActive = true + print("🔒 Wake lock enabled - device will not sleep") + } + + /// Disable wake lock and allow device to sleep normally + func disableWakeLock() { + guard isWakeLockActive else { return } + + // Allow device to sleep normally + UIApplication.shared.isIdleTimerDisabled = false + + // Stop the maintenance timer + wakeLockTimer?.invalidate() + wakeLockTimer = nil + + isWakeLockActive = false + print("🔓 Wake lock disabled - device can sleep normally") + } + + /// Toggle wake lock state + func toggleWakeLock() { + if isWakeLockActive { + disableWakeLock() + } else { + enableWakeLock() + } + } + + /// Check if wake lock is currently active + var isActive: Bool { + return isWakeLockActive + } +} diff --git a/TheNoiseClock/ViewModels/ClockViewModel.swift b/TheNoiseClock/ViewModels/ClockViewModel.swift index 34c0ef8..8a00441 100644 --- a/TheNoiseClock/ViewModels/ClockViewModel.swift +++ b/TheNoiseClock/ViewModels/ClockViewModel.swift @@ -19,6 +19,9 @@ class ClockViewModel { private(set) var style = ClockStyle() private(set) var isDisplayMode = false + // Wake lock service + private let wakeLockService = WakeLockService.shared + // Timer management private var secondTimer: Timer.TimerPublisher? private var minuteTimer: Timer.TimerPublisher? @@ -54,12 +57,16 @@ class ClockViewModel { withAnimation(UIConstants.AnimationCurves.bouncy) { isDisplayMode.toggle() } + + // Manage wake lock based on display mode and keep awake setting + updateWakeLockState() } func updateStyle(_ newStyle: ClockStyle) { style = newStyle saveStyle() updateTimersIfNeeded() + updateWakeLockState() } // MARK: - Private Methods @@ -130,4 +137,14 @@ class ClockViewModel { secondTimer = nil } } + + /// Update wake lock state based on current settings + private func updateWakeLockState() { + // Enable wake lock if in display mode and keep awake is enabled + if isDisplayMode && style.keepAwake { + wakeLockService.enableWakeLock() + } else { + wakeLockService.disableWakeLock() + } + } } diff --git a/TheNoiseClock/Views/Clock/ClockSettingsView.swift b/TheNoiseClock/Views/Clock/ClockSettingsView.swift index 9dcc1ea..5ad83e7 100644 --- a/TheNoiseClock/Views/Clock/ClockSettingsView.swift +++ b/TheNoiseClock/Views/Clock/ClockSettingsView.swift @@ -35,6 +35,7 @@ struct ClockSettingsView: View { backgroundColor: $backgroundColor, onCommit: onCommit ) + DisplaySection(style: $style) OverlaySection(style: $style) } .navigationTitle("Clock Settings") @@ -190,6 +191,15 @@ private struct OverlaySection: View { } } +private struct DisplaySection: View { + @Binding var style: ClockStyle + + var body: some View { + Section(header: Text("Display"), footer: Text("Keep the screen awake when in full-screen display mode. This prevents the device from sleeping while viewing the clock.")) { + Toggle("Keep Awake in Display Mode", isOn: $style.keepAwake) + } + } +} // MARK: - Preview #Preview {