Initial commit
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 706 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 557 B |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
[](https://www.apple.com/macos/sonoma/)
|
||||
[](https://swift.org)
|
||||
[](https://developer.apple.com/xcode/swiftui/)
|
||||
[](https://developer.apple.com/xcode/)
|
||||
[](https://github.com/codybrom/blankie/blob/master/LICENSE)
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 816 KiB |
|
After Width: | Height: | Size: 184 KiB |