Initial commit

This commit is contained in:
Cody Bromley
2025-01-02 11:33:13 -06:00
commit e2ec10f9dd
54 changed files with 3355 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+79
View File
@@ -0,0 +1,79 @@
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## User settings
xcuserdata/
## Obj-C/Swift specific
*.hmap
## App packaging
*.ipa
*.dSYM.zip
*.dSYM
## Playgrounds
timeline.xctimeline
playground.xcworkspace
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
# Packages/
# Package.pins
# Package.resolved
# *.xcodeproj
#
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
# hence it is not needed unless you have added a package configuration file to your project
# .swiftpm
.build/
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
# Pods/
#
# Add this line if you want to avoid checking in source code from the Xcode workspace
# *.xcworkspace
# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
# Carthage/Checkouts
Carthage/Build/
# Accio dependency management
Dependencies/
.accio/
# fastlane
#
# It is recommended to not store the screenshots in the git repo.
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/#source-control
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
# Code Injection
#
# After new code Injection tools there's a generated folder /iOSInjectionProject
# https://github.com/johnno1962/injectionforxcode
iOSInjectionProject/
# Ignore system junk files
.DS_Store
# Local configuration
Configuration.xcconfig
+20
View File
@@ -0,0 +1,20 @@
{
"gpt-context-generator.detectedFileExtensions": [
"js",
"jsx",
"ts",
"tsx",
"mdx",
"json",
"swift",
"plist",
"pbxproj",
"entitlements"
],
"markdownlint.config": {
"default": true,
"MD013": false,
"MD033": false,
"MD041": false
}
}
+298
View File
@@ -0,0 +1,298 @@
//
// AboutView.swift
// Blankie
//
// Created by Cody Bromley on 1/1/25.
//
import SwiftUI
struct AboutView: View {
@Environment(\.dismiss) private var dismiss
private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
var body: some View {
VStack(spacing: 20) {
// Fixed header
if let appIcon = NSApplication.shared.applicationIconImage {
Image(nsImage: appIcon)
.resizable()
.frame(width: 128, height: 128)
}
Text("Blankie")
.font(.system(size: 24, weight: .medium, design: .rounded))
Text("Version \(appVersion) (\(buildNumber))")
.font(.system(size: 12))
.foregroundStyle(.secondary)
inspirationSection
developerSection
Text("© 2025 Cody Bromley")
.font(.caption)
// GroupBox with internal scroll
GroupBox {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
softwareLicenseSection
Divider()
soundCreditsSection
editingNoteSection
}
.frame(width: 400)
.padding(.vertical, 4)
}
.frame(height: 200) // Added fixed height
}
.frame(width: 440)
reportIssueSection
// Close button
Button("Close") {
dismiss()
}
.keyboardShortcut(.defaultAction)
}
.padding(20)
.frame(width: 480, height: 650)
}
private var developerSection: some View {
VStack(spacing: 4) {
Text("Developed By")
.font(.system(size: 13, weight: .bold))
HStack(spacing: 2) {
Link("Cody Bromley", destination: URL(string: "https://github.com/codybrom")!)
Image(systemName: "square.and.arrow.up.right")
.font(.system(size: 8))
}
.foregroundColor(.accentColor)
.onHover { inside in
if inside {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
.font(.system(size: 12))
}
.frame(maxWidth: .infinity)
}
private var reportIssueSection: some View {
HStack(spacing: 2) {
Link("Report an Issue", destination: URL(string: "https://github.com/codybrom/blankie/issues")!)
Image(systemName: "square.and.arrow.up.right")
.font(.system(size: 8))
}
.foregroundColor(.accentColor)
.onHover { inside in
if inside {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
.font(.system(size: 12))
}
private var inspirationSection: some View {
HStack(spacing: 4) {
Text("Inspired by")
.font(.system(size: 12))
.italic()
Link("Blanket", destination: URL(string: "https://github.com/rafaelmardojai/blanket")!)
.foregroundColor(.accentColor)
.onHover { inside in
if inside {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
Text("by Rafael Mardojai CM")
}
.font(.system(size: 12))
.italic()
}
private var soundCreditsSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Sound Credits")
.font(.system(size: 13, weight: .bold))
VStack(alignment: .leading, spacing: 4) {
ForEach(soundCredits, id: \.name) { credit in
CreditRow(credit: credit)
}
}
}
}
struct CreditRow: View {
let credit: SoundCredit
var body: some View {
HStack(alignment: .top, spacing: 0) {
Text("")
Group {
if let soundUrl = credit.soundUrl {
HStack(spacing: 2) {
Link(credit.name, destination: soundUrl)
}
.foregroundColor(.accentColor)
.onHover { inside in
if inside {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
} else {
Text(credit.name)
}
}
Text(" by ")
Text(credit.author)
if let editor = credit.editor {
Text(", edited by ")
Text(editor)
}
Text(" (")
if let url = credit.license.url {
HStack(spacing: 2) {
Link(credit.license.linkText, destination: url)
}
.foregroundColor(.accentColor)
.onHover { inside in
if inside {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
} else {
Text(credit.license.linkText)
}
Text(")")
}
.font(.system(size: 12))
}
}
private var editingNoteSection: some View {
Text("Note: Sound editing involved optimizing audio levels and characteristics according to established guidelines for ambient sound playback.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
.italic()
}
}
// Sound credit model
struct SoundCredit {
let name: String
let author: String
let license: License
let editor: String?
let soundUrl: URL?
var attributionText: String {
let text = "\""
return text
}
}
enum License {
case cc0
case ccBy
case ccBySa
case ccBy3
case publicDomain
var linkText: String {
switch self {
case .cc0: return "CC0"
case .ccBy: return "CC BY"
case .ccBySa: return "CC BY-SA"
case .ccBy3: return "CC BY 3.0"
case .publicDomain: return "Public Domain"
}
}
var url: URL? {
switch self {
case .cc0: return URL(string: "https://creativecommons.org/publicdomain/zero/1.0/")
case .ccBy: return URL(string: "https://creativecommons.org/licenses/by/4.0/")
case .ccBySa: return URL(string: "https://creativecommons.org/licenses/by-sa/4.0/")
case .ccBy3: return URL(string: "https://creativecommons.org/licenses/by/3.0/")
case .publicDomain: return URL(string: "https://wiki.creativecommons.org/wiki/Public_domain")
}
}
}
// Sound credits data
let soundCredits: [SoundCredit] = [
SoundCredit(name: "Birds", author: "kvgarlic", license: .cc0, editor: "Porrumentzio",
soundUrl: URL(string: "https://freesound.org/people/kvgarlic/sounds/156826/")),
SoundCredit(name: "Boat", author: "Falcet", license: .cc0, editor: "Porrumentzio",
soundUrl: URL(string: "https://freesound.org/people/Falcet/sounds/439365/")),
SoundCredit(name: "City", author: "gezortenplotz", license: .ccBy, editor: "Porrumentzio",
soundUrl: URL(string: "https://freesound.org/people/gezortenplotz/sounds/44796/")),
SoundCredit(name: "Coffee Shop", author: "stephan", license: .publicDomain, editor: nil,
soundUrl: URL(string: "https://soundbible.com/1664-Restaurant-Ambiance.html")),
SoundCredit(name: "Fireplace", author: "ezwa", license: .publicDomain, editor: nil,
soundUrl: URL(string: "https://soundbible.com/1543-Fireplace.html")),
SoundCredit(name: "Pink noise", author: "Omegatron", license: .ccBySa, editor: nil,
soundUrl: URL(string: "https://es.wikipedia.org/wiki/Archivo:Pink_noise.ogg")),
SoundCredit(name: "Rain", author: "alex36917", license: .ccBy, editor: "Porrumentzio",
soundUrl: URL(string: "https://freesound.org/people/alex36917/sounds/524605/")),
SoundCredit(name: "Summer night", author: "Lisa Redfern", license: .publicDomain, editor: nil,
soundUrl: URL(string: "https://soundbible.com/2083-Crickets-Chirping-At-Night.html")),
SoundCredit(name: "Storm", author: "Digifish music", license: .ccBy, editor: "Porrumentzio",
soundUrl: URL(string: "https://freesound.org/people/digifishmusic/sounds/41739/")),
SoundCredit(name: "Stream", author: "gluckose", license: .cc0, editor: nil,
soundUrl: URL(string: "https://freesound.org/people/gluckose/sounds/333987/")),
SoundCredit(name: "Train", author: "SDLx", license: .ccBy3, editor: nil,
soundUrl: URL(string: "https://freesound.org/people/SDLx/sounds/259988/")),
SoundCredit(name: "Waves", author: "Luftrum", license: .ccBy, editor: "Porrumentzio",
soundUrl: URL(string: "https://freesound.org/people/Luftrum/sounds/48412/")),
SoundCredit(name: "White noise", author: "Jorge Stolfi", license: .ccBySa, editor: nil,
soundUrl: URL(string: "https://commons.wikimedia.org/w/index.php?title=File%3AWhite-noise-sound-20sec-mono-44100Hz.ogg")),
SoundCredit(name: "Wind", author: "felix.blume", license: .cc0, editor: "Porrumentzio",
soundUrl: URL(string: "https://freesound.org/people/felix.blume/sounds/217506/"))
]
private var softwareLicenseSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("This application comes with absolutely no warranty. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation.")
.font(.system(size: 12))
Link("See the GNU General Public License, version 3 or later for details.", destination: URL(string: "https://www.gnu.org/licenses/gpl-3.0.en.html")!)
.foregroundColor(.accentColor)
.onHover { inside in
if inside {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
.font(.system(size: 12))
}
}
#Preview {
AboutView()
}
+44
View File
@@ -0,0 +1,44 @@
//
// AppCommands.swift
// Blankie
//
// Created by Cody Bromley on 1/1/25.
//
import SwiftUI
struct AppCommands: Commands {
@Binding var showingAbout: Bool
@Binding var hasWindow: Bool
var body: some Commands {
CommandGroup(replacing: .appInfo) {
Button("About Blankie") {
showingAbout = true
}
}
CommandGroup(replacing: .newItem) {
Button("New Window") {
if !hasWindow {
let controller = NSWindowController(
window: NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 800),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
)
controller.window?.center()
let hostingView = NSHostingView(rootView: ContentView(showingAbout: $showingAbout))
controller.window?.contentView = hostingView
controller.showWindow(nil)
hasWindow = true
}
}
.disabled(hasWindow)
.keyboardShortcut("n", modifiers: .command)
}
}
}
+17
View File
@@ -0,0 +1,17 @@
//
// AppState.swift
// Blankie
//
// Created by Cody Bromley on 1/1/25.
//
import SwiftUI
class AppState: ObservableObject {
static let shared = AppState()
@Published var isAboutViewPresented = false
private init() {}
}
BIN
View File
Binary file not shown.
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0xE4",
"green" : "0x84",
"red" : "0x35"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,68 @@
{
"images" : [
{
"filename" : "16x16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "32x32 1.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "32x32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "64x64.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "128x128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "256x256 1.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "256x256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "512x512 1.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "512x512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "1024x1024 1.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+497
View File
@@ -0,0 +1,497 @@
//
// AudioManager.swift
// Blankie
//
// Created by Cody Bromley on 12/30/24.
//
import SwiftUI
import AVFoundation
import Combine
import MediaPlayer
// First, declare the Error types and ErrorReporter at the top
enum AudioError: Error, LocalizedError {
case fileNotFound
case loadFailed(Error)
case playbackFailed(Error)
case invalidVolume
case systemAudioError(String)
var errorDescription: String? {
switch self {
case .fileNotFound:
return "Audio file could not be found"
case .loadFailed(let error):
return "Failed to load audio: \(error.localizedDescription)"
case .playbackFailed(let error):
return "Playback failed: \(error.localizedDescription)"
case .invalidVolume:
return "Invalid volume level specified"
case .systemAudioError(let message):
return "System audio error: \(message)"
}
}
}
class ErrorReporter: ObservableObject {
static let shared = ErrorReporter()
@Published var lastError: Error?
func report(_ error: Error) {
DispatchQueue.main.async {
self.lastError = error
#if DEBUG
print("Error reported: \(error.localizedDescription)")
#endif
}
}
}
/// Represents a single sound with its associated properties and playback controls.
class Sound: ObservableObject, Identifiable {
let id = UUID()
let title: String
let systemIconName: String
let fileName: String
@Published var isSelected = false {
didSet {
UserDefaults.standard.set(isSelected, forKey: "\(fileName)_isSelected")
}
}
@Published var volume: Float = 1.0 {
didSet {
guard volume >= 0 && volume <= 1 else {
ErrorReporter.shared.report(AudioError.invalidVolume)
volume = oldValue
return
}
if player?.isPlaying == true {
updateVolume()
}
UserDefaults.standard.set(volume, forKey: "\(fileName)_volume")
}
}
var player: AVAudioPlayer?
private let fileExtension = "mp3"
private let fadeDuration: TimeInterval = 0.1
private var fadeTimer: Timer?
private var fadeStartVolume: Float = 0
private var targetVolume: Float = 1.0
private var globalSettingsObserver: AnyCancellable?
private var isResetting = false
init(title: String, systemIconName: String, fileName: String) {
self.title = title
self.systemIconName = systemIconName
self.fileName = fileName
// Restore saved volume
self.volume = UserDefaults.standard.float(forKey: "\(fileName)_volume")
if self.volume == 0 {
self.volume = 1.0
}
// Restore selected state
self.isSelected = UserDefaults.standard.bool(forKey: "\(fileName)_isSelected")
// Observe global volume changes
globalSettingsObserver = GlobalSettings.shared.$volume
.sink { [weak self] _ in
self?.updateVolume()
}
loadSound()
}
private func scaledVolume(_ linear: Float) -> Float {
return pow(linear, 3)
}
private func updateVolume() {
let scaledVol = scaledVolume(volume)
let effectiveVolume = scaledVol * Float(GlobalSettings.shared.volume)
player?.volume = effectiveVolume
}
private var loadedPlayer: AVAudioPlayer? {
if player == nil {
loadSound()
}
return player
}
private func loadSound() {
guard let url = Bundle.main.url(forResource: fileName, withExtension: fileExtension) else {
ErrorReporter.shared.report(AudioError.fileNotFound)
return
}
do {
player = try AVAudioPlayer(contentsOf: url)
player?.volume = volume * Float(GlobalSettings.shared.volume)
player?.numberOfLoops = -1
player?.enableRate = false // Disable rate/pitch adjustment
player?.prepareToPlay()
} catch {
ErrorReporter.shared.report(AudioError.loadFailed(error))
}
}
func play(completion: ((Result<Void, AudioError>) -> Void)? = nil) {
updateVolume() // Set correct volume before playing
guard let player = player else {
completion?(.failure(.fileNotFound))
return
}
player.play()
completion?(.success(()))
}
func pause(immediate: Bool = false) {
if immediate {
player?.pause()
player?.volume = 0
} else {
fadeOut()
}
}
private func fadeIn() {
fadeTimer?.invalidate()
fadeStartVolume = 0
targetVolume = volume * Float(GlobalSettings.shared.volume)
player?.volume = fadeStartVolume
fadeTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
let newVolume = self.player?.volume ?? 0
if newVolume < self.targetVolume {
self.player?.volume = min(newVolume + (self.targetVolume / 10), self.targetVolume)
} else {
timer.invalidate()
}
}
}
private func fadeOut() {
fadeTimer?.invalidate()
fadeStartVolume = player?.volume ?? 0
fadeTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
let newVolume = self.player?.volume ?? 0
if newVolume > 0 {
self.player?.volume = max(newVolume - (self.fadeStartVolume / 10), 0)
} else {
self.player?.pause()
timer.invalidate()
}
}
}
func toggle() {
let wasSelected = isSelected
isSelected.toggle()
if isSelected && !wasSelected { // Only when turning ON
// If we selected a new sound and the app is paused, start playing
if !AudioManager.shared.isGloballyPlaying {
AudioManager.shared.togglePlayback()
} else {
play()
}
} else {
pause()
}
}
deinit {
fadeTimer?.invalidate()
player?.stop()
player = nil
globalSettingsObserver?.cancel()
}
}
// AudioManager class
class AudioManager: ObservableObject {
static let shared = AudioManager()
var onReset: (() -> Void)?
@Published var sounds: [Sound] = []
@Published var isGloballyPlaying: Bool = false {
didSet {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
UserDefaults.standard.set(self.isGloballyPlaying, forKey: "isGloballyPlaying")
if self.isGloballyPlaying {
self.playSelected()
} else {
self.pauseAll()
}
}
}
}
private let commandCenter = MPRemoteCommandCenter.shared()
private var nowPlayingInfo: [String: Any] = [:]
private init() {
loadSounds()
loadSavedState()
setupMediaControls()
setupNowPlaying()
setupNotificationObservers()
// Handle autoplay behavior
if !GlobalSettings.shared.alwaysStartPaused {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let hasSelectedSounds = self.sounds.contains { $0.isSelected }
self.isGloballyPlaying = hasSelectedSounds
if hasSelectedSounds {
self.updateNowPlayingInfo()
}
}
}
}
private func loadSounds() {
sounds = [
Sound(title: "Rain", systemIconName: "cloud.rain", fileName: "rain"),
Sound(title: "Storm", systemIconName: "cloud.bolt.rain", fileName: "storm"),
Sound(title: "Wind", systemIconName: "wind", fileName: "wind"),
Sound(title: "Waves", systemIconName: "water.waves", fileName: "waves"),
Sound(title: "Stream", systemIconName: "humidity", fileName: "stream"),
Sound(title: "Birds", systemIconName: "bird", fileName: "birds"),
Sound(title: "Summer Night", systemIconName: "moon.stars.fill", fileName: "summer-night"),
Sound(title: "Train", systemIconName: "tram.fill", fileName: "train"),
Sound(title: "Boat", systemIconName: "sailboat.fill", fileName: "boat"),
Sound(title: "City", systemIconName: "building.2", fileName: "city"),
Sound(title: "Coffee Shop", systemIconName: "cup.and.saucer.fill", fileName: "coffee-shop"),
Sound(title: "Fireplace", systemIconName: "fireplace", fileName: "fireplace"),
Sound(title: "Pink Noise", systemIconName: "waveform.path", fileName: "pink-noise"),
Sound(title: "White Noise", systemIconName: "waveform", fileName: "white-noise"),
]
}
private func setupMediaControls() {
// Remove all previous handlers
commandCenter.playCommand.removeTarget(nil)
commandCenter.pauseCommand.removeTarget(nil)
commandCenter.togglePlayPauseCommand.removeTarget(nil)
// Add handlers
commandCenter.playCommand.addTarget { [weak self] _ in
self?.togglePlayback()
return .success
}
commandCenter.pauseCommand.addTarget { [weak self] _ in
self?.togglePlayback()
return .success
}
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
self?.togglePlayback()
return .success
}
}
private func playSelected() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
for sound in self.sounds where sound.isSelected {
sound.play()
}
self.updateNowPlayingInfo()
}
}
private func loadSavedState() {
guard let state = UserDefaults.standard.array(forKey: "soundState") as? [[String: Any]] else {
return
}
for savedState in state {
guard let fileName = savedState["fileName"] as? String,
let sound = sounds.first(where: { $0.fileName == fileName }) else {
continue
}
sound.isSelected = savedState["isSelected"] as? Bool ?? false
sound.volume = savedState["volume"] as? Float ?? 1.0
}
}
private func setupNowPlaying() {
// Set up now playing info
nowPlayingInfo[MPMediaItemPropertyTitle] = "Ambient Sounds"
nowPlayingInfo[MPMediaItemPropertyArtist] = "Blankie"
// // Optional: Add artwork
// if let image = NSImage(named: "AppIcon"), // Use your app icon or custom artwork
// let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) {
// let artwork = MPMediaItemArtwork(boundsSize: image.size) { size in
// NSImage(cgImage: cgImage, size: size)
// }
// nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
// }
updatePlaybackState()
}
private func updateNowPlayingInfo() {
var nowPlayingInfo = [String: Any]()
nowPlayingInfo[MPMediaItemPropertyTitle] = "Ambient Sounds"
nowPlayingInfo[MPMediaItemPropertyArtist] = "Blankie"
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isGloballyPlaying ? 1.0 : 0.0
// Add app icon as artwork
if let image = NSImage(named: "AppIcon"),
let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) {
let artwork = MPMediaItemArtwork(boundsSize: image.size) { size in
NSImage(cgImage: cgImage, size: size)
}
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
private func updatePlaybackState() {
// Update playback state
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isGloballyPlaying ? 1.0 : 0.0
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = 0
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = 0 // Infinite for ambient sounds
// Update the now playing info
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
private func setupNotificationObservers() {
NotificationCenter.default.addObserver(
forName: NSApplication.willTerminateNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.handleAppTermination()
}
}
private func handleAppTermination() {
cleanup()
}
private func cleanup() {
pauseAll()
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
}
func pauseAll() {
for sound in sounds {
sound.pause()
}
updateNowPlayingInfo() // Add this line
}
func saveState() {
let state = sounds.map { sound in
[
"id": sound.id.uuidString,
"fileName": sound.fileName,
"isSelected": sound.isSelected,
"volume": sound.volume
]
}
UserDefaults.standard.set(state, forKey: "soundState")
}
/// Toggles the playback state of all selected sounds
/// - Parameter completion: Optional completion handler
public func togglePlayback(completion: ((Result<Void, AudioError>) -> Void)? = nil) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.isGloballyPlaying.toggle()
self.updateNowPlayingInfo()
completion?(.success(()))
}
}
func resetSounds() {
// First pause all sounds immediately
sounds.forEach { sound in
sound.pause(immediate: true)
}
isGloballyPlaying = false
// Reset all sounds
sounds.forEach { sound in
sound.volume = 1.0
sound.isSelected = false
}
// Reset global volume using the setter method
GlobalSettings.shared.setVolume(1.0)
// Call the reset callback
onReset?()
}
deinit {
NotificationCenter.default.removeObserver(self)
cleanup()
}
}
struct AudioErrorHandler: ViewModifier {
@ObservedObject private var errorReporter = ErrorReporter.shared
@State private var showingError = false
func body(content: Content) -> some View {
content
.onChange(of: errorReporter.lastError != nil) { hasError in
showingError = hasError
}
.alert("Error", isPresented: $showingError) {
Button("OK") {
errorReporter.lastError = nil
}
} message: {
if let error = errorReporter.lastError {
Text(error.localizedDescription)
}
}
}
}
extension View {
func handleAudioErrors() -> some View {
self.modifier(AudioErrorHandler())
}
}
+5
View File
@@ -0,0 +1,5 @@
<?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/>
</plist>
+10
View 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>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>
+740
View File
@@ -0,0 +1,740 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
F93A3B332D26E93600EFC1C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F93A3B0E2D26E93600EFC1C9 /* AppState.swift */; };
F93A3B342D26E93600EFC1C9 /* WindowObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F93A3B302D26E93600EFC1C9 /* WindowObserver.swift */; };
F93A3B352D26E93600EFC1C9 /* PresetManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F93A3B1E2D26E93600EFC1C9 /* PresetManager.swift */; };
F93A3B362D26E93600EFC1C9 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F93A3B1C2D26E93600EFC1C9 /* PreferencesView.swift */; };
F93A3B372D26E93600EFC1C9 /* AppCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = F93A3B0D2D26E93600EFC1C9 /* AppCommands.swift */; };
F93A3B382D26E93600EFC1C9 /* Preset.swift in Sources */ = {isa = PBXBuildFile; fileRef = F93A3B1D2D26E93600EFC1C9 /* Preset.swift */; };
F93A3B392D26E93600EFC1C9 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F93A3B0C2D26E93600EFC1C9 /* AboutView.swift */; };
F93A3B3A2D26E93600EFC1C9 /* GlobalSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F93A3B192D26E93600EFC1C9 /* GlobalSettings.swift */; };
F93A3B3B2D26E93600EFC1C9 /* SoundIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = F93A3B202D26E93600EFC1C9 /* SoundIcon.swift */; };
F93A3B3C2D26E93600EFC1C9 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F93A3B172D26E93600EFC1C9 /* ContentView.swift */; };
F93A3B3D2D26E93600EFC1C9 /* MainMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = F93A3B1B2D26E93600EFC1C9 /* MainMenu.swift */; };
F93A3B3E2D26E93600EFC1C9 /* AudioManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F93A3B102D26E93600EFC1C9 /* AudioManager.swift */; };
F93A3B3F2D26E93600EFC1C9 /* BlankieApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F93A3B132D26E93600EFC1C9 /* BlankieApp.swift */; };
F93A3B402D26E93600EFC1C9 /* city.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B232D26E93600EFC1C9 /* city.mp3 */; };
F93A3B412D26E93600EFC1C9 /* rain.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B272D26E93600EFC1C9 /* rain.mp3 */; };
F93A3B432D26E93600EFC1C9 /* train.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B2B2D26E93600EFC1C9 /* train.mp3 */; };
F93A3B442D26E93600EFC1C9 /* white-noise.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B2D2D26E93600EFC1C9 /* white-noise.mp3 */; };
F93A3B452D26E93600EFC1C9 /* coffee-shop.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B242D26E93600EFC1C9 /* coffee-shop.mp3 */; };
F93A3B462D26E93600EFC1C9 /* fireplace.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B252D26E93600EFC1C9 /* fireplace.mp3 */; };
F93A3B472D26E93600EFC1C9 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B1F2D26E93600EFC1C9 /* README.md */; };
F93A3B482D26E93600EFC1C9 /* birds.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B212D26E93600EFC1C9 /* birds.mp3 */; };
F93A3B492D26E93600EFC1C9 /* summer-night.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B2A2D26E93600EFC1C9 /* summer-night.mp3 */; };
F93A3B4A2D26E93600EFC1C9 /* stream.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B292D26E93600EFC1C9 /* stream.mp3 */; };
F93A3B4B2D26E93600EFC1C9 /* boat.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B222D26E93600EFC1C9 /* boat.mp3 */; };
F93A3B4D2D26E93600EFC1C9 /* wind.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B2E2D26E93600EFC1C9 /* wind.mp3 */; };
F93A3B4E2D26E93600EFC1C9 /* screenshot.png in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B152D26E93600EFC1C9 /* screenshot.png */; };
F93A3B4F2D26E93600EFC1C9 /* icon.png in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B142D26E93600EFC1C9 /* icon.png */; };
F93A3B502D26E93600EFC1C9 /* waves.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B2C2D26E93600EFC1C9 /* waves.mp3 */; };
F93A3B512D26E93600EFC1C9 /* pink-noise.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B262D26E93600EFC1C9 /* pink-noise.mp3 */; };
F93A3B522D26E93600EFC1C9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B0F2D26E93600EFC1C9 /* Assets.xcassets */; };
F93A3B532D26E93600EFC1C9 /* storm.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = F93A3B282D26E93600EFC1C9 /* storm.mp3 */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
F91350732D233A44003C85BE /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = F91350572D233A43003C85BE /* Project object */;
proxyType = 1;
remoteGlobalIDString = F913505E2D233A43003C85BE;
remoteInfo = Blankie;
};
F913507D2D233A44003C85BE /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = F91350572D233A43003C85BE /* Project object */;
proxyType = 1;
remoteGlobalIDString = F913505E2D233A43003C85BE;
remoteInfo = Blankie;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
F913505F2D233A43003C85BE /* Blankie.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Blankie.app; sourceTree = BUILT_PRODUCTS_DIR; };
F91350722D233A44003C85BE /* BlankieTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BlankieTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
F913507C2D233A44003C85BE /* BlankieUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BlankieUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
F921ED992D272AE300D4F3D3 /* Configuration.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Configuration.xcconfig; sourceTree = "<group>"; };
F921EDD12D2744CE00D4F3D3 /* Blankie-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Blankie-Info.plist"; sourceTree = "<group>"; };
F93A3B0C2D26E93600EFC1C9 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
F93A3B0D2D26E93600EFC1C9 /* AppCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCommands.swift; sourceTree = "<group>"; };
F93A3B0E2D26E93600EFC1C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
F93A3B0F2D26E93600EFC1C9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
F93A3B102D26E93600EFC1C9 /* AudioManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioManager.swift; sourceTree = "<group>"; };
F93A3B112D26E93600EFC1C9 /* Blankie.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Blankie.entitlements; sourceTree = "<group>"; };
F93A3B122D26E93600EFC1C9 /* Blankie.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = Blankie.xcodeproj; sourceTree = "<group>"; };
F93A3B132D26E93600EFC1C9 /* BlankieApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlankieApp.swift; sourceTree = "<group>"; };
F93A3B142D26E93600EFC1C9 /* icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = icon.png; sourceTree = "<group>"; };
F93A3B152D26E93600EFC1C9 /* screenshot.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = screenshot.png; sourceTree = "<group>"; };
F93A3B172D26E93600EFC1C9 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
F93A3B192D26E93600EFC1C9 /* GlobalSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSettings.swift; sourceTree = "<group>"; };
F93A3B1B2D26E93600EFC1C9 /* MainMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenu.swift; sourceTree = "<group>"; };
F93A3B1C2D26E93600EFC1C9 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
F93A3B1D2D26E93600EFC1C9 /* Preset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preset.swift; sourceTree = "<group>"; };
F93A3B1E2D26E93600EFC1C9 /* PresetManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetManager.swift; sourceTree = "<group>"; };
F93A3B1F2D26E93600EFC1C9 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
F93A3B202D26E93600EFC1C9 /* SoundIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundIcon.swift; sourceTree = "<group>"; };
F93A3B212D26E93600EFC1C9 /* birds.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = birds.mp3; sourceTree = "<group>"; };
F93A3B222D26E93600EFC1C9 /* boat.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = boat.mp3; sourceTree = "<group>"; };
F93A3B232D26E93600EFC1C9 /* city.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = city.mp3; sourceTree = "<group>"; };
F93A3B242D26E93600EFC1C9 /* coffee-shop.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = "coffee-shop.mp3"; sourceTree = "<group>"; };
F93A3B252D26E93600EFC1C9 /* fireplace.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = fireplace.mp3; sourceTree = "<group>"; };
F93A3B262D26E93600EFC1C9 /* pink-noise.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = "pink-noise.mp3"; sourceTree = "<group>"; };
F93A3B272D26E93600EFC1C9 /* rain.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = rain.mp3; sourceTree = "<group>"; };
F93A3B282D26E93600EFC1C9 /* storm.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = storm.mp3; sourceTree = "<group>"; };
F93A3B292D26E93600EFC1C9 /* stream.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = stream.mp3; sourceTree = "<group>"; };
F93A3B2A2D26E93600EFC1C9 /* summer-night.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = "summer-night.mp3"; sourceTree = "<group>"; };
F93A3B2B2D26E93600EFC1C9 /* train.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = train.mp3; sourceTree = "<group>"; };
F93A3B2C2D26E93600EFC1C9 /* waves.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = waves.mp3; sourceTree = "<group>"; };
F93A3B2D2D26E93600EFC1C9 /* white-noise.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = "white-noise.mp3"; sourceTree = "<group>"; };
F93A3B2E2D26E93600EFC1C9 /* wind.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = wind.mp3; sourceTree = "<group>"; };
F93A3B302D26E93600EFC1C9 /* WindowObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowObserver.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
F913505C2D233A43003C85BE /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
F913506F2D233A44003C85BE /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
F91350792D233A44003C85BE /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
F91350562D233A43003C85BE = {
isa = PBXGroup;
children = (
F921EDD12D2744CE00D4F3D3 /* Blankie-Info.plist */,
F93A3B0C2D26E93600EFC1C9 /* AboutView.swift */,
F93A3B0D2D26E93600EFC1C9 /* AppCommands.swift */,
F93A3B0E2D26E93600EFC1C9 /* AppState.swift */,
F93A3B0F2D26E93600EFC1C9 /* Assets.xcassets */,
F93A3B102D26E93600EFC1C9 /* AudioManager.swift */,
F93A3B112D26E93600EFC1C9 /* Blankie.entitlements */,
F93A3B122D26E93600EFC1C9 /* Blankie.xcodeproj */,
F93A3B132D26E93600EFC1C9 /* BlankieApp.swift */,
F93A3B162D26E93600EFC1C9 /* brand */,
F93A3B172D26E93600EFC1C9 /* ContentView.swift */,
F93A3B192D26E93600EFC1C9 /* GlobalSettings.swift */,
F93A3B1B2D26E93600EFC1C9 /* MainMenu.swift */,
F93A3B1C2D26E93600EFC1C9 /* PreferencesView.swift */,
F93A3B1D2D26E93600EFC1C9 /* Preset.swift */,
F93A3B1E2D26E93600EFC1C9 /* PresetManager.swift */,
F93A3B1F2D26E93600EFC1C9 /* README.md */,
F93A3B202D26E93600EFC1C9 /* SoundIcon.swift */,
F93A3B2F2D26E93600EFC1C9 /* Sounds */,
F93A3B302D26E93600EFC1C9 /* WindowObserver.swift */,
F91350602D233A43003C85BE /* Products */,
F921ED992D272AE300D4F3D3 /* Configuration.xcconfig */,
);
sourceTree = "<group>";
};
F91350602D233A43003C85BE /* Products */ = {
isa = PBXGroup;
children = (
F913505F2D233A43003C85BE /* Blankie.app */,
F91350722D233A44003C85BE /* BlankieTests.xctest */,
F913507C2D233A44003C85BE /* BlankieUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
F93A3B162D26E93600EFC1C9 /* brand */ = {
isa = PBXGroup;
children = (
F93A3B142D26E93600EFC1C9 /* icon.png */,
F93A3B152D26E93600EFC1C9 /* screenshot.png */,
);
path = brand;
sourceTree = "<group>";
};
F93A3B2F2D26E93600EFC1C9 /* Sounds */ = {
isa = PBXGroup;
children = (
F93A3B212D26E93600EFC1C9 /* birds.mp3 */,
F93A3B222D26E93600EFC1C9 /* boat.mp3 */,
F93A3B232D26E93600EFC1C9 /* city.mp3 */,
F93A3B242D26E93600EFC1C9 /* coffee-shop.mp3 */,
F93A3B252D26E93600EFC1C9 /* fireplace.mp3 */,
F93A3B262D26E93600EFC1C9 /* pink-noise.mp3 */,
F93A3B272D26E93600EFC1C9 /* rain.mp3 */,
F93A3B282D26E93600EFC1C9 /* storm.mp3 */,
F93A3B292D26E93600EFC1C9 /* stream.mp3 */,
F93A3B2A2D26E93600EFC1C9 /* summer-night.mp3 */,
F93A3B2B2D26E93600EFC1C9 /* train.mp3 */,
F93A3B2C2D26E93600EFC1C9 /* waves.mp3 */,
F93A3B2D2D26E93600EFC1C9 /* white-noise.mp3 */,
F93A3B2E2D26E93600EFC1C9 /* wind.mp3 */,
);
path = Sounds;
sourceTree = "<group>";
};
F93A3B312D26E93600EFC1C9 /* Products */ = {
isa = PBXGroup;
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
F913505E2D233A43003C85BE /* Blankie */ = {
isa = PBXNativeTarget;
buildConfigurationList = F91350862D233A44003C85BE /* Build configuration list for PBXNativeTarget "Blankie" */;
buildPhases = (
F913505B2D233A43003C85BE /* Sources */,
F913505C2D233A43003C85BE /* Frameworks */,
F913505D2D233A43003C85BE /* Resources */,
F921EDC02D27408E00D4F3D3 /* ShellScript */,
);
buildRules = (
);
dependencies = (
);
name = Blankie;
packageProductDependencies = (
);
productName = Blankie;
productReference = F913505F2D233A43003C85BE /* Blankie.app */;
productType = "com.apple.product-type.application";
};
F91350712D233A44003C85BE /* BlankieTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = F91350892D233A44003C85BE /* Build configuration list for PBXNativeTarget "BlankieTests" */;
buildPhases = (
F913506E2D233A44003C85BE /* Sources */,
F913506F2D233A44003C85BE /* Frameworks */,
F91350702D233A44003C85BE /* Resources */,
);
buildRules = (
);
dependencies = (
F91350742D233A44003C85BE /* PBXTargetDependency */,
);
name = BlankieTests;
packageProductDependencies = (
);
productName = BlankieTests;
productReference = F91350722D233A44003C85BE /* BlankieTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
F913507B2D233A44003C85BE /* BlankieUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = F913508C2D233A44003C85BE /* Build configuration list for PBXNativeTarget "BlankieUITests" */;
buildPhases = (
F91350782D233A44003C85BE /* Sources */,
F91350792D233A44003C85BE /* Frameworks */,
F913507A2D233A44003C85BE /* Resources */,
);
buildRules = (
);
dependencies = (
F913507E2D233A44003C85BE /* PBXTargetDependency */,
);
name = BlankieUITests;
packageProductDependencies = (
);
productName = BlankieUITests;
productReference = F913507C2D233A44003C85BE /* BlankieUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
F91350572D233A43003C85BE /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 1620;
TargetAttributes = {
F913505E2D233A43003C85BE = {
CreatedOnToolsVersion = 16.2;
LastSwiftMigration = 1620;
};
F91350712D233A44003C85BE = {
CreatedOnToolsVersion = 16.2;
TestTargetID = F913505E2D233A43003C85BE;
};
F913507B2D233A44003C85BE = {
CreatedOnToolsVersion = 16.2;
TestTargetID = F913505E2D233A43003C85BE;
};
};
};
buildConfigurationList = F913505A2D233A43003C85BE /* Build configuration list for PBXProject "Blankie" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = F91350562D233A43003C85BE;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = F91350602D233A43003C85BE /* Products */;
projectDirPath = "";
projectReferences = (
{
ProductGroup = F93A3B312D26E93600EFC1C9 /* Products */;
ProjectRef = F93A3B122D26E93600EFC1C9 /* Blankie.xcodeproj */;
},
);
projectRoot = "";
targets = (
F913505E2D233A43003C85BE /* Blankie */,
F91350712D233A44003C85BE /* BlankieTests */,
F913507B2D233A44003C85BE /* BlankieUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
F913505D2D233A43003C85BE /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F93A3B402D26E93600EFC1C9 /* city.mp3 in Resources */,
F93A3B412D26E93600EFC1C9 /* rain.mp3 in Resources */,
F93A3B432D26E93600EFC1C9 /* train.mp3 in Resources */,
F93A3B442D26E93600EFC1C9 /* white-noise.mp3 in Resources */,
F93A3B452D26E93600EFC1C9 /* coffee-shop.mp3 in Resources */,
F93A3B462D26E93600EFC1C9 /* fireplace.mp3 in Resources */,
F93A3B472D26E93600EFC1C9 /* README.md in Resources */,
F93A3B482D26E93600EFC1C9 /* birds.mp3 in Resources */,
F93A3B492D26E93600EFC1C9 /* summer-night.mp3 in Resources */,
F93A3B4A2D26E93600EFC1C9 /* stream.mp3 in Resources */,
F93A3B4B2D26E93600EFC1C9 /* boat.mp3 in Resources */,
F93A3B4D2D26E93600EFC1C9 /* wind.mp3 in Resources */,
F93A3B4E2D26E93600EFC1C9 /* screenshot.png in Resources */,
F93A3B4F2D26E93600EFC1C9 /* icon.png in Resources */,
F93A3B502D26E93600EFC1C9 /* waves.mp3 in Resources */,
F93A3B512D26E93600EFC1C9 /* pink-noise.mp3 in Resources */,
F93A3B522D26E93600EFC1C9 /* Assets.xcassets in Resources */,
F93A3B532D26E93600EFC1C9 /* storm.mp3 in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
F91350702D233A44003C85BE /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
F913507A2D233A44003C85BE /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
F921EDC02D27408E00D4F3D3 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 12;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
outputFileListPaths = (
);
outputPaths = (
"$(INFOPLIST_FILE)",
"$(PROJECT_DIR)/Configuration.xcconfig",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "#!/bin/sh\nset -e\nset -x\n\n# Get paths\nPLIST_PATH=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nCONFIG_PATH=\"${PROJECT_DIR}/Configuration.xcconfig\"\nTEMP_PLIST=\"${DERIVED_FILE_DIR}/temp_Info.plist\"\n\necho \"Info.plist path: ${PLIST_PATH}\"\necho \"Config path: ${CONFIG_PATH}\"\n\n# Copy Info.plist to temp location\ncp \"${PLIST_PATH}\" \"${TEMP_PLIST}\"\n\n# Try to get current build number, initialize to 0 if it fails\nbuildNumber=$(/usr/libexec/PlistBuddy -c \"Print CFBundleVersion\" \"${TEMP_PLIST}\" 2>/dev/null || echo \"0\")\n\n# Clean up buildNumber to ensure it's just a number\nbuildNumber=$(echo \"$buildNumber\" | grep -o '[0-9]*' || echo \"0\")\n\n# If buildNumber is empty, set it to 0\nif [ -z \"$buildNumber\" ]; then\n buildNumber=0\nfi\n\n# Increment it\nbuildNumber=$((buildNumber + 1))\necho \"New build number: $buildNumber\"\n\n# Update the temp plist\nif ! /usr/libexec/PlistBuddy -c \"Set :CFBundleVersion $buildNumber\" \"${TEMP_PLIST}\" 2>/dev/null; then\n /usr/libexec/PlistBuddy -c \"Add :CFBundleVersion string $buildNumber\" \"${TEMP_PLIST}\"\nfi\n\n# Copy back to original location\ncp \"${TEMP_PLIST}\" \"${PLIST_PATH}\"\n\n# Update xcconfig using a temp file\nTEMP_CONFIG=\"${DERIVED_FILE_DIR}/temp_config.xcconfig\"\nsed \"s/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = $buildNumber/\" \"${CONFIG_PATH}\" > \"${TEMP_CONFIG}\"\ncp \"${TEMP_CONFIG}\" \"${CONFIG_PATH}\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
F913505B2D233A43003C85BE /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F93A3B332D26E93600EFC1C9 /* AppState.swift in Sources */,
F93A3B342D26E93600EFC1C9 /* WindowObserver.swift in Sources */,
F93A3B352D26E93600EFC1C9 /* PresetManager.swift in Sources */,
F93A3B362D26E93600EFC1C9 /* PreferencesView.swift in Sources */,
F93A3B372D26E93600EFC1C9 /* AppCommands.swift in Sources */,
F93A3B382D26E93600EFC1C9 /* Preset.swift in Sources */,
F93A3B392D26E93600EFC1C9 /* AboutView.swift in Sources */,
F93A3B3A2D26E93600EFC1C9 /* GlobalSettings.swift in Sources */,
F93A3B3B2D26E93600EFC1C9 /* SoundIcon.swift in Sources */,
F93A3B3C2D26E93600EFC1C9 /* ContentView.swift in Sources */,
F93A3B3D2D26E93600EFC1C9 /* MainMenu.swift in Sources */,
F93A3B3E2D26E93600EFC1C9 /* AudioManager.swift in Sources */,
F93A3B3F2D26E93600EFC1C9 /* BlankieApp.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
F913506E2D233A44003C85BE /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
F91350782D233A44003C85BE /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
F91350742D233A44003C85BE /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = F913505E2D233A43003C85BE /* Blankie */;
targetProxy = F91350732D233A44003C85BE /* PBXContainerItemProxy */;
};
F913507E2D233A44003C85BE /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = F913505E2D233A43003C85BE /* Blankie */;
targetProxy = F913507D2D233A44003C85BE /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
F91350842D233A44003C85BE /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_PREVIEWS = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
F91350852D233A44003C85BE /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_PREVIEWS = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_VERSION = 5.0;
};
name = Release;
};
F91350872D233A44003C85BE /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = F921ED992D272AE300D4F3D3 /* Configuration.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Blankie.entitlements;
CODE_SIGN_IDENTITY = "$(CODE_SIGN_IDENTITY)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Blankie-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Blankie;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2024 Cody Bromley. Sound effects under various Creative Commons licenses.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 13.5;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
"PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = auto;
SUPPORTED_PLATFORMS = macosx;
SWIFT_EMIT_LOC_STRINGS = YES;
};
name = Debug;
};
F91350882D233A44003C85BE /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = F921ED992D272AE300D4F3D3 /* Configuration.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Blankie.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "$(CODE_SIGN_IDENTITY)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Blankie-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Blankie;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2024 Cody Bromley. Sound effects under various Creative Commons licenses.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 13.5;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
"PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = auto;
SUPPORTED_PLATFORMS = macosx;
SWIFT_EMIT_LOC_STRINGS = YES;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
F913508A2D233A44003C85BE /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = macosx;
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = NO;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Blankie.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Blankie";
XROS_DEPLOYMENT_TARGET = 2.2;
};
name = Debug;
};
F913508B2D233A44003C85BE /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = macosx;
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = NO;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Blankie.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Blankie";
XROS_DEPLOYMENT_TARGET = 2.2;
};
name = Release;
};
F913508D2D233A44003C85BE /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = macosx;
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = NO;
TEST_TARGET_NAME = Blankie;
XROS_DEPLOYMENT_TARGET = 2.2;
};
name = Debug;
};
F913508E2D233A44003C85BE /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = macosx;
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = NO;
TEST_TARGET_NAME = Blankie;
XROS_DEPLOYMENT_TARGET = 2.2;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
F913505A2D233A43003C85BE /* Build configuration list for PBXProject "Blankie" */ = {
isa = XCConfigurationList;
buildConfigurations = (
F91350842D233A44003C85BE /* Debug */,
F91350852D233A44003C85BE /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
F91350862D233A44003C85BE /* Build configuration list for PBXNativeTarget "Blankie" */ = {
isa = XCConfigurationList;
buildConfigurations = (
F91350872D233A44003C85BE /* Debug */,
F91350882D233A44003C85BE /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
F91350892D233A44003C85BE /* Build configuration list for PBXNativeTarget "BlankieTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
F913508A2D233A44003C85BE /* Debug */,
F913508B2D233A44003C85BE /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
F913508C2D233A44003C85BE /* Build configuration list for PBXNativeTarget "BlankieUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
F913508D2D233A44003C85BE /* Debug */,
F913508E2D233A44003C85BE /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = F91350572D233A43003C85BE /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
+79
View File
@@ -0,0 +1,79 @@
//
// BlankieApp.swift
// Blankie
//
// Created by Cody Bromley on 12/30/24.
//
import SwiftUI
import SwiftData
@main
struct BlankieApp: App {
@StateObject private var audioManager = AudioManager.shared
@StateObject private var windowObserver = WindowObserver.shared
@State private var showingAbout = false
var body: some Scene {
WindowGroup {
ContentView(showingAbout: $showingAbout)
.frame(minWidth: 320, minHeight: 275)
.navigationTitle("")
.toolbar {
ToolbarItem(placement: .principal) {
Text("Blankie")
.font(.system(size: 15, weight: .medium, design: .rounded))
.foregroundStyle(.primary)
}
}
.sheet(isPresented: $showingAbout) {
AboutView()
}
.keyboardShortcut("w", modifiers: .command)
}
.defaultSize(width: 600, height: 800)
.windowToolbarStyle(.unified)
.commands {
AppCommands(showingAbout: $showingAbout, hasWindow: $windowObserver.hasVisibleWindow)
}
MenuBarExtra("Blankie", systemImage: "waveform") {
Button("Show Main Window") {
NSApp.activate(ignoringOtherApps: true)
}
Divider()
Button("About Blankie") {
NSApp.activate(ignoringOtherApps: true)
showingAbout = true
}
Divider()
Button("Quit Blankie") {
NSApplication.shared.terminate(nil)
}
}
Settings {
PreferencesView()
}
}
}
#if DEBUG
struct BlankieApp_Previews: PreviewProvider {
static var previews: some View {
ContentView(showingAbout: .constant(false))
.frame(minWidth: 320, minHeight: 275)
.toolbar {
ToolbarItem(placement: .principal) {
Text("Blankie")
.font(.system(size: 15, weight: .medium, design: .rounded))
.foregroundStyle(.primary)
}
}
}
}
#endif
+5
View File
@@ -0,0 +1,5 @@
DEVELOPMENT_TEAM = YOUR_TEAM_ID_HERE
PRODUCT_BUNDLE_IDENTIFIER = com.example.blankie
CODE_SIGN_IDENTITY = Apple Development
CODE_SIGN_STYLE = Automatic
CURRENT_PROJECT_VERSION = 1
+659
View File
@@ -0,0 +1,659 @@
//
// ContentView.swift
// Blankie
//
// Created by Cody Bromley on 12/30/24.
//
import SwiftUI
struct ContentView: View {
@Binding var showingAbout: Bool
@ObservedObject var audioManager = AudioManager.shared
@ObservedObject var globalSettings = GlobalSettings.shared
@ObservedObject private var appState = AppState.shared
@State private var showingVolumePopover = false
@State private var showingColorPicker = false
@State private var showingShortcuts = false
@State private var showingPreferences = false
@State private var hideInactiveSounds = false
@State private var showingNewPresetSheet = false
@State private var presetName = ""
@State private var showingNewPresetPopover = false
var textColor: Color {
audioManager.isGloballyPlaying ? .primary : .secondary
}
// Define constant sizes
private let itemWidth: CGFloat = 120 // Total width including padding
private let minimumSpacing: CGFloat = 10
var body: some View {
GeometryReader { geometry in
VStack(spacing: 0) {
if !audioManager.isGloballyPlaying {
HStack {
Image(systemName: "pause.circle.fill")
Text("Playback Paused")
.font(.system(.subheadline, design: .rounded))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(.ultraThinMaterial)
.foregroundStyle(.secondary)
}
// Main content
ScrollView(.vertical, showsIndicators: true) {
LazyVGrid(
columns: calculateColumns(for: geometry.size.width),
spacing: minimumSpacing
) {
ForEach(audioManager.sounds.filter { sound in
!hideInactiveSounds || sound.isSelected
}) { sound in
SoundIcon(sound: sound, maxWidth: itemWidth)
}
}
.padding()
}
.frame(maxHeight: .infinity)
// App bar
VStack(spacing: 0) {
Rectangle()
.frame(height: 1)
.foregroundColor(Color.gray.opacity(0.2))
HStack(spacing: 16) {
// Volume button with popover
Button(action: {
showingVolumePopover.toggle()
}) {
Image(systemName: "speaker.wave.2.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.foregroundColor(.primary)
}
.buttonStyle(.borderless)
.popover(isPresented: $showingVolumePopover, arrowEdge: .top) {
VolumePopoverView()
}
// Play/Pause button
Button(action: {
audioManager.togglePlayback()
}) {
ZStack {
Circle()
.fill(globalSettings.customAccentColor?.opacity(0.2) ?? Color.accentColor.opacity(0.2))
.frame(width: 50, height: 50)
Image(systemName: audioManager.isGloballyPlaying ? "pause.fill" : "play.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.foregroundColor(globalSettings.customAccentColor ?? .accentColor)
.offset(x: audioManager.isGloballyPlaying ? 0 : 2)
}
}
.buttonStyle(.borderless)
Menu {
Button {
hideInactiveSounds.toggle()
} label: {
HStack {
Text("Hide Inactive Sounds")
if hideInactiveSounds {
Spacer()
Image(systemName: "checkmark")
}
}
}
.keyboardShortcut("h", modifiers: [.control, .command])
Divider()
Button("Save as New Preset...") {
presetName = "" // Reset preset name
showingNewPresetPopover.toggle()
}
Button("Add Sound (Coming Soon!)") {
// Implement add sound functionality
}
.keyboardShortcut("o", modifiers: .command)
.disabled(true)
} label: {
Text("") // vertical ellipsis
.font(.system(size: 20))
.foregroundColor(.primary)
}
.buttonStyle(.borderless)
.menuIndicator(.hidden)
.popover(isPresented: $showingNewPresetPopover, arrowEdge: .top) {
NewPresetSheet(presetName: $presetName, isPresented: $showingNewPresetPopover)
}
// Color picker menu
// Button(action: {
// showingColorPicker.toggle()
// }) {
// Image(systemName: "paintpalette.fill")
// .resizable()
// .aspectRatio(contentMode: .fit)
// .frame(width: 20, height: 20)
// .foregroundColor(.primary)
// }
// .buttonStyle(.borderless)
// .popover(isPresented: $showingColorPicker) {
// ColorPickerView()
// .padding()
// }
}
.padding(.vertical, 12)
.padding(.horizontal, 16)
}
.frame(maxWidth: .infinity)
.background(Color(NSColor.windowBackgroundColor).opacity(0.3))
.background(.ultraThinMaterial)
}
}
.ignoresSafeArea(.container, edges: .horizontal)
.toolbar {
if !PresetManager.shared.presets.isEmpty {
ToolbarItem(placement: .primaryAction) {
PresetPicker()
}
}
// Right-side menu icon only
ToolbarItem(placement: .primaryAction) {
Menu {
if #available(macOS 14.0, *) {
SettingsLink {
Text("Preferences...")
}
.keyboardShortcut(",", modifiers: .command)
} else {
Button("Preferences...") {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
.keyboardShortcut(",", modifiers: .command)
}
Button("Keyboard Shortcuts") {
showingShortcuts.toggle()
}
.keyboardShortcut("?", modifiers: [.command, .shift])
Button("About Blankie") {
showingAbout = true
}
Divider()
Button("Quit Blankie") {
audioManager.pauseAll()
exit(0)
}
.keyboardShortcut("q", modifiers: .command)
} label: {
Image(systemName: "line.3.horizontal")
}
.menuIndicator(.hidden)
.menuStyle(.borderlessButton)
}
}
.animation(.easeInOut(duration: 0.2), value: audioManager.isGloballyPlaying)
.sheet(isPresented: $showingShortcuts) {
ShortcutsView()
.background(.ultraThinMaterial)
.presentationBackground(.ultraThinMaterial)
}
.sheet(isPresented: $appState.isAboutViewPresented) {
AboutView()
}
.onAppear {
setupResetHandler()
if !audioManager.isGloballyPlaying {
NSApp.dockTile.badgeLabel = ""
} else {
NSApp.dockTile.badgeLabel = nil
}
}
.onChange(of: audioManager.isGloballyPlaying) { isPlaying in
if !isPlaying {
NSApp.dockTile.badgeLabel = ""
} else {
NSApp.dockTile.badgeLabel = nil
}
}
}
private func calculateColumns(for availableWidth: CGFloat) -> [GridItem] {
let numberOfColumns = max(2, Int(availableWidth / (itemWidth + minimumSpacing)))
return Array(repeating: GridItem(.fixed(itemWidth), spacing: minimumSpacing), count: numberOfColumns)
}
private func setupResetHandler() {
audioManager.onReset = { @MainActor in
showingVolumePopover = false
}
}
}
struct VolumePopoverView: View {
@ObservedObject var audioManager = AudioManager.shared
@ObservedObject var globalSettings = GlobalSettings.shared
var accentColor: Color {
globalSettings.customAccentColor ?? .accentColor
}
var body: some View {
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Global Volume")
.font(.caption)
Slider(
value: Binding(
get: { globalSettings.volume },
set: { globalSettings.setVolume($0) }
),
in: 0...1
)
.frame(width: 200)
.tint(accentColor)
}
// Only show middle divider if there are active sounds
if audioManager.sounds.contains(where: \.isSelected) {
Divider()
// Active sound sliders
ForEach(audioManager.sounds.filter(\.isSelected)) { sound in
VStack(alignment: .leading, spacing: 4) {
Text(sound.title)
.font(.caption)
Slider(value: Binding(
get: { Double(sound.volume) },
set: { sound.volume = Float($0) }
), in: 0...1)
.frame(width: 200)
.tint(accentColor)
}
}
}
Divider()
// Reset button
Button("Reset Sounds") {
audioManager.resetSounds()
}
.font(.caption)
}
.padding()
}
}
struct ColorPickerView: View {
@ObservedObject var globalSettings = GlobalSettings.shared
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Appearance")
.font(.headline)
.padding(.bottom, 4)
ForEach(AppearanceMode.allCases, id: \.self) { mode in
Button(action: {
withAnimation {
globalSettings.setAppearance(mode)
}
}) {
HStack {
Image(systemName: mode.icon)
.frame(width: 16, height: 16)
Text(mode.rawValue)
.foregroundColor(.primary)
Spacer()
if globalSettings.appearance == mode {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.vertical, 4)
}
Divider()
.padding(.vertical, 8)
Text("Accent Color")
.font(.headline)
.padding(.bottom, 4)
ForEach(AccentColor.allCases, id: \.self) { color in
Button(action: {
globalSettings.setAccentColor(color.color)
}) {
HStack {
Circle()
.fill(color.color ?? .accentColor)
.frame(width: 16, height: 16)
Text(color.name)
.foregroundColor(.primary)
Spacer()
if (color == .system && globalSettings.customAccentColor == nil) ||
(color.color == globalSettings.customAccentColor) {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.vertical, 4)
}
}
.frame(width: 200)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView(showingAbout: .constant(false))
.frame(width: 600, height: 400)
}
.previewDisplayName("Blankie")
}
}
enum AccentColor: CaseIterable {
case system
case red
case pink
case orange
case brown
case yellow
case green
case mint
case teal
case cyan
case blue
case indigo
case purple
var name: String {
switch self {
case .system: return "System"
case .red: return "Red"
case .pink: return "Pink"
case .orange: return "Orange"
case .brown: return "Brown"
case .yellow: return "Yellow"
case .green: return "Green"
case .mint: return "Mint"
case .teal: return "Teal"
case .cyan: return "Cyan"
case .blue: return "Blue"
case .indigo: return "Indigo"
case .purple: return "Purple"
}
}
var color: Color? {
switch self {
case .system: return nil
case .red: return .red
case .pink: return .pink
case .orange: return .orange
case .brown: return .brown
case .yellow: return .yellow
case .green: return .green
case .mint: return .mint
case .teal: return .teal
case .cyan: return .cyan
case .blue: return .blue
case .indigo: return .indigo
case .purple: return .purple
}
}
}
struct ShortcutsView: View {
@Environment(\.dismiss) private var dismiss
let shortcuts: [(String, String)] = [
("", "Play/Pause Sounds"),
// (" O", "Add Custom Sound"),
("⌘ W", "Close Window"),
("⌘ ,", "Preferences"),
("⌘ ⇧ ?", "Keyboard Shortcuts"),
("⌘ Q", "Quit")
]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
// Header with close button
HStack {
Text("Keyboard Shortcuts")
.font(.headline)
Spacer()
Button(action: {
dismiss()
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
.imageScale(.large)
}
.buttonStyle(.plain)
}
.padding(.bottom, 8)
// Shortcuts list
VStack(spacing: 12) {
ForEach(shortcuts, id: \.0) { shortcut in
HStack {
Text(shortcut.1)
.foregroundColor(.primary)
Spacer()
Text(shortcut.0)
.foregroundColor(.secondary)
.font(.system(.body, design: .rounded))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.secondary.opacity(0.1))
.cornerRadius(6)
}
}
}
}
.padding()
.frame(width: 300)
.background(Color(NSColor.windowBackgroundColor))
.cornerRadius(12)
}
}
struct PresetPicker: View {
@ObservedObject private var presetManager = PresetManager.shared
@State private var showingPresetPopover = false
@State private var showingEditSheet = false
@State private var selectedPreset: Preset?
@State private var presetName = ""
var body: some View {
if presetManager.hasCustomPresets {
Button {
showingPresetPopover.toggle()
} label: {
HStack(spacing: 4) {
Text(presetManager.currentPreset?.name ?? "Default")
.fontWeight(.bold)
Image(systemName: "chevron.down")
.imageScale(.small)
}
}
.buttonStyle(.plain)
.popover(isPresented: $showingPresetPopover, arrowEdge: .bottom) {
VStack(spacing: 0) {
// Default preset
Button(action: {
presetManager.applyPreset(presetManager.presets[0])
showingPresetPopover = false
}) {
HStack {
Text("Default")
Spacer()
if presetManager.currentPreset?.id == presetManager.presets[0].id {
Image(systemName: "checkmark")
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// Custom presets
ForEach(presetManager.presets.dropFirst()) { preset in
Divider()
HStack(spacing: 8) {
Text(preset.name)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
presetManager.applyPreset(preset)
showingPresetPopover = false
}
Button {
selectedPreset = preset
presetName = preset.name
showingPresetPopover = false
showingEditSheet = true
} label: {
Image(systemName: "pencil")
}
.buttonStyle(.plain)
Button {
presetManager.deletePreset(preset)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.plain)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
}
.frame(width: 200)
.background(Color(NSColor.controlBackgroundColor))
}
.sheet(isPresented: $showingEditSheet) {
if let preset = selectedPreset {
EditPresetSheet(preset: preset, presetName: $presetName, isPresented: $showingEditSheet)
}
}
}
}
}
struct NewPresetSheet: View {
@Binding var presetName: String
@Binding var isPresented: Bool
@ObservedObject private var presetManager = PresetManager.shared
var body: some View {
VStack {
Text("New Preset")
.font(.headline)
TextField("Preset Name", text: $presetName)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
HStack {
Button("Cancel") {
isPresented = false
}
Button("Save") {
if !presetName.isEmpty {
presetManager.saveNewPreset(name: presetName)
isPresented = false
}
}
.disabled(presetName.isEmpty)
}
}
.padding()
.frame(width: 300)
}
}
struct EditPresetSheet: View {
let preset: Preset
@Binding var presetName: String
@Binding var isPresented: Bool
@ObservedObject private var presetManager = PresetManager.shared
var body: some View {
VStack(spacing: 16) {
Text("Edit Preset")
.font(.headline)
TextField("Preset Name", text: $presetName)
.textFieldStyle(RoundedBorderTextFieldStyle())
HStack(spacing: 16) {
Button("Cancel") {
isPresented = false
}
Button("Save") {
if !presetName.isEmpty {
presetManager.updatePreset(preset, newName: presetName)
isPresented = false
}
}
.buttonStyle(.borderedProminent)
.disabled(presetName.isEmpty)
}
}
.padding()
.frame(width: 300)
}
}
+163
View File
@@ -0,0 +1,163 @@
//
// GlobalSettings.swift
// Blankie
//
// Created by Cody Bromley on 1/1/25.
//
import SwiftUI
import Combine
private enum UserDefaultsKeys {
static let volume = "globalVolume"
static let appearance = "appearanceMode"
static let accentColor = "customAccentColor"
}
class GlobalSettings: ObservableObject {
static let shared = GlobalSettings()
@Published private(set) var volume: Double
@Published private(set) var appearance: AppearanceMode
@Published private(set) var customAccentColor: Color?
@Published private(set) var alwaysStartPaused: Bool
private var observers = Set<AnyCancellable>()
private init() {
// Initialize properties directly
let savedVolume = UserDefaults.standard.double(forKey: "globalVolume")
volume = savedVolume == 0 ? 1.0 : savedVolume
appearance = UserDefaults.standard.string(forKey: "appearanceMode")
.flatMap { AppearanceMode(rawValue: $0) } ?? .system
// Load saved accent color
if let colorString = UserDefaults.standard.string(forKey: "customAccentColor") {
customAccentColor = Color(fromString: colorString)
} else {
customAccentColor = nil
}
// Default to true for alwaysStartPaused if not set
alwaysStartPaused = UserDefaults.standard.object(forKey: "alwaysStartPaused") as? Bool ?? true
// After initialization, setup observers and update appearance
setupObservers()
updateAppAppearance()
}
private func validateVolume(_ volume: Double) -> Double {
min(max(volume, 0.0), 1.0)
}
private func setupObservers() {
_volume.projectedValue.sink { newValue in
UserDefaults.standard.set(newValue, forKey: "globalVolume")
}.store(in: &observers)
_appearance.projectedValue.sink { [weak self] newValue in
UserDefaults.standard.setValue(newValue.rawValue, forKey: "appearanceMode")
self?.updateAppAppearance()
}.store(in: &observers)
_customAccentColor.projectedValue.sink { newColor in
if let color = newColor {
UserDefaults.standard.set(color.toString, forKey: "customAccentColor")
} else {
UserDefaults.standard.removeObject(forKey: "customAccentColor")
}
}.store(in: &observers)
_alwaysStartPaused.projectedValue.sink { newValue in
UserDefaults.standard.set(newValue, forKey: "alwaysStartPaused")
}.store(in: &observers)
}
private func updateAppAppearance() {
DispatchQueue.main.async {
switch self.appearance {
case .system:
NSApp.appearance = nil
case .light:
NSApp.appearance = NSAppearance(named: .aqua)
case .dark:
NSApp.appearance = NSAppearance(named: .darkAqua)
}
}
}
// Public methods to update values
func setVolume(_ newVolume: Double) {
volume = newVolume
}
func setAppearance(_ newAppearance: AppearanceMode) {
DispatchQueue.main.async { [weak self] in
self?.appearance = newAppearance
self?.updateAppAppearance()
}
}
func setAccentColor(_ newColor: Color?) {
customAccentColor = newColor
}
func setAlwaysStartPaused(_ value: Bool) {
alwaysStartPaused = value
}
}
enum AppearanceMode: String, CaseIterable {
case system = "System"
case light = "Light"
case dark = "Dark"
var icon: String {
switch self {
case .system: return "circle.lefthalf.filled"
case .light: return "sun.max.fill"
case .dark: return "moon.fill"
}
}
}
// Add these extensions to help with color conversion
extension Color {
var toString: String {
switch self {
case .red: return "red"
case .pink: return "pink"
case .orange: return "orange"
case .brown: return "brown"
case .yellow: return "yellow"
case .green: return "green"
case .mint: return "mint"
case .teal: return "teal"
case .cyan: return "cyan"
case .blue: return "blue"
case .indigo: return "indigo"
case .purple: return "purple"
default: return ""
}
}
init?(fromString string: String) {
switch string {
case "red": self = .red
case "pink": self = .pink
case "orange": self = .orange
case "brown": self = .brown
case "yellow": self = .yellow
case "green": self = .green
case "mint": self = .mint
case "teal": self = .teal
case "cyan": self = .cyan
case "blue": self = .blue
case "indigo": self = .indigo
case "purple": self = .purple
default: return nil
}
}
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Cody Bromley
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+39
View File
@@ -0,0 +1,39 @@
//
// MainMenu.swift
// Blankie
//
// Created by Cody Bromley on 1/1/25.
//
import SwiftUI
class MainMenu: NSObject {
static func customize(showingAbout: Binding<Bool>) {
guard let mainMenu = NSApp.mainMenu,
let applicationMenu = mainMenu.items.first?.submenu else {
return
}
// Find and modify the About item
if let aboutItem = applicationMenu.items.first(where: { $0.identifier?.rawValue == "about" }) {
aboutItem.target = nil
aboutItem.action = #selector(NSApplication.sendAction(_:to:from:))
aboutItem.target = NSApp
aboutItem.action = #selector(trigger)
// Store the binding in a way that's accessible to the selector
UserDefaults.standard.set(true, forKey: "UseCustomAbout")
}
}
@objc static func trigger() {
if UserDefaults.standard.bool(forKey: "UseCustomAbout") {
NotificationCenter.default.post(name: .showCustomAbout, object: nil)
}
}
}
extension Notification.Name {
static let showCustomAbout = Notification.Name("ShowCustomAbout")
}
+149
View File
@@ -0,0 +1,149 @@
//
// PreferencesView.swift
// Blankie
//
// Created by Cody Bromley on 1/1/25.
//
import SwiftUI
struct PreferencesView: View {
@ObservedObject private var globalSettings = GlobalSettings.shared
private let colorsPerRow = 6
var accentColorForUI: Color {
globalSettings.customAccentColor ?? .accentColor
}
var textColorForAccent: Color {
if let nsColor = NSColor(accentColorForUI).usingColorSpace(.sRGB) {
let brightness = (0.299 * nsColor.redComponent) +
(0.587 * nsColor.greenComponent) +
(0.114 * nsColor.blueComponent)
return brightness > 0.5 ? .black : .white
}
return .white
}
var appearanceButtons: some View {
HStack(spacing: 8) {
ForEach(AppearanceMode.allCases, id: \.self) { mode in
Button(action: {
globalSettings.setAppearance(mode)
}) {
HStack(spacing: 4) {
Image(systemName: mode.icon)
Text(mode == .system ? "System" : mode.rawValue)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(globalSettings.appearance == mode ? accentColorForUI : Color.secondary.opacity(0.2))
.foregroundColor(globalSettings.appearance == mode ? textColorForAccent : .primary)
.cornerRadius(6)
}
.buttonStyle(.plain)
}
}
}
var colorButtons: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Button(action: {
globalSettings.setAccentColor(nil)
}) {
Text("System")
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(globalSettings.customAccentColor == nil ? accentColorForUI : Color.secondary.opacity(0.2))
.foregroundColor(globalSettings.customAccentColor == nil ? textColorForAccent : .primary)
.cornerRadius(6)
}
.buttonStyle(.plain)
ForEach(Array(AccentColor.allCases.dropFirst().prefix(colorsPerRow - 1)), id: \.self) { color in
ColorSquare(color: color, isSelected: color.color == globalSettings.customAccentColor)
}
}
HStack(spacing: 8) {
ForEach(Array(AccentColor.allCases.dropFirst().dropFirst(colorsPerRow - 1)), id: \.self) { color in
ColorSquare(color: color, isSelected: color.color == globalSettings.customAccentColor)
}
}
}
}
var body: some View {
Form {
Section("Appearance") {
HStack(spacing: 16) {
Text("Appearance")
.frame(width: 100, alignment: .leading)
appearanceButtons
}
HStack(alignment: .top, spacing: 16) {
Text("Accent Color")
.frame(width: 100, alignment: .leading)
colorButtons
}
}
Section("Behavior") {
Toggle("Always Start Paused", isOn: Binding(
get: { globalSettings.alwaysStartPaused },
set: { globalSettings.setAlwaysStartPaused($0) }
))
.help("Wait for play button before starting sounds")
.tint(accentColorForUI)
}
}
.formStyle(.grouped)
.padding()
.frame(width: 450)
}
}
struct ColorSquare: View {
let color: AccentColor
let isSelected: Bool
@ObservedObject private var globalSettings = GlobalSettings.shared
var textColorForAccent: Color {
if let nsColor = NSColor(color.color ?? .accentColor).usingColorSpace(.sRGB) {
let brightness = (0.299 * nsColor.redComponent) +
(0.587 * nsColor.greenComponent) +
(0.114 * nsColor.blueComponent)
return brightness > 0.5 ? .black : .white
}
return .white
}
var body: some View {
Button(action: {
globalSettings.setAccentColor(color.color)
}) {
RoundedRectangle(cornerRadius: 4)
.fill(color.color ?? Color.accentColor)
.frame(width: 24, height: 24)
.overlay {
if isSelected {
RoundedRectangle(cornerRadius: 4)
.strokeBorder(textColorForAccent, lineWidth: 2)
.padding(2)
}
}
}
.buttonStyle(.plain)
}
}
#Preview("Preferences") {
PreferencesView()
}
#Preview("Dark Mode") {
PreferencesView()
.preferredColorScheme(.dark)
}
+21
View File
@@ -0,0 +1,21 @@
//
// Preset.swift
// Blankie
//
// Created by Cody Bromley on 1/1/25.
//
import SwiftUI
struct Preset: Codable, Identifiable {
let id: UUID
var name: String
var soundStates: [SoundState]
var isDefault: Bool
struct SoundState: Codable {
let fileName: String
let isSelected: Bool
let volume: Float
}
}
+119
View File
@@ -0,0 +1,119 @@
//
// PresetManager.swift
// Blankie
//
// Created by Cody Bromley on 1/1/25.
//
import SwiftUI
class PresetManager: ObservableObject {
static let shared = PresetManager()
@Published private(set) var presets: [Preset] = []
@Published var currentPreset: Preset?
private let defaultPresetName = "Default"
private let presetKey = "savedPresets"
var hasCustomPresets: Bool {
presets.count > 1
}
init() {
loadPresets()
if presets.isEmpty {
createDefaultPreset()
}
}
private func createDefaultPreset() {
let defaultPreset = Preset(
id: UUID(),
name: defaultPresetName,
soundStates: AudioManager.shared.sounds.map { sound in
Preset.SoundState(
fileName: sound.fileName,
isSelected: sound.isSelected,
volume: sound.volume
)
},
isDefault: true
)
presets = [defaultPreset]
currentPreset = defaultPreset
savePresets()
}
func saveNewPreset(name: String) {
let newPreset = Preset(
id: UUID(),
name: name,
soundStates: AudioManager.shared.sounds.map { sound in
Preset.SoundState(
fileName: sound.fileName,
isSelected: sound.isSelected,
volume: sound.volume
)
},
isDefault: false
)
presets.append(newPreset)
currentPreset = newPreset
savePresets()
}
func updatePreset(_ preset: Preset, newName: String) {
print("Starting update - Current presets: \(presets.map { $0.name })") // Debug print
// First remove any existing preset with the same name if it exists
presets.removeAll { $0.name == newName }
if let index = presets.firstIndex(where: { $0.id == preset.id }) {
var updatedPreset = preset
updatedPreset.name = newName
presets[index] = updatedPreset
if currentPreset?.id == preset.id {
currentPreset = updatedPreset
}
print("After update - Current presets: \(presets.map { $0.name })") // Debug print
savePresets()
objectWillChange.send()
}
}
func deletePreset(_ preset: Preset) {
guard !preset.isDefault else { return }
presets.removeAll { $0.id == preset.id }
if currentPreset?.id == preset.id {
currentPreset = presets.first
}
savePresets()
}
func applyPreset(_ preset: Preset) {
for state in preset.soundStates {
if let sound = AudioManager.shared.sounds.first(where: { $0.fileName == state.fileName }) {
sound.volume = state.volume
sound.isSelected = state.isSelected
}
}
currentPreset = preset
}
private func savePresets() {
if let encoded = try? JSONEncoder().encode(presets) {
UserDefaults.standard.set(encoded, forKey: presetKey)
}
}
private func loadPresets() {
if let data = UserDefaults.standard.data(forKey: presetKey),
let decoded = try? JSONDecoder().decode([Preset].self, from: data) {
presets = decoded
currentPreset = decoded.first
}
}
}
+77
View File
@@ -0,0 +1,77 @@
<img src="brand/icon.png" alt="Blankie logo" width="128" height="128" align="left" style="margin-right: 16px; margin-bottom: 16px"/>
<h1 style="padding-bottom: 0; margin-bottom: 0; border-bottom: none">Blankie</h1>
## An ambient sound mixer for macOS
[![macOS](https://img.shields.io/badge/13.5+-111111?logo=macOS&logoColor=white&logoSize=auto&logoWidth=25)](https://www.apple.com/macos/sonoma/)
[![Swift](https://img.shields.io/badge/Swift%205-F05138?logo=Swift&logoColor=white)](https://swift.org)
[![SwiftUI](https://img.shields.io/badge/Swift%20UI-0071e3.svg?logo=swift&logoColor=white)](https://developer.apple.com/xcode/swiftui/)
[![Xcode](https://img.shields.io/badge/XCode%2016-007ACC?logo=xcode&logoColor=white)](https://developer.apple.com/xcode/)
[![GitHub](https://img.shields.io/github/license/codybrom/blankie.svg)](https://github.com/codybrom/blankie/blob/master/LICENSE)
![Screenshot](brand/screenshot.png)
## Description
Improve focus and increase your productivity by mixing different ambient sounds. Or use it to help you fall asleep in a noisy environment. Blankie provides a native macOS experience with support for system appearance modes and media controls.
## Features
- 14 high-quality ambient sounds:
- Nature sounds (rain, waves, birds, wind)
- Environmental sounds (train, city, coffee shop)
- White/pink noise
- Individual volume controls for each sound
- Global volume control
- Preset system to save your favorite sound combinations
- Native macOS integration
- System media controls
- Automatic or customizable light/dark modes
- Automatic or custom accent colors
## Requirements to Run
macOS Ventura 13.5 or later
## Development Setup
1. Clone the repository
2. Copy `Configuration.example.xcconfig` to `Configuration.xcconfig`
3. Edit `Configuration.xcconfig` to set your development team and bundle identifier:
```xcconfig
DEVELOPMENT_TEAM = YOUR_TEAM_ID_HERE
PRODUCT_BUNDLE_IDENTIFIER = com.your.identifier
```
4. Open `Blankie.xcodeproj` in Xcode
5. Build and run!
Note: `Configuration.xcconfig` is ignored by git to keep your personal development settings private.
## Sound Credits
All sounds are used under various open licenses. Full attribution information about sounds licensing is visible in the About screen in the app.
## Credits
Special thanks to [Rafael Mardojai CM](https://github.com/rafaelmardojai) and the [Blanket](https://github.com/rafaelmardojai/blanket) project, which inspired this app.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request. Here are some areas that still need work to bring it to parity with Blanket:
- Add custom sound support
- Translations
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Support
If you encounter any issues or have feature requests, please file them in the [GitHub Issues](https://github.com/codybrom/blankie/issues) section.
---
Note: Blankie is an independent project built for macOS and uses none of the original code from the Blanket project. Blankie is not affiliated with or endorsed by the Blanket project.
+163
View File
@@ -0,0 +1,163 @@
//
// SoundIcon.swift
// Blankie
//
// Created by Cody Bromley on 1/1/25.
//
import SwiftUI
struct SoundIcon: View {
@ObservedObject var sound: Sound
@ObservedObject var globalSettings = GlobalSettings.shared
@ObservedObject var audioManager = AudioManager.shared
let maxWidth: CGFloat
private struct Configuration {
static let iconSize: CGFloat = 100
static let sliderWidth: CGFloat = 85
static let spacing: CGFloat = 8
static let padding = EdgeInsets(
top: 12,
leading: 10,
bottom: 12,
trailing: 10
)
}
var accentColor: Color {
globalSettings.customAccentColor ?? .accentColor
}
var iconColor: Color {
if !audioManager.isGloballyPlaying {
return .gray
}
return sound.isSelected ? accentColor : .gray
}
var backgroundFill: Color {
if !audioManager.isGloballyPlaying {
return sound.isSelected ? Color.gray.opacity(0.2) : .clear
}
return sound.isSelected ? accentColor.opacity(0.2) : .clear
}
var body: some View {
VStack(spacing: Configuration.spacing) {
Button(action: {
sound.toggle()
}) {
ZStack {
Circle()
.fill(backgroundFill)
.frame(width: Configuration.iconSize, height: Configuration.iconSize)
Image(systemName: sound.systemIconName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: Configuration.iconSize * 0.64, height: Configuration.iconSize * 0.64)
.foregroundColor(iconColor)
}
}
.buttonStyle(.borderless)
.frame(width: Configuration.iconSize, height: Configuration.iconSize)
Text(sound.title)
.font(.callout)
.lineLimit(2)
.multilineTextAlignment(.center)
.frame(maxWidth: maxWidth - (Configuration.padding.leading * 2))
.foregroundColor(.primary)
Slider(value: Binding(
get: { Double(sound.volume) },
set: { sound.volume = Float($0) }
), in: 0...1)
.frame(width: Configuration.sliderWidth)
.tint(audioManager.isGloballyPlaying ? (sound.isSelected ? accentColor : .gray) : .gray)
.disabled(!sound.isSelected)
}
.padding(.vertical, Configuration.padding.top)
.padding(.horizontal, Configuration.padding.leading)
.frame(width: maxWidth)
}
}
#Preview("Selected") {
SoundIcon(
sound: Sound(
title: "Rain",
systemIconName: "cloud.rain",
fileName: "rain"
),
maxWidth: 150
)
.onAppear {
// Set up preview state using setter methods
GlobalSettings.shared.setAccentColor(.blue)
GlobalSettings.shared.setVolume(0.7)
}
}
#Preview("Not Selected") {
SoundIcon(
sound: Sound(
title: "Storm",
systemIconName: "cloud.bolt.rain",
fileName: "storm"
),
maxWidth: 150
)
}
#Preview("Long Title") {
SoundIcon(
sound: Sound(
title: "Very Long Sound Name That Should Truncate",
systemIconName: "speaker.wave.3.fill",
fileName: "test"
),
maxWidth: 150
)
}
#Preview("Grid Layout") {
LazyVGrid(columns: [
GridItem(.fixed(150)),
GridItem(.fixed(150))
], spacing: 20) {
SoundIcon(
sound: Sound(
title: "Rain",
systemIconName: "cloud.rain",
fileName: "rain"
),
maxWidth: 150
)
SoundIcon(
sound: Sound(
title: "Storm",
systemIconName: "cloud.bolt.rain",
fileName: "storm"
),
maxWidth: 150
)
SoundIcon(
sound: Sound(
title: "Wind",
systemIconName: "wind",
fileName: "wind"
),
maxWidth: 150
)
SoundIcon(
sound: Sound(
title: "Waves",
systemIconName: "water.waves",
fileName: "waves"
),
maxWidth: 150
)
}
.padding()
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+47
View File
@@ -0,0 +1,47 @@
//
// WindowObserver.swift
// Blankie
//
// Created by Cody Bromley on 1/1/25.
//
import SwiftUI
class WindowObserver: ObservableObject {
static let shared = WindowObserver()
@Published var hasVisibleWindow = false
init() {
NotificationCenter.default.addObserver(self,
selector: #selector(windowDidBecomeKey),
name: NSWindow.didBecomeKeyNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(windowDidClose),
name: NSWindow.willCloseNotification,
object: nil)
}
@objc private func windowDidBecomeKey(_ notification: Notification) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.hasVisibleWindow = true
}
}
@objc private func windowDidClose(_ notification: Notification) {
// Check if any main windows are still visible
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.checkVisibleWindows()
}
}
private func checkVisibleWindows() {
self.hasVisibleWindow = NSApp.windows.contains { window in
window.isVisible &&
!window.isMiniaturized &&
window.toolbar != nil
}
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 816 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB