// // DynamicIslandViewModel.swift // DynamicIsland // // Created by Harsh Vardhan Goswami on 04/08/24. // import Combine import Defaults import SwiftUI import TheBoringWorkerNotifier 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 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() 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() } 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() { withAnimation(.bouncy) { self.notchSize = calculateDynamicNotchSize() self.notchState = .open } // Force music information update when notch is opened MusicManager.shared.forceUpdate() } private func calculateDynamicNotchSize() -> CGSize { // Only apply dynamic sizing when on stats tab and stats are enabled guard DynamicIslandViewCoordinator.shared.currentView == .stats && Defaults[.enableStatsFeature] else { return openNotchSize } let enabledGraphsCount = [ Defaults[.showCpuGraph], Defaults[.showMemoryGraph], Defaults[.showGpuGraph], Defaults[.showNetworkGraph], Defaults[.showDiskGraph] ].filter { $0 }.count // If 4+ graphs are enabled, increase width if enabledGraphsCount >= 4 { let extraWidth: CGFloat = CGFloat(enabledGraphsCount - 3) * 120 return CGSize(width: openNotchSize.width + extraWidth, height: openNotchSize.height) } return openNotchSize } func close() { withAnimation(.smooth) { [weak self] in guard let self = self else { return } self.notchSize = getClosedNotchSize(screen: self.screen) self.closedNotchSize = self.notchSize self.notchState = .closed } // 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 !TrayDrop.shared.isEmpty && Defaults[.openShelfByDefault] { coordinator.currentView = .shelf } else if !coordinator.openLastTabByDefault { coordinator.currentView = .home } } 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 } } }