mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #92 from Ebullioscopic/feature/liveactivity
Feature/liveactivity
This commit is contained in:
commit
7bf71b205d
15 changed files with 946 additions and 446 deletions
|
|
@ -25,6 +25,7 @@ struct ContentView: View {
|
||||||
@ObservedObject var statsManager = StatsManager.shared
|
@ObservedObject var statsManager = StatsManager.shared
|
||||||
@ObservedObject var recordingManager = ScreenRecordingManager.shared
|
@ObservedObject var recordingManager = ScreenRecordingManager.shared
|
||||||
@ObservedObject var privacyManager = PrivacyIndicatorManager.shared
|
@ObservedObject var privacyManager = PrivacyIndicatorManager.shared
|
||||||
|
@ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared
|
||||||
@ObservedObject var lockScreenManager = LockScreenManager.shared
|
@ObservedObject var lockScreenManager = LockScreenManager.shared
|
||||||
|
|
||||||
@Default(.enableStatsFeature) var enableStatsFeature
|
@Default(.enableStatsFeature) var enableStatsFeature
|
||||||
|
|
@ -412,6 +413,8 @@ struct ContentView: View {
|
||||||
TimerLiveActivity()
|
TimerLiveActivity()
|
||||||
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed {
|
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed {
|
||||||
RecordingLiveActivity()
|
RecordingLiveActivity()
|
||||||
|
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && doNotDisturbManager.isDoNotDisturbActive && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && !vm.hideOnClosed && !lockScreenManager.isLocked {
|
||||||
|
DoNotDisturbLiveActivity()
|
||||||
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed {
|
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed {
|
||||||
LockScreenLiveActivity()
|
LockScreenLiveActivity()
|
||||||
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed {
|
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed {
|
||||||
|
|
|
||||||
|
|
@ -379,6 +379,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
ScreenRecordingManager.shared.startMonitoring()
|
ScreenRecordingManager.shared.startMonitoring()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup Do Not Disturb Manager
|
||||||
|
if Defaults[.enableDoNotDisturbDetection] {
|
||||||
|
dndManager.startMonitoring()
|
||||||
|
}
|
||||||
|
|
||||||
// Setup Privacy Indicator Manager (camera and microphone monitoring)
|
// Setup Privacy Indicator Manager (camera and microphone monitoring)
|
||||||
PrivacyIndicatorManager.shared.startMonitoring()
|
PrivacyIndicatorManager.shared.startMonitoring()
|
||||||
|
|
||||||
|
|
@ -435,6 +440,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
Defaults.publisher(.enableDoNotDisturbDetection, options: []).sink { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
if Defaults[.enableDoNotDisturbDetection] {
|
||||||
|
self.dndManager.startMonitoring()
|
||||||
|
} else {
|
||||||
|
self.dndManager.stopMonitoring()
|
||||||
|
}
|
||||||
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
// Note: Polling setting removed - now uses event-driven private API detection only
|
// Note: Polling setting removed - now uses event-driven private API detection only
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ enum SneakContentType {
|
||||||
case download
|
case download
|
||||||
case timer
|
case timer
|
||||||
case recording
|
case recording
|
||||||
|
case doNotDisturb
|
||||||
case bluetoothAudio
|
case bluetoothAudio
|
||||||
case privacy
|
case privacy
|
||||||
case lockScreen
|
case lockScreen
|
||||||
|
|
|
||||||
|
|
@ -1805,6 +1805,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Active - No Focus" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Active - No Recording" : {
|
"Active - No Recording" : {
|
||||||
"comment" : "A description of the recording detection feature, indicating that recording is not currently active.",
|
"comment" : "A description of the recording detection feature, indicating that recording is not currently active.",
|
||||||
|
|
@ -6769,6 +6772,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Do Not Disturb" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Download" : {
|
"Download" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -8491,6 +8497,9 @@
|
||||||
},
|
},
|
||||||
"Fit to Notch" : {
|
"Fit to Notch" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Focus Status" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Free · %@" : {
|
"Free · %@" : {
|
||||||
"comment" : "A label displaying the amount of free storage on a disk device. The argument is the number of free bytes on the disk.",
|
"comment" : "A label displaying the amount of free storage on a disk device. The argument is the number of free bytes on the disk.",
|
||||||
|
|
@ -10479,6 +10488,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Listens for Focus session changes via distributed notifications" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"LIVE" : {
|
||||||
|
"comment" : "A label indicating that the music is live.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Live Monitoring" : {
|
"Live Monitoring" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -10499,6 +10515,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Live stream indicator" : {
|
||||||
|
"comment" : "A visual indicator that appears when a music stream is live.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Load Average" : {
|
"Load Average" : {
|
||||||
"comment" : "A label displayed above the load average values.",
|
"comment" : "A label displayed above the load average values.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|
@ -15684,7 +15704,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Screen Recording Detection" : {
|
"Screen Recording" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Screenshot options" : {
|
"Screenshot options" : {
|
||||||
|
|
|
||||||
|
|
@ -181,18 +181,9 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol {
|
||||||
newPlaybackState.album = payload.album ?? (diff ? self.playbackState.album : "")
|
newPlaybackState.album = payload.album ?? (diff ? self.playbackState.album : "")
|
||||||
newPlaybackState.duration = payload.duration ?? (diff ? self.playbackState.duration : 0)
|
newPlaybackState.duration = payload.duration ?? (diff ? self.playbackState.duration : 0)
|
||||||
|
|
||||||
if let elapsedTime = payload.elapsedTime {
|
// Match boring.notch behavior: if elapsedTime is provided use it,
|
||||||
newPlaybackState.currentTime = elapsedTime
|
// if this update is a diff keep the previous currentTime, otherwise default to 0.
|
||||||
} else if diff {
|
newPlaybackState.currentTime = payload.elapsedTime ?? (diff ? self.playbackState.currentTime : 0)
|
||||||
if payload.playing == false {
|
|
||||||
let timeSinceLastUpdate = Date().timeIntervalSince(self.playbackState.lastUpdated)
|
|
||||||
newPlaybackState.currentTime = self.playbackState.currentTime + (self.playbackState.playbackRate * timeSinceLastUpdate)
|
|
||||||
} else {
|
|
||||||
newPlaybackState.currentTime = self.playbackState.currentTime
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
newPlaybackState.currentTime = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if let shuffleMode = payload.shuffleMode {
|
if let shuffleMode = payload.shuffleMode {
|
||||||
|
|
|
||||||
256
DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift
Normal file
256
DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
//
|
||||||
|
// DoNotDisturbLiveActivity.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Renders the closed-notch Focus indicator with per-mode colours and
|
||||||
|
// an icon-first layout that collapses gracefully when Focus ends.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Defaults
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DoNotDisturbLiveActivity: View {
|
||||||
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
|
@ObservedObject var manager = DoNotDisturbManager.shared
|
||||||
|
@Default(.showDoNotDisturbLabel) private var showLabelSetting
|
||||||
|
|
||||||
|
@State private var isExpanded = false
|
||||||
|
@State private var showInactiveIcon = false
|
||||||
|
@State private var iconScale: CGFloat = 1.0
|
||||||
|
@State private var scaleResetTask: Task<Void, Never>?
|
||||||
|
@State private var collapseTask: Task<Void, Never>?
|
||||||
|
@State private var cleanupTask: Task<Void, Never>?
|
||||||
|
@State private var labelIntrinsicWidth: CGFloat = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
iconWing
|
||||||
|
.frame(width: iconWingWidth, height: wingHeight)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.black)
|
||||||
|
.frame(width: vm.closedNotchSize.width)
|
||||||
|
|
||||||
|
labelWing
|
||||||
|
.frame(width: labelWingWidth, height: wingHeight)
|
||||||
|
}
|
||||||
|
.frame(height: vm.effectiveClosedNotchHeight)
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(accessibilityDescription)
|
||||||
|
.onAppear(perform: handleInitialState)
|
||||||
|
.onChange(of: manager.isDoNotDisturbActive, handleFocusStateChange)
|
||||||
|
.onDisappear(perform: cancelPendingTasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layout helpers
|
||||||
|
|
||||||
|
private var wingHeight: CGFloat {
|
||||||
|
max(vm.effectiveClosedNotchHeight - 10, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconWingWidth: CGFloat {
|
||||||
|
(isExpanded || showInactiveIcon) ? minimalWingWidth : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private var labelWingWidth: CGFloat {
|
||||||
|
if shouldShowLabel {
|
||||||
|
return max(desiredLabelWidth, minimalWingWidth)
|
||||||
|
}
|
||||||
|
return (isExpanded || showInactiveIcon) ? minimalWingWidth : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private var minimalWingWidth: CGFloat {
|
||||||
|
max(vm.effectiveClosedNotchHeight - 12, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var desiredLabelWidth: CGFloat {
|
||||||
|
let measuredWidth = labelIntrinsicWidth + 8 // horizontal padding inside the label
|
||||||
|
let fallbackWidth = max(vm.closedNotchSize.width * 0.52, 136)
|
||||||
|
return max(measuredWidth, fallbackWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shouldShowLabel: Bool {
|
||||||
|
showLabelSetting && isExpanded && !labelText.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Focus metadata
|
||||||
|
|
||||||
|
private var focusMode: FocusModeType {
|
||||||
|
FocusModeType.resolve(
|
||||||
|
identifier: manager.currentFocusModeIdentifier,
|
||||||
|
name: manager.currentFocusModeName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activeAccentColor: Color {
|
||||||
|
focusMode.accentColor
|
||||||
|
}
|
||||||
|
|
||||||
|
private var labelText: String {
|
||||||
|
let trimmed = manager.currentFocusModeName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty {
|
||||||
|
if focusMode == .doNotDisturb {
|
||||||
|
return "Focus"
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallback = focusMode.displayName
|
||||||
|
if focusMode == .doNotDisturb {
|
||||||
|
return "Focus"
|
||||||
|
}
|
||||||
|
return fallback.isEmpty ? "Focus" : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private var accessibilityDescription: String {
|
||||||
|
if manager.isDoNotDisturbActive {
|
||||||
|
return "Focus active: \(labelText)"
|
||||||
|
} else {
|
||||||
|
return "Focus inactive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentIconName: String {
|
||||||
|
if manager.isDoNotDisturbActive {
|
||||||
|
return focusMode.sfSymbol
|
||||||
|
} else if showInactiveIcon {
|
||||||
|
return focusMode.inactiveSymbol
|
||||||
|
} else {
|
||||||
|
return focusMode.sfSymbol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentIconColor: Color {
|
||||||
|
if manager.isDoNotDisturbActive {
|
||||||
|
return activeAccentColor
|
||||||
|
} else if showInactiveIcon {
|
||||||
|
return .white
|
||||||
|
} else {
|
||||||
|
return activeAccentColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subviews
|
||||||
|
|
||||||
|
private var iconWing: some View {
|
||||||
|
Color.clear
|
||||||
|
.overlay(alignment: .center) {
|
||||||
|
if iconWingWidth > 0 {
|
||||||
|
Image(systemName: currentIconName)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(currentIconColor)
|
||||||
|
.contentTransition(.opacity)
|
||||||
|
.scaleEffect(iconScale)
|
||||||
|
.animation(.none, value: iconScale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.smooth(duration: 0.3), value: iconWingWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var labelWing: some View {
|
||||||
|
Color.clear
|
||||||
|
.overlay(alignment: .trailing) {
|
||||||
|
if shouldShowLabel {
|
||||||
|
Text(labelText)
|
||||||
|
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(activeAccentColor)
|
||||||
|
.lineLimit(1)
|
||||||
|
.contentTransition(.opacity)
|
||||||
|
.background(
|
||||||
|
GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: FocusLabelWidthPreferenceKey.self, value: proxy.size.width)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.smooth(duration: 0.3), value: shouldShowLabel)
|
||||||
|
.onPreferenceChange(FocusLabelWidthPreferenceKey.self) { value in
|
||||||
|
labelIntrinsicWidth = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State transitions
|
||||||
|
|
||||||
|
private func handleInitialState() {
|
||||||
|
if manager.isDoNotDisturbActive {
|
||||||
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
|
||||||
|
isExpanded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleFocusStateChange(_ oldValue: Bool, _ isActive: Bool) {
|
||||||
|
if isActive {
|
||||||
|
cancelPendingTasks()
|
||||||
|
withAnimation(.smooth(duration: 0.2)) {
|
||||||
|
showInactiveIcon = false
|
||||||
|
}
|
||||||
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
|
||||||
|
iconScale = 1.0
|
||||||
|
isExpanded = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
triggerInactiveAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func triggerInactiveAnimation() {
|
||||||
|
withAnimation(.smooth(duration: 0.2)) {
|
||||||
|
showInactiveIcon = true
|
||||||
|
}
|
||||||
|
|
||||||
|
withAnimation(.interpolatingSpring(stiffness: 220, damping: 12)) {
|
||||||
|
iconScale = 1.2
|
||||||
|
}
|
||||||
|
|
||||||
|
scaleResetTask?.cancel()
|
||||||
|
scaleResetTask = Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(650))
|
||||||
|
withAnimation(.smooth(duration: 0.2)) {
|
||||||
|
showInactiveIcon = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collapseTask?.cancel()
|
||||||
|
collapseTask = Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(320))
|
||||||
|
withAnimation(.smooth(duration: 0.32)) {
|
||||||
|
isExpanded = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupTask?.cancel()
|
||||||
|
cleanupTask = Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(650))
|
||||||
|
withAnimation(.smooth(duration: 0.2)) {
|
||||||
|
showInactiveIcon = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelPendingTasks() {
|
||||||
|
scaleResetTask?.cancel()
|
||||||
|
collapseTask?.cancel()
|
||||||
|
cleanupTask?.cancel()
|
||||||
|
scaleResetTask = nil
|
||||||
|
collapseTask = nil
|
||||||
|
cleanupTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
DoNotDisturbLiveActivity()
|
||||||
|
.environmentObject(DynamicIslandViewModel())
|
||||||
|
.frame(width: 320, height: 54)
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FocusLabelWidthPreferenceKey: PreferenceKey {
|
||||||
|
static var defaultValue: CGFloat = 0
|
||||||
|
|
||||||
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||||
|
value = max(value, nextValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
//
|
||||||
|
// LiveStreamProgressIndicator.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Shared indicator used for live stream playback timelines.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LiveStreamProgressIndicator: View {
|
||||||
|
let tint: Color
|
||||||
|
|
||||||
|
private var baseGradient: LinearGradient {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
tint.opacity(0.18),
|
||||||
|
tint.opacity(0.42),
|
||||||
|
tint.opacity(0.18)
|
||||||
|
],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var centerShade: LinearGradient {
|
||||||
|
LinearGradient(
|
||||||
|
stops: [
|
||||||
|
.init(color: .black.opacity(0.0), location: 0.0),
|
||||||
|
.init(color: .black.opacity(0.38), location: 0.5),
|
||||||
|
.init(color: .black.opacity(0.0), location: 1.0)
|
||||||
|
],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.white.opacity(0.07))
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.fill(baseGradient)
|
||||||
|
.opacity(0.9)
|
||||||
|
.blendMode(.plusLighter)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.fill(centerShade)
|
||||||
|
.blendMode(.multiply)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.stroke(tint.opacity(0.6), lineWidth: 0.7)
|
||||||
|
.blendMode(.screen)
|
||||||
|
)
|
||||||
|
.frame(height: 10)
|
||||||
|
.overlay {
|
||||||
|
Text("LIVE")
|
||||||
|
.font(.system(size: 12, weight: .black, design: .rounded))
|
||||||
|
.tracking(1.4)
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.shadow(color: .black.opacity(0.65), radius: 4, y: 1)
|
||||||
|
}
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.accessibilityLabel("Live stream indicator")
|
||||||
|
.opacity(0.95)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ struct DynamicIslandHeader: View {
|
||||||
@ObservedObject var clipboardManager = ClipboardManager.shared
|
@ObservedObject var clipboardManager = ClipboardManager.shared
|
||||||
@ObservedObject var tvm = TrayDrop.shared
|
@ObservedObject var tvm = TrayDrop.shared
|
||||||
@ObservedObject var timerManager = TimerManager.shared
|
@ObservedObject var timerManager = TimerManager.shared
|
||||||
|
@ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared
|
||||||
@State private var showClipboardPopover = false
|
@State private var showClipboardPopover = false
|
||||||
@State private var showColorPickerPopover = false
|
@State private var showColorPickerPopover = false
|
||||||
@State private var showTimerPopover = false
|
@State private var showTimerPopover = false
|
||||||
|
|
@ -196,6 +197,12 @@ struct DynamicIslandHeader: View {
|
||||||
RecordingIndicator()
|
RecordingIndicator()
|
||||||
.frame(width: 30, height: 30) // Same size as other header elements
|
.frame(width: 30, height: 30) // Same size as other header elements
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && doNotDisturbManager.isDoNotDisturbActive {
|
||||||
|
FocusIndicator()
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
if Defaults[.showBatteryIndicator] {
|
if Defaults[.showBatteryIndicator] {
|
||||||
DynamicIslandBatteryView(
|
DynamicIslandBatteryView(
|
||||||
|
|
|
||||||
|
|
@ -89,49 +89,56 @@ struct MinimalisticMusicPlayerView: View {
|
||||||
|
|
||||||
private var progressBar: some View {
|
private var progressBar: some View {
|
||||||
TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in
|
TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in
|
||||||
let currentElapsed = currentSliderValue(timeline.date)
|
if musicManager.isLiveStream {
|
||||||
|
HStack(spacing: 8) {
|
||||||
HStack(spacing: 8) {
|
Spacer()
|
||||||
// Elapsed time - left
|
.frame(width: 42)
|
||||||
Text(formatTime(dragging ? sliderValue : currentElapsed))
|
LiveStreamProgressIndicator(tint: sliderColor)
|
||||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
.frame(maxWidth: .infinity, minHeight: 6, maxHeight: 6)
|
||||||
.foregroundColor(.white.opacity(0.7))
|
Spacer()
|
||||||
.frame(width: 42, alignment: .leading)
|
.frame(width: 48)
|
||||||
|
}
|
||||||
// Progress bar
|
.allowsHitTesting(false)
|
||||||
GeometryReader { geometry in
|
} else {
|
||||||
ZStack(alignment: .leading) {
|
let currentElapsed = currentSliderValue(timeline.date)
|
||||||
// Background track
|
|
||||||
RoundedRectangle(cornerRadius: 3)
|
HStack(spacing: 8) {
|
||||||
.fill(Color.white.opacity(0.2))
|
Text(formatTime(dragging ? sliderValue : currentElapsed))
|
||||||
.frame(height: 6)
|
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
// Filled portion
|
.frame(width: 42, alignment: .leading)
|
||||||
RoundedRectangle(cornerRadius: 3)
|
|
||||||
.fill(sliderColor)
|
GeometryReader { geometry in
|
||||||
.frame(width: max(0, geometry.size.width * (currentSliderValue(timeline.date) / max(musicManager.songDuration, 1))), height: 6)
|
ZStack(alignment: .leading) {
|
||||||
}
|
RoundedRectangle(cornerRadius: 3)
|
||||||
.gesture(
|
.fill(Color.white.opacity(0.2))
|
||||||
DragGesture(minimumDistance: 0)
|
.frame(height: 6)
|
||||||
.onChanged { value in
|
|
||||||
dragging = true
|
RoundedRectangle(cornerRadius: 3)
|
||||||
let newValue = min(max(0, Double(value.location.x / geometry.size.width) * musicManager.songDuration), musicManager.songDuration)
|
.fill(sliderColor)
|
||||||
sliderValue = newValue
|
.frame(width: max(0, geometry.size.width * (currentSliderValue(timeline.date) / max(musicManager.songDuration, 1))), height: 6)
|
||||||
lastDragged = Date()
|
}
|
||||||
}
|
.gesture(
|
||||||
.onEnded { _ in
|
DragGesture(minimumDistance: 0)
|
||||||
musicManager.seek(to: sliderValue)
|
.onChanged { value in
|
||||||
dragging = false
|
dragging = true
|
||||||
}
|
let newValue = min(max(0, Double(value.location.x / geometry.size.width) * musicManager.songDuration), musicManager.songDuration)
|
||||||
)
|
sliderValue = newValue
|
||||||
|
lastDragged = Date()
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
musicManager.seek(to: sliderValue)
|
||||||
|
dragging = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(height: 6)
|
||||||
|
|
||||||
|
Text("-\(formatTime(musicManager.songDuration - (dragging ? sliderValue : currentElapsed)))")
|
||||||
|
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
.frame(width: 48, alignment: .trailing)
|
||||||
}
|
}
|
||||||
.frame(height: 6)
|
|
||||||
|
|
||||||
// Time remaining - right
|
|
||||||
Text("-\(formatTime(musicManager.songDuration - (dragging ? sliderValue : currentElapsed)))")
|
|
||||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
|
||||||
.foregroundColor(.white.opacity(0.7))
|
|
||||||
.frame(width: 48, alignment: .trailing)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|
|
||||||
|
|
@ -170,8 +170,10 @@ struct MusicControlsView: View {
|
||||||
timestampDate: musicManager.timestampDate,
|
timestampDate: musicManager.timestampDate,
|
||||||
elapsedTime: musicManager.elapsedTime,
|
elapsedTime: musicManager.elapsedTime,
|
||||||
playbackRate: musicManager.playbackRate,
|
playbackRate: musicManager.playbackRate,
|
||||||
isPlaying: musicManager.isPlaying
|
isPlaying: musicManager.isPlaying,
|
||||||
|
isLiveStream: musicManager.isLiveStream
|
||||||
) { newValue in
|
) { newValue in
|
||||||
|
guard !musicManager.isLiveStream else { return }
|
||||||
MusicManager.shared.seek(to: newValue)
|
MusicManager.shared.seek(to: newValue)
|
||||||
}
|
}
|
||||||
.padding(.top, 5)
|
.padding(.top, 5)
|
||||||
|
|
@ -296,38 +298,71 @@ struct MusicSliderView: View {
|
||||||
let elapsedTime: Double
|
let elapsedTime: Double
|
||||||
let playbackRate: Double
|
let playbackRate: Double
|
||||||
let isPlaying: Bool
|
let isPlaying: Bool
|
||||||
|
let isLiveStream: Bool
|
||||||
var onValueChange: (Double) -> Void
|
var onValueChange: (Double) -> Void
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
|
sliderContent
|
||||||
|
.frame(height: 10, alignment: .center)
|
||||||
|
if !isLiveStream {
|
||||||
|
HStack {
|
||||||
|
Text(timeString(from: sliderValue))
|
||||||
|
Spacer()
|
||||||
|
Text(timeString(from: duration))
|
||||||
|
}
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(timeLabelColor)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: currentDate) { newDate in
|
||||||
|
guard !isLiveStream else { return }
|
||||||
|
guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return }
|
||||||
|
sliderValue = MusicManager.shared.estimatedPlaybackPosition(at: newDate)
|
||||||
|
}
|
||||||
|
.onChange(of: isLiveStream) { isLive in
|
||||||
|
if isLive {
|
||||||
|
sliderValue = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var sliderContent: some View {
|
||||||
|
if isLiveStream {
|
||||||
|
LiveStreamProgressIndicator(tint: sliderTint)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
CustomSlider(
|
CustomSlider(
|
||||||
value: $sliderValue,
|
value: $sliderValue,
|
||||||
range: 0 ... duration,
|
range: 0 ... duration,
|
||||||
color: Defaults[.sliderColor] == SliderColorEnum.albumArt ? Color(
|
color: sliderTint,
|
||||||
nsColor: color
|
|
||||||
).ensureMinimumBrightness(factor: 0.8) : Defaults[.sliderColor] == SliderColorEnum.accent ? .accentColor : .white,
|
|
||||||
dragging: $dragging,
|
dragging: $dragging,
|
||||||
lastDragged: $lastDragged,
|
lastDragged: $lastDragged,
|
||||||
onValueChange: onValueChange
|
onValueChange: onValueChange
|
||||||
)
|
)
|
||||||
.frame(height: 10, alignment: .center)
|
|
||||||
HStack {
|
|
||||||
Text(timeString(from: sliderValue))
|
|
||||||
Spacer()
|
|
||||||
Text(timeString(from: duration))
|
|
||||||
}
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: color)
|
|
||||||
.ensureMinimumBrightness(factor: 0.6) : .gray)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
}
|
||||||
.onChange(of: currentDate) { newDate in
|
}
|
||||||
guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return }
|
|
||||||
sliderValue = MusicManager.shared.estimatedPlaybackPosition(at: newDate)
|
private var sliderTint: Color {
|
||||||
|
switch Defaults[.sliderColor] {
|
||||||
|
case .albumArt:
|
||||||
|
return Color(nsColor: color).ensureMinimumBrightness(factor: 0.8)
|
||||||
|
case .accent:
|
||||||
|
return .accentColor
|
||||||
|
case .white:
|
||||||
|
return .white
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var timeLabelColor: Color {
|
||||||
|
Defaults[.playerColorTinting]
|
||||||
|
? Color(nsColor: color).ensureMinimumBrightness(factor: 0.6)
|
||||||
|
: .gray
|
||||||
|
}
|
||||||
|
|
||||||
func timeString(from seconds: Double) -> String {
|
func timeString(from seconds: Double) -> String {
|
||||||
let totalMinutes = Int(seconds) / 60
|
let totalMinutes = Int(seconds) / 60
|
||||||
let remainingSeconds = Int(seconds) % 60
|
let remainingSeconds = Int(seconds) % 60
|
||||||
|
|
@ -340,8 +375,10 @@ struct MusicSliderView: View {
|
||||||
return String(format: "%d:%02d", minutes, remainingSeconds)
|
return String(format: "%d:%02d", minutes, remainingSeconds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
struct CustomSlider: View {
|
struct CustomSlider: View {
|
||||||
@Binding var value: Double
|
@Binding var value: Double
|
||||||
var range: ClosedRange<Double>
|
var range: ClosedRange<Double>
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ struct GeneralSettings: View {
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
@ObservedObject var recordingManager = ScreenRecordingManager.shared
|
@ObservedObject var recordingManager = ScreenRecordingManager.shared
|
||||||
@ObservedObject var privacyManager = PrivacyIndicatorManager.shared
|
@ObservedObject var privacyManager = PrivacyIndicatorManager.shared
|
||||||
|
@ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared
|
||||||
|
|
||||||
@Default(.mirrorShape) var mirrorShape
|
@Default(.mirrorShape) var mirrorShape
|
||||||
@Default(.showEmojis) var showEmojis
|
@Default(.showEmojis) var showEmojis
|
||||||
|
|
@ -172,6 +173,9 @@ struct GeneralSettings: View {
|
||||||
@Default(.openNotchOnHover) var openNotchOnHover
|
@Default(.openNotchOnHover) var openNotchOnHover
|
||||||
@Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection
|
@Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection
|
||||||
@Default(.showRecordingIndicator) var showRecordingIndicator
|
@Default(.showRecordingIndicator) var showRecordingIndicator
|
||||||
|
@Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection
|
||||||
|
@Default(.showDoNotDisturbIndicator) var showDoNotDisturbIndicator
|
||||||
|
@Default(.showDoNotDisturbLabel) var showDoNotDisturbLabel
|
||||||
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
||||||
@Default(.lockScreenGlassStyle) var lockScreenGlassStyle
|
@Default(.lockScreenGlassStyle) var lockScreenGlassStyle
|
||||||
@Default(.lockScreenShowAppIcon) var lockScreenShowAppIcon
|
@Default(.lockScreenShowAppIcon) var lockScreenShowAppIcon
|
||||||
|
|
@ -235,12 +239,12 @@ struct GeneralSettings: View {
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Defaults.Toggle("Enable Screen Recording Detection", key: .enableScreenRecordingDetection)
|
Defaults.Toggle("Enable Screen Recording Detection", key: .enableScreenRecordingDetection)
|
||||||
|
|
||||||
Defaults.Toggle("Show Recording Indicator", key: .showRecordingIndicator)
|
Defaults.Toggle("Show Recording Indicator", key: .showRecordingIndicator)
|
||||||
.disabled(!enableScreenRecordingDetection)
|
.disabled(!enableScreenRecordingDetection)
|
||||||
|
|
||||||
// Note: Polling removed - now uses event-driven private API detection
|
// Note: Polling removed - now uses event-driven private API detection
|
||||||
|
|
||||||
if recordingManager.isMonitoring {
|
if recordingManager.isMonitoring {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Detection Status")
|
Text("Detection Status")
|
||||||
|
|
@ -260,11 +264,51 @@ struct GeneralSettings: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Screen Recording Detection")
|
Text("Screen Recording")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Uses event-driven private API for real-time screen recording detection")
|
Text("Uses event-driven private API for real-time screen recording detection")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Defaults.Toggle("Enable Focus Detection", key: .enableDoNotDisturbDetection)
|
||||||
|
|
||||||
|
Defaults.Toggle("Show Focus Indicator", key: .showDoNotDisturbIndicator)
|
||||||
|
.disabled(!enableDoNotDisturbDetection)
|
||||||
|
|
||||||
|
Defaults.Toggle("Show Focus Label", key: .showDoNotDisturbLabel)
|
||||||
|
.disabled(!enableDoNotDisturbDetection)
|
||||||
|
|
||||||
|
if doNotDisturbManager.isMonitoring {
|
||||||
|
HStack {
|
||||||
|
Text("Focus Status")
|
||||||
|
Spacer()
|
||||||
|
if doNotDisturbManager.isDoNotDisturbActive {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.purple)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(doNotDisturbManager.currentFocusModeName.isEmpty ? "Focus Enabled" : doNotDisturbManager.currentFocusModeName)
|
||||||
|
.foregroundColor(.purple)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Active - No Focus")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack {
|
||||||
|
Text("Focus Status")
|
||||||
|
Spacer()
|
||||||
|
Text("Disabled")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Do Not Disturb")
|
||||||
|
} footer: {
|
||||||
|
Text("Listens for Focus session changes via distributed notifications")
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Defaults.Toggle("Enable Camera Detection", key: .enableCameraDetection)
|
Defaults.Toggle("Enable Camera Detection", key: .enableCameraDetection)
|
||||||
Defaults.Toggle("Enable Microphone Detection", key: .enableMicrophoneDetection)
|
Defaults.Toggle("Enable Microphone Detection", key: .enableMicrophoneDetection)
|
||||||
|
|
|
||||||
59
DynamicIsland/components/UI/FocusIndicator.swift
Normal file
59
DynamicIsland/components/UI/FocusIndicator.swift
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
//
|
||||||
|
// FocusIndicator.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Small purple moon badge shown while Focus mode is active.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FocusIndicator: View {
|
||||||
|
@ObservedObject var manager = DoNotDisturbManager.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.black)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: focusSymbol)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(accentColor)
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(accessibilityLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var focusMode: FocusModeType {
|
||||||
|
FocusModeType.resolve(
|
||||||
|
identifier: manager.currentFocusModeIdentifier,
|
||||||
|
name: manager.currentFocusModeName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var focusSymbol: String {
|
||||||
|
focusMode.sfSymbol
|
||||||
|
}
|
||||||
|
|
||||||
|
private var accentColor: Color {
|
||||||
|
focusMode.accentColor
|
||||||
|
}
|
||||||
|
|
||||||
|
private var accessibilityLabel: String {
|
||||||
|
let trimmedName = manager.currentFocusModeName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let baseName: String
|
||||||
|
if trimmedName.isEmpty {
|
||||||
|
baseName = focusMode.displayName
|
||||||
|
} else {
|
||||||
|
baseName = trimmedName
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalName = focusMode == .doNotDisturb ? "Focus" : baseName
|
||||||
|
return "Focus active: \(finalName)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
FocusIndicator()
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
.background(Color.gray.opacity(0.2))
|
||||||
|
}
|
||||||
|
|
@ -2,403 +2,168 @@
|
||||||
// DoNotDisturbManager.swift
|
// DoNotDisturbManager.swift
|
||||||
// DynamicIsland
|
// DynamicIsland
|
||||||
//
|
//
|
||||||
// Created for Do Not Disturb / Focus Mode detection
|
// Replaces the legacy polling-based Focus detection with
|
||||||
// Monitors macOS Focus Mode state changes via distributed notifications
|
// NSDistributedNotificationCenter-backed monitoring.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import Combine
|
||||||
import Defaults
|
import Defaults
|
||||||
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications // For notification permission checks
|
|
||||||
import ApplicationServices // For AXIsProcessTrusted
|
|
||||||
|
|
||||||
/// Manages detection and state tracking for macOS Focus Mode (Do Not Disturb)
|
final class DoNotDisturbManager: ObservableObject {
|
||||||
class DoNotDisturbManager: ObservableObject {
|
|
||||||
static let shared = DoNotDisturbManager()
|
static let shared = DoNotDisturbManager()
|
||||||
|
|
||||||
// MARK: - Published Properties
|
@Published private(set) var isMonitoring = false
|
||||||
@Published var isDoNotDisturbActive: Bool = false
|
@Published var isDoNotDisturbActive = false
|
||||||
@Published var currentFocusModeName: String = ""
|
@Published var currentFocusModeName: String = ""
|
||||||
@Published var currentFocusModeIdentifier: String = ""
|
@Published var currentFocusModeIdentifier: String = ""
|
||||||
|
|
||||||
// MARK: - Private Properties
|
private let notificationCenter = DistributedNotificationCenter.default()
|
||||||
private var observers: [NSObjectProtocol] = []
|
private let metadataExtractionQueue = DispatchQueue(label: "com.dynamicisland.focus.metadata", qos: .userInitiated)
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
private init() {}
|
||||||
// MARK: - Initialization
|
|
||||||
private init() {
|
|
||||||
print("🔔 [DoNotDisturbManager] Initializing...")
|
|
||||||
checkPermissions()
|
|
||||||
setupFocusModeObservers()
|
|
||||||
checkInitialState()
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
cleanup()
|
stopMonitoring()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Permission Checks
|
func startMonitoring() {
|
||||||
|
guard !isMonitoring else { return }
|
||||||
/// Checks if the app has necessary permissions for notification observation
|
|
||||||
private func checkPermissions() {
|
notificationCenter.addObserver(
|
||||||
print("🔔 [DoNotDisturbManager] ==========================================")
|
self,
|
||||||
print("🔔 [DoNotDisturbManager] 🔐 CHECKING PERMISSIONS & CAPABILITIES")
|
selector: #selector(handleFocusEnabled(_:)),
|
||||||
print("🔔 [DoNotDisturbManager] ==========================================")
|
name: .focusModeEnabled,
|
||||||
|
|
||||||
// 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,
|
object: nil,
|
||||||
queue: .main,
|
suspensionBehavior: .deliverImmediately
|
||||||
using: { notification in
|
|
||||||
print(" ✅ Local NotificationCenter works!")
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("TEST_NOTIFICATION"), object: nil)
|
|
||||||
NotificationCenter.default.removeObserver(testObserver)
|
notificationCenter.addObserver(
|
||||||
|
self,
|
||||||
// Test 2: Distributed NotificationCenter (this is what we need)
|
selector: #selector(handleFocusDisabled(_:)),
|
||||||
let dnc = DistributedNotificationCenter.default()
|
name: .focusModeDisabled,
|
||||||
print(" 🌐 DistributedNotificationCenter instance: \(dnc)")
|
|
||||||
print(" 🌐 Attempting to post test distributed notification...")
|
|
||||||
|
|
||||||
let testDistributedObserver = dnc.addObserver(
|
|
||||||
forName: NSNotification.Name("com.test.distributed.notification"),
|
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main,
|
suspensionBehavior: .deliverImmediately
|
||||||
using: { notification in
|
|
||||||
print(" ✅ Distributed NotificationCenter observation works!")
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Post a test distributed notification
|
isMonitoring = true
|
||||||
dnc.postNotificationName(
|
}
|
||||||
NSNotification.Name("com.test.distributed.notification"),
|
|
||||||
object: nil,
|
func stopMonitoring() {
|
||||||
userInfo: ["test": "data"],
|
guard isMonitoring else { return }
|
||||||
deliverImmediately: true
|
|
||||||
)
|
notificationCenter.removeObserver(self, name: .focusModeEnabled, object: nil)
|
||||||
|
notificationCenter.removeObserver(self, name: .focusModeDisabled, object: nil)
|
||||||
// Wait a bit then remove observer
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
isMonitoring = false
|
||||||
dnc.removeObserver(testDistributedObserver)
|
|
||||||
print(" 🧹 Test observer cleaned up")
|
DispatchQueue.main.async {
|
||||||
|
self.isDoNotDisturbActive = false
|
||||||
|
self.currentFocusModeIdentifier = ""
|
||||||
|
self.currentFocusModeName = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Setup Methods
|
@objc private func handleFocusEnabled(_ notification: Notification) {
|
||||||
|
apply(notification: notification, isActive: true)
|
||||||
/// 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() {
|
@objc private func handleFocusDisabled(_ notification: Notification) {
|
||||||
print("🔔 [DoNotDisturbManager] Setting up DARWIN notification observer...")
|
apply(notification: notification, isActive: false)
|
||||||
|
|
||||||
// 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) {
|
private func apply(notification: Notification, isActive: Bool) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
metadataExtractionQueue.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 }
|
guard let self = self else { return }
|
||||||
|
|
||||||
// Parse notification payload
|
let metadata = self.extractMetadata(from: notification)
|
||||||
let userInfo = notification.userInfo ?? [:]
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
// Try to extract Focus Mode state and identifier
|
let trimmedIdentifier = metadata.identifier?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
// Note: Actual keys depend on the notification structure (to be discovered)
|
let trimmedName = metadata.name?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if let stateString = userInfo["state"] as? String {
|
|
||||||
self.isDoNotDisturbActive = (stateString == "enabled" || stateString == "on" || stateString == "active")
|
let resolvedMode = FocusModeType.resolve(identifier: trimmedIdentifier, name: trimmedName)
|
||||||
} else if let enabled = userInfo["enabled"] as? Bool {
|
|
||||||
self.isDoNotDisturbActive = enabled
|
debugPrint("[DoNotDisturbManager] Focus update -> notification: \(notification.name.rawValue) | identifier: \(trimmedIdentifier ?? "<nil>") | name: \(trimmedName ?? "<nil>") | resolved: \(resolvedMode.rawValue)")
|
||||||
} else if let active = userInfo["active"] as? Bool {
|
|
||||||
self.isDoNotDisturbActive = active
|
let finalIdentifier: String
|
||||||
}
|
if let identifier = trimmedIdentifier, !identifier.isEmpty {
|
||||||
|
finalIdentifier = identifier
|
||||||
// Try to extract mode name
|
} else {
|
||||||
if let modeName = userInfo["name"] as? String {
|
finalIdentifier = resolvedMode.rawValue
|
||||||
self.currentFocusModeName = modeName
|
}
|
||||||
} else if let mode = userInfo["mode"] as? String {
|
|
||||||
self.currentFocusModeName = mode
|
if finalIdentifier != self.currentFocusModeIdentifier {
|
||||||
}
|
self.currentFocusModeIdentifier = finalIdentifier
|
||||||
|
}
|
||||||
// Try to extract mode identifier
|
|
||||||
if let identifier = userInfo["identifier"] as? String {
|
let finalName: String
|
||||||
self.currentFocusModeIdentifier = identifier
|
if let name = trimmedName, !name.isEmpty {
|
||||||
}
|
finalName = name
|
||||||
|
} else if !resolvedMode.displayName.isEmpty {
|
||||||
print("🔔 [DoNotDisturbManager] State updated:")
|
finalName = resolvedMode.displayName
|
||||||
print(" Active: \(self.isDoNotDisturbActive)")
|
} else if let identifier = trimmedIdentifier, !identifier.isEmpty {
|
||||||
print(" Mode Name: \(self.currentFocusModeName.isEmpty ? "N/A" : self.currentFocusModeName)")
|
finalName = identifier
|
||||||
print(" Identifier: \(self.currentFocusModeIdentifier.isEmpty ? "N/A" : self.currentFocusModeIdentifier)")
|
} else {
|
||||||
|
finalName = "Focus"
|
||||||
// Trigger HUD display
|
}
|
||||||
if self.isDoNotDisturbActive {
|
|
||||||
self.showFocusModeActivatedHUD()
|
if finalName != self.currentFocusModeName {
|
||||||
} else {
|
self.currentFocusModeName = finalName
|
||||||
self.showFocusModeDeactivatedHUD()
|
}
|
||||||
|
|
||||||
|
guard self.isDoNotDisturbActive != isActive else { return }
|
||||||
|
|
||||||
|
withAnimation(.smooth(duration: 0.25)) {
|
||||||
|
self.isDoNotDisturbActive = isActive
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - HUD Display Methods
|
private func extractMetadata(from notification: Notification) -> (name: String?, identifier: String?) {
|
||||||
|
guard let userInfo = notification.userInfo else { return (nil, nil) }
|
||||||
/// Shows HUD when Focus Mode is activated
|
|
||||||
private func showFocusModeActivatedHUD() {
|
debugPrint("[DoNotDisturbManager] raw focus payload -> name: \(notification.name.rawValue), object: \(String(describing: notification.object)), userInfo: \(userInfo)")
|
||||||
print("🔔 [DoNotDisturbManager] 📱 Showing Focus Mode ACTIVATED HUD")
|
|
||||||
|
let identifierKeys = [
|
||||||
// TODO: Integrate with DynamicIslandViewCoordinator to show HUD
|
"FocusModeIdentifier",
|
||||||
// coordinator.toggleSneakPeek(status: true, type: .doNotDisturb, value: 1)
|
"focusModeIdentifier",
|
||||||
}
|
"FocusModeUUID",
|
||||||
|
"focusModeUUID",
|
||||||
/// Shows HUD when Focus Mode is deactivated
|
"UUID",
|
||||||
private func showFocusModeDeactivatedHUD() {
|
"uuid",
|
||||||
print("🔔 [DoNotDisturbManager] 📱 Showing Focus Mode DEACTIVATED HUD")
|
"identifier",
|
||||||
|
"Identifier"
|
||||||
// TODO: Integrate with DynamicIslandViewCoordinator to show HUD
|
]
|
||||||
// coordinator.toggleSneakPeek(status: true, type: .doNotDisturb, value: 0)
|
|
||||||
}
|
let nameKeys = [
|
||||||
|
"FocusModeName",
|
||||||
// MARK: - Public Methods
|
"focusModeName",
|
||||||
|
"FocusMode",
|
||||||
/// Manually refresh Focus Mode state
|
"focusMode",
|
||||||
func refreshState() {
|
"name",
|
||||||
print("🔔 [DoNotDisturbManager] Manual state refresh requested")
|
"Name"
|
||||||
checkInitialState()
|
]
|
||||||
}
|
|
||||||
|
let identifier = firstMatch(for: identifierKeys, in: userInfo)
|
||||||
// MARK: - Cleanup
|
let name = firstMatch(for: nameKeys, in: userInfo)
|
||||||
|
|
||||||
private func cleanup() {
|
return (name, identifier)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Notification.Name {
|
||||||
|
static let focusModeEnabled = Notification.Name("_NSDoNotDisturbEnabledNotification")
|
||||||
|
static let focusModeDisabled = Notification.Name("_NSDoNotDisturbDisabledNotification")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Focus Mode Types
|
// MARK: - Focus Mode Types
|
||||||
|
|
||||||
enum FocusModeType: String {
|
enum FocusModeType: String, CaseIterable {
|
||||||
case doNotDisturb = "com.apple.donotdisturb.mode"
|
case doNotDisturb = "com.apple.donotdisturb.mode"
|
||||||
case work = "com.apple.focus.work"
|
case work = "com.apple.focus.work"
|
||||||
case personal = "com.apple.focus.personal"
|
case personal = "com.apple.focus.personal"
|
||||||
|
|
@ -413,7 +178,7 @@ enum FocusModeType: String {
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .doNotDisturb: return "Do Not Disturb"
|
case .doNotDisturb: return "Do Not Disturb"
|
||||||
case .work: return "Work"
|
case .work: return "Work"
|
||||||
case .personal: return "Personal"
|
case .personal: return "Personal"
|
||||||
case .sleep: return "Sleep"
|
case .sleep: return "Sleep"
|
||||||
|
|
@ -439,7 +204,152 @@ enum FocusModeType: String {
|
||||||
case .mindfulness: return "brain.head.profile"
|
case .mindfulness: return "brain.head.profile"
|
||||||
case .reading: return "book.fill"
|
case .reading: return "book.fill"
|
||||||
case .custom: return "app.badge"
|
case .custom: return "app.badge"
|
||||||
case .unknown: return "moon.zzz.fill"
|
case .unknown: return "moon.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var accentColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .doNotDisturb:
|
||||||
|
return Color(red: 0.370, green: 0.360, blue: 0.902)
|
||||||
|
case .work:
|
||||||
|
return Color(red: 0.133, green: 0.475, blue: 0.992)
|
||||||
|
case .personal:
|
||||||
|
return Color(red: 0.937, green: 0.282, blue: 0.624)
|
||||||
|
case .sleep:
|
||||||
|
return Color(red: 0.341, green: 0.384, blue: 0.980)
|
||||||
|
case .driving:
|
||||||
|
return Color(red: 0.988, green: 0.561, blue: 0.153)
|
||||||
|
case .fitness:
|
||||||
|
return Color(red: 0.176, green: 0.804, blue: 0.459)
|
||||||
|
case .gaming:
|
||||||
|
return Color(red: 0.639, green: 0.329, blue: 0.937)
|
||||||
|
case .mindfulness:
|
||||||
|
return Color(red: 0.239, green: 0.718, blue: 0.682)
|
||||||
|
case .reading:
|
||||||
|
return Color(red: 0.239, green: 0.596, blue: 0.965)
|
||||||
|
case .custom:
|
||||||
|
return Color(red: 0.513, green: 0.478, blue: 0.965)
|
||||||
|
case .unknown:
|
||||||
|
return Color(red: 0.370, green: 0.360, blue: 0.902)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var inactiveSymbol: String {
|
||||||
|
switch self {
|
||||||
|
case .doNotDisturb:
|
||||||
|
return "moon.circle.fill"
|
||||||
|
default:
|
||||||
|
return sfSymbol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FocusModeType {
|
||||||
|
init(identifier: String) {
|
||||||
|
let normalized = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalizedLowercased = normalized.lowercased()
|
||||||
|
|
||||||
|
guard !normalized.isEmpty else {
|
||||||
|
self = .doNotDisturb
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let direct = FocusModeType(rawValue: normalized) ?? FocusModeType(rawValue: normalizedLowercased) {
|
||||||
|
self = direct
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let resolved = FocusModeType.allCases.first(where: {
|
||||||
|
guard !$0.rawValue.isEmpty else { return false }
|
||||||
|
return normalized.hasPrefix($0.rawValue) || normalizedLowercased.hasPrefix($0.rawValue)
|
||||||
|
}) {
|
||||||
|
self = resolved
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalizedLowercased.hasPrefix("com.apple.focus.") {
|
||||||
|
self = .custom
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self = .doNotDisturb
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolve(identifier: String?, name: String?) -> FocusModeType {
|
||||||
|
let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedName.isEmpty {
|
||||||
|
if let match = FocusModeType.allCases.first(where: {
|
||||||
|
guard !$0.displayName.isEmpty else { return false }
|
||||||
|
return $0.displayName.compare(trimmedName, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
|
||||||
|
}) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedIdentifier.isEmpty {
|
||||||
|
return FocusModeType(identifier: trimmedIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .doNotDisturb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Metadata helpers
|
||||||
|
|
||||||
|
private extension DoNotDisturbManager {
|
||||||
|
func firstMatch(for keys: [String], in value: Any) -> String? {
|
||||||
|
if let dictionary = value as? [AnyHashable: Any] {
|
||||||
|
for key in keys {
|
||||||
|
if let candidate = dictionary[key], let string = normalizedString(from: candidate) {
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for nestedValue in dictionary.values {
|
||||||
|
if let nestedMatch = firstMatch(for: keys, in: nestedValue) {
|
||||||
|
return nestedMatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let array = value as? [Any] {
|
||||||
|
for element in array {
|
||||||
|
if let nestedMatch = firstMatch(for: keys, in: element) {
|
||||||
|
return nestedMatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedString(from value: Any) -> String? {
|
||||||
|
switch value {
|
||||||
|
case let string as String:
|
||||||
|
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
|
case let number as NSNumber:
|
||||||
|
return number.stringValue
|
||||||
|
case let uuid as UUID:
|
||||||
|
return uuid.uuidString
|
||||||
|
case let uuid as NSUUID:
|
||||||
|
return uuid.uuidString
|
||||||
|
case let data as Data:
|
||||||
|
if let string = String(data: data, encoding: .utf8) {
|
||||||
|
return string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case let dict as [AnyHashable: Any]:
|
||||||
|
// Attempt to pull common keys from nested dictionaries
|
||||||
|
if let nested = firstMatch(for: ["identifier", "Identifier", "uuid", "UUID"], in: dict) {
|
||||||
|
return nested
|
||||||
|
}
|
||||||
|
if let name = firstMatch(for: ["name", "Name", "displayName", "display_name"], in: dict) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,17 @@ class MusicManager: ObservableObject {
|
||||||
@Published var playbackRate: Double = 1
|
@Published var playbackRate: Double = 1
|
||||||
@Published var isShuffled: Bool = false
|
@Published var isShuffled: Bool = false
|
||||||
@Published var repeatMode: RepeatMode = .off
|
@Published var repeatMode: RepeatMode = .off
|
||||||
|
@Published var isLiveStream: Bool = false
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
@Published var usingAppIconForArtwork: Bool = false
|
@Published var usingAppIconForArtwork: Bool = false
|
||||||
|
|
||||||
private var artworkData: Data? = nil
|
private var artworkData: Data? = nil
|
||||||
|
|
||||||
|
private var liveStreamUnknownDurationCount: Int = 0
|
||||||
|
private var liveStreamEdgeObservationCount: Int = 0
|
||||||
|
private var liveStreamCompletionObservationCount: Int = 0
|
||||||
|
private var liveStreamCompletionReleaseCount: Int = 0
|
||||||
|
|
||||||
// Store last values at the time artwork was changed
|
// Store last values at the time artwork was changed
|
||||||
private var lastArtworkTitle: String = "I'm Handsome"
|
private var lastArtworkTitle: String = "I'm Handsome"
|
||||||
private var lastArtworkArtist: String = "Me"
|
private var lastArtworkArtist: String = "Me"
|
||||||
|
|
@ -262,6 +268,7 @@ class MusicManager: ObservableObject {
|
||||||
self.repeatMode = state.repeatMode
|
self.repeatMode = state.repeatMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateLiveStreamState(with: state)
|
||||||
self.timestampDate = state.lastUpdated
|
self.timestampDate = state.lastUpdated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,6 +278,76 @@ class MusicManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateLiveStreamState(with state: PlaybackState) {
|
||||||
|
let duration = state.duration
|
||||||
|
let current = max(state.currentTime, elapsedTime)
|
||||||
|
let hasKnownDuration = duration.isFinite && duration > 0
|
||||||
|
let isPlaying = state.isPlaying
|
||||||
|
|
||||||
|
if hasKnownDuration {
|
||||||
|
liveStreamUnknownDurationCount = 0
|
||||||
|
|
||||||
|
let remaining = duration - current
|
||||||
|
let clampedDuration = max(duration, 0)
|
||||||
|
let clampedCurrent = clampedDuration > 0
|
||||||
|
? max(0, min(current, clampedDuration))
|
||||||
|
: max(0, current)
|
||||||
|
let progress = clampedDuration > 0 ? clampedCurrent / clampedDuration : 0
|
||||||
|
let sliderAppearsComplete = isPlaying && clampedDuration > 0 && progress >= 0.999
|
||||||
|
let nearDurationEdge = isPlaying && remaining.isFinite && remaining <= 1.0 && clampedCurrent >= 10
|
||||||
|
|
||||||
|
if sliderAppearsComplete {
|
||||||
|
liveStreamCompletionObservationCount = min(liveStreamCompletionObservationCount + 1, 8)
|
||||||
|
liveStreamCompletionReleaseCount = 0
|
||||||
|
} else {
|
||||||
|
liveStreamCompletionReleaseCount = min(liveStreamCompletionReleaseCount + 1, 8)
|
||||||
|
if liveStreamCompletionObservationCount > 0 {
|
||||||
|
liveStreamCompletionObservationCount = max(liveStreamCompletionObservationCount - 1, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if nearDurationEdge || sliderAppearsComplete {
|
||||||
|
liveStreamEdgeObservationCount = min(liveStreamEdgeObservationCount + 1, 12)
|
||||||
|
} else if liveStreamEdgeObservationCount > 0 {
|
||||||
|
liveStreamEdgeObservationCount = max(liveStreamEdgeObservationCount - 1, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isLiveStream {
|
||||||
|
if liveStreamCompletionObservationCount >= 3 || liveStreamEdgeObservationCount >= 5 {
|
||||||
|
isLiveStream = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let shouldClearForKnownDuration =
|
||||||
|
!isPlaying
|
||||||
|
|| (duration > 10 && remaining > 5)
|
||||||
|
|| (liveStreamCompletionObservationCount == 0
|
||||||
|
&& liveStreamEdgeObservationCount == 0
|
||||||
|
&& liveStreamCompletionReleaseCount >= 4)
|
||||||
|
|
||||||
|
if shouldClearForKnownDuration {
|
||||||
|
isLiveStream = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if isPlaying {
|
||||||
|
liveStreamEdgeObservationCount = max(liveStreamEdgeObservationCount - 1, 0)
|
||||||
|
liveStreamCompletionObservationCount = max(liveStreamCompletionObservationCount - 1, 0)
|
||||||
|
liveStreamCompletionReleaseCount = 0
|
||||||
|
|
||||||
|
liveStreamUnknownDurationCount = min(liveStreamUnknownDurationCount + 1, 8)
|
||||||
|
if liveStreamUnknownDurationCount >= 3 && !isLiveStream {
|
||||||
|
isLiveStream = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
liveStreamUnknownDurationCount = 0
|
||||||
|
liveStreamEdgeObservationCount = 0
|
||||||
|
liveStreamCompletionObservationCount = 0
|
||||||
|
liveStreamCompletionReleaseCount = 0
|
||||||
|
if !hasKnownDuration && isLiveStream {
|
||||||
|
isLiveStream = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func updateArtwork(_ artworkData: Data) {
|
private func updateArtwork(_ artworkData: Data) {
|
||||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
|
||||||
|
|
@ -483,6 +483,11 @@ extension Defaults.Keys {
|
||||||
static let showRecordingIndicator = Key<Bool>("showRecordingIndicator", default: true)
|
static let showRecordingIndicator = Key<Bool>("showRecordingIndicator", default: true)
|
||||||
// Polling removed - now uses event-driven private API detection (CGSIsScreenWatcherPresent)
|
// Polling removed - now uses event-driven private API detection (CGSIsScreenWatcherPresent)
|
||||||
// static let enableScreenRecordingPolling = Key<Bool>("enableScreenRecordingPolling", default: false)
|
// static let enableScreenRecordingPolling = Key<Bool>("enableScreenRecordingPolling", default: false)
|
||||||
|
|
||||||
|
// MARK: Focus / Do Not Disturb Detection
|
||||||
|
static let enableDoNotDisturbDetection = Key<Bool>("enableDoNotDisturbDetection", default: true)
|
||||||
|
static let showDoNotDisturbIndicator = Key<Bool>("showDoNotDisturbIndicator", default: true)
|
||||||
|
static let showDoNotDisturbLabel = Key<Bool>("showDoNotDisturbLabel", default: true)
|
||||||
|
|
||||||
// MARK: Privacy Indicators (Camera & Microphone Detection)
|
// MARK: Privacy Indicators (Camera & Microphone Detection)
|
||||||
static let enableCameraDetection = Key<Bool>("enableCameraDetection", default: true)
|
static let enableCameraDetection = Key<Bool>("enableCameraDetection", default: true)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue