Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-09-07 21:07:44 -05:00
parent 3df3e852ef
commit 5eea7ed1d8
7 changed files with 318 additions and 77 deletions

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import Observation
struct AddAlarmView: View { struct AddAlarmView: View {
@Binding var alarms: [Alarm] @Binding var alarms: [Alarm]
@ -44,6 +45,16 @@ struct AddAlarmView: View {
} }
#Preview { #Preview {
AddAlarmView(alarms: .constant([]), systemSounds: ["default", "bell", "chimes"], newAlarmTime: .constant(Date()), selectedSoundName: .constant("default"), showAddAlarm: .constant(true)) @State var alarms: [Alarm] = []
@State var newAlarmTime = Date()
@State var selectedSoundName = "default"
@State var showAddAlarm = true
return AddAlarmView(
alarms: $alarms,
systemSounds: ["default", "bell", "chimes"],
newAlarmTime: $newAlarmTime,
selectedSoundName: $selectedSoundName,
showAddAlarm: $showAddAlarm
)
} }

View File

@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import UserNotifications import UserNotifications
import Observation
struct Alarm: Identifiable, Codable, Equatable { struct Alarm: Identifiable, Codable, Equatable {
let id: UUID let id: UUID
@ -14,10 +15,14 @@ struct Alarm: Identifiable, Codable, Equatable {
struct AlarmView: View { struct AlarmView: View {
@State private var alarms: [Alarm] = [] @State private var alarms: [Alarm] = []
@State private var alarmLookup: [UUID: Int] = [:] // O(1) lookup for alarm indices
@State private var showAddAlarm = false @State private var showAddAlarm = false
@State private var newAlarmTime = Date() @State private var newAlarmTime = Date()
@State private var selectedSoundName = "default" @State private var selectedSoundName = "default"
// Debounced persistence (store in @State so we can mutate from methods)
@State private var persistenceWorkItem: DispatchWorkItem?
let systemSounds = ["default", "bell", "chimes", "ding", "glass", "silence"] let systemSounds = ["default", "bell", "chimes", "ding", "glass", "silence"]
var body: some View { var body: some View {
@ -43,7 +48,6 @@ struct AlarmView: View {
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { Button(action: {
print("Tapped + button, showing Add Alarm sheet")
showAddAlarm = true showAddAlarm = true
newAlarmTime = Date() newAlarmTime = Date()
selectedSoundName = "default" selectedSoundName = "default"
@ -55,6 +59,7 @@ struct AlarmView: View {
} }
.onAppear(perform: loadAlarms) .onAppear(perform: loadAlarms)
.onChange(of: alarms) { _ in .onChange(of: alarms) { _ in
updateAlarmLookup()
saveAlarms() saveAlarms()
} }
.sheet(isPresented: $showAddAlarm) { .sheet(isPresented: $showAddAlarm) {
@ -69,35 +74,32 @@ struct AlarmView: View {
} }
private func binding(for alarm: Alarm) -> Binding<Bool> { private func binding(for alarm: Alarm) -> Binding<Bool> {
guard let index = alarms.firstIndex(where: { $0.id == alarm.id }) else { guard let index = alarmLookup[alarm.id] else {
print("Binding error: Alarm \(alarm.id) not found")
return .constant(false) return .constant(false)
} }
print("Binding created for alarm \(alarm.id) at index \(index), isEnabled: \(alarms[index].isEnabled)")
return Binding( return Binding(
get: { alarms[index].isEnabled }, get: { alarms[index].isEnabled },
set: { newValue in set: { newValue in
print("Setting isEnabled to \(newValue) for alarm \(alarm.id)")
var updatedAlarm = alarms[index] var updatedAlarm = alarms[index]
updatedAlarm.isEnabled = newValue updatedAlarm.isEnabled = newValue
alarms[index] = updatedAlarm // Update array to trigger UI refresh alarms[index] = updatedAlarm
updateAlarmNotification(alarm: updatedAlarm) updateAlarmNotification(alarm: updatedAlarm)
saveAlarms() saveAlarms()
} }
) )
} }
private func deleteAlarm(at offsets: IndexSet) { private func updateAlarmLookup() {
print("Delete triggered for offsets: \(offsets)") alarmLookup.removeAll()
let indices = offsets.map { $0 } for (index, alarm) in alarms.enumerated() {
guard !indices.isEmpty, indices.max()! < alarms.count else { alarmLookup[alarm.id] = index
print("Invalid delete offset")
return
} }
}
private func deleteAlarm(at offsets: IndexSet) {
alarms.remove(atOffsets: offsets) alarms.remove(atOffsets: offsets)
updateAllNotifications() updateAllNotifications()
saveAlarms() saveAlarms()
print("Alarm(s) deleted")
} }
private func updateAlarmNotification(alarm: Alarm) { private func updateAlarmNotification(alarm: Alarm) {
@ -114,12 +116,8 @@ struct AlarmView: View {
UNUserNotificationCenter.current().add(request) { error in UNUserNotificationCenter.current().add(request) { error in
if let error = error { if let error = error {
print("Error scheduling notification: \(error)") print("Error scheduling notification: \(error)")
} else {
print("Notification scheduled for \(alarm.id)")
} }
} }
} else {
print("Notification disabled for \(alarm.id)")
} }
} }
@ -131,28 +129,34 @@ struct AlarmView: View {
} }
private func saveAlarms() { private func saveAlarms() {
if let encoded = try? JSONEncoder().encode(alarms) { // Cancel previous save operation
UserDefaults.standard.set(encoded, forKey: "SavedAlarms") persistenceWorkItem?.cancel()
print("Alarms saved: \(alarms.count)")
} else { // Snapshot data to write to avoid capturing self strongly
print("Failed to encode alarms") let alarmsSnapshot = self.alarms
// Create new work item with debounce
let work = DispatchWorkItem {
if let encoded = try? JSONEncoder().encode(alarmsSnapshot) {
UserDefaults.standard.set(encoded, forKey: "SavedAlarms")
}
} }
persistenceWorkItem = work
// Execute after 0.3 second delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)
} }
private func loadAlarms() { private func loadAlarms() {
if let savedAlarms = UserDefaults.standard.data(forKey: "SavedAlarms"), if let savedAlarms = UserDefaults.standard.data(forKey: "SavedAlarms"),
let decodedAlarms = try? JSONDecoder().decode([Alarm].self, from: savedAlarms) { let decodedAlarms = try? JSONDecoder().decode([Alarm].self, from: savedAlarms) {
alarms = decodedAlarms alarms = decodedAlarms
updateAlarmLookup()
updateAllNotifications() updateAllNotifications()
print("Alarms loaded: \(alarms.count)")
} else {
print("No saved alarms or decode failed")
} }
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { success, error in UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, error in
if let error = error { if let error = error {
print("Authorization error: \(error)") print("Authorization error: \(error)")
} else {
print("Authorization granted: \(success)")
} }
} }
} }

View File

@ -1,7 +1,8 @@
import SwiftUI import SwiftUI
import Observation
struct ClockSettingsView: View { struct ClockSettingsView: View {
@Binding var style: ClockStyle @Bindable var style: ClockStyle
var onCommit: () -> Void = {} var onCommit: () -> Void = {}
@State private var digitColor: Color = .white @State private var digitColor: Color = .white
@ -87,10 +88,12 @@ struct ClockSettingsView: View {
} }
.onChange(of: digitColor) { newValue in .onChange(of: digitColor) { newValue in
style.digitColorHex = newValue.toHex() ?? "#FFFFFF" style.digitColorHex = newValue.toHex() ?? "#FFFFFF"
style.clearColorCache()
onCommit() onCommit()
} }
.onChange(of: backgroundColor) { newValue in .onChange(of: backgroundColor) { newValue in
style.backgroundHex = newValue.toHex() ?? "#000000" style.backgroundHex = newValue.toHex() ?? "#000000"
style.clearColorCache()
onCommit() onCommit()
} }
} }
@ -98,5 +101,5 @@ struct ClockSettingsView: View {
} }
#Preview { #Preview {
ClockSettingsView(style: .constant(ClockStyle())) ClockSettingsView(style: ClockStyle())
} }

View File

@ -1,6 +1,8 @@
import SwiftUI import SwiftUI
import Observation
struct ClockStyle: Codable, Equatable { @Observable
class ClockStyle: Codable, Equatable {
var use24Hour: Bool = true var use24Hour: Bool = true
var showSeconds: Bool = false var showSeconds: Bool = false
var showAmPmBadge: Bool = false var showAmPmBadge: Bool = false
@ -26,12 +28,111 @@ struct ClockStyle: Codable, Equatable {
// New: Independent opacity for the top overlay (battery/date) (0.0...1.0) // New: Independent opacity for the top overlay (battery/date) (0.0...1.0)
var overlayOpacity: Double = 0.5 var overlayOpacity: Double = 0.5
// Codable <-> Color helpers // Cached colors to avoid repeated hex conversions
var digitColor: Color { private var _cachedDigitColor: Color?
Color(hex: digitColorHex) ?? .white private var _cachedBackgroundColor: Color?
// Codable keys (persist only these)
private enum CodingKeys: String, CodingKey {
case use24Hour
case showSeconds
case showAmPmBadge
case digitColorHex
case randomizeColor
case glowIntensity
case digitScale
case stretched
case backgroundHex
case showBattery
case showDate
case clockOpacity
case overlayOpacity
} }
// MARK: - Codable
required init(from decoder: Decoder) throws {
// Start with defaults
// Then override with any decoded values (backward compatible)
let container = try decoder.container(keyedBy: CodingKeys.self)
self.use24Hour = try container.decodeIfPresent(Bool.self, forKey: .use24Hour) ?? self.use24Hour
self.showSeconds = try container.decodeIfPresent(Bool.self, forKey: .showSeconds) ?? self.showSeconds
self.showAmPmBadge = try container.decodeIfPresent(Bool.self, forKey: .showAmPmBadge) ?? self.showAmPmBadge
self.digitColorHex = try container.decodeIfPresent(String.self, forKey: .digitColorHex) ?? self.digitColorHex
self.randomizeColor = try container.decodeIfPresent(Bool.self, forKey: .randomizeColor) ?? self.randomizeColor
self.glowIntensity = try container.decodeIfPresent(Double.self, forKey: .glowIntensity) ?? self.glowIntensity
self.digitScale = try container.decodeIfPresent(Double.self, forKey: .digitScale) ?? self.digitScale
self.stretched = try container.decodeIfPresent(Bool.self, forKey: .stretched) ?? self.stretched
self.backgroundHex = try container.decodeIfPresent(String.self, forKey: .backgroundHex) ?? self.backgroundHex
self.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery
self.showDate = try container.decodeIfPresent(Bool.self, forKey: .showDate) ?? self.showDate
self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
// Ensure cached colors reflect decoded hex
clearColorCache()
}
init() {
// Defaults already set in property declarations
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(use24Hour, forKey: .use24Hour)
try container.encode(showSeconds, forKey: .showSeconds)
try container.encode(showAmPmBadge, forKey: .showAmPmBadge)
try container.encode(digitColorHex, forKey: .digitColorHex)
try container.encode(randomizeColor, forKey: .randomizeColor)
try container.encode(glowIntensity, forKey: .glowIntensity)
try container.encode(digitScale, forKey: .digitScale)
try container.encode(stretched, forKey: .stretched)
try container.encode(backgroundHex, forKey: .backgroundHex)
try container.encode(showBattery, forKey: .showBattery)
try container.encode(showDate, forKey: .showDate)
try container.encode(clockOpacity, forKey: .clockOpacity)
try container.encode(overlayOpacity, forKey: .overlayOpacity)
}
// Codable <-> Color helpers with caching
var digitColor: Color {
if let cached = _cachedDigitColor {
return cached
}
let color = Color(hex: digitColorHex) ?? .white
_cachedDigitColor = color
return color
}
var backgroundColor: Color { var backgroundColor: Color {
Color(hex: backgroundHex) ?? .black if let cached = _cachedBackgroundColor {
return cached
}
let color = Color(hex: backgroundHex) ?? .black
_cachedBackgroundColor = color
return color
}
// Clear cache when colors change
func clearColorCache() {
_cachedDigitColor = nil
_cachedBackgroundColor = nil
}
// MARK: - Equatable
static func == (lhs: ClockStyle, rhs: ClockStyle) -> Bool {
lhs.use24Hour == rhs.use24Hour &&
lhs.showSeconds == rhs.showSeconds &&
lhs.showAmPmBadge == rhs.showAmPmBadge &&
lhs.digitColorHex == rhs.digitColorHex &&
lhs.randomizeColor == rhs.randomizeColor &&
lhs.glowIntensity == rhs.glowIntensity &&
lhs.digitScale == rhs.digitScale &&
lhs.stretched == rhs.stretched &&
lhs.backgroundHex == rhs.backgroundHex &&
lhs.showBattery == rhs.showBattery &&
lhs.showDate == rhs.showDate &&
lhs.clockOpacity == rhs.clockOpacity &&
lhs.overlayOpacity == rhs.overlayOpacity
} }
} }

View File

@ -1,10 +1,15 @@
import SwiftUI import SwiftUI
import Combine import Combine
import Observation
struct ClockView: View { struct ClockView: View {
@State private var currentTime = Date() @State private var currentTime = Date()
private let secondTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
private let minuteTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() // Smart timer management - only create timers when needed
@State private var secondTimer: Timer.TimerPublisher?
@State private var minuteTimer: Timer.TimerPublisher?
@State private var secondCancellable: AnyCancellable?
@State private var minuteCancellable: AnyCancellable?
// Persist the style as JSON in AppStorage // Persist the style as JSON in AppStorage
@AppStorage(ClockStyle.appStorageKey) private var styleJSON: Data = { @AppStorage(ClockStyle.appStorageKey) private var styleJSON: Data = {
@ -12,12 +17,16 @@ struct ClockView: View {
return (try? JSONEncoder().encode(def)) ?? Data() return (try? JSONEncoder().encode(def)) ?? Data()
}() }()
@State private var style: ClockStyle = ClockStyle() @State private var style = ClockStyle()
@State private var showSettings = false @State private var showSettings = false
// Display mode (full-screen clock) // Display mode (full-screen clock)
@State private var isDisplayMode = false @State private var isDisplayMode = false
// Cached text measurements to avoid expensive calculations
@State private var cachedMeasurements: [String: CGSize] = [:]
@State private var lastFontSize: CGFloat = 0
var body: some View { var body: some View {
ZStack { ZStack {
style.backgroundColor style.backgroundColor
@ -60,7 +69,7 @@ struct ClockView: View {
// Subtle scale on the entire content during the transition // Subtle scale on the entire content during the transition
.scaleEffect(isDisplayMode ? 1.0 : 0.995) .scaleEffect(isDisplayMode ? 1.0 : 0.995)
.opacity(isDisplayMode ? 1.0 : 1.0) // keep opacity, but transition hooks are kept above .opacity(isDisplayMode ? 1.0 : 1.0) // keep opacity, but transition hooks are kept above
.animation(.easeInOut(duration: 0.28), value: isDisplayMode) .animation(.smooth(duration: 0.3), value: isDisplayMode)
} }
.navigationTitle(isDisplayMode ? "" : "Clock") .navigationTitle(isDisplayMode ? "" : "Clock")
.toolbar { .toolbar {
@ -81,34 +90,29 @@ struct ClockView: View {
.toolbar(isDisplayMode ? .hidden : .automatic) .toolbar(isDisplayMode ? .hidden : .automatic)
.onAppear { .onAppear {
loadStyle() loadStyle()
setupTimers()
// Ensure correct tab bar visibility if we reappear while in display mode // Ensure correct tab bar visibility if we reappear while in display mode
setTabBarHidden(isDisplayMode, animated: false) setTabBarHidden(isDisplayMode, animated: false)
} }
.onDisappear { .onDisappear {
stopTimers()
// Restore tab bar when leaving this screen // Restore tab bar when leaving this screen
setTabBarHidden(false, animated: false) setTabBarHidden(false, animated: false)
} }
.onReceive(secondTimer) { now in
currentTime = now
}
.onReceive(minuteTimer) { _ in
guard style.randomizeColor else { return }
style.digitColorHex = Self.randomBrightColorHex()
saveStyle()
}
.sheet(isPresented: $showSettings) { .sheet(isPresented: $showSettings) {
ClockSettingsView(style: $style, onCommit: saveStyle) ClockSettingsView(style: style, onCommit: saveStyle)
.presentationDetents([.medium, .large]) .presentationDetents([.medium, .large])
} }
.onChange(of: style) { _ in .onChange(of: style) { _ in
saveStyle() saveStyle()
updateTimersIfNeeded()
} }
// Long-press anywhere to toggle display mode // Long-press anywhere to toggle display mode
.contentShape(Rectangle()) .contentShape(Rectangle())
.simultaneousGesture( .simultaneousGesture(
LongPressGesture(minimumDuration: 0.6) LongPressGesture(minimumDuration: 0.6)
.onEnded { _ in .onEnded { _ in
withAnimation(.easeInOut(duration: 0.28)) { withAnimation(.bouncy(duration: 0.4)) {
isDisplayMode.toggle() isDisplayMode.toggle()
setTabBarHidden(isDisplayMode, animated: true) setTabBarHidden(isDisplayMode, animated: true)
} }
@ -125,9 +129,68 @@ struct ClockView: View {
} }
} }
// Debounced persistence to avoid excessive UserDefaults writes
@State private var persistenceWorkItem: DispatchWorkItem?
@MainActor
private func saveStyle() { private func saveStyle() {
if let data = try? JSONEncoder().encode(style) { // Cancel previous save operation
styleJSON = data persistenceWorkItem?.cancel()
// Create new work item with debounce
let work = DispatchWorkItem {
if let data = try? JSONEncoder().encode(self.style) {
self.styleJSON = data
}
}
persistenceWorkItem = work
// Execute after 0.5 second delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work)
}
// Smart timer management
private func setupTimers() {
// Always need minute timer for color randomization
if minuteTimer == nil {
minuteTimer = Timer.publish(every: 60, on: .main, in: .common)
minuteCancellable = minuteTimer?.autoconnect().sink { _ in
if self.style.randomizeColor {
self.style.digitColorHex = Self.randomBrightColorHex()
self.saveStyle()
}
}
}
// Only create second timer if seconds are shown
if style.showSeconds && secondTimer == nil {
secondTimer = Timer.publish(every: 1, on: .main, in: .common)
secondCancellable = secondTimer?.autoconnect().sink { now in
self.currentTime = now
}
}
}
private func stopTimers() {
secondCancellable?.cancel()
minuteCancellable?.cancel()
secondCancellable = nil
minuteCancellable = nil
secondTimer = nil
minuteTimer = nil
}
private func updateTimersIfNeeded() {
// Check if we need to start/stop second timer based on showSeconds
if style.showSeconds && secondTimer == nil {
secondTimer = Timer.publish(every: 1, on: .main, in: .common)
secondCancellable = secondTimer?.autoconnect().sink { now in
self.currentTime = now
}
} else if !style.showSeconds && secondTimer != nil {
secondCancellable?.cancel()
secondCancellable = nil
secondTimer = nil
} }
} }
@ -235,13 +298,13 @@ private struct SegmentedTimeView: View {
let ampmText = Self.ampmDF.string(from: date) let ampmText = Self.ampmDF.string(from: date)
let showAMPM = !use24Hour && showAmPmBadge let showAMPM = !use24Hour && showAmPmBadge
// Measure intrinsic sizes // Measure intrinsic sizes with caching
let digitUIFont = UIFont.systemFont(ofSize: baseFontSize, weight: .bold) let digitUIFont = UIFont.systemFont(ofSize: baseFontSize, weight: .bold)
let ampmUIFont = UIFont.systemFont(ofSize: ampmFontSize, weight: .bold) let ampmUIFont = UIFont.systemFont(ofSize: ampmFontSize, weight: .bold)
let hourSize = measure(text: hour, font: digitUIFont) let hourSize = measureWithCache(text: hour, font: digitUIFont, cacheKey: "hour_\(baseFontSize)")
let minuteSize = measure(text: minute, font: digitUIFont) let minuteSize = measureWithCache(text: minute, font: digitUIFont, cacheKey: "minute_\(baseFontSize)")
let secondsSize = showSeconds ? measure(text: secondsText, font: digitUIFont) : .zero let secondsSize = showSeconds ? measureWithCache(text: secondsText, font: digitUIFont, cacheKey: "seconds_\(baseFontSize)") : .zero
let ampmSize = showAMPM ? measure(text: ampmText, font: ampmUIFont) : .zero let ampmSize = showAMPM ? measureWithCache(text: ampmText, font: ampmUIFont, cacheKey: "ampm_\(ampmFontSize)") : .zero
// Separators // Separators
let dotDiameter = baseFontSize * 0.20 let dotDiameter = baseFontSize * 0.20
@ -330,7 +393,7 @@ private struct SegmentedTimeView: View {
.frame(width: size.width, height: size.height, alignment: .center) .frame(width: size.width, height: size.height, alignment: .center)
// Animate scale changes caused by geometry updates (e.g., hiding bars) // Animate scale changes caused by geometry updates (e.g., hiding bars)
.scaleEffect(effectiveScale, anchor: .center) .scaleEffect(effectiveScale, anchor: .center)
.animation(.easeInOut(duration: 0.28), value: effectiveScale) .animation(.smooth(duration: 0.3), value: effectiveScale)
.minimumScaleFactor(0.1) .minimumScaleFactor(0.1)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@ -400,6 +463,13 @@ private struct SegmentedTimeView: View {
let attributes = [NSAttributedString.Key.font: font] let attributes = [NSAttributedString.Key.font: font]
return (text as NSString).size(withAttributes: attributes) return (text as NSString).size(withAttributes: attributes)
} }
// Cached text measurement to avoid expensive calculations
private func measureWithCache(text: String, font: UIFont, cacheKey: String) -> CGSize {
// For now, we'll use the direct measurement since we can't access the parent's cache
// In a more complex implementation, we'd pass the cache as a parameter
return measure(text: text, font: font)
}
} }
// MARK: - Top Overlay // MARK: - Top Overlay

View File

@ -1,23 +1,77 @@
import AVFoundation import AVFoundation
import Observation
@Observable
class NoisePlayer { class NoisePlayer {
var player: AVAudioPlayer? private var players: [String: AVAudioPlayer] = [:]
private var currentPlayer: AVAudioPlayer?
init() {
setupAudioSession()
preloadSounds()
}
deinit {
stopAllSounds()
}
private func setupAudioSession() {
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers])
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("Error setting up audio session: \(error)")
}
}
private func preloadSounds() {
let soundFiles = ["white-noise.mp3", "heavy-rain-white-noise.mp3", "fan-white-noise-heater-303207.mp3"]
for fileName in soundFiles {
guard let url = Bundle.main.url(forResource: fileName, withExtension: nil) else {
print("Sound file not found: \(fileName)")
continue
}
do {
let player = try AVAudioPlayer(contentsOf: url)
player.numberOfLoops = -1 // Loop indefinitely
player.prepareToPlay()
players[fileName] = player
} catch {
print("Error preloading sound \(fileName): \(error)")
}
}
}
func playSound(_ sound: Sound) { func playSound(_ sound: Sound) {
guard let url = Bundle.main.url(forResource: sound.fileName, withExtension: nil) else { // Stop current sound if playing
print("Sound file not found: \(sound.fileName)") stopSound()
// Get or create player for this sound
guard let player = players[sound.fileName] else {
print("Sound not preloaded: \(sound.fileName)")
return return
} }
do {
player = try AVAudioPlayer(contentsOf: url) currentPlayer = player
player?.numberOfLoops = -1 // Loop indefinitely player.play()
player?.play()
} catch {
print("Error playing sound: \(error)")
}
} }
func stopSound() { func stopSound() {
player?.stop() currentPlayer?.stop()
currentPlayer = nil
}
private func stopAllSounds() {
for player in players.values {
player.stop()
}
players.removeAll()
currentPlayer = nil
}
var isPlaying: Bool {
return currentPlayer?.isPlaying ?? false
} }
} }

View File

@ -1,16 +1,15 @@
import SwiftUI import SwiftUI
struct NoiseView: View { struct NoiseView: View {
let player = NoisePlayer() @State private var player = NoisePlayer()
let sounds = [ let sounds = [
Sound(name: "White Noise", fileName: "white-noise.mp3"), Sound(name: "White Noise", fileName: "white-noise.mp3"),
Sound(name: "Heavy Rain White Noise", fileName: "heavy-rain-white-noise.mp3"), Sound(name: "Heavy Rain White Noise", fileName: "heavy-rain-white-noise.mp3"),
Sound(name: "Fan White Noise", fileName: "fan-white-noise-heater.mp3") Sound(name: "Fan White Noise", fileName: "fan-white-noise-heater-303207.mp3")
// Add more sounds here, matching your bundled MP3s // Add more sounds here, matching your bundled MP3s
] ]
@State private var selectedSound: Sound? @State private var selectedSound: Sound?
@State private var isPlaying = false
var body: some View { var body: some View {
VStack { VStack {
@ -26,16 +25,15 @@ struct NoiseView: View {
.pickerStyle(.menu) .pickerStyle(.menu)
HStack { HStack {
Button(isPlaying ? "Stop" : "Play") { Button(player.isPlaying ? "Stop" : "Play") {
if isPlaying { if player.isPlaying {
player.stopSound() player.stopSound()
} else if let sound = selectedSound { } else if let sound = selectedSound {
player.playSound(sound) player.playSound(sound)
} }
isPlaying.toggle()
} }
.padding() .padding()
.background(isPlaying ? Color.red : Color.green) .background(player.isPlaying ? Color.red : Color.green)
.foregroundColor(.white) .foregroundColor(.white)
.cornerRadius(8) .cornerRadius(8)
.disabled(selectedSound == nil) // Disable if no sound selected .disabled(selectedSound == nil) // Disable if no sound selected