Atoll/DynamicIsland/ContentView.swift
2025-08-23 23:50:04 +05:30

611 lines
29 KiB
Swift

//
// 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()
case .colorPicker:
NotchColorPickerView()
}
}
.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
}
}