// // 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 var dynamicNotchSize: CGSize { // Only apply dynamic sizing when on stats tab and stats are enabled guard coordinator.currentView == .stats && enableStatsFeature else { return openNotchSize } let enabledGraphsCount = [showCpuGraph, showMemoryGraph, showGpuGraph, showNetworkGraph, showDiskGraph].filter { $0 }.count // Calculate height based on layout: 1-3 graphs = single row, 4+ graphs = two rows var requiredHeight = openNotchSize.height if enabledGraphsCount >= 4 { // Two rows needed - add height for second row plus spacing let extraHeight: CGFloat = 120 + 12 // Graph height + spacing requiredHeight = openNotchSize.height + extraHeight } // Width stays constant - no horizontal expansion return CGSize(width: openNotchSize.width, height: requiredHeight) } @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 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) return view .animation(hoverAnimationAnimation, value: isHovering) .animation(notchStateAnimation, value: vm.notchState) .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) return view .animation(hoverAnimationAnimation, value: isHovering) .animation(notchStateAnimation, value: vm.notchState) } .conditionalModifier(Defaults[.openNotchOnHover]) { view in view.onHover { hovering in handleHover(hovering) } } .conditionalModifier(!Defaults[.openNotchOnHover]) { view in view .onHover { hovering in withAnimation(vm.animation) { isHovering = hovering } // Only close if mouse leaves and the notch is open and no popovers are active if !hovering && vm.notchState == .open && !vm.isBatteryPopoverActive { // Add slight delay for stats tab to prevent accidental closure during layout changes let delay: Double = (coordinator.currentView == .stats) ? 0.2 : 0.0 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { // Double-check conditions after delay if !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive { vm.close() } } } } .onTapGesture { if (vm.notchState == .closed) && Defaults[.enableHaptics] { haptics.toggle() } 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.close() } } } .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() } } .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] { haptics.toggle() } // Don't open notch if there's a sneak peek showing if coordinator.sneakPeek.show { return } // 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 } 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 if vm.notchState == .open && !vm.isBatteryPopoverActive { vm.close() } } debounceWorkItem = debounce // Add a small delay to debounce rapid mouse movements DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: debounce) } } // 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] { haptics.toggle() } 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] { haptics.toggle() } } } } } 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 } }