Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
ebcc18db6a
commit
bec3092001
32
PRD.md
32
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
|
- **Safe area expansion**: Clock expands into tab bar area when hidden
|
||||||
- **Dynamic Island awareness**: Proper spacing to avoid Dynamic Island overlap
|
- **Dynamic Island awareness**: Proper spacing to avoid Dynamic Island overlap
|
||||||
- **Orientation handling**: Full-screen mode works in both portrait and landscape
|
- **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
|
### 4. Information Overlays
|
||||||
- **Battery level display**: Real-time battery percentage with icon
|
- **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
|
- **JSON-based configuration**: Sound definitions loaded from external configuration
|
||||||
- **Bundle organization**: Sounds organized in category-based bundles
|
- **Bundle organization**: Sounds organized in category-based bundles
|
||||||
- **Shared audio player**: Singleton pattern prevents audio conflicts
|
- **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
|
### 6. Advanced Alarm System
|
||||||
- **Multiple alarms**: Create and manage unlimited alarms
|
- **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
|
- **AVFoundation**: AVAudioPlayer for noise playback
|
||||||
- **@Observable NoisePlayer**: Modern state management with preloading
|
- **@Observable NoisePlayer**: Modern state management with preloading
|
||||||
- **Looping playback**: Infinite loop for ambient sounds
|
- **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
|
- **Error handling**: Graceful handling of missing audio files
|
||||||
- **AlarmTonePlayer**: Dedicated player for alarm sound previews
|
- **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
|
### Notification System
|
||||||
- **UserNotifications**: iOS notification framework
|
- **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
|
- **Sound customization**: System sound selection with volume control
|
||||||
- **Multiple notifications**: Support for repeating alarms with unique identifiers
|
- **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
|
## User Interface Design
|
||||||
|
|
||||||
### Navigation
|
### Navigation
|
||||||
@ -292,9 +309,10 @@ TheNoiseClock/
|
|||||||
│ ├── SoundPickerView.swift # Sound selection component
|
│ ├── SoundPickerView.swift # Sound selection component
|
||||||
│ └── SoundControlView.swift # Playback controls component
|
│ └── SoundControlView.swift # Playback controls component
|
||||||
├── Services/
|
├── Services/
|
||||||
│ ├── NoisePlayer.swift # Audio playback service
|
│ ├── NoisePlayer.swift # Audio playback service with background support
|
||||||
│ ├── AlarmService.swift # Alarm management service
|
│ ├── AlarmService.swift # Alarm management service
|
||||||
│ └── NotificationService.swift # Notification handling service
|
│ ├── NotificationService.swift # Notification handling service
|
||||||
|
│ └── WakeLockService.swift # Screen wake lock management service
|
||||||
└── Resources/
|
└── Resources/
|
||||||
├── sounds.json # Sound configuration and definitions
|
├── sounds.json # Sound configuration and definitions
|
||||||
├── Ambient.bundle/ # Ambient sound category
|
├── Ambient.bundle/ # Ambient sound category
|
||||||
@ -370,8 +388,9 @@ The following changes **automatically require** PRD updates:
|
|||||||
### Settings
|
### Settings
|
||||||
1. **Time format**: Toggle 24-hour, seconds, AM/PM display
|
1. **Time format**: Toggle 24-hour, seconds, AM/PM display
|
||||||
2. **Appearance**: Adjust colors, glow, size, opacity
|
2. **Appearance**: Adjust colors, glow, size, opacity
|
||||||
3. **Overlays**: Control battery and date display
|
3. **Display**: Control keep awake functionality for display mode
|
||||||
4. **Background**: Set background color and use presets
|
4. **Overlays**: Control battery and date display
|
||||||
|
5. **Background**: Set background color and use presets
|
||||||
|
|
||||||
### Alarms Tab
|
### Alarms Tab
|
||||||
1. **View alarms**: List of all created alarms with labels and repeat schedules
|
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
|
### Dependencies
|
||||||
- **SwiftUI**: Native iOS UI framework with latest features
|
- **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
|
- **UserNotifications**: Alarm notifications with rich content support
|
||||||
- **Combine**: Timer publishers and reactive programming
|
- **Combine**: Timer publishers and reactive programming
|
||||||
- **Observation**: Modern state management with @Observable
|
- **Observation**: Modern state management with @Observable
|
||||||
- **Foundation**: Core system frameworks and utilities
|
- **Foundation**: Core system frameworks and utilities
|
||||||
- **UIKit**: UIFont integration for precise text measurement and font customization
|
- **UIKit**: UIFont integration for precise text measurement and font customization
|
||||||
|
- **UIApplication**: Screen wake lock management and idle timer control
|
||||||
|
|
||||||
### Font and Typography Utilities
|
### Font and Typography Utilities
|
||||||
- **FontUtils.optimalFontSize()**: Calculates optimal font size for portrait orientation
|
- **FontUtils.optimalFontSize()**: Calculates optimal font size for portrait orientation
|
||||||
|
|||||||
@ -29,9 +29,22 @@
|
|||||||
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
EA384B122E6E6B6100CA7D50 /* TheNoiseClockUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TheNoiseClockUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* 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 */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */ = {
|
EA384AFD2E6E6B6000CA7D50 /* TheNoiseClock */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
EA384D3B2E6F554D00CA7D50 /* Exceptions for "TheNoiseClock" folder in "TheNoiseClock" target */,
|
||||||
|
);
|
||||||
path = TheNoiseClock;
|
path = TheNoiseClock;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@ -400,6 +413,7 @@
|
|||||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = TheNoiseClock/Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@ -431,6 +445,7 @@
|
|||||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = TheNoiseClock/Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
|||||||
10
TheNoiseClock/Info.plist
Normal file
10
TheNoiseClock/Info.plist
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -36,6 +36,9 @@ class ClockStyle: Codable, Equatable {
|
|||||||
var clockOpacity: Double = AppConstants.Defaults.clockOpacity
|
var clockOpacity: Double = AppConstants.Defaults.clockOpacity
|
||||||
var overlayOpacity: Double = AppConstants.Defaults.overlayOpacity
|
var overlayOpacity: Double = AppConstants.Defaults.overlayOpacity
|
||||||
|
|
||||||
|
// MARK: - Display Settings
|
||||||
|
var keepAwake: Bool = false // Keep screen awake in display mode
|
||||||
|
|
||||||
// MARK: - Cached Colors
|
// MARK: - Cached Colors
|
||||||
private var _cachedDigitColor: Color?
|
private var _cachedDigitColor: Color?
|
||||||
private var _cachedBackgroundColor: Color?
|
private var _cachedBackgroundColor: Color?
|
||||||
@ -58,6 +61,7 @@ class ClockStyle: Codable, Equatable {
|
|||||||
case dateFormat
|
case dateFormat
|
||||||
case clockOpacity
|
case clockOpacity
|
||||||
case overlayOpacity
|
case overlayOpacity
|
||||||
|
case keepAwake
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
@ -85,6 +89,7 @@ class ClockStyle: Codable, Equatable {
|
|||||||
self.dateFormat = try container.decodeIfPresent(String.self, forKey: .dateFormat) ?? self.dateFormat
|
self.dateFormat = try container.decodeIfPresent(String.self, forKey: .dateFormat) ?? self.dateFormat
|
||||||
self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity
|
self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity
|
||||||
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
|
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
|
||||||
|
self.keepAwake = try container.decodeIfPresent(Bool.self, forKey: .keepAwake) ?? self.keepAwake
|
||||||
|
|
||||||
clearColorCache()
|
clearColorCache()
|
||||||
}
|
}
|
||||||
@ -107,6 +112,7 @@ class ClockStyle: Codable, Equatable {
|
|||||||
try container.encode(dateFormat, forKey: .dateFormat)
|
try container.encode(dateFormat, forKey: .dateFormat)
|
||||||
try container.encode(clockOpacity, forKey: .clockOpacity)
|
try container.encode(clockOpacity, forKey: .clockOpacity)
|
||||||
try container.encode(overlayOpacity, forKey: .overlayOpacity)
|
try container.encode(overlayOpacity, forKey: .overlayOpacity)
|
||||||
|
try container.encode(keepAwake, forKey: .keepAwake)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
@ -151,7 +157,8 @@ class ClockStyle: Codable, Equatable {
|
|||||||
lhs.showDate == rhs.showDate &&
|
lhs.showDate == rhs.showDate &&
|
||||||
lhs.dateFormat == rhs.dateFormat &&
|
lhs.dateFormat == rhs.dateFormat &&
|
||||||
lhs.clockOpacity == rhs.clockOpacity &&
|
lhs.clockOpacity == rhs.clockOpacity &&
|
||||||
lhs.overlayOpacity == rhs.overlayOpacity
|
lhs.overlayOpacity == rhs.overlayOpacity &&
|
||||||
|
lhs.keepAwake == rhs.keepAwake
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,11 +18,15 @@ class NoisePlayer {
|
|||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
private var players: [String: AVAudioPlayer] = [:]
|
private var players: [String: AVAudioPlayer] = [:]
|
||||||
private var currentPlayer: AVAudioPlayer?
|
private var currentPlayer: AVAudioPlayer?
|
||||||
|
private var currentSound: Sound?
|
||||||
|
private var shouldResumeAfterInterruption = false
|
||||||
|
private let wakeLockService = WakeLockService.shared
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
private init() {
|
private init() {
|
||||||
setupAudioSession()
|
setupAudioSession()
|
||||||
preloadSounds()
|
preloadSounds()
|
||||||
|
setupAudioInterruptionHandling()
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
@ -39,6 +43,9 @@ class NoisePlayer {
|
|||||||
// Stop current sound if playing
|
// Stop current sound if playing
|
||||||
stopSound()
|
stopSound()
|
||||||
|
|
||||||
|
// Store current sound for interruption handling
|
||||||
|
currentSound = sound
|
||||||
|
|
||||||
// Get or create player for this sound
|
// Get or create player for this sound
|
||||||
guard let player = players[sound.fileName] else {
|
guard let player = players[sound.fileName] else {
|
||||||
print("❌ Sound not preloaded: \(sound.fileName)")
|
print("❌ Sound not preloaded: \(sound.fileName)")
|
||||||
@ -71,11 +78,21 @@ class NoisePlayer {
|
|||||||
print("🎵 Play result: \(success ? "SUCCESS" : "FAILED")")
|
print("🎵 Play result: \(success ? "SUCCESS" : "FAILED")")
|
||||||
print("🔊 Player isPlaying: \(player.isPlaying)")
|
print("🔊 Player isPlaying: \(player.isPlaying)")
|
||||||
print("🔊 Player volume: \(player.volume)")
|
print("🔊 Player volume: \(player.volume)")
|
||||||
|
|
||||||
|
// Enable wake lock when playing audio to prevent device sleep
|
||||||
|
if success {
|
||||||
|
wakeLockService.enableWakeLock()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopSound() {
|
func stopSound() {
|
||||||
currentPlayer?.stop()
|
currentPlayer?.stop()
|
||||||
currentPlayer = nil
|
currentPlayer = nil
|
||||||
|
currentSound = nil
|
||||||
|
shouldResumeAfterInterruption = false
|
||||||
|
|
||||||
|
// Disable wake lock when stopping audio
|
||||||
|
wakeLockService.disableWakeLock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
@ -117,6 +134,12 @@ class NoisePlayer {
|
|||||||
|
|
||||||
try AVAudioSession.sharedInstance().setCategory(category, mode: mode, options: options)
|
try AVAudioSession.sharedInstance().setCategory(category, mode: mode, options: options)
|
||||||
try AVAudioSession.sharedInstance().setActive(true)
|
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 {
|
} catch {
|
||||||
print("Error setting up audio session: \(error)")
|
print("Error setting up audio session: \(error)")
|
||||||
}
|
}
|
||||||
@ -157,5 +180,100 @@ class NoisePlayer {
|
|||||||
}
|
}
|
||||||
players.removeAll()
|
players.removeAll()
|
||||||
currentPlayer = nil
|
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)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
79
TheNoiseClock/Services/WakeLockService.swift
Normal file
79
TheNoiseClock/Services/WakeLockService.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,6 +19,9 @@ class ClockViewModel {
|
|||||||
private(set) var style = ClockStyle()
|
private(set) var style = ClockStyle()
|
||||||
private(set) var isDisplayMode = false
|
private(set) var isDisplayMode = false
|
||||||
|
|
||||||
|
// Wake lock service
|
||||||
|
private let wakeLockService = WakeLockService.shared
|
||||||
|
|
||||||
// Timer management
|
// Timer management
|
||||||
private var secondTimer: Timer.TimerPublisher?
|
private var secondTimer: Timer.TimerPublisher?
|
||||||
private var minuteTimer: Timer.TimerPublisher?
|
private var minuteTimer: Timer.TimerPublisher?
|
||||||
@ -54,12 +57,16 @@ class ClockViewModel {
|
|||||||
withAnimation(UIConstants.AnimationCurves.bouncy) {
|
withAnimation(UIConstants.AnimationCurves.bouncy) {
|
||||||
isDisplayMode.toggle()
|
isDisplayMode.toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manage wake lock based on display mode and keep awake setting
|
||||||
|
updateWakeLockState()
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateStyle(_ newStyle: ClockStyle) {
|
func updateStyle(_ newStyle: ClockStyle) {
|
||||||
style = newStyle
|
style = newStyle
|
||||||
saveStyle()
|
saveStyle()
|
||||||
updateTimersIfNeeded()
|
updateTimersIfNeeded()
|
||||||
|
updateWakeLockState()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
@ -130,4 +137,14 @@ class ClockViewModel {
|
|||||||
secondTimer = nil
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ struct ClockSettingsView: View {
|
|||||||
backgroundColor: $backgroundColor,
|
backgroundColor: $backgroundColor,
|
||||||
onCommit: onCommit
|
onCommit: onCommit
|
||||||
)
|
)
|
||||||
|
DisplaySection(style: $style)
|
||||||
OverlaySection(style: $style)
|
OverlaySection(style: $style)
|
||||||
}
|
}
|
||||||
.navigationTitle("Clock Settings")
|
.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
|
// MARK: - Preview
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user