Atoll/DynamicIsland/models/DynamicIslandViewModel.swift
2025-08-24 19:43:27 +05:30

223 lines
7.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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<AnyCancellable> = []
@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 shouldRecheckHover: 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
}
}
}