// // DynamicIslandViewModel.swift // DynamicIsland // // Created by Harsh Vardhan Goswami on 04/08/24. // import Combine import Defaults import SwiftUI @MainActor class DynamicIslandViewModel: NSObject, ObservableObject { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var detector = FullscreenMediaDetector.shared let animationLibrary: DynamicIslandAnimations = .init() let animation: Animation? @Published var contentType: ContentType = .normal @Published private(set) var notchState: NotchState = .closed @Published var dragDetectorTargeting: Bool = false @Published var dropZoneTargeting: Bool = false @Published var dropEvent: Bool = false @Published var anyDropZoneTargeting: Bool = false var cancellables: Set = [] @Published var hideOnClosed: Bool = true @Published var isHoveringCalendar: Bool = false @Published var isBatteryPopoverActive: Bool = false @Published var isClipboardPopoverActive: Bool = false @Published var isColorPickerPopoverActive: Bool = false @Published var isStatsPopoverActive: Bool = false @Published var isReminderPopoverActive: Bool = false @Published var isMediaOutputPopoverActive: Bool = false @Published var isTimerPopoverActive: Bool = false @Published var shouldRecheckHover: Bool = false @Published var isScrollGestureActive: Bool = false private var scrollGestureSuppressionTokens: Set = [] @Published private(set) var isAutoCloseSuppressed: Bool = false private var autoCloseSuppressionTokens: Set = [] private let clipboardFocusWindow: TimeInterval = 10 func setScrollGestureSuppression(_ active: Bool, token: UUID) { if active { let inserted = scrollGestureSuppressionTokens.insert(token).inserted if inserted { isScrollGestureActive = true } } else { if scrollGestureSuppressionTokens.remove(token) != nil { isScrollGestureActive = !scrollGestureSuppressionTokens.isEmpty } } } private func resetScrollGestureSuppression() { scrollGestureSuppressionTokens.removeAll() isScrollGestureActive = false } func setAutoCloseSuppression(_ active: Bool, token: UUID) { if active { let inserted = autoCloseSuppressionTokens.insert(token).inserted if inserted { isAutoCloseSuppressed = true } } else if autoCloseSuppressionTokens.remove(token) != nil { isAutoCloseSuppressed = !autoCloseSuppressionTokens.isEmpty } } private func resetAutoCloseSuppression() { autoCloseSuppressionTokens.removeAll() isAutoCloseSuppressed = false } private func focusClipboardTabIfNeeded() { guard !Defaults[.enableMinimalisticUI] else { return } guard Defaults[.enableClipboardManager] else { return } guard Defaults[.clipboardDisplayMode] == .separateTab else { return } guard let lastCopyDate = ClipboardManager.shared.lastCopiedItemDate else { return } guard Date().timeIntervalSince(lastCopyDate) <= clipboardFocusWindow else { return } guard coordinator.currentView != .notes else { return } withAnimation(.smooth) { coordinator.currentView = .notes } } let webcamManager = WebcamManager.shared @Published var isCameraExpanded: Bool = false @Published var isRequestingAuthorization: Bool = false @Published var screen: String? @Published var notchSize: CGSize = getClosedNotchSize() @Published var closedNotchSize: CGSize = getClosedNotchSize() @MainActor deinit { destroy() } func destroy() { cancellables.forEach { $0.cancel() } cancellables.removeAll() } init(screen: String? = nil) { animation = animationLibrary.animation super.init() self.screen = screen notchSize = getClosedNotchSize(screen: screen) closedNotchSize = notchSize Publishers.CombineLatest($dropZoneTargeting, $dragDetectorTargeting) .map { value1, value2 in value1 || value2 } .assign(to: \.anyDropZoneTargeting, on: self) .store(in: &cancellables) setupDetectorObserver() ReminderLiveActivityManager.shared.$activeWindowReminders .removeDuplicates() .receive(on: RunLoop.main) .sink { [weak self] _ in guard let self else { return } let updatedTarget = self.calculateDynamicNotchSize() guard self.notchState == .open else { return } guard self.notchSize != updatedTarget else { return } withAnimation(.smooth) { self.notchSize = updatedTarget } if let delegate = AppDelegate.shared { delegate.ensureWindowSize( addShadowPadding(to: updatedTarget, isMinimalistic: Defaults[.enableMinimalisticUI]), animated: true, force: false ) } } .store(in: &cancellables) // Observe settings + lyrics changes to dynamically resize the notch let enableLyricsPublisher = Defaults.publisher(.enableLyrics).map { $0.newValue } enableLyricsPublisher .combineLatest(MusicManager.shared.$currentLyrics) .removeDuplicates { $0.0 == $1.0 && $0.1 == $1.1 } .receive(on: RunLoop.main) .sink { [weak self] _ in guard let self else { return } guard Defaults[.enableMinimalisticUI] else { return } let updatedTarget = self.calculateDynamicNotchSize() guard self.notchState == .open else { return } guard self.notchSize != updatedTarget else { return } withAnimation(.smooth) { self.notchSize = updatedTarget } if let delegate = AppDelegate.shared { delegate.ensureWindowSize( addShadowPadding(to: updatedTarget, isMinimalistic: Defaults[.enableMinimalisticUI]), animated: true, force: false ) } } .store(in: &cancellables) TimerManager.shared.$activeSource .combineLatest(TimerManager.shared.$isTimerActive) .receive(on: RunLoop.main) .sink { [weak self] _, _ in self?.handleMinimalisticTimerHeightChange() } .store(in: &cancellables) coordinator.$statsSecondRowExpansion .removeDuplicates() .receive(on: RunLoop.main) .sink { [weak self] _ in guard let self else { return } guard self.notchState == .open else { return } let updatedTarget = self.calculateDynamicNotchSize() guard self.notchSize != updatedTarget else { return } withAnimation(.easeInOut(duration: 0.3)) { self.notchSize = updatedTarget } if let delegate = AppDelegate.shared { delegate.ensureWindowSize( addShadowPadding(to: updatedTarget, isMinimalistic: Defaults[.enableMinimalisticUI]), animated: false, force: false ) } } .store(in: &cancellables) coordinator.$notesLayoutState .removeDuplicates() .receive(on: RunLoop.main) .sink { [weak self] _ in guard let self else { return } guard self.notchState == .open else { return } let updatedTarget = self.calculateDynamicNotchSize() guard self.notchSize != updatedTarget else { return } withAnimation(.easeInOut(duration: 0.25)) { self.notchSize = updatedTarget } if let delegate = AppDelegate.shared { delegate.ensureWindowSize( addShadowPadding(to: updatedTarget, isMinimalistic: Defaults[.enableMinimalisticUI]), animated: true, force: false ) } } .store(in: &cancellables) Defaults.publisher(.openNotchWidth, options: []) .map { $0.newValue } .removeDuplicates() .receive(on: RunLoop.main) .sink { [weak self] _ in guard let self else { return } guard self.notchState == .open else { return } guard !Defaults[.enableMinimalisticUI] else { return } let updatedTarget = self.calculateDynamicNotchSize() guard self.notchSize != updatedTarget else { return } withAnimation(.smooth) { self.notchSize = updatedTarget } if let delegate = AppDelegate.shared { delegate.ensureWindowSize( addShadowPadding(to: updatedTarget, isMinimalistic: false), animated: true, force: false ) } } .store(in: &cancellables) } private func handleMinimalisticTimerHeightChange() { guard Defaults[.enableMinimalisticUI] else { return } guard notchState == .open else { return } let updatedTarget = calculateDynamicNotchSize() guard notchSize != updatedTarget else { return } withAnimation(.smooth) { notchSize = updatedTarget } if let delegate = AppDelegate.shared { delegate.ensureWindowSize( addShadowPadding(to: updatedTarget, isMinimalistic: Defaults[.enableMinimalisticUI]), animated: true, force: false ) } } private func setupDetectorObserver() { // 1) Publisher for the user’s fullscreen detection setting let enabledPublisher = Defaults .publisher(.enableFullscreenMediaDetection) .map(\.newValue) // 2) For each non‑nil screen name, map to a Bool publisher for that screen's status let statusPublisher = $screen .compactMap { $0 } .removeDuplicates() .map { screenName in self.detector.$fullscreenStatus .map { $0[screenName] ?? false } .removeDuplicates() } .switchToLatest() // 3) Combine enabled & status, animate only on changes Publishers.CombineLatest(statusPublisher, enabledPublisher) .map { status, enabled in enabled && status } .removeDuplicates() .receive(on: RunLoop.main) .sink { [weak self] shouldHide in withAnimation(.smooth) { self?.hideOnClosed = shouldHide } } .store(in: &cancellables) } // Computed property for effective notch height var effectiveClosedNotchHeight: CGFloat { let currentScreen = NSScreen.screens.first { $0.localizedName == screen } let noNotchAndFullscreen = hideOnClosed && (currentScreen?.safeAreaInsets.top ?? 0 <= 0 || currentScreen == nil) return noNotchAndFullscreen ? 0 : closedNotchSize.height } func isMouseHovering(position: NSPoint = NSEvent.mouseLocation) -> Bool { let screenFrame = getScreenFrame(screen) if let frame = screenFrame { let baseY = frame.maxY - notchSize.height let baseX = frame.midX - notchSize.width / 2 return position.y >= baseY && position.x >= baseX && position.x <= baseX + notchSize.width } return false } func open() { let targetSize = calculateDynamicNotchSize() let applyWindowResize: () -> Void = { guard let delegate = AppDelegate.shared else { return } delegate.ensureWindowSize( addShadowPadding(to: targetSize, isMinimalistic: Defaults[.enableMinimalisticUI]), animated: false, force: true ) } if Thread.isMainThread { applyWindowResize() } else { DispatchQueue.main.async(execute: applyWindowResize) } notchSize = targetSize notchState = .open // Force music information update when notch is opened MusicManager.shared.forceUpdate() focusClipboardTabIfNeeded() } private func calculateDynamicNotchSize() -> CGSize { let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize var adjustedSize = baseSize if coordinator.currentView == .notes || coordinator.currentView == .clipboard { let preferred = coordinator.notesLayoutState.preferredHeight adjustedSize.height = max(adjustedSize.height, preferred) return adjustedSize } return statsAdjustedNotchSize( from: adjustedSize, isStatsTabActive: coordinator.currentView == .stats, secondRowProgress: coordinator.statsSecondRowExpansion ) } func close() { let targetSize = getClosedNotchSize(screen: screen) notchSize = targetSize closedNotchSize = targetSize notchState = .closed resetScrollGestureSuppression() resetAutoCloseSuppression() // Set the current view to shelf if it contains files and the user enables openShelfByDefault // Otherwise, if the user has not enabled openLastShelfByDefault, set the view to home if !ShelfStateViewModel.shared.isEmpty && Defaults[.openShelfByDefault] && !Defaults[.enableMinimalisticUI] { coordinator.currentView = .shelf } else if !coordinator.openLastTabByDefault { coordinator.currentView = .home } } func closeForLockScreen() { let targetSize = getClosedNotchSize(screen: screen) withAnimation(.none) { notchSize = targetSize closedNotchSize = targetSize notchState = .closed resetScrollGestureSuppression() resetAutoCloseSuppression() } } func closeHello() { DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in self?.coordinator.firstLaunch = false withAnimation(self?.animationLibrary.animation) { self?.close() } } } func toggleCameraPreview() { if isRequestingAuthorization { return } switch webcamManager.authorizationStatus { case .authorized: if webcamManager.isSessionRunning { webcamManager.stopSession() isCameraExpanded = false } else if webcamManager.cameraAvailable { webcamManager.startSession() isCameraExpanded = true } case .denied, .restricted: DispatchQueue.main.async { NSApp.setActivationPolicy(.regular) NSApp.activate(ignoringOtherApps: true) let alert = NSAlert() alert.messageText = "Camera Access Required" alert.informativeText = "Please allow camera access in System Settings." alert.addButton(withTitle: "OK") alert.runModal() NSApp.setActivationPolicy(.accessory) NSApp.deactivate() } case .notDetermined: isRequestingAuthorization = true webcamManager.checkAndRequestVideoAuthorization() DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.isRequestingAuthorization = false } default: break } } }