mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
357 lines
13 KiB
Swift
357 lines
13 KiB
Swift
//
|
||
// 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<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 isStatsPopoverActive: Bool = false
|
||
@Published var isReminderPopoverActive: Bool = false
|
||
@Published var isMediaOutputPopoverActive: Bool = false
|
||
@Published var isTimerPopoverActive: 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()
|
||
|
||
@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)
|
||
|
||
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()
|
||
}
|
||
|
||
private func calculateDynamicNotchSize() -> CGSize {
|
||
// Use minimalistic size if minimalistic UI is enabled
|
||
let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
||
if DynamicIslandViewCoordinator.shared.currentView == .notes {
|
||
return CGSize(width: baseSize.width, height: 360)
|
||
}
|
||
|
||
return statsAdjustedNotchSize(
|
||
from: baseSize,
|
||
isStatsTabActive: DynamicIslandViewCoordinator.shared.currentView == .stats,
|
||
secondRowProgress: coordinator.statsSecondRowExpansion
|
||
)
|
||
}
|
||
|
||
func close() {
|
||
let targetSize = getClosedNotchSize(screen: screen)
|
||
notchSize = targetSize
|
||
closedNotchSize = targetSize
|
||
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 !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
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
}
|