// // ContentView.swift // DynamicIslandApp // // Created by Harsh Vardhan Goswami on 02/08/24 // Modified by Richard Kunkli on 24/08/2024. // import AVFoundation import Combine import Defaults import KeyboardShortcuts import SwiftUI import SwiftUIIntrospect struct ContentView: View { @EnvironmentObject var vm: DynamicIslandViewModel @EnvironmentObject var webcamManager: WebcamManager @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var musicManager = MusicManager.shared @ObservedObject var timerManager = TimerManager.shared @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var statsManager = StatsManager.shared @Default(.enableStatsFeature) var enableStatsFeature @Default(.showCpuGraph) var showCpuGraph @Default(.showMemoryGraph) var showMemoryGraph @Default(.showGpuGraph) var showGpuGraph @Default(.showNetworkGraph) var showNetworkGraph @Default(.showDiskGraph) var showDiskGraph // Dynamic sizing based on view type and graph count with smooth transitions var dynamicNotchSize: CGSize { // Always return the target size for the current view to ensure smooth transitions let targetSize: CGSize if coordinator.currentView == .stats { // Count enabled graphs using the same logic as NotchStatsView var enabledGraphsCount = 0 if showCpuGraph { enabledGraphsCount += 1 } if showMemoryGraph { enabledGraphsCount += 1 } if showGpuGraph { enabledGraphsCount += 1 } if showNetworkGraph { enabledGraphsCount += 1 } if showDiskGraph { enabledGraphsCount += 1 } if enabledGraphsCount > 3 { // Expand height for 4+ graphs targetSize = CGSize( width: openNotchSize.width, height: openNotchSize.height + 65 // Add extra height for second row ) } else { targetSize = openNotchSize } } else { targetSize = openNotchSize } return targetSize } @State private var isHovering: Bool = false @State private var hoverWorkItem: DispatchWorkItem? @State private var debounceWorkItem: DispatchWorkItem? @State private var isHoverStateChanging: Bool = false @State private var isStatsTransitioning: Bool = false @State private var lastHapticTime: Date = Date() @State private var gestureProgress: CGFloat = .zero @State private var haptics: Bool = false @Namespace var albumArtNamespace @Default(.useMusicVisualizer) var useMusicVisualizer @Default(.showNotHumanFace) var showNotHumanFace @Default(.useModernCloseAnimation) var useModernCloseAnimation private let extendedHoverPadding: CGFloat = 30 private let zeroHeightHoverPadding: CGFloat = 10 var body: some View { ZStack(alignment: .top) { let mainLayout = NotchLayout() .frame(alignment: .top) .padding(.horizontal, vm.notchState == .open ? Defaults[.cornerRadiusScaling] ? (cornerRadiusInsets.opened.top - 5) : (cornerRadiusInsets.opened.bottom - 5) : cornerRadiusInsets.closed.bottom ) .padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0) .background(.black) .mask { ((vm.notchState == .open) && Defaults[.cornerRadiusScaling]) ? NotchShape(topCornerRadius: cornerRadiusInsets.opened.top, bottomCornerRadius: cornerRadiusInsets.opened.bottom) .drawingGroup() : NotchShape(topCornerRadius: cornerRadiusInsets.closed.top, bottomCornerRadius: cornerRadiusInsets.closed.bottom) .drawingGroup() } .padding(.bottom, vm.notchState == .open && Defaults[.extendHoverArea] ? 0 : (vm.effectiveClosedNotchHeight == 0) ? zeroHeightHoverPadding : 0 ) mainLayout .conditionalModifier(!useModernCloseAnimation) { view in let hoverAnimationAnimation = Animation.bouncy.speed(1.2) let notchStateAnimation = Animation.spring.speed(1.2) let viewTransitionAnimation = Animation.easeInOut(duration: 0.4) return view .animation(hoverAnimationAnimation, value: isHovering) .animation(notchStateAnimation, value: vm.notchState) .animation(viewTransitionAnimation, value: coordinator.currentView) .animation(.smooth, value: gestureProgress) .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) } .conditionalModifier(useModernCloseAnimation) { view in let hoverAnimationAnimation = Animation.bouncy.speed(1.2) let notchStateAnimation = Animation.spring.speed(1.2) let viewTransitionAnimation = Animation.easeInOut(duration: 0.4) return view .animation(hoverAnimationAnimation, value: isHovering) .animation(notchStateAnimation, value: vm.notchState) .animation(viewTransitionAnimation, value: coordinator.currentView) } .conditionalModifier(Defaults[.openNotchOnHover]) { view in view.onHover { hovering in handleHover(hovering) } } .conditionalModifier(!Defaults[.openNotchOnHover]) { view in view .onHover { hovering in handleSimpleHover(hovering) } .onTapGesture { if (vm.notchState == .closed) && Defaults[.enableHaptics] { triggerHapticIfAllowed() } doOpen() } .conditionalModifier(Defaults[.enableGestures]) { view in view .panGesture(direction: .down) { translation, phase in handleDownGesture(translation: translation, phase: phase) } } } .conditionalModifier(Defaults[.closeGestureEnabled] && Defaults[.enableGestures]) { view in view .panGesture(direction: .up) { translation, phase in handleUpGesture(translation: translation, phase: phase) } } .onAppear(perform: { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { withAnimation(vm.animation) { if coordinator.firstLaunch { doOpen() } } } }) .onChange(of: vm.notchState) { _, newState in // Reset hover state when notch state changes if newState == .closed && isHovering { // Only reset visually, without triggering the hover logic again isHoverStateChanging = true withAnimation { isHovering = false } // Reset the flag after the animation completes DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { isHoverStateChanging = false } } } .onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isStatsPopoverActive { vm.close() } } } .onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive { vm.close() } } } .onChange(of: vm.shouldRecheckHover) { _, _ in // Recheck hover state when popovers are closed DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive && !isHovering { vm.close() } } } .onChange(of: coordinator.sneakPeek.show) { _, sneakPeekShowing in // When sneak peek finishes, check if user is still hovering and open notch if needed if !sneakPeekShowing { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { if isHovering && vm.notchState == .closed { doOpen() } } } } .onChange(of: coordinator.currentView) { _, newView in // Cancel any pending hover actions during view transitions hoverWorkItem?.cancel() debounceWorkItem?.cancel() // Set transition state briefly to prevent immediate closure isStatsTransitioning = true let delay = newView == .stats ? 1.0 : 0.5 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { isStatsTransitioning = false } } .sensoryFeedback(.alignment, trigger: haptics) .contextMenu { SettingsLink(label: { Text("Settings") }) .keyboardShortcut(KeyEquivalent(","), modifiers: .command) // Button("Edit") { // Doesnt work.... // let dn = DynamicNotch(content: EditPanelView()) // dn.toggle() // } // #if DEBUG // .disabled(false) // #else // .disabled(true) // #endif // .keyboardShortcut("E", modifiers: .command) } } .frame(maxWidth: dynamicNotchSize.width, maxHeight: dynamicNotchSize.height, alignment: .top) .animation(.easeInOut(duration: 0.4), value: dynamicNotchSize) .animation(.easeInOut(duration: 0.4), value: coordinator.currentView) .shadow(color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) ? .black.opacity(0.6) : .clear, radius: Defaults[.cornerRadiusScaling] ? 10 : 5) .background(dragDetector) .environmentObject(vm) .environmentObject(webcamManager) } @ViewBuilder func NotchLayout() -> some View { VStack(alignment: .leading) { VStack(alignment: .leading) { if coordinator.firstLaunch { Spacer() HelloAnimation().frame(width: 200, height: 80).onAppear(perform: { vm.closeHello() }) .padding(.top, 40) Spacer() } else { if coordinator.expandingView.type == .battery && coordinator.expandingView.show && vm.notchState == .closed && Defaults[.showPowerStatusNotifications] { HStack(spacing: 0) { HStack { Text(batteryModel.statusText) .font(.subheadline) } Rectangle() .fill(.black) .frame(width: vm.closedNotchSize.width + 10) HStack { DynamicIslandBatteryView( batteryWidth: 30, isCharging: batteryModel.isCharging, isInLowPowerMode: batteryModel.isInLowPowerMode, isPluggedIn: batteryModel.isPluggedIn, levelBattery: batteryModel.levelBattery, isForNotification: true ) } .frame(width: 76, alignment: .trailing) } .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) { InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) .transition(.opacity) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed { MusicLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { TimerLiveActivity() } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle) } else if vm.notchState == .open { DynamicIslandHeader() .frame(height: max(24, vm.effectiveClosedNotchHeight)) .blur(radius: abs(gestureProgress) > 0.3 ? min(abs(gestureProgress), 8) : 0) .animation(.spring(response: 1, dampingFraction: 1, blendDuration: 0.8), value: vm.notchState) } else { Rectangle().fill(.clear).frame(width: vm.closedNotchSize.width - 20, height: vm.effectiveClosedNotchHeight) } if coordinator.sneakPeek.show { if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && !Defaults[.inlineHUD] { SystemEventIndicatorModifier(eventType: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, sendEventBack: { _ in // }) .padding(.bottom, 10) .padding(.leading, 4) .padding(.trailing, 8) } // Old sneak peek music else if coordinator.sneakPeek.type == .music { if vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard { HStack(alignment: .center) { Image(systemName: "music.note") GeometryReader { geo in MarqueeText(.constant(musicManager.songTitle + " - " + musicManager.artistName), textColor: .gray, minDuration: 1, frameWidth: geo.size.width) } } .foregroundStyle(.gray) .padding(.bottom, 10) } } // Timer sneak peek else if coordinator.sneakPeek.type == .timer { if !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard { HStack(alignment: .center) { Image(systemName: "timer") GeometryReader { geo in MarqueeText(.constant(timerManager.timerName + " - " + timerManager.formattedRemainingTime()), textColor: timerManager.timerColor, minDuration: 1, frameWidth: geo.size.width) } } .foregroundStyle(timerManager.timerColor) .padding(.bottom, 10) } } } } } .conditionalModifier((coordinator.sneakPeek.show && coordinator.sneakPeek.type == .music && vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.sneakPeek.show && coordinator.sneakPeek.type == .timer && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.sneakPeek.show && (coordinator.sneakPeek.type != .music && coordinator.sneakPeek.type != .timer) && (vm.notchState == .closed))) { view in view .fixedSize() } .zIndex(2) ZStack { if vm.notchState == .open { Group { switch coordinator.currentView { case .home: NotchHomeView(albumArtNamespace: albumArtNamespace) case .shelf: NotchShelfView() case .timer: NotchTimerView() case .stats: NotchStatsView() case .colorPicker: NotchColorPickerView() } } .transition(.asymmetric( insertion: .scale(scale: 0.8).combined(with: .opacity).animation(.easeInOut(duration: 0.4)), removal: .scale(scale: 0.8).combined(with: .opacity).animation(.easeInOut(duration: 0.4)) )) .id(coordinator.currentView) // Force SwiftUI to treat each view as unique } } .animation(.easeInOut(duration: 0.4), value: coordinator.currentView) .zIndex(1) .allowsHitTesting(vm.notchState == .open) .blur(radius: abs(gestureProgress) > 0.3 ? min(abs(gestureProgress), 8) : 0) .opacity(abs(gestureProgress) > 0.3 ? min(abs(gestureProgress * 2), 0.8) : 1) } } @ViewBuilder func DynamicIslandFaceAnimation() -> some View { HStack { HStack { Rectangle() .fill(.clear) .frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12)) Rectangle() .fill(.black) .frame(width: vm.closedNotchSize.width - 20) MinimalFaceFeatures() } }.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) } @ViewBuilder func MusicLiveActivity() -> some View { HStack { HStack { Color.clear .aspectRatio(1, contentMode: .fit) .background( Image(nsImage: musicManager.albumArt) .resizable() .aspectRatio(contentMode: .fill) ) .clipped() .clipShape(RoundedRectangle(cornerRadius: MusicPlayerImageSizes.cornerRadiusInset.closed)) .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) .frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12)) } .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))) Rectangle() .fill(.black) .overlay( HStack(alignment: .top){ if(coordinator.expandingView.show && coordinator.expandingView.type == .music) { MarqueeText( .constant(musicManager.songTitle), textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray, minDuration: 0.4, frameWidth: 100 ) .opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) Spacer(minLength: vm.closedNotchSize.width) // Song Artist Text(musicManager.artistName) .lineLimit(1) .truncationMode(.tail) .foregroundStyle(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray) .opacity((coordinator.expandingView.show && coordinator.expandingView.type == .music && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) } else if(coordinator.expandingView.show && coordinator.expandingView.type == .timer) { MarqueeText( .constant(timerManager.timerName), textColor: timerManager.timerColor, minDuration: 0.4, frameWidth: 100 ) .opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) Spacer(minLength: vm.closedNotchSize.width) // Timer Status Text(timerManager.formattedRemainingTime()) .lineLimit(1) .truncationMode(.tail) .foregroundStyle(timerManager.timerColor) .opacity((coordinator.expandingView.show && coordinator.expandingView.type == .timer && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) } } ) .frame(width: (coordinator.expandingView.show && (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer) && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 380 : vm.closedNotchSize.width + (isHovering ? 8 : 0)) HStack { if useMusicVisualizer { Rectangle() .fill(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).gradient : Color.gray.gradient) .frame(width: 50, alignment: .center) .matchedGeometryEffect(id: "spectrum", in: albumArtNamespace) .mask { AudioSpectrumView(isPlaying: $musicManager.isPlaying) .frame(width: 16, height: 12) } .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) } else { LottieAnimationView() .frame(maxWidth: .infinity, maxHeight: .infinity) } } .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) } .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) } @ViewBuilder var dragDetector: some View { if Defaults[.dynamicShelf] { Color.clear .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) .onDrop(of: [.data], isTargeted: $vm.dragDetectorTargeting) { _ in true } .onChange(of: vm.anyDropZoneTargeting) { _, isTargeted in if isTargeted, vm.notchState == .closed { coordinator.currentView = .shelf doOpen() } else if !isTargeted { print("DROP EVENT", vm.dropEvent) if vm.dropEvent { vm.dropEvent = false return } vm.dropEvent = false vm.close() } } } else { EmptyView() } } private func doOpen() { // Switch to timer tab if timer is active if timerManager.isTimerActive { coordinator.currentView = .timer } withAnimation(.bouncy.speed(1.2)) { vm.open() } } // MARK: - Hover Management /// Handle hover state changes with debouncing private func handleHover(_ hovering: Bool) { // Don't process events if we're already transitioning if isHoverStateChanging { return } // Cancel any pending tasks hoverWorkItem?.cancel() hoverWorkItem = nil debounceWorkItem?.cancel() debounceWorkItem = nil if hovering { // Handle mouse enter withAnimation(.bouncy.speed(1.2)) { isHovering = true } // Only provide haptic feedback if notch is closed if vm.notchState == .closed && Defaults[.enableHaptics] { triggerHapticIfAllowed() } // Delay opening the notch let task = DispatchWorkItem { // ContentView is a struct, so we don't use weak self here guard vm.notchState == .closed, isHovering else { return } // Additional check: don't open if a sneak peek just started showing // to avoid conflicts during system HUD display if coordinator.sneakPeek.show { // Schedule a retry after the sneak peek should be gone (default duration + small buffer) DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) { if isHovering && vm.notchState == .closed && !coordinator.sneakPeek.show { doOpen() } } return } doOpen() } hoverWorkItem = task DispatchQueue.main.asyncAfter( deadline: .now() + Defaults[.minimumHoverDuration], execute: task ) } else { // Handle mouse exit with debounce to prevent flickering let debounce = DispatchWorkItem { // ContentView is a struct, so we don't use weak self here // Update visual state withAnimation(.bouncy.speed(1.2)) { isHovering = false } // Close the notch if it's open and no popovers are active let hasActivePopovers = vm.isBatteryPopoverActive || vm.isClipboardPopoverActive || vm.isColorPickerPopoverActive || vm.isStatsPopoverActive if vm.notchState == .open && !hasActivePopovers { vm.close() } } debounceWorkItem = debounce // Add a small delay to debounce rapid mouse movements DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: debounce) } } // Simple hover handler for non-openNotchOnHover mode private func handleSimpleHover(_ hovering: Bool) { // Cancel any existing work items first hoverWorkItem?.cancel() debounceWorkItem?.cancel() if hovering { // Mouse entered withAnimation(vm.animation) { isHovering = true } return } // Mouse exited - use debounce to prevent rapid firing let debounce = DispatchWorkItem { withAnimation(vm.animation) { isHovering = false } // Check if we should close after a delay let hasPopovers = hasAnyActivePopovers() if vm.notchState == .open && !hasPopovers { let isStatsTab = coordinator.currentView == .stats let delay: Double = isStatsTab ? 0.8 : 0.2 let closeTask = DispatchWorkItem { if !isHovering && vm.notchState == .open && !hasAnyActivePopovers() { vm.close() } } hoverWorkItem = closeTask DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: closeTask) } } debounceWorkItem = debounce // Longer debounce for stats tab to prevent rapid hover events let debounceDelay = coordinator.currentView == .stats ? 0.2 : 0.05 DispatchQueue.main.asyncAfter(deadline: .now() + debounceDelay, execute: debounce) } // Helper function to check if any popovers are active private func hasAnyActivePopovers() -> Bool { return vm.isBatteryPopoverActive || vm.isClipboardPopoverActive || vm.isColorPickerPopoverActive || vm.isStatsPopoverActive } // Helper to prevent rapid haptic feedback private func triggerHapticIfAllowed() { let now = Date() if now.timeIntervalSince(lastHapticTime) > 0.3 { // Minimum 300ms between haptics haptics.toggle() lastHapticTime = now } } // MARK: - Gesture Handling private func handleDownGesture(translation: CGFloat, phase: NSEvent.Phase) { guard vm.notchState == .closed else { return } withAnimation(.smooth) { gestureProgress = (translation / Defaults[.gestureSensitivity]) * 20 } if phase == .ended { withAnimation(.smooth) { gestureProgress = .zero } } if translation > Defaults[.gestureSensitivity] { if Defaults[.enableHaptics] { triggerHapticIfAllowed() } withAnimation(.smooth) { gestureProgress = .zero } doOpen() } } private func handleUpGesture(translation: CGFloat, phase: NSEvent.Phase) { if vm.notchState == .open && !vm.isHoveringCalendar { withAnimation(.smooth) { gestureProgress = (translation / Defaults[.gestureSensitivity]) * -20 } if phase == .ended { withAnimation(.smooth) { gestureProgress = .zero } } if translation > Defaults[.gestureSensitivity] { withAnimation(.smooth) { gestureProgress = .zero isHovering = false } vm.close() if Defaults[.enableHaptics] { triggerHapticIfAllowed() } } } } } struct FullScreenDropDelegate: DropDelegate { @Binding var isTargeted: Bool let onDrop: () -> Void func dropEntered(info _: DropInfo) { isTargeted = true } func dropExited(info _: DropInfo) { isTargeted = false } func performDrop(info _: DropInfo) -> Bool { isTargeted = false onDrop() return true } }