mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
commit
b509be88c1
16 changed files with 13490 additions and 11485 deletions
|
|
@ -361,6 +361,7 @@
|
|||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 9Y64TRM77N;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
|
|
@ -371,9 +372,12 @@
|
|||
);
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = DynamicIsland/Info.plist;
|
||||
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music";
|
||||
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";
|
||||
INFOPLIST_KEY_NSDesktopFolderUsageDescription = "Dynamic Island needs access to save screenshots to your desktop.";
|
||||
INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Dynamic Island needs access to save and read AI assistant files.";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
|
@ -403,6 +407,7 @@
|
|||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 9Y64TRM77N;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
|
|
@ -413,9 +418,12 @@
|
|||
);
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = DynamicIsland/Info.plist;
|
||||
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music";
|
||||
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";
|
||||
INFOPLIST_KEY_NSDesktopFolderUsageDescription = "Dynamic Island needs access to save screenshots to your desktop.";
|
||||
INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Dynamic Island needs access to save and read AI assistant files.";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
|
|
|||
|
|
@ -2,22 +2,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.automation.apple-events</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.calendars</key>
|
||||
<true/>
|
||||
<key>com.apple.security.temporary-exception.apple-events</key>
|
||||
<array>
|
||||
<string>com.spotify.client</string>
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
var timer: Timer?
|
||||
let calendarManager = CalendarManager.shared
|
||||
let webcamManager = WebcamManager.shared
|
||||
let dndManager = DoNotDisturbManager.shared // NEW: DND detection
|
||||
let bluetoothAudioManager = BluetoothAudioManager.shared // NEW: Bluetooth audio detection
|
||||
var closeNotchWorkItem: DispatchWorkItem?
|
||||
private var previousScreens: [NSScreen]?
|
||||
private var onboardingWindowController: NSWindowController?
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ enum SneakContentType {
|
|||
case download
|
||||
case timer
|
||||
case recording
|
||||
case bluetoothAudio
|
||||
}
|
||||
|
||||
struct sneakPeek {
|
||||
|
|
|
|||
|
|
@ -7,16 +7,10 @@
|
|||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>Dynamic Island uses AppleScripts to control Spotify and Apple Music</string>
|
||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||
<string>Show calender events inside the notch</string>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD.</string>
|
||||
<key>NSScreenCaptureUsageDescription</key>
|
||||
<string>Dynamic Island needs screen recording permission to capture screenshots for the AI assistant feature.</string>
|
||||
<key>NSDesktopFolderUsageDescription</key>
|
||||
<string>Dynamic Island needs access to save screenshots to your desktop.</string>
|
||||
<key>NSDocumentsFolderUsageDescription</key>
|
||||
<string>Dynamic Island needs access to save and read AI assistant files.</string>
|
||||
<key>SUEnableDownloaderService</key>
|
||||
<true/>
|
||||
<key>SUEnableInstallerLauncherService</key>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
179
DynamicIsland/components/ColorCodedProgressBar.swift
Normal file
179
DynamicIsland/components/ColorCodedProgressBar.swift
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
//
|
||||
// ColorCodedProgressBar.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created for color-coded battery and volume displays
|
||||
// Provides smooth gradient transitions from red to green (or reversed)
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
struct ColorCodedProgressBar: View {
|
||||
let value: CGFloat // 0.0 to 1.0
|
||||
let reversed: Bool // If true: green at low, red at high (for volume)
|
||||
let width: CGFloat
|
||||
let height: CGFloat
|
||||
let smoothGradient: Bool // If true: smooth gradient, if false: discrete colors
|
||||
|
||||
init(value: CGFloat, reversed: Bool = false, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) {
|
||||
self.value = min(max(value, 0), 1) // Clamp between 0 and 1
|
||||
self.reversed = reversed
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.smoothGradient = smoothGradient
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
// Background track
|
||||
RoundedRectangle(cornerRadius: height / 2)
|
||||
.fill(Color.white.opacity(0.2))
|
||||
.frame(width: width, height: height)
|
||||
|
||||
// Filled track with color gradient or solid color
|
||||
RoundedRectangle(cornerRadius: height / 2)
|
||||
.fill(smoothGradient ?
|
||||
AnyShapeStyle(LinearGradient(
|
||||
gradient: gradientForValue,
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)) :
|
||||
AnyShapeStyle(colorForPercentage(value, reversed: reversed))
|
||||
)
|
||||
.frame(width: width * value, height: height)
|
||||
}
|
||||
}
|
||||
.frame(width: width, height: height)
|
||||
}
|
||||
|
||||
/// Generates a gradient based on the current value
|
||||
private var gradientForValue: Gradient {
|
||||
let color = colorForPercentage(value, reversed: reversed)
|
||||
|
||||
// Create a gradient that transitions smoothly
|
||||
let startColor = colorForPercentage(max(value - 0.1, 0), reversed: reversed)
|
||||
let endColor = color
|
||||
|
||||
return Gradient(colors: [startColor, endColor])
|
||||
}
|
||||
|
||||
/// Calculates discrete color for a given percentage (no smooth transitions)
|
||||
/// - Parameters:
|
||||
/// - percentage: Value from 0.0 to 1.0
|
||||
/// - reversed: If true, reverses the color scale (green at low, red at high)
|
||||
/// - Returns: Discrete color (red, yellow, or green only)
|
||||
private func discreteColorForPercentage(_ percentage: CGFloat, reversed: Bool) -> Color {
|
||||
let normalized = reversed ? (1.0 - percentage) : percentage
|
||||
|
||||
switch normalized {
|
||||
case 0.0..<0.33:
|
||||
// Red zone (critical/low)
|
||||
return Color(red: 1.0, green: 0.23, blue: 0.19)
|
||||
case 0.33..<0.67:
|
||||
// Yellow zone (medium)
|
||||
return Color(red: 1.0, green: 0.8, blue: 0.0)
|
||||
default:
|
||||
// Green zone (healthy/high)
|
||||
return Color(red: 0.2, green: 0.78, blue: 0.35)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates color for a given percentage with smooth transitions
|
||||
/// - Parameters:
|
||||
/// - percentage: Value from 0.0 to 1.0
|
||||
/// - reversed: If true, reverses the color scale (green at low, red at high)
|
||||
/// - Returns: Color with smooth gradient transitions
|
||||
private func colorForPercentage(_ percentage: CGFloat, reversed: Bool) -> Color {
|
||||
let normalized = reversed ? (1.0 - percentage) : percentage
|
||||
|
||||
switch normalized {
|
||||
case 0.0..<0.2:
|
||||
// Pure Red zone (critical/low)
|
||||
return Color(red: 1.0, green: 0.23, blue: 0.19)
|
||||
|
||||
case 0.2..<0.35:
|
||||
// Red → Orange transition
|
||||
let t = (normalized - 0.2) / 0.15
|
||||
let red: CGFloat = 1.0
|
||||
let green: CGFloat = 0.23 + (0.37 * t) // 0.23 → 0.6
|
||||
let blue: CGFloat = 0.19
|
||||
return Color(red: red, green: green, blue: blue)
|
||||
|
||||
case 0.35..<0.5:
|
||||
// Orange → Yellow transition
|
||||
let t = (normalized - 0.35) / 0.15
|
||||
let red: CGFloat = 1.0
|
||||
let green: CGFloat = 0.6 + (0.4 * t) // 0.6 → 1.0
|
||||
let blue: CGFloat = 0.19
|
||||
return Color(red: red, green: green, blue: blue)
|
||||
|
||||
case 0.5..<0.65:
|
||||
// Yellow → Yellow-Green transition
|
||||
let t = (normalized - 0.5) / 0.15
|
||||
let red: CGFloat = 1.0 - (0.4 * t) // 1.0 → 0.6
|
||||
let green: CGFloat = 1.0
|
||||
let blue: CGFloat = 0.19 + (0.4 * t) // 0.19 → 0.59
|
||||
return Color(red: red, green: green, blue: blue)
|
||||
|
||||
case 0.65..<0.8:
|
||||
// Yellow-Green → Green transition
|
||||
let t = (normalized - 0.65) / 0.15
|
||||
let red: CGFloat = 0.6 - (0.4 * t) // 0.6 → 0.2
|
||||
let green: CGFloat = 1.0 - (0.22 * t) // 1.0 → 0.78
|
||||
let blue: CGFloat = 0.59 - (0.24 * t) // 0.59 → 0.35
|
||||
return Color(red: red, green: green, blue: blue)
|
||||
|
||||
default:
|
||||
// Pure Green zone (healthy/high)
|
||||
return Color(red: 0.2, green: 0.78, blue: 0.35)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Extensions
|
||||
|
||||
extension ColorCodedProgressBar {
|
||||
/// Creates a battery-style progress bar (green at high, red at low)
|
||||
static func battery(value: CGFloat, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) -> some View {
|
||||
ColorCodedProgressBar(value: value, reversed: false, width: width, height: height, smoothGradient: smoothGradient)
|
||||
}
|
||||
|
||||
/// Creates a volume-style progress bar (red at high, green at low)
|
||||
static func volume(value: CGFloat, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) -> some View {
|
||||
ColorCodedProgressBar(value: value, reversed: true, width: width, height: height, smoothGradient: smoothGradient)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview("Battery Mode") {
|
||||
VStack(spacing: 20) {
|
||||
ForEach([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], id: \.self) { value in
|
||||
HStack {
|
||||
Text("\(Int(value * 100))%")
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundColor(.white)
|
||||
ColorCodedProgressBar.battery(value: value, width: 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
#Preview("Volume Mode (Reversed)") {
|
||||
VStack(spacing: 20) {
|
||||
ForEach([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], id: \.self) { value in
|
||||
HStack {
|
||||
Text("\(Int(value * 100))%")
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundColor(.white)
|
||||
ColorCodedProgressBar.volume(value: value, width: 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
struct InlineHUD: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
|
|
@ -14,6 +15,15 @@ struct InlineHUD: View {
|
|||
@Binding var icon: String
|
||||
@Binding var hoverAnimation: Bool
|
||||
@Binding var gestureProgress: CGFloat
|
||||
|
||||
@Default(.useColorCodedBatteryDisplay) var useColorCodedBatteryDisplay
|
||||
@Default(.useColorCodedVolumeDisplay) var useColorCodedVolumeDisplay
|
||||
@Default(.useSmoothColorGradient) var useSmoothColorGradient
|
||||
@Default(.progressBarStyle) var progressBarStyle
|
||||
@ObservedObject var bluetoothManager = BluetoothAudioManager.shared
|
||||
|
||||
@State private var displayName: String = ""
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
HStack(spacing: 5) {
|
||||
|
|
@ -21,7 +31,9 @@ struct InlineHUD: View {
|
|||
switch (type) {
|
||||
case .volume:
|
||||
if icon.isEmpty {
|
||||
Image(systemName: SpeakerSymbol(value))
|
||||
// Show headphone icon if Bluetooth audio is connected, otherwise speaker
|
||||
let baseIcon = bluetoothManager.isBluetoothAudioConnected ? "headphones" : SpeakerSymbol(value)
|
||||
Image(systemName: baseIcon)
|
||||
.contentTransition(.interpolate)
|
||||
.symbolVariant(value > 0 ? .none : .slash)
|
||||
.frame(width: 20, height: 15, alignment: .leading)
|
||||
|
|
@ -51,6 +63,11 @@ struct InlineHUD: View {
|
|||
.symbolRenderingMode(.hierarchical)
|
||||
.contentTransition(.interpolate)
|
||||
.frame(width: 20, height: 15, alignment: .center)
|
||||
case .bluetoothAudio:
|
||||
Image(systemName: icon.isEmpty ? "bluetooth" : icon)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.contentTransition(.interpolate)
|
||||
.frame(width: 20, height: 15, alignment: .center)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
|
|
@ -58,6 +75,26 @@ struct InlineHUD: View {
|
|||
.foregroundStyle(.white)
|
||||
.symbolVariant(.fill)
|
||||
|
||||
// Use marquee text for device names to handle long names
|
||||
if type == .bluetoothAudio {
|
||||
MarqueeText(
|
||||
$displayName,
|
||||
font: .system(size: 13, weight: .medium),
|
||||
nsFont: .body,
|
||||
textColor: .white,
|
||||
minDuration: 0.5,
|
||||
frameWidth: 85 - (hoverAnimation ? 0 : 12) + gestureProgress / 2
|
||||
)
|
||||
.onAppear {
|
||||
displayName = Type2Name(type)
|
||||
}
|
||||
.onChange(of: type) { _, _ in
|
||||
displayName = Type2Name(type)
|
||||
}
|
||||
.onChange(of: bluetoothManager.lastConnectedDevice?.name) { _, _ in
|
||||
displayName = Type2Name(type)
|
||||
}
|
||||
} else {
|
||||
Text(Type2Name(type))
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
|
@ -65,6 +102,7 @@ struct InlineHUD: View {
|
|||
.allowsTightening(true)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
}
|
||||
.frame(width: 100 - (hoverAnimation ? 0 : 12) + gestureProgress / 2, height: vm.notchSize.height - (hoverAnimation ? 0 : 12), alignment: .leading)
|
||||
|
||||
Rectangle()
|
||||
|
|
@ -88,9 +126,45 @@ struct InlineHUD: View {
|
|||
.multilineTextAlignment(.trailing)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.contentTransition(.interpolate)
|
||||
} else if (type == .bluetoothAudio) {
|
||||
// Bluetooth device battery display
|
||||
HStack(spacing: 4) {
|
||||
if value > 0 {
|
||||
// Show color-coded battery bar if battery info available
|
||||
// Color-coding disabled for segmented or hierarchical mode
|
||||
if useColorCodedBatteryDisplay && progressBarStyle == .gradient {
|
||||
ColorCodedProgressBar.battery(value: value, width: 60, height: 4, smoothGradient: useSmoothColorGradient)
|
||||
} else {
|
||||
DraggableProgressBar(value: .constant(value))
|
||||
.frame(width: 60)
|
||||
}
|
||||
Text("\(Int(value * 100))%")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
// No battery info available
|
||||
Text("Connected")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.gray)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
} else {
|
||||
// Volume and brightness displays
|
||||
HStack {
|
||||
// Color-coding only available in gradient mode, not hierarchical or segmented
|
||||
if type == .volume && useColorCodedVolumeDisplay && progressBarStyle == .gradient {
|
||||
// Color-coded volume (reversed: red at high, green at low)
|
||||
ColorCodedProgressBar.volume(value: value, width: 70, height: 4, smoothGradient: useSmoothColorGradient)
|
||||
} else {
|
||||
// Default draggable progress bar
|
||||
DraggableProgressBar(value: $value)
|
||||
}
|
||||
|
||||
if (type == .volume && value.isZero) {
|
||||
Text("muted")
|
||||
.font(.caption)
|
||||
|
|
@ -145,6 +219,8 @@ struct InlineHUD: View {
|
|||
return "Backlight"
|
||||
case .mic:
|
||||
return "Mic"
|
||||
case .bluetoothAudio:
|
||||
return BluetoothAudioManager.shared.lastConnectedDevice?.name ?? "Bluetooth"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,11 +75,21 @@ struct MarqueeText: View {
|
|||
self.textSize = CGSize(width: size.width / 2, height: NSFont.preferredFont(forTextStyle: nsFont).pointSize)
|
||||
self.animate = false
|
||||
self.offset = 0
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01){
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02){
|
||||
if needsScrolling {
|
||||
self.animate = true
|
||||
self.offset = -(textSize.width + 10)
|
||||
|
||||
self.offset = -(textSize.width + 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: text) { _, _ in
|
||||
// Reset animation when text changes
|
||||
self.animate = false
|
||||
self.offset = 0
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02){
|
||||
if needsScrolling {
|
||||
self.animate = true
|
||||
self.offset = -(textSize.width + 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,14 +43,14 @@ struct MinimalisticMusicPlayerView: View {
|
|||
|
||||
// Compact progress bar
|
||||
progressBar
|
||||
.padding(.top, 4)
|
||||
.padding(.top, 6)
|
||||
|
||||
// Compact playback controls
|
||||
playbackControls
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, -20)
|
||||
.padding(.top, -15)
|
||||
.padding(.bottom, 3)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
|
@ -246,7 +246,7 @@ struct MinimalisticAlbumArtView: View {
|
|||
.aspectRatio(contentMode: .fill)
|
||||
)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.scaleEffect(x: 1.3, y: 1.4)
|
||||
.rotationEffect(.degrees(92))
|
||||
.blur(radius: 35)
|
||||
|
|
@ -266,7 +266,7 @@ struct MinimalisticAlbumArtView: View {
|
|||
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: musicManager.isFlipping)
|
||||
)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
|
|
|||
|
|
@ -543,6 +543,43 @@ struct HUD: View {
|
|||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Defaults.Toggle("Show Bluetooth device connections", key: .showBluetoothDeviceConnections)
|
||||
} header: {
|
||||
Text("Bluetooth Audio Devices")
|
||||
} footer: {
|
||||
Text("Displays a HUD notification when Bluetooth audio devices (headphones, AirPods, speakers) connect, showing device name and battery level.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Section {
|
||||
Defaults.Toggle("Color-coded battery display", key: .useColorCodedBatteryDisplay)
|
||||
.disabled(progressBarStyle != .gradient)
|
||||
Defaults.Toggle("Color-coded volume display", key: .useColorCodedVolumeDisplay)
|
||||
.disabled(progressBarStyle != .gradient)
|
||||
|
||||
if progressBarStyle == .gradient && (Defaults[.useColorCodedBatteryDisplay] || Defaults[.useColorCodedVolumeDisplay]) {
|
||||
Defaults.Toggle("Smooth color transitions", key: .useSmoothColorGradient)
|
||||
}
|
||||
} header: {
|
||||
Text("Color-Coded Progress Bars")
|
||||
} footer: {
|
||||
if progressBarStyle != .gradient {
|
||||
Text("Color-coded displays are only available in Gradient mode. Switch Progressbar style to Gradient to enable this feature.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
} else if Defaults[.useSmoothColorGradient] {
|
||||
Text("Smooth: Battery/Volume gradually transitions through all colors\nBattery: Green (high) → Yellow-Green → Yellow → Orange → Red (low)\nVolume: Green (quiet) → Yellow → Orange → Red (loud)")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
} else {
|
||||
Text("Discrete: Battery/Volume uses 3 distinct colors only\nBattery: Green (high), Yellow (medium), Red (low)\nVolume: Green (quiet), Yellow (medium), Red (loud)")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("HUD style", selection: $inlineHUD) {
|
||||
Text("Default")
|
||||
|
|
|
|||
470
DynamicIsland/managers/BluetoothAudioManager.swift
Normal file
470
DynamicIsland/managers/BluetoothAudioManager.swift
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
//
|
||||
// BluetoothAudioManager.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created for Bluetooth audio device connection detection and monitoring
|
||||
// Detects when audio devices connect and displays HUD with battery status
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import AppKit
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
import IOBluetooth
|
||||
|
||||
/// Manages detection and monitoring of Bluetooth audio device connections
|
||||
class BluetoothAudioManager: ObservableObject {
|
||||
static let shared = BluetoothAudioManager()
|
||||
|
||||
// MARK: - Published Properties
|
||||
@Published var lastConnectedDevice: BluetoothAudioDevice?
|
||||
@Published var connectedDevices: [BluetoothAudioDevice] = []
|
||||
@Published var isBluetoothAudioConnected: Bool = false
|
||||
|
||||
// MARK: - Private Properties
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let coordinator = DynamicIslandViewCoordinator.shared
|
||||
private var pollingTimer: Timer?
|
||||
|
||||
// MARK: - Initialization
|
||||
private init() {
|
||||
print("🎧 [BluetoothAudioManager] Initializing...")
|
||||
setupBluetoothObservers()
|
||||
checkInitialDevices()
|
||||
startPollingForChanges()
|
||||
}
|
||||
|
||||
deinit {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
// MARK: - Setup Methods
|
||||
|
||||
/// Sets up observers for Bluetooth device connection/disconnection events
|
||||
private func setupBluetoothObservers() {
|
||||
print("🎧 [BluetoothAudioManager] Setting up Bluetooth observers...")
|
||||
|
||||
// Use DistributedNotificationCenter for IOBluetooth notifications
|
||||
let dnc = DistributedNotificationCenter.default()
|
||||
|
||||
// Observe device connected notifications
|
||||
dnc.addObserver(
|
||||
self,
|
||||
selector: #selector(handleDeviceConnectedNotification(_:)),
|
||||
name: NSNotification.Name("IOBluetoothDeviceConnectedNotification"),
|
||||
object: nil
|
||||
)
|
||||
|
||||
// Observe device disconnected notifications
|
||||
dnc.addObserver(
|
||||
self,
|
||||
selector: #selector(handleDeviceDisconnectedNotification(_:)),
|
||||
name: NSNotification.Name("IOBluetoothDeviceDisconnectedNotification"),
|
||||
object: nil
|
||||
)
|
||||
|
||||
print("🎧 [BluetoothAudioManager] ✅ Observers registered with DistributedNotificationCenter")
|
||||
}
|
||||
|
||||
/// Starts polling for device connection changes (fallback mechanism)
|
||||
private func startPollingForChanges() {
|
||||
print("🎧 [BluetoothAudioManager] Starting polling timer (3s interval)...")
|
||||
|
||||
pollingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
||||
self?.checkForDeviceChanges()
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks for device connection/disconnection changes
|
||||
private func checkForDeviceChanges() {
|
||||
// Check if Bluetooth is powered on
|
||||
guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
|
||||
// Bluetooth is off - clear connected devices if any
|
||||
if !connectedDevices.isEmpty {
|
||||
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth powered off - clearing connected devices")
|
||||
connectedDevices.removeAll()
|
||||
isBluetoothAudioConnected = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
|
||||
return
|
||||
}
|
||||
|
||||
let currentlyConnectedAddresses = Set(
|
||||
pairedDevices
|
||||
.filter { $0.isConnected() && isAudioDevice($0) }
|
||||
.compactMap { $0.addressString }
|
||||
)
|
||||
|
||||
let previousAddresses = Set(connectedDevices.map { $0.address })
|
||||
|
||||
// Check for new connections
|
||||
let newAddresses = currentlyConnectedAddresses.subtracting(previousAddresses)
|
||||
if !newAddresses.isEmpty {
|
||||
print("🎧 [BluetoothAudioManager] 🔍 Polling detected new connection(s)")
|
||||
checkForNewlyConnectedDevices()
|
||||
}
|
||||
|
||||
// Check for disconnections
|
||||
let removedAddresses = previousAddresses.subtracting(currentlyConnectedAddresses)
|
||||
if !removedAddresses.isEmpty {
|
||||
print("🎧 [BluetoothAudioManager] 🔍 Polling detected disconnection(s)")
|
||||
updateConnectedDevices()
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks for already connected Bluetooth audio devices on init
|
||||
private func checkInitialDevices() {
|
||||
print("🎧 [BluetoothAudioManager] Checking for initially connected devices...")
|
||||
|
||||
// Check if Bluetooth is powered on
|
||||
guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
|
||||
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth is powered off - skipping initial check")
|
||||
return
|
||||
}
|
||||
|
||||
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
|
||||
print("🎧 [BluetoothAudioManager] No paired devices found")
|
||||
return
|
||||
}
|
||||
|
||||
let connectedAudioDevices = pairedDevices.filter { device in
|
||||
device.isConnected() && isAudioDevice(device)
|
||||
}
|
||||
|
||||
print("🎧 [BluetoothAudioManager] Found \(connectedAudioDevices.count) connected audio devices")
|
||||
|
||||
connectedDevices = connectedAudioDevices.compactMap { device in
|
||||
createBluetoothAudioDevice(from: device)
|
||||
}
|
||||
|
||||
// Update connection state
|
||||
isBluetoothAudioConnected = !connectedDevices.isEmpty
|
||||
|
||||
if let lastDevice = connectedDevices.last {
|
||||
lastConnectedDevice = lastDevice
|
||||
print("🎧 [BluetoothAudioManager] ✅ Bluetooth audio connected: \(lastDevice.name)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Device Event Handlers
|
||||
|
||||
/// Handles Bluetooth device connection notification from DistributedNotificationCenter
|
||||
@objc private func handleDeviceConnectedNotification(_ notification: Notification) {
|
||||
print("🎧 [BluetoothAudioManager] 📡 Device connection notification received")
|
||||
|
||||
// Re-check all devices since distributed notification doesn't contain device object
|
||||
checkForNewlyConnectedDevices()
|
||||
}
|
||||
|
||||
/// Handles Bluetooth device disconnection notification from DistributedNotificationCenter
|
||||
@objc private func handleDeviceDisconnectedNotification(_ notification: Notification) {
|
||||
print("🎧 [BluetoothAudioManager] 📡 Device disconnection notification received")
|
||||
|
||||
// Re-check all devices to update connection state
|
||||
updateConnectedDevices()
|
||||
}
|
||||
|
||||
/// Checks for newly connected devices and displays HUD for new ones
|
||||
private func checkForNewlyConnectedDevices() {
|
||||
// Check if Bluetooth is powered on
|
||||
guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
|
||||
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth is powered off - skipping device check")
|
||||
return
|
||||
}
|
||||
|
||||
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
|
||||
return
|
||||
}
|
||||
|
||||
let currentlyConnectedDevices = pairedDevices.filter { device in
|
||||
device.isConnected() && isAudioDevice(device)
|
||||
}
|
||||
|
||||
// Find devices that are newly connected
|
||||
for device in currentlyConnectedDevices {
|
||||
let address = device.addressString ?? "Unknown"
|
||||
|
||||
// Check if this device wasn't in our list before
|
||||
if !connectedDevices.contains(where: { $0.address == address }) {
|
||||
print("🎧 [BluetoothAudioManager] 🎉 New audio device connected: \(device.name ?? "Unknown")")
|
||||
|
||||
guard let audioDevice = createBluetoothAudioDevice(from: device) else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Add to connected devices
|
||||
connectedDevices.append(audioDevice)
|
||||
lastConnectedDevice = audioDevice
|
||||
isBluetoothAudioConnected = true
|
||||
|
||||
// Show HUD for new connection
|
||||
showDeviceConnectedHUD(audioDevice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the list of connected devices (for disconnections)
|
||||
private func updateConnectedDevices() {
|
||||
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
|
||||
return
|
||||
}
|
||||
|
||||
let currentlyConnectedAddresses = pairedDevices
|
||||
.filter { $0.isConnected() && isAudioDevice($0) }
|
||||
.compactMap { $0.addressString }
|
||||
|
||||
// Remove disconnected devices
|
||||
let previousCount = connectedDevices.count
|
||||
connectedDevices.removeAll { device in
|
||||
!currentlyConnectedAddresses.contains(device.address)
|
||||
}
|
||||
|
||||
if connectedDevices.count < previousCount {
|
||||
print("🎧 [BluetoothAudioManager] 👋 Audio device(s) disconnected")
|
||||
}
|
||||
|
||||
isBluetoothAudioConnected = !connectedDevices.isEmpty
|
||||
}
|
||||
|
||||
/// Handles Bluetooth device connection event (legacy - kept for compatibility)
|
||||
private func handleDeviceConnected(_ notification: Notification) {
|
||||
guard let device = notification.object as? IOBluetoothDevice else {
|
||||
print("🎧 [BluetoothAudioManager] ⚠️ Could not extract device from notification")
|
||||
return
|
||||
}
|
||||
|
||||
// Only handle audio devices
|
||||
guard isAudioDevice(device) else {
|
||||
print("🎧 [BluetoothAudioManager] Device is not an audio device, ignoring")
|
||||
return
|
||||
}
|
||||
|
||||
print("🎧 [BluetoothAudioManager] 🎉 Audio device connected: \(device.name ?? "Unknown")")
|
||||
|
||||
guard let audioDevice = createBluetoothAudioDevice(from: device) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Add to connected devices list
|
||||
if !connectedDevices.contains(where: { $0.address == audioDevice.address }) {
|
||||
connectedDevices.append(audioDevice)
|
||||
}
|
||||
|
||||
// Update last connected device
|
||||
lastConnectedDevice = audioDevice
|
||||
isBluetoothAudioConnected = true
|
||||
|
||||
// Show HUD
|
||||
showDeviceConnectedHUD(audioDevice)
|
||||
}
|
||||
|
||||
/// Handles Bluetooth device disconnection event
|
||||
private func handleDeviceDisconnected(_ notification: Notification) {
|
||||
guard let device = notification.object as? IOBluetoothDevice else {
|
||||
return
|
||||
}
|
||||
|
||||
guard isAudioDevice(device) else {
|
||||
return
|
||||
}
|
||||
|
||||
print("🎧 [BluetoothAudioManager] 👋 Audio device disconnected: \(device.name ?? "Unknown")")
|
||||
|
||||
// Remove from connected devices
|
||||
let address = device.addressString ?? "Unknown"
|
||||
connectedDevices.removeAll { $0.address == address }
|
||||
isBluetoothAudioConnected = !connectedDevices.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Device Detection Helpers
|
||||
|
||||
/// Determines if a Bluetooth device is an audio device
|
||||
private func isAudioDevice(_ device: IOBluetoothDevice) -> Bool {
|
||||
// Check if device has audio service UUID
|
||||
let audioServiceUUID = IOBluetoothSDPUUID(uuid16: 0x110B) // Audio Sink
|
||||
let headsetServiceUUID = IOBluetoothSDPUUID(uuid16: 0x1108) // Headset
|
||||
let handsfreeServiceUUID = IOBluetoothSDPUUID(uuid16: 0x111E) // Handsfree
|
||||
|
||||
// Check if device has any audio-related services
|
||||
if device.getServiceRecord(for: audioServiceUUID) != nil {
|
||||
return true
|
||||
}
|
||||
if device.getServiceRecord(for: headsetServiceUUID) != nil {
|
||||
return true
|
||||
}
|
||||
if device.getServiceRecord(for: handsfreeServiceUUID) != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check device class (major class: Audio/Video)
|
||||
let deviceClass = device.classOfDevice
|
||||
let majorClass = (deviceClass >> 8) & 0x1F
|
||||
let audioVideoMajorClass: UInt32 = 0x04
|
||||
|
||||
return majorClass == audioVideoMajorClass
|
||||
}
|
||||
|
||||
/// Creates a BluetoothAudioDevice model from IOBluetoothDevice
|
||||
private func createBluetoothAudioDevice(from device: IOBluetoothDevice) -> BluetoothAudioDevice? {
|
||||
let name = device.name ?? "Bluetooth Device"
|
||||
let address = device.addressString ?? "Unknown"
|
||||
let batteryLevel = getBatteryLevel(from: device)
|
||||
let deviceType = detectDeviceType(from: device, name: name)
|
||||
|
||||
return BluetoothAudioDevice(
|
||||
name: name,
|
||||
address: address,
|
||||
batteryLevel: batteryLevel,
|
||||
deviceType: deviceType
|
||||
)
|
||||
}
|
||||
|
||||
/// Extracts battery level from Bluetooth device
|
||||
private func getBatteryLevel(from device: IOBluetoothDevice) -> Int? {
|
||||
// Try to read battery level from device
|
||||
// Note: This may require additional permissions or may not be available for all devices
|
||||
|
||||
// Placeholder: Return nil if not available
|
||||
// TODO: Implement actual battery level reading if device supports it
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Detects the type of audio device based on name and properties
|
||||
private func detectDeviceType(from device: IOBluetoothDevice, name: String) -> BluetoothAudioDeviceType {
|
||||
let lowercaseName = name.lowercased()
|
||||
|
||||
// Check for specific AirPods models
|
||||
if lowercaseName.contains("airpods") {
|
||||
if lowercaseName.contains("max") {
|
||||
return .airpodsMax
|
||||
} else if lowercaseName.contains("pro") {
|
||||
return .airpodsPro
|
||||
}
|
||||
return .airpods
|
||||
}
|
||||
|
||||
// Check for other brands
|
||||
if lowercaseName.contains("beats") {
|
||||
return .beats
|
||||
} else if lowercaseName.contains("speaker") || lowercaseName.contains("boombox") {
|
||||
return .speaker
|
||||
} else if lowercaseName.contains("headphone") || lowercaseName.contains("headset") ||
|
||||
lowercaseName.contains("buds") || lowercaseName.contains("earbuds") {
|
||||
return .headphones
|
||||
}
|
||||
|
||||
// Check device class for more specific detection
|
||||
let deviceClass = device.classOfDevice
|
||||
let minorClass = (deviceClass >> 2) & 0x3F
|
||||
|
||||
// Minor classes for audio devices
|
||||
switch minorClass {
|
||||
case 0x01: return .headphones // Wearable Headset
|
||||
case 0x02: return .headphones // Hands-free
|
||||
case 0x06: return .headphones // Headphones
|
||||
case 0x08: return .speaker // Portable Audio
|
||||
case 0x0C: return .speaker // Loudspeaker
|
||||
default: return .generic
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HUD Display
|
||||
|
||||
/// Shows HUD notification for newly connected audio device
|
||||
private func showDeviceConnectedHUD(_ device: BluetoothAudioDevice) {
|
||||
guard Defaults[.showBluetoothDeviceConnections] else { return }
|
||||
|
||||
print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD")
|
||||
|
||||
// Convert battery percentage to 0.0-1.0 value
|
||||
let batteryValue: CGFloat = if let battery = device.batteryLevel {
|
||||
CGFloat(battery) / 100.0
|
||||
} else {
|
||||
0.0 // 0 means battery info not available
|
||||
}
|
||||
|
||||
// Show HUD via coordinator
|
||||
coordinator.toggleSneakPeek(
|
||||
status: true,
|
||||
type: .bluetoothAudio,
|
||||
duration: 2.5,
|
||||
value: batteryValue,
|
||||
icon: device.deviceType.sfSymbol
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
private func cleanup() {
|
||||
print("🎧 [BluetoothAudioManager] Cleaning up observers...")
|
||||
|
||||
pollingTimer?.invalidate()
|
||||
pollingTimer = nil
|
||||
|
||||
let dnc = DistributedNotificationCenter.default()
|
||||
dnc.removeObserver(self)
|
||||
observers.removeAll()
|
||||
cancellables.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
struct BluetoothAudioDevice: Identifiable {
|
||||
let id = UUID()
|
||||
let name: String
|
||||
let address: String
|
||||
let batteryLevel: Int? // 0-100, nil if not available
|
||||
let deviceType: BluetoothAudioDeviceType
|
||||
}
|
||||
|
||||
enum BluetoothAudioDeviceType {
|
||||
case airpods
|
||||
case airpodsPro
|
||||
case airpodsMax
|
||||
case beats
|
||||
case headphones
|
||||
case speaker
|
||||
case generic
|
||||
|
||||
var sfSymbol: String {
|
||||
switch self {
|
||||
case .airpods:
|
||||
return "airpods"
|
||||
case .airpodsPro:
|
||||
return "airpodspro"
|
||||
case .airpodsMax:
|
||||
return "airpodsmax"
|
||||
case .beats:
|
||||
return "beats.headphones"
|
||||
case .headphones:
|
||||
return "headphones"
|
||||
case .speaker:
|
||||
return "hifispeaker.fill"
|
||||
case .generic:
|
||||
return "bluetooth.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .airpods: return "AirPods"
|
||||
case .airpodsPro: return "AirPods Pro"
|
||||
case .airpodsMax: return "AirPods Max"
|
||||
case .beats: return "Beats"
|
||||
case .headphones: return "Headphones"
|
||||
case .speaker: return "Speaker"
|
||||
case .generic: return "Bluetooth Device"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Name Constants
|
||||
|
||||
private let IOBluetoothDeviceConnectionNotification = "IOBluetoothDeviceConnectionNotification"
|
||||
private let IOBluetoothDeviceDisconnectionNotification = "IOBluetoothDeviceDisconnectionNotification"
|
||||
445
DynamicIsland/managers/DoNotDisturbManager.swift
Normal file
445
DynamicIsland/managers/DoNotDisturbManager.swift
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
//
|
||||
// DoNotDisturbManager.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created for Do Not Disturb / Focus Mode detection
|
||||
// Monitors macOS Focus Mode state changes via distributed notifications
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import AppKit
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
import UserNotifications // For notification permission checks
|
||||
import ApplicationServices // For AXIsProcessTrusted
|
||||
|
||||
/// Manages detection and state tracking for macOS Focus Mode (Do Not Disturb)
|
||||
class DoNotDisturbManager: ObservableObject {
|
||||
static let shared = DoNotDisturbManager()
|
||||
|
||||
// MARK: - Published Properties
|
||||
@Published var isDoNotDisturbActive: Bool = false
|
||||
@Published var currentFocusModeName: String = ""
|
||||
@Published var currentFocusModeIdentifier: String = ""
|
||||
|
||||
// MARK: - Private Properties
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Initialization
|
||||
private init() {
|
||||
print("🔔 [DoNotDisturbManager] Initializing...")
|
||||
checkPermissions()
|
||||
setupFocusModeObservers()
|
||||
checkInitialState()
|
||||
}
|
||||
|
||||
deinit {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
// MARK: - Permission Checks
|
||||
|
||||
/// Checks if the app has necessary permissions for notification observation
|
||||
private func checkPermissions() {
|
||||
print("🔔 [DoNotDisturbManager] ==========================================")
|
||||
print("🔔 [DoNotDisturbManager] 🔐 CHECKING PERMISSIONS & CAPABILITIES")
|
||||
print("🔔 [DoNotDisturbManager] ==========================================")
|
||||
|
||||
// Check if running in sandbox
|
||||
let isSandboxed = ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil
|
||||
print(" 📦 Sandboxed: \(isSandboxed ? "YES ⚠️" : "NO ✅")")
|
||||
|
||||
// Check notification center permissions
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
notificationCenter.getNotificationSettings { settings in
|
||||
print(" 🔔 Notification Authorization: \(settings.authorizationStatus.rawValue)")
|
||||
print(" - .notDetermined = 0")
|
||||
print(" - .denied = 1")
|
||||
print(" - .authorized = 2")
|
||||
print(" - .provisional = 3")
|
||||
print(" - .ephemeral = 4")
|
||||
}
|
||||
|
||||
// Check accessibility permissions
|
||||
let trusted = AXIsProcessTrusted()
|
||||
print(" ♿️ Accessibility Trusted: \(trusted ? "YES ✅" : "NO ⚠️")")
|
||||
if !trusted {
|
||||
print(" ⚠️ App needs Accessibility permission!")
|
||||
print(" ⚠️ Go to: System Settings > Privacy & Security > Accessibility")
|
||||
}
|
||||
|
||||
// Check entitlements
|
||||
print(" 📝 Checking Entitlements...")
|
||||
if let entitlements = Bundle.main.object(forInfoDictionaryKey: "Entitlements") as? [String: Any] {
|
||||
print(" Entitlements found: \(entitlements.keys)")
|
||||
} else {
|
||||
print(" ⚠️ No entitlements in Info.plist")
|
||||
}
|
||||
|
||||
// Test if we can observe notifications at all
|
||||
print(" 🧪 Testing notification observation capability...")
|
||||
testNotificationObservation()
|
||||
|
||||
print("🔔 [DoNotDisturbManager] ==========================================")
|
||||
}
|
||||
|
||||
/// Test if notification observation is working at all
|
||||
private func testNotificationObservation() {
|
||||
// Test 1: Local NotificationCenter
|
||||
let testObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name("TEST_NOTIFICATION"),
|
||||
object: nil,
|
||||
queue: .main,
|
||||
using: { notification in
|
||||
print(" ✅ Local NotificationCenter works!")
|
||||
}
|
||||
)
|
||||
NotificationCenter.default.post(name: NSNotification.Name("TEST_NOTIFICATION"), object: nil)
|
||||
NotificationCenter.default.removeObserver(testObserver)
|
||||
|
||||
// Test 2: Distributed NotificationCenter (this is what we need)
|
||||
let dnc = DistributedNotificationCenter.default()
|
||||
print(" 🌐 DistributedNotificationCenter instance: \(dnc)")
|
||||
print(" 🌐 Attempting to post test distributed notification...")
|
||||
|
||||
let testDistributedObserver = dnc.addObserver(
|
||||
forName: NSNotification.Name("com.test.distributed.notification"),
|
||||
object: nil,
|
||||
queue: .main,
|
||||
using: { notification in
|
||||
print(" ✅ Distributed NotificationCenter observation works!")
|
||||
}
|
||||
)
|
||||
|
||||
// Post a test distributed notification
|
||||
dnc.postNotificationName(
|
||||
NSNotification.Name("com.test.distributed.notification"),
|
||||
object: nil,
|
||||
userInfo: ["test": "data"],
|
||||
deliverImmediately: true
|
||||
)
|
||||
|
||||
// Wait a bit then remove observer
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
dnc.removeObserver(testDistributedObserver)
|
||||
print(" 🧹 Test observer cleaned up")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Setup Methods
|
||||
|
||||
/// Sets up distributed notification observers for Focus Mode changes
|
||||
private func setupFocusModeObservers() {
|
||||
print("🔔 [DoNotDisturbManager] ==========================================")
|
||||
print("🔔 [DoNotDisturbManager] 🚨 SETTING UP NOTIFICATION OBSERVERS 🚨")
|
||||
print("🔔 [DoNotDisturbManager] ==========================================")
|
||||
|
||||
// Method 1: Darwin Notifications (low-level, no permissions needed)
|
||||
setupDarwinNotificationObserver()
|
||||
|
||||
// Method 2: Distributed Notifications (system-wide)
|
||||
setupDistributedNotificationObserver()
|
||||
|
||||
// Method 3: NSWorkspace Notifications (local)
|
||||
setupNSWorkspaceNotificationObserver()
|
||||
|
||||
// Method 4: Polling as fallback
|
||||
startPollingForDND()
|
||||
|
||||
print("🔔 [DoNotDisturbManager] ✅ All observers registered")
|
||||
print("🔔 [DoNotDisturbManager] Please toggle Do Not Disturb now...")
|
||||
print("🔔 [DoNotDisturbManager] ==========================================")
|
||||
}
|
||||
|
||||
private func setupDarwinNotificationObserver() {
|
||||
print("🔔 [DoNotDisturbManager] Setting up DARWIN notification observer...")
|
||||
|
||||
// Darwin notifications - these are low-level and work without special permissions
|
||||
let darwinNotificationNames = [
|
||||
"com.apple.controlcenter.modeChanged",
|
||||
"com.apple.donotdisturb.changed",
|
||||
"com.apple.donotdisturb.state",
|
||||
"com.apple.springboard.donotdisturb"
|
||||
]
|
||||
|
||||
let center = CFNotificationCenterGetDarwinNotifyCenter()
|
||||
|
||||
for notificationName in darwinNotificationNames {
|
||||
CFNotificationCenterAddObserver(
|
||||
center,
|
||||
Unmanaged.passUnretained(self).toOpaque(),
|
||||
{ (center, observer, name, object, userInfo) in
|
||||
guard let name = name else { return }
|
||||
let notificationName = name.rawValue as String
|
||||
print("🎯 [DARWIN NOTIFICATION] \(notificationName)")
|
||||
|
||||
// Get the manager instance
|
||||
if let observer = observer {
|
||||
let manager = Unmanaged<DoNotDisturbManager>.fromOpaque(observer).takeUnretainedValue()
|
||||
manager.handleDarwinNotification(notificationName)
|
||||
}
|
||||
},
|
||||
notificationName as CFString,
|
||||
nil,
|
||||
.deliverImmediately
|
||||
)
|
||||
print(" ✅ Darwin observer registered for: \(notificationName)")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDarwinNotification(_ name: String) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
print("🔔🔔🔔 [DARWIN] Focus Mode notification received: \(name)")
|
||||
// Toggle DND state
|
||||
self?.isDoNotDisturbActive.toggle()
|
||||
self?.showFocusModeActivatedHUD()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupDistributedNotificationObserver() {
|
||||
let dnc = DistributedNotificationCenter.default()
|
||||
|
||||
print("🔔 [DoNotDisturbManager] Setting up DISTRIBUTED notification observer...")
|
||||
|
||||
// Observer for ANY AND ALL distributed notifications (for discovery)
|
||||
let allNotificationsObserver = dnc.addObserver(
|
||||
forName: nil, // nil = listen to EVERYTHING
|
||||
object: nil,
|
||||
queue: .main,
|
||||
using: { [weak self] notification in
|
||||
let notificationName = notification.name.rawValue
|
||||
|
||||
// PRINT EVERY SINGLE NOTIFICATION (no filtering)
|
||||
print("📢 [DISTRIBUTED] \(notificationName)")
|
||||
|
||||
// Also check if it looks Focus Mode related
|
||||
if notificationName.lowercased().contains("focus") ||
|
||||
notificationName.lowercased().contains("dnd") ||
|
||||
notificationName.lowercased().contains("disturb") ||
|
||||
notificationName.lowercased().contains("mode") ||
|
||||
notificationName.contains("com.apple.controlcenter") ||
|
||||
notificationName.contains("com.apple.donotdisturb") {
|
||||
|
||||
print("🔔🔔🔔 [DoNotDisturbManager] ⚡️⚡️⚡️ POTENTIAL FOCUS MODE NOTIFICATION:")
|
||||
print(" Name: \(notificationName)")
|
||||
print(" Object: \(String(describing: notification.object))")
|
||||
print(" UserInfo Keys: \(notification.userInfo?.keys.map { String(describing: $0) } ?? [])")
|
||||
print(" UserInfo: \(notification.userInfo ?? [:])")
|
||||
print(" ===============================================")
|
||||
|
||||
self?.handleFocusModeNotification(notification)
|
||||
}
|
||||
}
|
||||
)
|
||||
observers.append(allNotificationsObserver)
|
||||
print(" ✅ Distributed notification observer registered")
|
||||
}
|
||||
|
||||
private func setupNSWorkspaceNotificationObserver() {
|
||||
let nc = NSWorkspace.shared.notificationCenter
|
||||
|
||||
print("🔔 [DoNotDisturbManager] Setting up NSWORKSPACE notification observer...")
|
||||
|
||||
// Try to observe workspace notifications
|
||||
let workspaceObserver = nc.addObserver(
|
||||
forName: nil, // Listen to all workspace notifications
|
||||
object: nil,
|
||||
queue: .main,
|
||||
using: { notification in
|
||||
let notificationName = notification.name.rawValue
|
||||
|
||||
// Print all workspace notifications
|
||||
print("🏢 [NSWORKSPACE] \(notificationName)")
|
||||
|
||||
// Check for Focus Mode related
|
||||
if notificationName.lowercased().contains("focus") ||
|
||||
notificationName.lowercased().contains("dnd") ||
|
||||
notificationName.lowercased().contains("disturb") {
|
||||
print("🔔🔔🔔 [WORKSPACE FOCUS NOTIFICATION]")
|
||||
print(" Name: \(notificationName)")
|
||||
print(" UserInfo: \(notification.userInfo ?? [:])")
|
||||
}
|
||||
}
|
||||
)
|
||||
observers.append(workspaceObserver)
|
||||
print(" ✅ NSWorkspace notification observer registered")
|
||||
}
|
||||
|
||||
private func startPollingForDND() {
|
||||
print("🔔 [DoNotDisturbManager] Starting polling fallback (every 2 seconds)...")
|
||||
|
||||
// Poll every 2 seconds to check for DND state changes
|
||||
Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
|
||||
self?.checkDNDStateViaDefaults()
|
||||
}
|
||||
print(" ✅ Polling timer started")
|
||||
}
|
||||
|
||||
private func checkDNDStateViaDefaults() {
|
||||
// Try to read DND state from UserDefaults or system preferences
|
||||
// This is a fallback detection method
|
||||
|
||||
let cfPrefs = CFPreferencesCopyAppValue("dndStart" as CFString, "com.apple.controlcenter" as CFString)
|
||||
if let prefs = cfPrefs {
|
||||
print("🔍 [POLLING] Found controlcenter prefs: \(prefs)")
|
||||
}
|
||||
|
||||
// Try reading notification center settings
|
||||
let ncPrefs = CFPreferencesCopyAppValue("doNotDisturb" as CFString, "com.apple.notificationcenterui" as CFString)
|
||||
if let nc = ncPrefs {
|
||||
print("🔍 [POLLING] Found notification center prefs: \(nc)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks initial Focus Mode state on app launch
|
||||
private func checkInitialState() {
|
||||
// TODO: Find a way to check current Focus Mode state
|
||||
// Options to explore:
|
||||
// 1. NSWorkspace properties (if any exist)
|
||||
// 2. Private APIs
|
||||
// 3. AppleScript (last resort)
|
||||
|
||||
print("🔔 [DoNotDisturbManager] Checking initial Focus Mode state...")
|
||||
print(" Note: Initial state detection not yet implemented")
|
||||
}
|
||||
|
||||
// MARK: - Notification Handling
|
||||
|
||||
/// Handles incoming Focus Mode notifications
|
||||
private func handleFocusModeNotification(_ notification: Notification) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Parse notification payload
|
||||
let userInfo = notification.userInfo ?? [:]
|
||||
|
||||
// Try to extract Focus Mode state and identifier
|
||||
// Note: Actual keys depend on the notification structure (to be discovered)
|
||||
if let stateString = userInfo["state"] as? String {
|
||||
self.isDoNotDisturbActive = (stateString == "enabled" || stateString == "on" || stateString == "active")
|
||||
} else if let enabled = userInfo["enabled"] as? Bool {
|
||||
self.isDoNotDisturbActive = enabled
|
||||
} else if let active = userInfo["active"] as? Bool {
|
||||
self.isDoNotDisturbActive = active
|
||||
}
|
||||
|
||||
// Try to extract mode name
|
||||
if let modeName = userInfo["name"] as? String {
|
||||
self.currentFocusModeName = modeName
|
||||
} else if let mode = userInfo["mode"] as? String {
|
||||
self.currentFocusModeName = mode
|
||||
}
|
||||
|
||||
// Try to extract mode identifier
|
||||
if let identifier = userInfo["identifier"] as? String {
|
||||
self.currentFocusModeIdentifier = identifier
|
||||
}
|
||||
|
||||
print("🔔 [DoNotDisturbManager] State updated:")
|
||||
print(" Active: \(self.isDoNotDisturbActive)")
|
||||
print(" Mode Name: \(self.currentFocusModeName.isEmpty ? "N/A" : self.currentFocusModeName)")
|
||||
print(" Identifier: \(self.currentFocusModeIdentifier.isEmpty ? "N/A" : self.currentFocusModeIdentifier)")
|
||||
|
||||
// Trigger HUD display
|
||||
if self.isDoNotDisturbActive {
|
||||
self.showFocusModeActivatedHUD()
|
||||
} else {
|
||||
self.showFocusModeDeactivatedHUD()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HUD Display Methods
|
||||
|
||||
/// Shows HUD when Focus Mode is activated
|
||||
private func showFocusModeActivatedHUD() {
|
||||
print("🔔 [DoNotDisturbManager] 📱 Showing Focus Mode ACTIVATED HUD")
|
||||
|
||||
// TODO: Integrate with DynamicIslandViewCoordinator to show HUD
|
||||
// coordinator.toggleSneakPeek(status: true, type: .doNotDisturb, value: 1)
|
||||
}
|
||||
|
||||
/// Shows HUD when Focus Mode is deactivated
|
||||
private func showFocusModeDeactivatedHUD() {
|
||||
print("🔔 [DoNotDisturbManager] 📱 Showing Focus Mode DEACTIVATED HUD")
|
||||
|
||||
// TODO: Integrate with DynamicIslandViewCoordinator to show HUD
|
||||
// coordinator.toggleSneakPeek(status: true, type: .doNotDisturb, value: 0)
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Manually refresh Focus Mode state
|
||||
func refreshState() {
|
||||
print("🔔 [DoNotDisturbManager] Manual state refresh requested")
|
||||
checkInitialState()
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
private func cleanup() {
|
||||
print("🔔 [DoNotDisturbManager] Cleaning up observers...")
|
||||
|
||||
// Remove Darwin notification observers
|
||||
let center = CFNotificationCenterGetDarwinNotifyCenter()
|
||||
CFNotificationCenterRemoveEveryObserver(center, Unmanaged.passUnretained(self).toOpaque())
|
||||
|
||||
// Remove other observers
|
||||
let dnc = DistributedNotificationCenter.default()
|
||||
for observer in observers {
|
||||
dnc.removeObserver(observer)
|
||||
}
|
||||
observers.removeAll()
|
||||
cancellables.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Focus Mode Types
|
||||
|
||||
enum FocusModeType: String {
|
||||
case doNotDisturb = "com.apple.donotdisturb.mode"
|
||||
case work = "com.apple.focus.work"
|
||||
case personal = "com.apple.focus.personal"
|
||||
case sleep = "com.apple.focus.sleep"
|
||||
case driving = "com.apple.focus.driving"
|
||||
case fitness = "com.apple.focus.fitness"
|
||||
case gaming = "com.apple.focus.gaming"
|
||||
case mindfulness = "com.apple.focus.mindfulness"
|
||||
case reading = "com.apple.focus.reading"
|
||||
case custom = "com.apple.focus.custom"
|
||||
case unknown = ""
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .doNotDisturb: return "Do Not Disturb"
|
||||
case .work: return "Work"
|
||||
case .personal: return "Personal"
|
||||
case .sleep: return "Sleep"
|
||||
case .driving: return "Driving"
|
||||
case .fitness: return "Fitness"
|
||||
case .gaming: return "Gaming"
|
||||
case .mindfulness: return "Mindfulness"
|
||||
case .reading: return "Reading"
|
||||
case .custom: return "Focus"
|
||||
case .unknown: return "Focus Mode"
|
||||
}
|
||||
}
|
||||
|
||||
var sfSymbol: String {
|
||||
switch self {
|
||||
case .doNotDisturb: return "moon.fill"
|
||||
case .work: return "briefcase.fill"
|
||||
case .personal: return "person.fill"
|
||||
case .sleep: return "bed.double.fill"
|
||||
case .driving: return "car.fill"
|
||||
case .fitness: return "figure.run"
|
||||
case .gaming: return "gamecontroller.fill"
|
||||
case .mindfulness: return "brain.head.profile"
|
||||
case .reading: return "book.fill"
|
||||
case .custom: return "app.badge"
|
||||
case .unknown: return "moon.zzz.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
import Cocoa
|
||||
import Foundation
|
||||
import Defaults
|
||||
import CoreAudio
|
||||
|
||||
class SystemChangesObserver {
|
||||
private var oldVolume: Float = 0
|
||||
|
|
@ -17,8 +18,22 @@ class SystemChangesObserver {
|
|||
|
||||
private weak var coordinator: DynamicIslandViewCoordinator?
|
||||
|
||||
// Backup timer for edge cases only
|
||||
private var backupTimer: Timer?
|
||||
// Adaptive polling timer
|
||||
private var pollingTimer: Timer?
|
||||
private var currentPollingInterval: TimeInterval = 2.0
|
||||
|
||||
// Polling intervals
|
||||
private let rapidPollingInterval: TimeInterval = 0.05 // Burst mode for active changes
|
||||
private let normalPollingInterval: TimeInterval = 2.0 // Normal mode when idle
|
||||
|
||||
// Activity tracking
|
||||
private var lastBrightnessChangeTime: Date = Date.distantPast
|
||||
private var lastVolumeChangeTime: Date = Date.distantPast
|
||||
private var lastVolumeCheckTime: Date = Date.distantPast
|
||||
private var lastExternalChangeTime: Date = Date.distantPast // Track external volume changes
|
||||
|
||||
// Headphone detection (for info only, doesn't affect polling)
|
||||
private var hasHeadphonesConnected: Bool = false
|
||||
|
||||
init(coordinator: DynamicIslandViewCoordinator) {
|
||||
self.coordinator = coordinator
|
||||
|
|
@ -32,38 +47,171 @@ class SystemChangesObserver {
|
|||
} catch {
|
||||
NSLog("Failed to retrieve initial display brightness: \(error)")
|
||||
}
|
||||
|
||||
// Detect headphones on init
|
||||
updateHeadphoneStatus()
|
||||
setupAudioRouteChangeListener()
|
||||
}
|
||||
|
||||
func startObserving() {
|
||||
guard Defaults[.enableSystemHUD] else { return }
|
||||
|
||||
startMinimalBackupTimer()
|
||||
// Start with appropriate polling rate
|
||||
startPollingTimer(interval: determinePollingInterval())
|
||||
}
|
||||
|
||||
private func startMinimalBackupTimer() {
|
||||
// More frequent backup check (every 300ms) for better responsiveness
|
||||
// This is a reasonable compromise between the original 200ms and the previous 5s
|
||||
backupTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||
guard Defaults[.enableSystemHUD] else { return }
|
||||
self?.performBackupCheck()
|
||||
private func determinePollingInterval() -> TimeInterval {
|
||||
let now = Date()
|
||||
|
||||
// Burst mode: Rapid polling within 5 seconds of any activity
|
||||
// Activity = key press OR external change detected
|
||||
let timeSinceKeyPress = min(
|
||||
now.timeIntervalSince(lastVolumeChangeTime),
|
||||
now.timeIntervalSince(lastBrightnessChangeTime)
|
||||
)
|
||||
let timeSinceExternalChange = now.timeIntervalSince(lastExternalChangeTime)
|
||||
|
||||
// Use burst mode if there's been recent activity (within 5 seconds)
|
||||
if timeSinceKeyPress < 5.0 || timeSinceExternalChange < 5.0 {
|
||||
return rapidPollingInterval
|
||||
}
|
||||
|
||||
// Normal mode: Idle state (regardless of headphones)
|
||||
return normalPollingInterval
|
||||
}
|
||||
|
||||
private func startPollingTimer(interval: TimeInterval) {
|
||||
// Don't restart if same interval
|
||||
if pollingTimer != nil && currentPollingInterval == interval {
|
||||
return
|
||||
}
|
||||
|
||||
currentPollingInterval = interval
|
||||
pollingTimer?.invalidate()
|
||||
|
||||
pollingTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
guard let self = self, Defaults[.enableSystemHUD] else { return }
|
||||
|
||||
// Check if we need to adjust polling rate
|
||||
let newInterval = self.determinePollingInterval()
|
||||
if newInterval != self.currentPollingInterval {
|
||||
self.startPollingTimer(interval: newInterval)
|
||||
return
|
||||
}
|
||||
|
||||
// Poll volume
|
||||
self.checkVolumeChanges()
|
||||
|
||||
// Poll brightness only within 5 seconds of key press
|
||||
if Date().timeIntervalSince(self.lastBrightnessChangeTime) < 5.0 {
|
||||
self.checkBrightnessChanges()
|
||||
}
|
||||
}
|
||||
|
||||
private func performBackupCheck() {
|
||||
// Only check if we haven't had recent activity
|
||||
checkVolumeChanges()
|
||||
checkBrightnessChanges()
|
||||
let mode = interval == rapidPollingInterval ? "BURST (50ms)" : "NORMAL (2s)"
|
||||
print("✅ Polling started: \(mode) mode\(hasHeadphonesConnected ? " [🎧 Headphones connected]" : "")")
|
||||
}
|
||||
|
||||
// MARK: - Headphone Detection
|
||||
|
||||
private func setupAudioRouteChangeListener() {
|
||||
// Listen for default audio device changes
|
||||
var propertyAddress = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
AudioObjectAddPropertyListener(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&propertyAddress,
|
||||
audioDeviceChangeListener,
|
||||
Unmanaged.passUnretained(self).toOpaque()
|
||||
)
|
||||
}
|
||||
|
||||
@objc fileprivate func audioRouteChanged() {
|
||||
updateHeadphoneStatus()
|
||||
|
||||
// Adjust polling rate immediately
|
||||
startPollingTimer(interval: determinePollingInterval())
|
||||
}
|
||||
|
||||
private func updateHeadphoneStatus() {
|
||||
// Check for headphones/external audio devices
|
||||
var propertyAddress = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
var deviceID: AudioDeviceID = 0
|
||||
var propertySize = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||
|
||||
let status = AudioObjectGetPropertyData(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&propertyAddress,
|
||||
0,
|
||||
nil,
|
||||
&propertySize,
|
||||
&deviceID
|
||||
)
|
||||
|
||||
guard status == noErr else {
|
||||
hasHeadphonesConnected = false
|
||||
return
|
||||
}
|
||||
|
||||
// Check if device is not built-in speakers
|
||||
var transportType: UInt32 = 0
|
||||
var transportSize = UInt32(MemoryLayout<UInt32>.size)
|
||||
var transportAddress = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyTransportType,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
let transportStatus = AudioObjectGetPropertyData(
|
||||
deviceID,
|
||||
&transportAddress,
|
||||
0,
|
||||
nil,
|
||||
&transportSize,
|
||||
&transportType
|
||||
)
|
||||
|
||||
// Consider headphones if: Bluetooth, USB, or any non-built-in device
|
||||
let wasConnected = hasHeadphonesConnected
|
||||
hasHeadphonesConnected = transportStatus == noErr &&
|
||||
transportType != kAudioDeviceTransportTypeBuiltIn
|
||||
|
||||
if wasConnected != hasHeadphonesConnected {
|
||||
print(hasHeadphonesConnected ? "🎧 Headphones connected (will use burst mode on change detection)" :
|
||||
"🔊 Headphones disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public Media Key Event Handlers (called from MediaKeyApplication)
|
||||
|
||||
func handleVolumeKeyEvent() {
|
||||
// Mark as user-initiated and trigger burst mode
|
||||
lastVolumeChangeTime = Date()
|
||||
|
||||
// Switch to burst mode immediately
|
||||
startPollingTimer(interval: rapidPollingInterval)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
self.handleVolumeKeyPress()
|
||||
}
|
||||
}
|
||||
|
||||
func handleBrightnessKeyEvent() {
|
||||
// Mark as user-initiated and trigger burst mode
|
||||
lastBrightnessChangeTime = Date()
|
||||
|
||||
// Switch to burst mode immediately
|
||||
startPollingTimer(interval: rapidPollingInterval)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
self.handleBrightnessKeyPress()
|
||||
}
|
||||
|
|
@ -74,6 +222,9 @@ class SystemChangesObserver {
|
|||
private func handleVolumeKeyPress() {
|
||||
guard Defaults[.enableVolumeHUD] else { return }
|
||||
|
||||
// Mark as user-initiated (don't debounce key presses)
|
||||
lastVolumeChangeTime = Date()
|
||||
|
||||
let newVolume = SystemVolumeManager.getOutputVolume()
|
||||
let newMuted = SystemVolumeManager.isMuted()
|
||||
|
||||
|
|
@ -99,7 +250,7 @@ class SystemChangesObserver {
|
|||
let newBrightness = try SystemDisplayManager.getDisplayBrightness()
|
||||
|
||||
// Always show HUD for key press feedback, even if value doesn't change
|
||||
print("☀️ Brightness key pressed - showing HUD: brightness=\(newBrightness)")
|
||||
print("☀️ Brightness key pressed - showing HUD: brightness=\(newBrightness) [BURST MODE]")
|
||||
sendBrightnessNotification(value: newBrightness)
|
||||
|
||||
// Update stored value
|
||||
|
|
@ -109,9 +260,20 @@ class SystemChangesObserver {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Polling Check Methods
|
||||
|
||||
private func checkVolumeChanges() {
|
||||
guard Defaults[.enableVolumeHUD] else { return }
|
||||
|
||||
// Throttle AppleScript checks based on current mode
|
||||
let now = Date()
|
||||
let minInterval: TimeInterval = currentPollingInterval == rapidPollingInterval ? 0.05 : 0.2
|
||||
|
||||
if now.timeIntervalSince(lastVolumeCheckTime) < minInterval {
|
||||
return
|
||||
}
|
||||
lastVolumeCheckTime = now
|
||||
|
||||
let newVolume = SystemVolumeManager.getOutputVolume()
|
||||
let newMuted = SystemVolumeManager.isMuted()
|
||||
|
||||
|
|
@ -119,13 +281,35 @@ class SystemChangesObserver {
|
|||
let muteChanged = newMuted != oldMuted
|
||||
|
||||
if volumeChanged || muteChanged {
|
||||
print("🔊 Volume change detected: \(oldVolume) → \(newVolume), muted: \(oldMuted) → \(newMuted)")
|
||||
// Check if this is an external change (not from key press)
|
||||
let debounceWindow = currentPollingInterval == rapidPollingInterval ? 0.1 : 0.5
|
||||
let timeSinceKeyPress = now.timeIntervalSince(lastVolumeChangeTime)
|
||||
|
||||
// External change detected!
|
||||
if timeSinceKeyPress > debounceWindow {
|
||||
// Mark it and trigger burst mode
|
||||
lastExternalChangeTime = now
|
||||
|
||||
let mode = currentPollingInterval == rapidPollingInterval ? "[BURST]" : "[EXTERNAL→BURST]"
|
||||
print("🔊 Volume change detected \(mode): \(oldVolume) → \(newVolume), muted: \(oldMuted) → \(newMuted)")
|
||||
|
||||
// Show HUD immediately
|
||||
if newMuted {
|
||||
sendVolumeNotification(value: 0.0)
|
||||
} else {
|
||||
sendVolumeNotification(value: newVolume)
|
||||
}
|
||||
|
||||
// Switch to burst mode immediately if we're in normal mode
|
||||
if currentPollingInterval == normalPollingInterval {
|
||||
print("⚡ Switching from NORMAL to BURST mode due to external change")
|
||||
startPollingTimer(interval: rapidPollingInterval)
|
||||
}
|
||||
} else {
|
||||
// Within debounce window of key press - still update values but don't show HUD
|
||||
print("🔇 Volume change within debounce window (\(timeSinceKeyPress)s) - skipping HUD")
|
||||
}
|
||||
|
||||
oldVolume = newVolume
|
||||
oldMuted = newMuted
|
||||
}
|
||||
|
|
@ -150,10 +334,9 @@ class SystemChangesObserver {
|
|||
}
|
||||
|
||||
private func isAlmost(firstNumber: Float, secondNumber: Float) -> Bool {
|
||||
// Convert sensitivity setting to a more responsive threshold
|
||||
// Sensitivity 1-10 scale maps to 0.01-0.10 threshold range
|
||||
let marginValue = Float(Defaults[.systemHUDSensitivity]) / 100.0
|
||||
let threshold = max(0.01, marginValue) // Minimum threshold for responsiveness
|
||||
// Use a fixed sensitive threshold for volume changes
|
||||
// 0.015 = 1.5% change required (sensitive enough for headphone increments)
|
||||
let threshold: Float = 0.015
|
||||
return abs(firstNumber - secondNumber) <= threshold
|
||||
}
|
||||
|
||||
|
|
@ -178,16 +361,50 @@ class SystemChangesObserver {
|
|||
}
|
||||
|
||||
deinit {
|
||||
// Remove audio device change listener
|
||||
var propertyAddress = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
AudioObjectRemovePropertyListener(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&propertyAddress,
|
||||
audioDeviceChangeListener,
|
||||
Unmanaged.passUnretained(self).toOpaque()
|
||||
)
|
||||
|
||||
stopObserving()
|
||||
}
|
||||
|
||||
func stopObserving() {
|
||||
// Clean up backup timer
|
||||
backupTimer?.invalidate()
|
||||
backupTimer = nil
|
||||
// Clean up polling timer
|
||||
pollingTimer?.invalidate()
|
||||
pollingTimer = nil
|
||||
|
||||
print("🎹 Media key detection stopped")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CoreAudio Callback for Device Changes
|
||||
|
||||
private func audioDeviceChangeListener(
|
||||
_ inObjectID: AudioObjectID,
|
||||
_ inNumberAddresses: UInt32,
|
||||
_ inAddresses: UnsafePointer<AudioObjectPropertyAddress>,
|
||||
_ inClientData: UnsafeMutableRawPointer?
|
||||
) -> OSStatus {
|
||||
guard let inClientData = inClientData else { return noErr }
|
||||
|
||||
let observer = Unmanaged<SystemChangesObserver>.fromOpaque(inClientData).takeUnretainedValue()
|
||||
|
||||
// Handle on main thread
|
||||
DispatchQueue.main.async {
|
||||
observer.audioRouteChanged()
|
||||
}
|
||||
|
||||
return noErr
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import Foundation
|
||||
import Defaults
|
||||
import Combine
|
||||
import AppKit
|
||||
|
||||
class SystemHUDManager {
|
||||
static let shared = SystemHUDManager()
|
||||
|
|
@ -23,7 +24,7 @@ class SystemHUDManager {
|
|||
}
|
||||
|
||||
private func setupSettingsObserver() {
|
||||
// Use Defaults publisher instead of @Default property wrapper
|
||||
// Observe master toggle
|
||||
Defaults.publisher(.enableSystemHUD, options: []).sink { [weak self] change in
|
||||
guard let self = self, self.isSetupComplete else {
|
||||
return
|
||||
|
|
@ -36,6 +37,27 @@ class SystemHUDManager {
|
|||
}
|
||||
}
|
||||
}.store(in: &cancellables)
|
||||
|
||||
// Observe individual HUD toggles
|
||||
Defaults.publisher(.enableVolumeHUD, options: []).sink { [weak self] change in
|
||||
guard let self = self, self.isSetupComplete, Defaults[.enableSystemHUD] else {
|
||||
return
|
||||
}
|
||||
// When volume HUD is enabled, ensure OSD is disabled
|
||||
if change.newValue {
|
||||
self.disableSystemHUD()
|
||||
}
|
||||
}.store(in: &cancellables)
|
||||
|
||||
Defaults.publisher(.enableBrightnessHUD, options: []).sink { [weak self] change in
|
||||
guard let self = self, self.isSetupComplete, Defaults[.enableSystemHUD] else {
|
||||
return
|
||||
}
|
||||
// When brightness HUD is enabled, ensure OSD is disabled
|
||||
if change.newValue {
|
||||
self.disableSystemHUD()
|
||||
}
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
|
@ -71,6 +93,9 @@ class SystemHUDManager {
|
|||
// Disable system HUD if possible
|
||||
disableSystemHUD()
|
||||
|
||||
// Start event-driven OSD monitoring
|
||||
startOSDMonitoring()
|
||||
|
||||
print("System HUD replacement started")
|
||||
isSystemOperationInProgress = false
|
||||
}
|
||||
|
|
@ -83,6 +108,9 @@ class SystemHUDManager {
|
|||
changesObserver?.stopObserving()
|
||||
changesObserver = nil
|
||||
|
||||
// Stop OSD monitoring
|
||||
stopOSDMonitoring()
|
||||
|
||||
// Re-enable system HUD
|
||||
enableOriginalSystemHUD()
|
||||
|
||||
|
|
@ -121,6 +149,68 @@ class SystemHUDManager {
|
|||
changesObserver?.handleBrightnessKeyEvent()
|
||||
}
|
||||
|
||||
// MARK: - OSD Monitoring
|
||||
|
||||
/// Start event-driven monitoring for OSDUIHelper process launches
|
||||
@MainActor
|
||||
private func startOSDMonitoring() {
|
||||
stopOSDMonitoring() // Stop any existing observer
|
||||
|
||||
// Listen for process launch notifications
|
||||
NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didLaunchApplicationNotification)
|
||||
.compactMap { notification -> NSRunningApplication? in
|
||||
notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
|
||||
}
|
||||
.filter { app in
|
||||
// Check if OSDUIHelper was launched
|
||||
app.bundleIdentifier == "com.apple.OSDUIHelper" ||
|
||||
app.localizedName?.contains("OSDUIHelper") == true
|
||||
}
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Check if any HUD is enabled
|
||||
let anyHUDEnabled = Defaults[.enableVolumeHUD] || Defaults[.enableBrightnessHUD]
|
||||
guard anyHUDEnabled else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
print("⚠️ OSDUIHelper launched - re-disabling")
|
||||
self.disableSystemHUD()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Also listen for screen wake events (OSDUIHelper often restarts after wake)
|
||||
NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.screensDidWakeNotification)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
let anyHUDEnabled = Defaults[.enableVolumeHUD] || Defaults[.enableBrightnessHUD]
|
||||
guard anyHUDEnabled else { return }
|
||||
|
||||
// Check after a short delay to ensure system is ready
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
|
||||
|
||||
let isRunning = await SystemOSDManager.isOSDUIHelperRunningAsync()
|
||||
if isRunning {
|
||||
print("⚠️ OSDUIHelper detected running after wake - re-disabling")
|
||||
self.disableSystemHUD()
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
print("✅ OSD monitoring started (event-driven)")
|
||||
}
|
||||
|
||||
/// Stop OSD monitoring
|
||||
@MainActor
|
||||
private func stopOSDMonitoring() {
|
||||
// Cancellables are automatically cleaned up
|
||||
print("🛑 OSD monitoring stopped")
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancellables.removeAll()
|
||||
Task { @MainActor in
|
||||
|
|
|
|||
|
|
@ -298,6 +298,12 @@ extension Defaults.Keys {
|
|||
// MARK: Media Controller
|
||||
static let mediaController = Key<MediaControllerType>("mediaController", default: defaultMediaController)
|
||||
|
||||
// MARK: Bluetooth Audio Devices
|
||||
static let showBluetoothDeviceConnections = Key<Bool>("showBluetoothDeviceConnections", default: true)
|
||||
static let useColorCodedBatteryDisplay = Key<Bool>("useColorCodedBatteryDisplay", default: true)
|
||||
static let useColorCodedVolumeDisplay = Key<Bool>("useColorCodedVolumeDisplay", default: false)
|
||||
static let useSmoothColorGradient = Key<Bool>("useSmoothColorGradient", default: true)
|
||||
|
||||
// MARK: Stats Feature
|
||||
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)
|
||||
static let autoStartStatsMonitoring = Key<Bool>("autoStartStatsMonitoring", default: true)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue