diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index b5dc5ee..5a85727 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -25,6 +25,7 @@ struct ContentView: View { @ObservedObject var statsManager = StatsManager.shared @ObservedObject var recordingManager = ScreenRecordingManager.shared @ObservedObject var privacyManager = PrivacyIndicatorManager.shared + @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared @ObservedObject var lockScreenManager = LockScreenManager.shared @Default(.enableStatsFeature) var enableStatsFeature @@ -412,6 +413,8 @@ struct ContentView: View { TimerLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed { 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 { LockScreenLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index c5147c6..653b143 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -379,6 +379,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { ScreenRecordingManager.shared.startMonitoring() } + // Setup Do Not Disturb Manager + if Defaults[.enableDoNotDisturbDetection] { + dndManager.startMonitoring() + } + // Setup Privacy Indicator Manager (camera and microphone monitoring) PrivacyIndicatorManager.shared.startMonitoring() @@ -435,6 +440,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { } }.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 NotificationCenter.default.addObserver( diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index ebb402b..2eba167 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -20,6 +20,7 @@ enum SneakContentType { case download case timer case recording + case doNotDisturb case bluetoothAudio case privacy case lockScreen diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 2210001..2d008e7 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1805,6 +1805,9 @@ } } } + }, + "Active - No Focus" : { + }, "Active - No Recording" : { "comment" : "A description of the recording detection feature, indicating that recording is not currently active.", @@ -6769,6 +6772,9 @@ } } } + }, + "Do Not Disturb" : { + }, "Download" : { "localizations" : { @@ -8491,6 +8497,9 @@ }, "Fit to Notch" : { + }, + "Focus Status" : { + }, "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.", @@ -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" : { "localizations" : { "fr" : { @@ -10499,6 +10515,10 @@ } } }, + "Live stream indicator" : { + "comment" : "A visual indicator that appears when a music stream is live.", + "isCommentAutoGenerated" : true + }, "Load Average" : { "comment" : "A label displayed above the load average values.", "isCommentAutoGenerated" : true, @@ -15684,7 +15704,7 @@ } } }, - "Screen Recording Detection" : { + "Screen Recording" : { }, "Screenshot options" : { diff --git a/DynamicIsland/MediaControllers/NowPlayingController.swift b/DynamicIsland/MediaControllers/NowPlayingController.swift index f357908..2c0faa0 100644 --- a/DynamicIsland/MediaControllers/NowPlayingController.swift +++ b/DynamicIsland/MediaControllers/NowPlayingController.swift @@ -181,18 +181,9 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { newPlaybackState.album = payload.album ?? (diff ? self.playbackState.album : "") newPlaybackState.duration = payload.duration ?? (diff ? self.playbackState.duration : 0) - if let elapsedTime = payload.elapsedTime { - newPlaybackState.currentTime = elapsedTime - } else if diff { - 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 - } + // Match boring.notch behavior: if elapsedTime is provided use it, + // if this update is a diff keep the previous currentTime, otherwise default to 0. + newPlaybackState.currentTime = payload.elapsedTime ?? (diff ? self.playbackState.currentTime : 0) if let shuffleMode = payload.shuffleMode { diff --git a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift new file mode 100644 index 0000000..c01aca0 --- /dev/null +++ b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift @@ -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? + @State private var collapseTask: Task? + @State private var cleanupTask: Task? + @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()) + } +} diff --git a/DynamicIsland/components/Music/LiveStreamProgressIndicator.swift b/DynamicIsland/components/Music/LiveStreamProgressIndicator.swift new file mode 100644 index 0000000..5ca0f58 --- /dev/null +++ b/DynamicIsland/components/Music/LiveStreamProgressIndicator.swift @@ -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) + } +} diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index 83dc5ec..6204e9c 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -16,6 +16,7 @@ struct DynamicIslandHeader: View { @ObservedObject var clipboardManager = ClipboardManager.shared @ObservedObject var tvm = TrayDrop.shared @ObservedObject var timerManager = TimerManager.shared + @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared @State private var showClipboardPopover = false @State private var showColorPickerPopover = false @State private var showTimerPopover = false @@ -196,6 +197,12 @@ struct DynamicIslandHeader: View { RecordingIndicator() .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] { DynamicIslandBatteryView( diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 4726843..e10e5f9 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -89,49 +89,56 @@ struct MinimalisticMusicPlayerView: View { private var progressBar: some View { TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in - let currentElapsed = currentSliderValue(timeline.date) - - HStack(spacing: 8) { - // Elapsed time - left - Text(formatTime(dragging ? sliderValue : currentElapsed)) - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundColor(.white.opacity(0.7)) - .frame(width: 42, alignment: .leading) - - // Progress bar - GeometryReader { geometry in - ZStack(alignment: .leading) { - // Background track - RoundedRectangle(cornerRadius: 3) - .fill(Color.white.opacity(0.2)) - .frame(height: 6) - - // Filled portion - RoundedRectangle(cornerRadius: 3) - .fill(sliderColor) - .frame(width: max(0, geometry.size.width * (currentSliderValue(timeline.date) / max(musicManager.songDuration, 1))), height: 6) - } - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - 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 - } - ) + if musicManager.isLiveStream { + HStack(spacing: 8) { + Spacer() + .frame(width: 42) + LiveStreamProgressIndicator(tint: sliderColor) + .frame(maxWidth: .infinity, minHeight: 6, maxHeight: 6) + Spacer() + .frame(width: 48) + } + .allowsHitTesting(false) + } else { + let currentElapsed = currentSliderValue(timeline.date) + + HStack(spacing: 8) { + Text(formatTime(dragging ? sliderValue : currentElapsed)) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + .frame(width: 42, alignment: .leading) + + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3) + .fill(Color.white.opacity(0.2)) + .frame(height: 6) + + RoundedRectangle(cornerRadius: 3) + .fill(sliderColor) + .frame(width: max(0, geometry.size.width * (currentSliderValue(timeline.date) / max(musicManager.songDuration, 1))), height: 6) + } + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + 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 { diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 1744fb5..40eb0d8 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -170,8 +170,10 @@ struct MusicControlsView: View { timestampDate: musicManager.timestampDate, elapsedTime: musicManager.elapsedTime, playbackRate: musicManager.playbackRate, - isPlaying: musicManager.isPlaying + isPlaying: musicManager.isPlaying, + isLiveStream: musicManager.isLiveStream ) { newValue in + guard !musicManager.isLiveStream else { return } MusicManager.shared.seek(to: newValue) } .padding(.top, 5) @@ -296,38 +298,71 @@ struct MusicSliderView: View { let elapsedTime: Double let playbackRate: Double let isPlaying: Bool + let isLiveStream: Bool var onValueChange: (Double) -> Void var body: some View { 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( value: $sliderValue, range: 0 ... duration, - color: Defaults[.sliderColor] == SliderColorEnum.albumArt ? Color( - nsColor: color - ).ensureMinimumBrightness(factor: 0.8) : Defaults[.sliderColor] == SliderColorEnum.accent ? .accentColor : .white, + color: sliderTint, dragging: $dragging, lastDragged: $lastDragged, 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 { let totalMinutes = Int(seconds) / 60 let remainingSeconds = Int(seconds) % 60 @@ -340,8 +375,10 @@ struct MusicSliderView: View { return String(format: "%d:%02d", minutes, remainingSeconds) } } + } + struct CustomSlider: View { @Binding var value: Double var range: ClosedRange diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 9e9135f..2245fdb 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -157,6 +157,7 @@ struct GeneralSettings: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var recordingManager = ScreenRecordingManager.shared @ObservedObject var privacyManager = PrivacyIndicatorManager.shared + @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared @Default(.mirrorShape) var mirrorShape @Default(.showEmojis) var showEmojis @@ -172,6 +173,9 @@ struct GeneralSettings: View { @Default(.openNotchOnHover) var openNotchOnHover @Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection @Default(.showRecordingIndicator) var showRecordingIndicator + @Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection + @Default(.showDoNotDisturbIndicator) var showDoNotDisturbIndicator + @Default(.showDoNotDisturbLabel) var showDoNotDisturbLabel @Default(.enableMinimalisticUI) var enableMinimalisticUI @Default(.lockScreenGlassStyle) var lockScreenGlassStyle @Default(.lockScreenShowAppIcon) var lockScreenShowAppIcon @@ -235,12 +239,12 @@ struct GeneralSettings: View { Section { Defaults.Toggle("Enable Screen Recording Detection", key: .enableScreenRecordingDetection) - + Defaults.Toggle("Show Recording Indicator", key: .showRecordingIndicator) .disabled(!enableScreenRecordingDetection) - + // Note: Polling removed - now uses event-driven private API detection - + if recordingManager.isMonitoring { HStack { Text("Detection Status") @@ -260,11 +264,51 @@ struct GeneralSettings: View { } } } header: { - Text("Screen Recording Detection") + Text("Screen Recording") } footer: { 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 { Defaults.Toggle("Enable Camera Detection", key: .enableCameraDetection) Defaults.Toggle("Enable Microphone Detection", key: .enableMicrophoneDetection) diff --git a/DynamicIsland/components/UI/FocusIndicator.swift b/DynamicIsland/components/UI/FocusIndicator.swift new file mode 100644 index 0000000..f887d13 --- /dev/null +++ b/DynamicIsland/components/UI/FocusIndicator.swift @@ -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)) +} diff --git a/DynamicIsland/managers/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift index 355b12b..ce3ad64 100644 --- a/DynamicIsland/managers/DoNotDisturbManager.swift +++ b/DynamicIsland/managers/DoNotDisturbManager.swift @@ -2,403 +2,168 @@ // DoNotDisturbManager.swift // DynamicIsland // -// Created for Do Not Disturb / Focus Mode detection -// Monitors macOS Focus Mode state changes via distributed notifications +// Replaces the legacy polling-based Focus detection with +// NSDistributedNotificationCenter-backed monitoring. // -import Foundation -import Combine import AppKit +import Combine import Defaults +import Foundation 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 { +final class DoNotDisturbManager: ObservableObject { static let shared = DoNotDisturbManager() - - // MARK: - Published Properties - @Published var isDoNotDisturbActive: Bool = false + + @Published private(set) var isMonitoring = false + @Published var isDoNotDisturbActive = false @Published var currentFocusModeName: String = "" @Published var currentFocusModeIdentifier: String = "" - - // MARK: - Private Properties - private var observers: [NSObjectProtocol] = [] - private var cancellables = Set() - - // MARK: - Initialization - private init() { - print("๐Ÿ”” [DoNotDisturbManager] Initializing...") - checkPermissions() - setupFocusModeObservers() - checkInitialState() - } - + + private let notificationCenter = DistributedNotificationCenter.default() + private let metadataExtractionQueue = DispatchQueue(label: "com.dynamicisland.focus.metadata", qos: .userInitiated) + + private init() {} + deinit { - cleanup() + stopMonitoring() } - - // 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"), + + func startMonitoring() { + guard !isMonitoring else { return } + + notificationCenter.addObserver( + self, + selector: #selector(handleFocusEnabled(_:)), + name: .focusModeEnabled, object: nil, - queue: .main, - using: { notification in - print(" โœ… Local NotificationCenter works!") - } + suspensionBehavior: .deliverImmediately ) - 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"), + + notificationCenter.addObserver( + self, + selector: #selector(handleFocusDisabled(_:)), + name: .focusModeDisabled, object: nil, - queue: .main, - using: { notification in - print(" โœ… Distributed NotificationCenter observation works!") - } + suspensionBehavior: .deliverImmediately ) - - // 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") + + isMonitoring = true + } + + func stopMonitoring() { + guard isMonitoring else { return } + + notificationCenter.removeObserver(self, name: .focusModeEnabled, object: nil) + notificationCenter.removeObserver(self, name: .focusModeDisabled, object: nil) + + isMonitoring = false + + DispatchQueue.main.async { + self.isDoNotDisturbActive = false + self.currentFocusModeIdentifier = "" + self.currentFocusModeName = "" } } - - // 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] ==========================================") + + @objc private func handleFocusEnabled(_ notification: Notification) { + apply(notification: notification, isActive: true) } - - 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.fromOpaque(observer).takeUnretainedValue() - manager.handleDarwinNotification(notificationName) - } - }, - notificationName as CFString, - nil, - .deliverImmediately - ) - print(" โœ… Darwin observer registered for: \(notificationName)") - } + + @objc private func handleFocusDisabled(_ notification: Notification) { + apply(notification: notification, isActive: false) } - - 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 + + private func apply(notification: Notification, isActive: Bool) { + metadataExtractionQueue.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() + + let metadata = self.extractMetadata(from: notification) + + DispatchQueue.main.async { + let trimmedIdentifier = metadata.identifier?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedName = metadata.name?.trimmingCharacters(in: .whitespacesAndNewlines) + + let resolvedMode = FocusModeType.resolve(identifier: trimmedIdentifier, name: trimmedName) + + debugPrint("[DoNotDisturbManager] Focus update -> notification: \(notification.name.rawValue) | identifier: \(trimmedIdentifier ?? "") | name: \(trimmedName ?? "") | resolved: \(resolvedMode.rawValue)") + + let finalIdentifier: String + if let identifier = trimmedIdentifier, !identifier.isEmpty { + finalIdentifier = identifier + } else { + finalIdentifier = resolvedMode.rawValue + } + + if finalIdentifier != self.currentFocusModeIdentifier { + self.currentFocusModeIdentifier = finalIdentifier + } + + let finalName: String + if let name = trimmedName, !name.isEmpty { + finalName = name + } else if !resolvedMode.displayName.isEmpty { + finalName = resolvedMode.displayName + } else if let identifier = trimmedIdentifier, !identifier.isEmpty { + finalName = identifier + } else { + finalName = "Focus" + } + + if finalName != self.currentFocusModeName { + self.currentFocusModeName = finalName + } + + guard self.isDoNotDisturbActive != isActive else { return } + + withAnimation(.smooth(duration: 0.25)) { + self.isDoNotDisturbActive = isActive + } } } } - - // 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() + + private func extractMetadata(from notification: Notification) -> (name: String?, identifier: String?) { + guard let userInfo = notification.userInfo else { return (nil, nil) } + + debugPrint("[DoNotDisturbManager] raw focus payload -> name: \(notification.name.rawValue), object: \(String(describing: notification.object)), userInfo: \(userInfo)") + + let identifierKeys = [ + "FocusModeIdentifier", + "focusModeIdentifier", + "FocusModeUUID", + "focusModeUUID", + "UUID", + "uuid", + "identifier", + "Identifier" + ] + + let nameKeys = [ + "FocusModeName", + "focusModeName", + "FocusMode", + "focusMode", + "name", + "Name" + ] + + let identifier = firstMatch(for: identifierKeys, in: userInfo) + let name = firstMatch(for: nameKeys, in: userInfo) + + return (name, identifier) } + +} + +private extension Notification.Name { + static let focusModeEnabled = Notification.Name("_NSDoNotDisturbEnabledNotification") + static let focusModeDisabled = Notification.Name("_NSDoNotDisturbDisabledNotification") } // MARK: - Focus Mode Types -enum FocusModeType: String { +enum FocusModeType: String, CaseIterable { case doNotDisturb = "com.apple.donotdisturb.mode" case work = "com.apple.focus.work" case personal = "com.apple.focus.personal" @@ -413,7 +178,7 @@ enum FocusModeType: String { var displayName: String { switch self { - case .doNotDisturb: return "Do Not Disturb" + case .doNotDisturb: return "Do Not Disturb" case .work: return "Work" case .personal: return "Personal" case .sleep: return "Sleep" @@ -439,7 +204,152 @@ enum FocusModeType: String { case .mindfulness: return "brain.head.profile" case .reading: return "book.fill" 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 } } } diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index 3691163..0604315 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -44,11 +44,17 @@ class MusicManager: ObservableObject { @Published var playbackRate: Double = 1 @Published var isShuffled: Bool = false @Published var repeatMode: RepeatMode = .off + @Published var isLiveStream: Bool = false @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @Published var usingAppIconForArtwork: Bool = false 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 private var lastArtworkTitle: String = "I'm Handsome" private var lastArtworkArtist: String = "Me" @@ -262,6 +268,7 @@ class MusicManager: ObservableObject { self.repeatMode = state.repeatMode } + updateLiveStreamState(with: state) 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) { DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self = self else { return } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index a02af29..e936a74 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -483,6 +483,11 @@ extension Defaults.Keys { static let showRecordingIndicator = Key("showRecordingIndicator", default: true) // Polling removed - now uses event-driven private API detection (CGSIsScreenWatcherPresent) // static let enableScreenRecordingPolling = Key("enableScreenRecordingPolling", default: false) + + // MARK: Focus / Do Not Disturb Detection + static let enableDoNotDisturbDetection = Key("enableDoNotDisturbDetection", default: true) + static let showDoNotDisturbIndicator = Key("showDoNotDisturbIndicator", default: true) + static let showDoNotDisturbLabel = Key("showDoNotDisturbLabel", default: true) // MARK: Privacy Indicators (Camera & Microphone Detection) static let enableCameraDetection = Key("enableCameraDetection", default: true)