Atoll/DynamicIsland/ContentView.swift

2579 lines
111 KiB
Swift

/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* Originally from boring.notch project
* Modified and adapted for Atoll (DynamicIsland)
* See NOTICE for details.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import AVFoundation
import Combine
import Defaults
import Foundation
import KeyboardShortcuts
import SwiftUI
import SwiftUIIntrospect
import AtollExtensionKit
#if canImport(AppKit)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
@MainActor
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 reminderManager = ReminderLiveActivityManager.shared
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
@ObservedObject var statsManager = StatsManager.shared
@ObservedObject var recordingManager = ScreenRecordingManager.shared
@ObservedObject var privacyManager = PrivacyIndicatorManager.shared
@ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared
@ObservedObject var lockScreenManager = LockScreenManager.shared
@ObservedObject var capsLockManager = CapsLockManager.shared
@ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared
@ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
@ObservedObject var localSendService = LocalSendService.shared
@State private var downloadManager = DownloadManager.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
@Default(.enableReminderLiveActivity) var enableReminderLiveActivity
@Default(.enableTimerFeature) var enableTimerFeature
@Default(.timerDisplayMode) var timerDisplayMode
@Default(.enableHorizontalMusicGestures) var enableHorizontalMusicGestures
@Default(.reminderPresentationStyle) var reminderPresentationStyle
@Default(.timerShowsCountdown) var timerShowsCountdown
@Default(.timerShowsProgress) var timerShowsProgress
@Default(.timerProgressStyle) var timerProgressStyle
@Default(.timerIconColorMode) var timerIconColorMode
@Default(.timerSolidColor) var timerSolidColor
@Default(.timerPresets) var timerPresets
@Default(.showCapsLockLabel) var showCapsLockLabel
@Default(.capsLockIndicatorTintMode) var capsLockTintMode
@Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection
@Default(.showDoNotDisturbIndicator) var showDoNotDisturbIndicator
@Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection
@Default(.enableCapsLockIndicator) var enableCapsLockIndicator
@Default(.enableExtensionLiveActivities) var enableExtensionLiveActivities
@Default(.showStandardMediaControls) var showStandardMediaControls
@Default(.externalDisplayStyle) var externalDisplayStyle
@Default(.hideNonNotchUntilHover) var hideNonNotchUntilHover
// Dynamic sizing based on view type and graph count with smooth transitions
var dynamicNotchSize: CGSize {
let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
// When inline sneak peek is active in closed notch, use the wider inline width
// so the outer maxWidth frame doesn't clip the expanded content
let inlineSneakPeekActive = vm.notchState == .closed
&& coordinator.expandingView.show
&& (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer)
&& Defaults[.enableSneakPeek]
&& Defaults[.sneakPeekStyles] == .inline
if inlineSneakPeekActive {
let inlineWidth: CGFloat = 460
return CGSize(width: max(baseSize.width, inlineWidth), height: baseSize.height)
}
if coordinator.currentView == .timer {
return CGSize(width: baseSize.width, height: 250) // Extra height for timer presets
}
if coordinator.currentView == .notes || coordinator.currentView == .clipboard {
let preferredHeight = coordinator.notesLayoutState.preferredHeight
let resolvedHeight = max(baseSize.height, preferredHeight)
return CGSize(width: baseSize.width, height: resolvedHeight)
}
if coordinator.currentView == .terminal {
// Dynamic height: up to terminalMaxHeightFraction of screen, min 300pt
let screenHeight = NSScreen.main?.visibleFrame.height ?? 800
let maxFraction = Defaults[.terminalMaxHeightFraction]
let terminalHeight = min(screenHeight * maxFraction, max(300, screenHeight * maxFraction))
return CGSize(width: baseSize.width, height: terminalHeight)
}
if coordinator.currentView == .extensionExperience {
if let preferredHeight = extensionTabPreferredHeight(baseSize: baseSize) {
return CGSize(width: baseSize.width, height: preferredHeight)
}
return baseSize
}
if enableMinimalisticUI,
coordinator.currentView == .home,
let preferredHeight = extensionMinimalisticPreferredHeight(baseSize: baseSize) {
return CGSize(width: baseSize.width, height: preferredHeight)
}
guard coordinator.currentView == .stats else {
return baseSize
}
let rows = statsRowCount()
if rows <= 1 {
return baseSize
}
let additionalRows = max(rows - 1, 0)
let extraHeight = CGFloat(additionalRows) * statsAdditionalRowHeight
return CGSize(width: baseSize.width, height: baseSize.height + extraHeight)
}
@State private var hoverTask: Task<Void, Never>?
@State private var isHovering: Bool = false
@State private var lastHapticTime: Date = Date()
@State private var hoverClickMonitor: Any?
@State private var hoverClickLocalMonitor: Any?
@State private var stickyTerminalClickMonitor: Any?
@State private var hiddenEdgeHoverPollingTask: Task<Void, Never>?
@State private var gestureProgress: CGFloat = .zero
@State private var skipGestureActiveDirection: MusicManager.SkipDirection?
@State private var isMusicControlWindowVisible = false
@State private var pendingMusicControlTask: Task<Void, Never>?
@State private var musicControlHideTask: Task<Void, Never>?
@State private var musicControlVisibilityDeadline: Date?
@State private var isMusicControlWindowSuppressed = false
@State private var hasPendingMusicControlSync = false
@State private var pendingMusicControlForceRefresh = false
@State private var musicControlSuppressionTask: Task<Void, Never>?
@State private var haptics: Bool = false
@Namespace var albumArtNamespace
@Default(.useMusicVisualizer) var useMusicVisualizer
@Default(.musicControlWindowEnabled) var musicControlWindowEnabled
@Default(.showNotHumanFace) var showNotHumanFace
@Default(.useModernCloseAnimation) var useModernCloseAnimation
@Default(.enableMinimalisticUI) var enableMinimalisticUI
private static let musicControlLogFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss.SSS"
return formatter
}()
private func logMusicControlEvent(_ message: String) {
#if DEBUG
let timestamp = Self.musicControlLogFormatter.string(from: Date())
print("[MusicControl] \(timestamp): \(message)")
#endif
}
private func runAfter(_ delay: TimeInterval, _ action: @escaping @Sendable @MainActor () -> Void) {
guard delay >= 0 else { return }
Task { @MainActor in
let nanoseconds = UInt64(delay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
action()
}
}
private func requestMusicControlWindowSyncIfHidden(forceRefresh: Bool = false, delay: TimeInterval = 0) {
guard !isMusicControlWindowVisible else { return }
enqueueMusicControlWindowSync(forceRefresh: forceRefresh, delay: delay)
}
private var dynamicNotchResizeAnimation: Animation? {
nil
}
private let zeroHeightHoverPadding: CGFloat = 10
private let statsAdditionalRowHeight: CGFloat = statsSecondRowContentHeight + statsGridSpacingHeight
private let musicControlPauseGrace: TimeInterval = 5
private let musicControlResumeDelay: TimeInterval = 0.24
// MARK: - Tab switch direction for smooth transitions
private var tabSwitchTransition: AnyTransition {
if coordinator.tabSwitchForward {
return .asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
)
} else {
return .asymmetric(
insertion: .move(edge: .leading).combined(with: .opacity),
removal: .move(edge: .trailing).combined(with: .opacity)
)
}
}
private var standardMediaControlsActive: Bool {
showStandardMediaControls && !enableMinimalisticUI
}
private var closedMusicContentEnabled: Bool {
enableMinimalisticUI || showStandardMediaControls
}
// Use minimalistic corner radius ONLY when opened, keep normal when closed
private var activeCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) {
if enableMinimalisticUI {
// Keep normal closed corner radius, use minimalistic when opened
return (opened: minimalisticCornerRadiusInsets.opened, closed: cornerRadiusInsets.closed)
}
return cornerRadiusInsets
}
private var currentShadowPadding: CGFloat {
notchShadowPaddingValue(isMinimalistic: enableMinimalisticUI)
}
private var currentNotchShape: NotchShape {
let topRadius = (vm.notchState == .open && Defaults[.cornerRadiusScaling])
? activeCornerRadiusInsets.opened.top
: activeCornerRadiusInsets.closed.top
let bottomRadius = (vm.notchState == .open && Defaults[.cornerRadiusScaling])
? activeCornerRadiusInsets.opened.bottom
: activeCornerRadiusInsets.closed.bottom
return NotchShape(topCornerRadius: topRadius, bottomCornerRadius: bottomRadius)
}
/// Whether the current screen should render as a Dynamic Island pill
/// rather than the standard notch shape. Always false on physical notch screens.
private var isDynamicIslandMode: Bool {
shouldUseDynamicIslandMode(for: currentScreenName)
}
private var currentScreenName: String {
vm.screen ?? coordinator.selectedScreen
}
/// Whether the current screen lacks a physical notch.
private var isNonNotchScreen: Bool {
guard let screen = NSScreen.screens.first(where: { $0.localizedName == currentScreenName }) else {
return true
}
return screen.safeAreaInsets.top <= 0
}
/// Whether the global sneak peek is visible on this specific screen.
private var isSneakPeekVisibleOnCurrentScreen: Bool {
guard coordinator.sneakPeek.show else { return false }
guard Defaults[.showOnAllDisplays] else { return true }
guard let targetScreenName = coordinator.sneakPeek.targetScreenName else { return true }
return currentScreenName == targetScreenName
}
/// Whether the notch/island should hide off-screen when closed on a non-notch display.
/// Temporarily reveals the notch when a sneakPeek HUD (volume, brightness, music, etc.) is active.
private var shouldHideUntilHover: Bool {
hideNonNotchUntilHover && isNonNotchScreen && vm.notchState == .closed && !isSneakPeekVisibleOnCurrentScreen
}
/// Whether the fallback top-edge hover detector should run.
/// This is only needed when the notch is fully hidden off-screen and
/// regular `.onHover` hit-testing may not trigger reliably.
private var shouldUseHiddenEdgeHoverPolling: Bool {
shouldHideUntilHover && !lockScreenManager.isLocked
}
/// Whether the LocalSend live activity should be shown
private var localSendLiveActivityActive: Bool {
localSendService.isSending ||
localSendService.transferState == .completed ||
isLocalSendFailedOrRejected
}
private var isLocalSendFailedOrRejected: Bool {
if case .failed = localSendService.transferState { return true }
if case .rejected = localSendService.transferState { return true }
return false
}
/// Pill shape for Dynamic Island mode with animated corner radius transitions.
private var currentPillShape: DynamicIslandPillShape {
let radius: CGFloat
if vm.notchState == .open {
radius = enableMinimalisticUI
? minimalisticCornerRadiusInsets.opened.top
: dynamicIslandPillCornerRadiusInsets.opened
} else {
// Use half the closed height for a true capsule shape
radius = max(vm.closedNotchSize.height / 2, dynamicIslandPillCornerRadiusInsets.closed.standard)
}
return DynamicIslandPillShape(cornerRadius: radius)
}
/// Resolves the clip/content shape per-screen: pill on non-notch screens
/// when dynamic island mode is active, standard notch shape otherwise.
private var resolvedClipShape: AnyShape {
if isDynamicIslandMode {
return AnyShape(currentPillShape)
}
return AnyShape(currentNotchShape)
}
var body: some View {
let interactionsEnabled = !lockScreenManager.isLocked
let isIslandMode = isDynamicIslandMode
let notchHorizontalPadding: CGFloat = {
guard vm.notchState == .open else {
return activeCornerRadiusInsets.closed.bottom
}
if Defaults[.cornerRadiusScaling] {
return activeCornerRadiusInsets.opened.top - 5
}
return activeCornerRadiusInsets.opened.bottom - 5
}()
let hoverAreaPadding: CGFloat = {
if vm.notchState == .open && Defaults[.extendHoverArea] {
return 0
}
return vm.effectiveClosedNotchHeight == 0 ? zeroHeightHoverPadding : 0
}()
let notchBottomPadding = currentShadowPadding + hoverAreaPadding
// Extra top padding to detach pill from screen edge in Dynamic Island mode
let pillTopOffset: CGFloat = isIslandMode ? dynamicIslandTopOffset : 0
ZStack(alignment: .top) {
let mainLayout = NotchLayout()
.frame(alignment: .top)
.padding(.horizontal, notchHorizontalPadding)
.padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0)
.background(.black)
.clipShape(resolvedClipShape)
.compositingGroup()
.shadow(
color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow])
? .black.opacity(0.6)
: .clear,
radius: Defaults[.cornerRadiusScaling] ? 10 : 5
)
// Extra horizontal inset for Dynamic Island mode so the shadow
// is not clipped by the outer frame constraint
.padding(.horizontal, isIslandMode ? dynamicIslandShadowInset : 0)
.padding(.top, pillTopOffset)
mainLayout
.conditionalModifier(!useModernCloseAnimation) { view in
let hoverAnimation = Animation.bouncy.speed(1.2)
let notchStateAnimation = Animation.spring.speed(1.2)
return view
.animation(hoverAnimation, value: isHovering)
.animation(notchStateAnimation, value: vm.notchState)
.animation(.smooth, value: gestureProgress)
.transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2)))
}
.conditionalModifier(useModernCloseAnimation) { view in
let hoverAnimation = Animation.bouncy.speed(1.2)
let openAnimation = Animation.spring(response: 0.42, dampingFraction: 0.8, blendDuration: 0)
let closeAnimation = Animation.spring(response: 0.45, dampingFraction: 1.0, blendDuration: 0)
let notchAnimation = vm.notchState == .open ? openAnimation : closeAnimation
return view
.animation(hoverAnimation, value: isHovering)
.animation(notchAnimation, value: vm.notchState)
.animation(.smooth, value: gestureProgress)
}
.conditionalModifier(interactionsEnabled) { view in
view
.contentShape(resolvedClipShape)
.onHover { hovering in
handleHover(hovering)
}
.onTapGesture {
if vm.notchState == .closed && Defaults[.enableHaptics] {
triggerHapticIfAllowed()
}
openNotch()
}
.conditionalModifier(Defaults[.enableGestures]) { view in
view
.panGesture(direction: .down) { translation, phase in
handleDownGesture(translation: translation, phase: phase)
}
.panGesture(direction: .left) { translation, phase in
handleSkipGesture(direction: .forward, translation: translation, phase: phase)
}
.panGesture(direction: .right) { translation, phase in
handleSkipGesture(direction: .backward, translation: translation, phase: phase)
}
}
}
.conditionalModifier((Defaults[.closeGestureEnabled] || Defaults[.reverseScrollGestures]) && Defaults[.enableGestures] && interactionsEnabled) { view in
view
.panGesture(direction: .up) { translation, phase in
handleUpGesture(translation: translation, phase: phase)
}
}
// Shadow bottom padding and hide-until-hover offset applied AFTER
// interaction modifiers so .contentShape / .onHover only covers
// the actual notch content, not the shadow clearance below it.
.padding(.bottom, notchBottomPadding)
.offset(y: shouldHideUntilHover && !isHovering
? -(vm.closedNotchSize.height + pillTopOffset + currentShadowPadding + 10)
: 0
)
.onAppear(perform: {
if coordinator.firstLaunch {
// Single open during first launch; closeHello() handles the timed close.
runAfter(1) {
withAnimation(vm.animation) {
openNotch()
}
}
}
})
.onChange(of: vm.notchState) { _, newState in
// Update smart monitoring based on notch state
if enableStatsFeature {
let currentViewString = coordinator.currentView == .stats ? "stats" : "other"
statsManager.updateMonitoringState(
notchIsOpen: newState == .open,
currentView: currentViewString
)
}
// Reset hover state when notch state changes
if newState == .closed && isHovering {
withAnimation {
isHovering = false
}
}
if newState == .closed {
removeStickyTerminalClickMonitor()
}
#if os(macOS)
if newState == .open {
TimerControlWindowManager.shared.hide()
}
#endif
}
.onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in
runAfter(0.1) {
if !newPopoverState && !isHovering && vm.notchState == .open && !shouldPreventAutoClose() {
vm.close()
}
}
}
.onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in
runAfter(0.1) {
if !newPopoverState && !isHovering && vm.notchState == .open && !shouldPreventAutoClose() {
vm.close()
}
}
}
.onChange(of: vm.shouldRecheckHover) { _, _ in
// Recheck hover state when popovers are closed
runAfter(0.1) {
if vm.notchState == .open && !shouldPreventAutoClose() && !isHovering {
vm.close()
}
}
}
.onReceive(NotificationCenter.default.publisher(for: .sharingDidFinish)) { _ in
runAfter(0.1) {
if vm.notchState == .open && !isHovering && !shouldPreventAutoClose() {
vm.close()
}
}
}
.onChange(of: coordinator.sneakPeek.show) { _, sneakPeekShowing in
// When sneak peek finishes, check if user is still hovering and open notch if needed
if !sneakPeekShowing {
runAfter(0.2) {
if isHovering && vm.notchState == .closed {
openNotch()
}
}
}
}
.onChange(of: coordinator.currentView) { _, newValue in
if enableStatsFeature {
let currentViewString = newValue == .stats ? "stats" : "other"
statsManager.updateMonitoringState(
notchIsOpen: vm.notchState == .open,
currentView: currentViewString
)
}
}
.sensoryFeedback(.alignment, trigger: haptics)
.contextMenu {
Button("Settings") {
SettingsWindowController.shared.showWindow()
}
// 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 + (isDynamicIslandMode ? dynamicIslandShadowInset * 2 : 0),
maxHeight: dynamicNotchSize.height + currentShadowPadding + (isDynamicIslandMode ? dynamicIslandTopOffset : 0),
alignment: .top
)
.frame(maxHeight: .infinity, alignment: .top)
.environmentObject(privacyManager)
.onChange(of: dynamicNotchSize) { oldSize, newSize in
guard oldSize != newSize else { return }
runAfter(0.1) {
vm.shouldRecheckHover.toggle()
}
}
.background(dragDetector)
.environmentObject(vm)
.environmentObject(webcamManager)
.onAppear {
isMusicControlWindowSuppressed = vm.notchState != .closed || lockScreenManager.isLocked
if musicManager.isPlaying || !musicManager.isPlayerIdle {
clearMusicControlVisibilityDeadline()
}
if let deadline = musicControlVisibilityDeadline, Date() > deadline {
clearMusicControlVisibilityDeadline()
}
enqueueMusicControlWindowSync(forceRefresh: true)
startHiddenEdgeHoverPolling()
}
.onChange(of: vm.notchState) { _, state in
if state == .open {
suppressMusicControlWindowUpdates()
cancelMusicControlWindowSync()
hideMusicControlWindow()
} else {
releaseMusicControlWindowUpdates(after: musicControlResumeDelay)
enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05)
}
}
.onChange(of: musicControlWindowEnabled) { _, enabled in
if enabled {
if musicManager.isPlaying || !musicManager.isPlayerIdle {
clearMusicControlVisibilityDeadline()
}
enqueueMusicControlWindowSync(forceRefresh: true)
} else {
cancelMusicControlWindowSync()
hideMusicControlWindow()
clearMusicControlVisibilityDeadline()
hasPendingMusicControlSync = false
pendingMusicControlForceRefresh = false
}
}
.onChange(of: coordinator.musicLiveActivityEnabled) { _, enabled in
if enabled {
enqueueMusicControlWindowSync(forceRefresh: true)
} else {
cancelMusicControlWindowSync()
hideMusicControlWindow()
clearMusicControlVisibilityDeadline()
hasPendingMusicControlSync = false
pendingMusicControlForceRefresh = false
}
}
.onChange(of: vm.hideOnClosed) { _, hidden in
if hidden {
cancelMusicControlWindowSync()
hideMusicControlWindow()
} else {
enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05)
}
}
.onChange(of: lockScreenManager.isLocked) { _, locked in
if locked {
suppressMusicControlWindowUpdates()
cancelMusicControlWindowSync()
hideMusicControlWindow()
} else {
releaseMusicControlWindowUpdates(after: musicControlResumeDelay)
enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05)
}
}
.onChange(of: showStandardMediaControls) { _, _ in
handleStandardMediaControlsAvailabilityChange()
}
.onChange(of: enableMinimalisticUI) { _, _ in
handleStandardMediaControlsAvailabilityChange()
}
.onChange(of: gestureProgress) { _, _ in
if shouldShowMusicControlWindow() {
enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05)
}
}
.onChange(of: isHovering) { _, hovering in
if shouldShowMusicControlWindow() {
enqueueMusicControlWindowSync(forceRefresh: true, delay: hovering ? 0.05 : 0.12)
}
}
.onChange(of: musicManager.isPlaying) { _, isPlaying in
handleMusicControlPlaybackChange(isPlaying: isPlaying)
}
.onChange(of: musicManager.isPlayerIdle) { _, isIdle in
handleMusicControlIdleChange(isIdle: isIdle)
}
.onChange(of: vm.closedNotchSize) { _, _ in
if shouldShowMusicControlWindow() {
enqueueMusicControlWindowSync(forceRefresh: true)
}
}
.onChange(of: vm.effectiveClosedNotchHeight) { _, _ in
if shouldShowMusicControlWindow() {
enqueueMusicControlWindowSync(forceRefresh: true)
}
}
.onDisappear {
hoverTask?.cancel()
stopHoverClickMonitor()
removeStickyTerminalClickMonitor()
stopHiddenEdgeHoverPolling()
cancelMusicControlWindowSync()
hideMusicControlWindow()
cancelMusicControlVisibilityTimer()
clearMusicControlVisibilityDeadline()
musicControlSuppressionTask?.cancel()
}
}
@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 {
let hasMusicMetadata = !musicManager.songTitle.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty
|| !musicManager.artistName.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty
let hasActiveMusicSnapshot: Bool = {
if musicManager.isPlaying { return true }
return !musicManager.isPlayerIdle && hasMusicMetadata
}()
let musicPairingEligible = vm.notchState == .closed
&& hasActiveMusicSnapshot
&& coordinator.musicLiveActivityEnabled
&& closedMusicContentEnabled
&& !vm.hideOnClosed
&& !lockScreenManager.isLocked
let musicSecondary = resolveMusicSecondaryLiveActivity(isMusicPairingEligible: musicPairingEligible)
let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary)
let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID)
let activeSneakPeekStyle = resolvedSneakPeekStyle()
let expansionMatchesSecondary: Bool = {
guard let musicSecondary else { return false }
switch musicSecondary {
case .timer:
return coordinator.expandingView.type == .timer
case .reminder:
return coordinator.expandingView.type == .reminder
case .recording:
return coordinator.expandingView.type == .recording
case .focus:
return coordinator.expandingView.type == .doNotDisturb
case .capsLock:
return false
case .extensionPayload:
return false
}
}()
let canShowMusicDuringExpansion = !coordinator.expandingView.show
|| coordinator.expandingView.type == .music
|| expansionMatchesSecondary
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 isSneakPeekVisibleOnCurrentScreen && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .reminder) && !coordinator.sneakPeek.type.isExtensionPayload && ((coordinator.sneakPeek.type != .volume && coordinator.sneakPeek.type != .brightness && coordinator.sneakPeek.type != .backlight) || vm.notchState == .closed) {
InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress)
.transition(
coordinator.sneakPeek.type == .capsLock
? AnyTransition.move(edge: .trailing).combined(with: .opacity)
: AnyTransition.opacity
)
} else if vm.notchState == .closed && capsLockManager.isCapsLockActive && Defaults[.enableCapsLockIndicator] && !vm.hideOnClosed && !lockScreenManager.isLocked {
InlineHUD(type: .constant(.capsLock), value: .constant(1.0), icon: .constant(""), hoverAnimation: $isHovering, gestureProgress: $gestureProgress)
.transition(AnyTransition.move(edge: .trailing).combined(with: .opacity))
} else if canShowMusicDuringExpansion && musicPairingEligible {
MusicLiveActivity(secondary: musicSecondary)
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed {
TimerLiveActivity()
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .reminder) && vm.notchState == .closed && reminderManager.isActive && enableReminderLiveActivity && !vm.hideOnClosed {
ReminderLiveActivity()
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed && !musicPairingEligible {
RecordingLiveActivity()
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed {
DownloadLiveActivity()
.transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2)))
} else if !coordinator.expandingView.show && vm.notchState == .closed && localSendLiveActivityActive && !vm.hideOnClosed {
LocalSendLiveActivity()
.transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2)))
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && (doNotDisturbManager.isDoNotDisturbActive || doNotDisturbManager.isFocusToastDismissing) && !vm.hideOnClosed && !lockScreenManager.isLocked {
DoNotDisturbLiveActivity()
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed {
LockScreenLiveActivity()
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed {
PrivacyLiveActivity()
} else if let extensionPayload = extensionStandalonePayload {
let layout = extensionStandaloneLayout(
for: extensionPayload,
notchHeight: vm.effectiveClosedNotchHeight,
isHovering: isHovering
)
ExtensionLiveActivityStandaloneView(
payload: extensionPayload,
layout: layout,
isHovering: isHovering
)
} 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))
} else {
Rectangle().fill(.clear).frame(width: vm.closedNotchSize.width - 20, height: vm.effectiveClosedNotchHeight)
}
if isSneakPeekVisibleOnCurrentScreen {
if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .reminder) && (coordinator.sneakPeek.type != .capsLock) && !coordinator.sneakPeek.type.isExtensionPayload && !Defaults[.inlineHUD] && ((coordinator.sneakPeek.type != .volume && coordinator.sneakPeek.type != .brightness && coordinator.sneakPeek.type != .backlight) || vm.notchState == .closed) {
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 && activeSneakPeekStyle == .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 && activeSneakPeekStyle == .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)
}
}
else if coordinator.sneakPeek.type == .reminder {
if !vm.hideOnClosed && activeSneakPeekStyle == .standard, let reminder = reminderManager.activeReminder {
GeometryReader { geo in
let chipColor = Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7)
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(chipColor)
.frame(width: 8, height: 12)
MarqueeText(
.constant(reminderSneakPeekText(for: reminder, now: reminderManager.currentDate)),
textColor: reminderColor(for: reminder, now: reminderManager.currentDate),
minDuration: 1,
frameWidth: max(0, geo.size.width - 14)
)
}
}
.padding(.bottom, 10)
}
}
// Extension live activity sneak peek
else if case let .extensionLiveActivity(bundleID, activityID) = coordinator.sneakPeek.type {
if !vm.hideOnClosed && activeSneakPeekStyle == .standard {
let payload = extensionLiveActivityManager.payload(bundleIdentifier: bundleID, activityID: activityID)
let descriptor = payload?.descriptor
let accent = (descriptor?.accentColor.swiftUIColor ?? coordinator.sneakPeek.accentColor ?? .gray)
.ensureMinimumBrightness(factor: 0.7)
GeometryReader { geo in
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(accent)
.frame(width: 8, height: 12)
MarqueeText(
.constant(
extensionSneakPeekText(
preferredTitle: coordinator.sneakPeek.title,
preferredSubtitle: coordinator.sneakPeek.subtitle,
descriptor: descriptor
)
),
textColor: accent,
minDuration: 1,
frameWidth: max(0, geo.size.width - 14)
)
}
}
.padding(.bottom, 10)
}
}
}
}
}
.conditionalModifier(shouldFixSizeForSneakPeek()) { 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()
case .notes:
NotchNotesView()
case .clipboard:
NotchNotesView()
case .terminal:
NotchTerminalView()
case .extensionExperience:
if let payload = currentExtensionTabPayload() {
ExtensionNotchExperienceTabView(payload: payload)
} else {
NotchHomeView(albumArtNamespace: albumArtNamespace)
}
}
}
.id(coordinator.currentView)
.transition(tabSwitchTransition)
}
}
.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)
.animation(.smooth(duration: 0.3), value: coordinator.currentView)
}
}
private func reminderColor(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Color {
if isReminderCritical(reminder, now: now) {
return .red
}
return Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7)
}
private func reminderSneakPeekText(for entry: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String {
let title = entry.event.title.isEmpty ? "Upcoming Reminder" : entry.event.title
let remaining = max(entry.event.start.timeIntervalSince(now), 0)
let window = TimeInterval(Defaults[.reminderSneakPeekDuration])
if window > 0 && remaining <= window {
return "\(title) • now"
}
let minutes = Int(ceil(remaining / 60))
let timeString = reminderTimeFormatter.string(from: entry.event.start)
if minutes <= 0 {
return "\(title) • now • \(timeString)"
} else if minutes == 1 {
return "\(title) • in 1 min • \(timeString)"
} else {
return "\(title) • in \(minutes) min • \(timeString)"
}
}
private func extensionSneakPeekText(preferredTitle: String, preferredSubtitle: String?, descriptor: AtollLiveActivityDescriptor?) -> String {
let trimmedPreferredTitle = preferredTitle.trimmingCharacters(in: .whitespacesAndNewlines)
let descriptorTitle = descriptor?.title.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Extension"
let title = trimmedPreferredTitle.isEmpty ? descriptorTitle : trimmedPreferredTitle
let trimmedPreferredSubtitle = preferredSubtitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let descriptorSubtitle = descriptor?.subtitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let subtitle = !trimmedPreferredSubtitle.isEmpty ? trimmedPreferredSubtitle : descriptorSubtitle
guard !subtitle.isEmpty else { return title }
return "\(title) • \(subtitle)"
}
private let reminderTimeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
@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)
IdleAnimationView()
}
}.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
}
@ViewBuilder
private func MusicLiveActivity(secondary preResolvedSecondary: MusicSecondaryLiveActivity? = nil) -> some View {
let secondary = preResolvedSecondary ?? resolveMusicSecondaryLiveActivity()
let notchContentHeight = max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))
let wingBaseWidth = max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2)
let rawCenterBaseWidth = vm.closedNotchSize.width + (isHovering ? 8 : 0)
let centerBaseWidth = max(rawCenterBaseWidth, 96)
let inlineSneakPeekActive = (
coordinator.expandingView.show &&
(coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer) &&
Defaults[.enableSneakPeek] &&
Defaults[.sneakPeekStyles] == .inline
)
let rightWingWidth = resolvedRightWingWidth(
for: secondary,
baseWidth: wingBaseWidth,
centerBaseWidth: centerBaseWidth,
notchHeight: notchContentHeight
)
let effectiveCenterWidth = inlineSneakPeekActive ? 380 : centerBaseWidth
let notchWidth = wingBaseWidth + effectiveCenterWidth + rightWingWidth
let badgeBaseSize = max(13, notchContentHeight * 0.36)
let badgeDisplaySize = badgeDisplaySize(for: secondary, baseSize: badgeBaseSize)
let badgeOffset = badgeOverlayOffset(for: secondary, badgeSize: badgeDisplaySize)
HStack(spacing: 0) {
ZStack(alignment: .bottomTrailing) {
Color.clear
.aspectRatio(1, contentMode: .fit)
.background(
Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.closed/3.0 : MusicPlayerImageSizes.cornerRadiusInset.closed))
)
.clipped()
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)
.albumArtFlip(angle: musicManager.flipAngle)
albumArtBadge(for: secondary, badgeSize: badgeDisplaySize)
.offset(x: badgeOffset.width, y: badgeOffset.height)
.id(secondary?.id ?? "music-badge")
.contentTransition(.symbolEffect(.replace))
}
.frame(width: wingBaseWidth, height: notchContentHeight)
Rectangle()
.fill(.black)
.frame(width: effectiveCenterWidth, height: notchContentHeight)
.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: max(0, (effectiveCenterWidth - vm.closedNotchSize.width) / 2 - 12)
)
.padding(.leading, 8)
.opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0)
Spacer(minLength: vm.closedNotchSize.width)
Text(musicManager.artistName)
.lineLimit(1)
.truncationMode(.tail)
.foregroundStyle(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray)
.padding(.trailing, 8)
.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: max(0, (effectiveCenterWidth - vm.closedNotchSize.width) / 2 - 12)
)
.padding(.leading, 8)
.opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0)
Spacer(minLength: vm.closedNotchSize.width)
Text(timerManager.formattedRemainingTime())
.lineLimit(1)
.truncationMode(.tail)
.foregroundStyle(timerManager.timerColor)
.padding(.trailing, 8)
.opacity((coordinator.expandingView.show && coordinator.expandingView.type == .timer && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0)
}
}
.clipped()
)
musicRightWing(for: secondary, notchHeight: notchContentHeight, trailingWidth: rightWingWidth)
.frame(width: rightWingWidth, height: notchContentHeight, alignment: .center)
.id(secondary?.id ?? "music-spectrum")
.contentTransition(.symbolEffect(.replace))
}
.frame(width: notchWidth, height: notchContentHeight)
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
.animation(.smooth(duration: 0.25), value: secondary?.id)
}
private func resolveMusicSecondaryLiveActivity(isMusicPairingEligible: Bool = true) -> MusicSecondaryLiveActivity? {
if coordinator.timerLiveActivityEnabled && timerManager.isTimerActive {
return .timer
}
if enableReminderLiveActivity, reminderManager.isActive, let reminder = reminderManager.activeReminder {
return .reminder(reminder)
}
if enableScreenRecordingDetection && (recordingManager.isRecording || !recordingManager.isRecorderIdle) {
return .recording
}
if enableDoNotDisturbDetection && showDoNotDisturbIndicator && doNotDisturbManager.isDoNotDisturbActive {
let mode = FocusModeType.resolve(identifier: doNotDisturbManager.currentFocusModeIdentifier, name: doNotDisturbManager.currentFocusModeName)
return .focus(mode)
}
if enableCapsLockIndicator && capsLockManager.isCapsLockActive {
return .capsLock(showLabel: showCapsLockLabel)
}
if isMusicPairingEligible, let extensionPayload = resolvedExtensionMusicPayload() {
return .extensionPayload(extensionPayload)
}
return nil
}
private func resolvedRightWingWidth(for secondary: MusicSecondaryLiveActivity?, baseWidth: CGFloat, centerBaseWidth: CGFloat, notchHeight: CGFloat) -> CGFloat {
guard let secondary else { return baseWidth }
switch secondary {
case .timer:
return timerRightWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth)
case .reminder(let entry):
return reminderRightWingWidth(for: entry, baseWidth: baseWidth, notchHeight: notchHeight, now: reminderManager.currentDate)
case .capsLock(let showLabel):
return showLabel ? scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.4, extra: 12) : baseWidth
case .focus:
return focusRightWingWidth(baseWidth: baseWidth)
case .recording:
return recordingRightWingWidth(baseWidth: baseWidth)
case .extensionPayload(let payload):
let maxWidth = baseWidth + centerBaseWidth * 0.6
return ExtensionLayoutMetrics.trailingWidth(for: payload, baseWidth: baseWidth, maxWidth: maxWidth)
}
}
private func timerRightWingWidth(baseWidth: CGFloat, centerBaseWidth: CGFloat) -> CGFloat {
if timerShowsCountdown {
return timerCountdownWingWidth(baseWidth: baseWidth)
}
let showsProgress = timerShowsProgress
let usesRingProgress = timerProgressStyle == .ring
switch (showsProgress, usesRingProgress) {
case (true, true):
return scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.46, extra: 18)
case (true, false):
return scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.52, extra: 24)
case (false, _):
return scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.38, extra: 12)
}
}
private func timerCountdownWingWidth(baseWidth: CGFloat) -> CGFloat {
let padding: CGFloat = 18
let ringWidth: CGFloat = (timerShowsProgress && timerProgressStyle == .ring) ? 30 : 0
let spacing: CGFloat = (ringWidth > 0) ? 8 : 0
let countdownText = timerManager.formattedRemainingTime()
let countdownWidth = TimerSupplementMetrics.countdownFrameWidth(for: countdownText)
return max(baseWidth, padding + ringWidth + spacing + countdownWidth)
}
private func reminderRightWingWidth(for entry: ReminderLiveActivityManager.ReminderEntry, baseWidth: CGFloat, notchHeight: CGFloat, now: Date) -> CGFloat {
let padding: CGFloat = 16
switch reminderPresentationStyle {
case .ringCountdown:
let diameter = ReminderSupplementMetrics.ringDiameter(for: notchHeight)
return max(baseWidth, padding + diameter)
case .digital:
let countdownText = ReminderSupplementMetrics.digitalCountdownText(for: entry, now: now)
let width = ReminderSupplementMetrics.digitalFrameWidth(for: countdownText)
return max(baseWidth, padding + width)
case .minutes:
let minutesText = ReminderSupplementMetrics.minutesCountdownText(for: entry, now: now)
let width = ReminderSupplementMetrics.minutesFrameWidth(for: minutesText)
return max(baseWidth, padding + width)
}
}
private func focusRightWingWidth(baseWidth: CGFloat) -> CGFloat {
// Focus pairings now mirror the default music spectrum width to keep the notch compact.
return baseWidth
}
private func recordingRightWingWidth(baseWidth: CGFloat) -> CGFloat {
// Keep recording pairings compact by reducing the width relative to the notch height.
let absoluteMin: CGFloat = 38
let preferredWidth = max(baseWidth * 0.6, 0)
let maxWidth = min(baseWidth - 6, 52)
let clampedPreferred = min(preferredWidth, maxWidth)
return min(baseWidth, max(absoluteMin, clampedPreferred))
}
private func scaledWingWidth(baseWidth: CGFloat, centerBaseWidth: CGFloat, factor: CGFloat, extra: CGFloat) -> CGFloat {
max(baseWidth, max(centerBaseWidth * factor, baseWidth + extra))
}
@ViewBuilder
private func albumArtBadge(for secondary: MusicSecondaryLiveActivity?, badgeSize: CGFloat) -> some View {
if let secondary, badgeSize > 0 {
ZStack {
Circle()
.fill(Color.black)
switch secondary {
case .timer:
Image(systemName: "timer")
.font(.system(size: badgeSize * 0.55, weight: .semibold))
.foregroundStyle(timerAccentColor)
case .reminder(let entry):
let accent = reminderColor(for: entry, now: reminderManager.currentDate)
Image(systemName: "clock")
.font(.system(size: badgeSize * 0.55, weight: .semibold))
.foregroundStyle(accent)
case .focus(let mode):
mode.resolvedActiveIcon(usePrivateSymbol: true)
.renderingMode(.template)
.font(.system(size: badgeSize * 0.5, weight: .semibold))
.foregroundStyle(mode.accentColor)
case .recording:
Circle()
.fill(Color.red)
.frame(width: badgeSize * 0.45, height: badgeSize * 0.45)
.modifier(PulsingModifier())
case .capsLock:
Image(systemName: "capslock.fill")
.font(.system(size: badgeSize * 0.5, weight: .semibold))
.foregroundStyle(capsLockTintMode.color)
case .extensionPayload(let payload):
ExtensionBadgeIconView(
descriptor: payload.descriptor.leadingIcon,
accent: payload.descriptor.accentColor.swiftUIColor,
size: badgeSize
)
}
}
.frame(width: badgeSize, height: badgeSize)
.shadow(color: .black.opacity(0.35), radius: 3, x: 0, y: 1)
.transition(.opacity.combined(with: .scale))
} else {
EmptyView()
}
}
private func badgeDisplaySize(for secondary: MusicSecondaryLiveActivity?, baseSize: CGFloat) -> CGFloat {
guard let secondary else { return baseSize }
switch secondary {
default:
return baseSize
}
}
private func badgeOverlayOffset(for secondary: MusicSecondaryLiveActivity?, badgeSize: CGFloat) -> CGSize {
guard let secondary else { return CGSize(width: badgeSize * 0.2, height: badgeSize * 0.25) }
switch secondary {
default:
return CGSize(width: badgeSize * 0.2, height: badgeSize * 0.25)
}
}
@ViewBuilder
private func musicRightWing(for secondary: MusicSecondaryLiveActivity?, notchHeight: CGFloat, trailingWidth: CGFloat) -> some View {
switch secondary {
case .timer:
MusicTimerSupplementView(
timerManager: timerManager,
accentColor: timerAccentColor,
showsCountdown: timerShowsCountdown,
showsProgress: timerShowsProgress,
progressStyle: timerProgressStyle,
notchHeight: notchHeight
)
case .reminder(let entry):
MusicReminderSupplementView(
entry: entry,
now: reminderManager.currentDate,
style: reminderPresentationStyle,
accent: reminderColor(for: entry, now: reminderManager.currentDate),
notchHeight: notchHeight
)
case .capsLock(let showLabel):
if showLabel {
MusicCapsLockLabelView(color: capsLockTintMode.color)
} else {
spectrumView(forceSpectrum: true)
}
case .focus:
spectrumView(forceSpectrum: true)
case .recording:
spectrumView(forceSpectrum: true, trailingInset: 6)
case .extensionPayload(let payload):
ExtensionMusicWingView(payload: payload, notchHeight: notchHeight, trailingWidth: trailingWidth)
case .none:
spectrumView(forceSpectrum: false)
}
}
@ViewBuilder
private func spectrumView(forceSpectrum: Bool, trailingInset: CGFloat = 0) -> some View {
if useMusicVisualizer || forceSpectrum {
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(maxWidth: .infinity, maxHeight: .infinity)
.padding(.trailing, trailingInset)
} else {
LottieAnimationView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private var timerAccentColor: Color {
switch timerIconColorMode {
case .adaptive:
if let presetId = timerManager.activePresetId,
let preset = timerPresets.first(where: { $0.id == presetId }) {
return preset.color
}
return timerManager.timerColor
case .solid:
return timerSolidColor
}
}
private func reminderIconName(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String {
isReminderCritical(reminder, now: now) ? ReminderLiveActivityManager.criticalIconName : ReminderLiveActivityManager.standardIconName
}
private func isReminderCritical(_ reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Bool {
let window = TimeInterval(Defaults[.reminderSneakPeekDuration])
guard window > 0 else { return false }
let remaining = reminder.event.start.timeIntervalSince(now)
return remaining > 0 && remaining <= window
}
private func extensionSecondaryPayloadID(for secondary: MusicSecondaryLiveActivity?) -> String? {
guard case let .extensionPayload(payload) = secondary else { return nil }
return payload.id
}
private func resolvedExtensionMusicPayload() -> ExtensionLiveActivityPayload? {
let candidates = extensionLiveActivityManager.sortedActivities(for: true)
guard let payload = candidates.first else {
ExtensionRoutingDiagnostics.shared.logSuppression(
.music,
reason: "no eligible coexistence payloads",
pendingCount: candidates.count
)
ExtensionRoutingDiagnostics.shared.reset(.music)
return nil
}
guard enableExtensionLiveActivities else {
ExtensionRoutingDiagnostics.shared.logSuppression(
.music,
reason: "feature toggle disabled",
pendingCount: candidates.count
)
return nil
}
guard closedMusicContentEnabled else {
ExtensionRoutingDiagnostics.shared.logSuppression(
.music,
reason: "music content disabled",
pendingCount: candidates.count
)
return nil
}
guard vm.notchState == .closed else {
ExtensionRoutingDiagnostics.shared.logSuppression(
.music,
reason: "notch is \(vm.notchState)",
pendingCount: candidates.count
)
return nil
}
guard !vm.hideOnClosed else {
ExtensionRoutingDiagnostics.shared.logSuppression(
.music,
reason: "hideOnClosed engaged (fullscreen)",
pendingCount: candidates.count
)
return nil
}
guard !lockScreenManager.isLocked else {
ExtensionRoutingDiagnostics.shared.logSuppression(
.music,
reason: "lock screen currently active",
pendingCount: candidates.count
)
return nil
}
guard coordinator.musicLiveActivityEnabled else {
ExtensionRoutingDiagnostics.shared.logSuppression(
.music,
reason: "music live activity disabled in settings",
pendingCount: candidates.count
)
return nil
}
ExtensionRoutingDiagnostics.shared.logDisplay(.music, payload: payload)
return payload
}
private func resolvedExtensionStandalonePayload(excluding musicPayloadID: String?) -> ExtensionLiveActivityPayload? {
let baseCandidates = extensionLiveActivityManager.sortedActivities()
guard !baseCandidates.isEmpty else {
ExtensionRoutingDiagnostics.shared.logSuppression(
.standalone,
reason: "no active extension payloads",
pendingCount: 0
)
ExtensionRoutingDiagnostics.shared.reset(.standalone)
return nil
}
let candidates = baseCandidates.filter { $0.id != musicPayloadID }
guard let payload = candidates.first else {
if let musicPayloadID {
ExtensionRoutingDiagnostics.shared.logSuppression(
.standalone,
reason: "all pending payloads are paired with music (\(musicPayloadID))",
pendingCount: baseCandidates.count
)
} else {
ExtensionRoutingDiagnostics.shared.logSuppression(
.standalone,
reason: "no standalone payloads after filtering",
pendingCount: baseCandidates.count
)
ExtensionRoutingDiagnostics.shared.reset(.standalone)
}
return nil
}
guard enableExtensionLiveActivities else {
ExtensionRoutingDiagnostics.shared.logSuppression(
.standalone,
reason: "feature toggle disabled",
pendingCount: candidates.count
)
return nil
}
guard vm.notchState == .closed else {
ExtensionRoutingDiagnostics.shared.logSuppression(
.standalone,
reason: "notch is \(vm.notchState)",
pendingCount: candidates.count
)
return nil
}
guard !vm.hideOnClosed else {
ExtensionRoutingDiagnostics.shared.logSuppression(
.standalone,
reason: "hideOnClosed engaged (fullscreen)",
pendingCount: candidates.count
)
return nil
}
guard !lockScreenManager.isLocked else {
ExtensionRoutingDiagnostics.shared.logSuppression(
.standalone,
reason: "lock screen currently active",
pendingCount: candidates.count
)
return nil
}
guard vm.effectiveClosedNotchHeight > 0 else {
ExtensionRoutingDiagnostics.shared.logSuppression(
.standalone,
reason: "effective notch height is \(vm.effectiveClosedNotchHeight)",
pendingCount: candidates.count
)
return nil
}
guard !coordinator.expandingView.show else {
ExtensionRoutingDiagnostics.shared.logSuppression(
.standalone,
reason: "expanding view \(coordinator.expandingView.type) visible",
pendingCount: candidates.count
)
return nil
}
ExtensionRoutingDiagnostics.shared.logDisplay(.standalone, payload: payload)
return payload
}
private func extensionStandaloneLayout(for payload: ExtensionLiveActivityPayload, notchHeight: CGFloat, isHovering: Bool) -> ExtensionStandaloneLayout {
let outerHeight = notchHeight
let contentHeight = max(0, notchHeight - (isHovering ? 0 : 12))
let leadingWidth = max(contentHeight, 44)
let centerWidth: CGFloat = max(vm.closedNotchSize.width + (isHovering ? 8 : 0), 96)
let trailingWidth = ExtensionLayoutMetrics.trailingWidth(
for: payload,
baseWidth: leadingWidth,
maxWidth: leadingWidth + centerWidth * 0.6
)
let totalWidth = leadingWidth + centerWidth + trailingWidth
return ExtensionStandaloneLayout(
totalWidth: totalWidth,
outerHeight: outerHeight,
contentHeight: contentHeight,
leadingWidth: leadingWidth,
centerWidth: centerWidth,
trailingWidth: trailingWidth
)
}
@MainActor
private final class ExtensionRoutingDiagnostics {
static let shared = ExtensionRoutingDiagnostics()
enum Channel: Hashable {
case music
case standalone
var label: String {
switch self {
case .music:
return "music pairing"
case .standalone:
return "standalone notch"
}
}
}
private var lastMessages: [Channel: String] = [:]
func logSuppression(_ channel: Channel, reason: String, pendingCount: Int) {
log("Extension \(channel.label) suppressed: \(reason) (pending: \(pendingCount))", channel: channel)
}
func logDisplay(_ channel: Channel, payload: ExtensionLiveActivityPayload) {
log("Extension \(channel.label) showing \(payload.descriptor.id) from \(payload.bundleIdentifier)", channel: channel)
}
func reset(_ channel: Channel) {
lastMessages.removeValue(forKey: channel)
}
private func log(_ message: String, channel: Channel) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
guard lastMessages[channel] != message else { return }
lastMessages[channel] = message
Logger.log(message, category: .extensions)
}
}
@ViewBuilder
var dragDetector: some View {
if lockScreenManager.isLocked {
EmptyView()
} else if Defaults[.dynamicShelf] && !Defaults[.enableMinimalisticUI] {
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
openNotch()
} else if !isTargeted {
if vm.dropEvent {
vm.dropEvent = false
return
}
vm.dropEvent = false
if !shouldPreventAutoClose() {
vm.close()
}
}
}
} else {
EmptyView()
}
}
// MARK: - Private Methods
private func openNotch() {
withAnimation(.bouncy.speed(1.2)) {
vm.open()
}
}
private func hiddenHoverActivationContainsMouse(_ location: NSPoint = NSEvent.mouseLocation) -> Bool {
guard let screen = NSScreen.screens.first(where: { $0.localizedName == currentScreenName }) else {
return false
}
let horizontalPadding: CGFloat = 8
let activationWidth = vm.closedNotchSize.width + horizontalPadding * 2
let activationHeight = max(vm.closedNotchSize.height + zeroHeightHoverPadding, 14)
let activationRect = CGRect(
x: screen.frame.midX - activationWidth / 2,
y: screen.frame.maxY - activationHeight,
width: activationWidth,
height: activationHeight
)
return activationRect.contains(location)
}
private func startHiddenEdgeHoverPolling() {
guard hiddenEdgeHoverPollingTask == nil else { return }
hiddenEdgeHoverPollingTask = Task { @MainActor in
while !Task.isCancelled {
if self.shouldUseHiddenEdgeHoverPolling {
let hovering = self.hiddenHoverActivationContainsMouse()
if hovering != self.isHovering {
self.handleHover(hovering)
}
}
try? await Task.sleep(for: .milliseconds(50))
}
self.hiddenEdgeHoverPollingTask = nil
}
}
private func stopHiddenEdgeHoverPolling() {
hiddenEdgeHoverPollingTask?.cancel()
hiddenEdgeHoverPollingTask = nil
}
private func startHoverClickMonitor() {
guard hoverClickMonitor == nil else { return }
let handleClick: @Sendable () -> Void = { [weak vm, weak lockScreenManager] in
Task { @MainActor in
guard let vm, let lockScreenManager else { return }
guard !lockScreenManager.isLocked else { return }
guard vm.notchState == .closed else { return }
guard self.isHovering else { return }
if Defaults[.enableHaptics] {
self.triggerHapticIfAllowed()
}
self.openNotch()
}
}
// Global monitor catches clicks outside the app window (e.g. when
// the cursor is at the very top screen edge and the click goes to
// the system rather than our panel).
hoverClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown]) { _ in
handleClick()
}
// Local monitor catches clicks that DO hit our window — at the
// screen edge SwiftUI's .onTapGesture may not fire reliably, but
// the NSEvent local monitor will.
hoverClickLocalMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { event in
handleClick()
return event
}
}
private func stopHoverClickMonitor() {
if let hoverClickMonitor {
NSEvent.removeMonitor(hoverClickMonitor)
self.hoverClickMonitor = nil
}
if let hoverClickLocalMonitor {
NSEvent.removeMonitor(hoverClickLocalMonitor)
self.hoverClickLocalMonitor = nil
}
}
private func installStickyTerminalClickMonitor() {
guard stickyTerminalClickMonitor == nil else { return }
stickyTerminalClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown]) { [weak vm] _ in
Task { @MainActor in
guard let vm, vm.notchState == .open else { return }
vm.close()
}
}
}
private func removeStickyTerminalClickMonitor() {
if let stickyTerminalClickMonitor {
NSEvent.removeMonitor(stickyTerminalClickMonitor)
self.stickyTerminalClickMonitor = nil
}
}
// MARK: - Hover Management
/// Handle hover state changes with debouncing
private func handleHover(_ hovering: Bool) {
hoverTask?.cancel()
if hovering {
startHoverClickMonitor()
removeStickyTerminalClickMonitor()
} else {
stopHoverClickMonitor()
}
if hovering {
withAnimation(.bouncy.speed(1.2)) {
isHovering = true
}
if vm.notchState == .closed && Defaults[.enableHaptics] {
triggerHapticIfAllowed()
}
let shouldFocusTimerTab = enableTimerFeature && timerDisplayMode == .tab && timerManager.isTimerActive && !enableMinimalisticUI
guard vm.notchState == .closed,
!isSneakPeekVisibleOnCurrentScreen,
(Defaults[.openNotchOnHover] || shouldFocusTimerTab) else { return }
hoverTask = Task {
try? await Task.sleep(for: .seconds(Defaults[.minimumHoverDuration]))
guard !Task.isCancelled else { return }
await MainActor.run {
guard self.vm.notchState == .closed,
self.isHovering,
!self.isSneakPeekVisibleOnCurrentScreen else { return }
if shouldFocusTimerTab {
withAnimation(.smooth) {
self.coordinator.currentView = .timer
}
}
self.openNotch()
}
}
} else {
hoverTask = Task {
try? await Task.sleep(for: .milliseconds(100))
guard !Task.isCancelled else { return }
await MainActor.run {
withAnimation(.bouncy.speed(1.2)) {
self.isHovering = false
}
if self.vm.notchState == .open && !self.shouldPreventAutoClose() {
self.vm.close()
} else if self.vm.notchState == .open
&& Defaults[.terminalStickyMode]
&& self.coordinator.currentView == .terminal {
// Terminal sticky mode kept the notch open — install a
// global click monitor so a click outside closes it.
self.installStickyTerminalClickMonitor()
}
}
}
}
}
// Helper function to check if any popovers are active
private func hasAnyActivePopovers() -> Bool {
return vm.isBatteryPopoverActive ||
vm.isClipboardPopoverActive ||
vm.isColorPickerPopoverActive ||
vm.isStatsPopoverActive ||
vm.isTimerPopoverActive ||
vm.isMediaOutputPopoverActive ||
vm.isReminderPopoverActive
}
private func shouldPreventAutoClose() -> Bool {
coordinator.firstLaunch || hasAnyActivePopovers() || vm.isAutoCloseSuppressed || SharingStateManager.shared.preventNotchClose || (Defaults[.terminalStickyMode] && coordinator.currentView == .terminal)
}
// Helper to prevent rapid haptic feedback
private func triggerHapticIfAllowed() {
let now = Date()
if now.timeIntervalSince(lastHapticTime) > 0.3 { // Minimum 300ms between haptics
haptics.toggle()
lastHapticTime = now
}
}
// Helper to check if stats tab has 4+ graphs (needs expanded height)
private func enabledStatsGraphCount() -> Int {
var enabledCount = 0
if showCpuGraph { enabledCount += 1 }
if showMemoryGraph { enabledCount += 1 }
if showGpuGraph { enabledCount += 1 }
if showNetworkGraph { enabledCount += 1 }
if showDiskGraph { enabledCount += 1 }
return enabledCount
}
private func statsRowCount() -> Int {
let count = enabledStatsGraphCount()
if count == 0 { return 0 }
return count <= 3 ? 1 : 2
}
private func currentExtensionTabPayload() -> ExtensionNotchExperiencePayload? {
guard Defaults[.enableThirdPartyExtensions],
Defaults[.enableExtensionNotchExperiences],
Defaults[.enableExtensionNotchTabs] else {
return nil
}
if let selectedID = coordinator.selectedExtensionExperienceID,
let payload = extensionNotchExperienceManager.payload(experienceID: selectedID) {
return payload
}
return extensionNotchExperienceManager.highestPriorityTabPayload()
}
private func extensionTabPreferredHeight(baseSize: CGSize) -> CGFloat? {
guard let preferred = currentExtensionTabPayload()?.descriptor.tab?.preferredHeight else {
return nil
}
let minHeight = baseSize.height
let maxHeight = baseSize.height + statsAdditionalRowHeight
return min(max(preferred, minHeight), maxHeight)
}
// Estimate the height required for minimalistic overrides (notably web content) and clamp it to the notch bounds.
private func extensionMinimalisticPreferredHeight(baseSize: CGSize) -> CGFloat? {
guard let configuration = extensionNotchExperienceManager.minimalisticReplacementPayload()?.descriptor.minimalistic else {
return nil
}
let minHeight = baseSize.height
let maxHeight = baseSize.height + statsAdditionalRowHeight
var contentHeight: CGFloat = 0
var blockCount = 0
if configuration.headline != nil {
contentHeight += 24
blockCount += 1
}
if configuration.subtitle != nil {
contentHeight += 20
blockCount += 1
}
if !configuration.sections.isEmpty {
let sectionEstimate: CGFloat = 98
contentHeight += CGFloat(configuration.sections.count) * sectionEstimate
blockCount += configuration.sections.count
}
if let webDescriptor = configuration.webContent {
contentHeight += webDescriptor.preferredHeight
blockCount += 1
}
guard blockCount > 0 else { return nil }
let spacingAllowance = CGFloat(max(blockCount - 1, 0)) * 16
let topPadding: CGFloat = 10
let bottomPadding: CGFloat = configuration.webContent == nil ? 10 : 0
let estimatedHeight = contentHeight + spacingAllowance + topPadding + bottomPadding
let clampedHeight = min(max(estimatedHeight, minHeight), maxHeight)
return clampedHeight > minHeight ? clampedHeight : nil
}
// MARK: - Gesture Handling
private func handleDownGesture(translation: CGFloat, phase: NSEvent.Phase) {
handleScrollGesture(isDownward: true, translation: translation, phase: phase)
}
private func handleUpGesture(translation: CGFloat, phase: NSEvent.Phase) {
handleScrollGesture(isDownward: false, translation: translation, phase: phase)
}
private func handleScrollGesture(isDownward: Bool, translation: CGFloat, phase: NSEvent.Phase) {
let reverse = Defaults[.reverseScrollGestures]
let shouldOpen = isDownward ? !reverse : reverse
if shouldOpen {
handleOpenScrollGesture(translation: translation, phase: phase)
} else {
guard Defaults[.closeGestureEnabled] else { return }
handleCloseScrollGesture(translation: translation, phase: phase)
}
}
private func handleOpenScrollGesture(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] {
triggerHapticIfAllowed()
}
withAnimation(.smooth) {
gestureProgress = .zero
}
openNotch()
}
}
private func handleCloseScrollGesture(translation: CGFloat, phase: NSEvent.Phase) {
guard vm.notchState == .open, !vm.isHoveringCalendar, !vm.isScrollGestureActive else { return }
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] {
triggerHapticIfAllowed()
}
}
}
private func handleSkipGesture(direction: MusicManager.SkipDirection, translation: CGFloat, phase: NSEvent.Phase) {
if phase == .ended {
skipGestureActiveDirection = nil
return
}
guard canPerformSkipGesture() else {
skipGestureActiveDirection = nil
return
}
if skipGestureActiveDirection == nil && translation > Defaults[.gestureSensitivity] {
let effectiveDirection: MusicManager.SkipDirection
if Defaults[.reverseSwipeGestures] {
effectiveDirection = direction == .forward ? .backward : .forward
} else {
effectiveDirection = direction
}
skipGestureActiveDirection = effectiveDirection
if Defaults[.enableHaptics] {
triggerHapticIfAllowed()
}
musicManager.handleSkipGesture(direction: effectiveDirection)
}
}
private func canPerformSkipGesture() -> Bool {
enableHorizontalMusicGestures
&& vm.notchState == .open
&& coordinator.currentView == .home
&& (!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil)
&& !lockScreenManager.isLocked
&& !hasAnyActivePopovers()
&& !vm.isHoveringCalendar
&& !vm.isScrollGestureActive
}
private func handleMusicControlPlaybackChange(isPlaying: Bool) {
guard musicControlWindowEnabled else { return }
if isPlaying {
clearMusicControlVisibilityDeadline()
requestMusicControlWindowSyncIfHidden()
} else {
extendMusicControlVisibilityAfterPause()
}
}
private func handleMusicControlIdleChange(isIdle: Bool) {
guard musicControlWindowEnabled else { return }
if isIdle {
if musicControlVisibilityDeadline == nil {
extendMusicControlVisibilityAfterPause()
}
} else if musicManager.isPlaying {
clearMusicControlVisibilityDeadline()
}
}
private func handleStandardMediaControlsAvailabilityChange() {
guard musicControlWindowEnabled else {
hideMusicControlWindow()
return
}
if standardMediaControlsActive {
if musicManager.isPlaying || !musicManager.isPlayerIdle {
clearMusicControlVisibilityDeadline()
}
enqueueMusicControlWindowSync(forceRefresh: true)
} else {
cancelMusicControlWindowSync()
hideMusicControlWindow()
clearMusicControlVisibilityDeadline()
hasPendingMusicControlSync = false
pendingMusicControlForceRefresh = false
}
}
private func extendMusicControlVisibilityAfterPause() {
let deadline = Date().addingTimeInterval(musicControlPauseGrace)
musicControlVisibilityDeadline = deadline
scheduleMusicControlVisibilityCheck(deadline: deadline)
requestMusicControlWindowSyncIfHidden()
}
private func clearMusicControlVisibilityDeadline() {
musicControlVisibilityDeadline = nil
cancelMusicControlVisibilityTimer()
}
private func scheduleMusicControlVisibilityCheck(deadline: Date) {
cancelMusicControlVisibilityTimer()
let interval = max(0, deadline.timeIntervalSinceNow)
musicControlHideTask = Task.detached(priority: .background) { [interval] in
if interval > 0 {
let nanoseconds = UInt64(interval * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
}
guard !Task.isCancelled else { return }
await MainActor.run {
if let currentDeadline = musicControlVisibilityDeadline, currentDeadline <= Date() {
musicControlVisibilityDeadline = nil
}
enqueueMusicControlWindowSync(forceRefresh: false)
musicControlHideTask = nil
}
}
}
private func cancelMusicControlVisibilityTimer() {
musicControlHideTask?.cancel()
musicControlHideTask = nil
}
private func musicControlVisibilityIsActive() -> Bool {
if musicManager.isPlaying {
return true
}
guard let deadline = musicControlVisibilityDeadline else { return false }
return Date() <= deadline
}
private func suppressMusicControlWindowUpdates() {
isMusicControlWindowSuppressed = true
musicControlSuppressionTask?.cancel()
musicControlSuppressionTask = nil
}
private func releaseMusicControlWindowUpdates(after delay: TimeInterval) {
musicControlSuppressionTask?.cancel()
musicControlSuppressionTask = Task { [delay] in
if delay > 0 {
let nanoseconds = UInt64(delay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
}
guard !Task.isCancelled else { return }
await MainActor.run {
if vm.notchState == .closed && !lockScreenManager.isLocked {
isMusicControlWindowSuppressed = false
triggerPendingMusicControlSyncIfNeeded()
} else {
isMusicControlWindowSuppressed = true
}
musicControlSuppressionTask = nil
}
}
}
private func triggerPendingMusicControlSyncIfNeeded() {
guard hasPendingMusicControlSync else { return }
let shouldForce = pendingMusicControlForceRefresh
hasPendingMusicControlSync = false
pendingMusicControlForceRefresh = false
logMusicControlEvent("Flushing pending floating window sync (force: \(shouldForce))")
scheduleMusicControlWindowSync(forceRefresh: shouldForce, bypassSuppression: true)
}
private func shouldDeferMusicControlSync() -> Bool {
vm.notchState != .closed || lockScreenManager.isLocked || isMusicControlWindowSuppressed
}
private func enqueueMusicControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0) {
if shouldDeferMusicControlSync() {
hasPendingMusicControlSync = true
if forceRefresh {
pendingMusicControlForceRefresh = true
}
logMusicControlEvent("Queued floating window sync (force: \(forceRefresh)) while deferred")
return
}
logMusicControlEvent("Scheduling floating window sync (force: \(forceRefresh), delay: \(delay))")
scheduleMusicControlWindowSync(forceRefresh: forceRefresh, delay: delay)
}
private func shouldShowMusicControlWindow() -> Bool {
guard musicControlWindowEnabled,
coordinator.musicLiveActivityEnabled,
standardMediaControlsActive,
vm.notchState == .closed,
!vm.hideOnClosed,
!lockScreenManager.isLocked,
!isMusicControlWindowSuppressed else {
return false
}
return musicControlVisibilityIsActive()
}
private func scheduleMusicControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0, bypassSuppression: Bool = false) {
#if os(macOS)
cancelMusicControlWindowSync()
guard shouldShowMusicControlWindow() else {
hasPendingMusicControlSync = false
pendingMusicControlForceRefresh = false
hideMusicControlWindow()
return
}
if !bypassSuppression && (isMusicControlWindowSuppressed || lockScreenManager.isLocked) {
hasPendingMusicControlSync = true
if forceRefresh {
pendingMusicControlForceRefresh = true
}
return
}
hasPendingMusicControlSync = false
pendingMusicControlForceRefresh = false
let syncDelay = max(0, delay)
pendingMusicControlTask = Task.detached(priority: .userInitiated) { [forceRefresh, syncDelay] in
if syncDelay > 0 {
let nanoseconds = UInt64(syncDelay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
}
guard !Task.isCancelled else { return }
await MainActor.run {
if shouldShowMusicControlWindow() {
logMusicControlEvent("Running floating window sync (force: \(forceRefresh))")
syncMusicControlWindow(forceRefresh: forceRefresh)
} else {
logMusicControlEvent("Skipping floating window sync (conditions changed)")
hideMusicControlWindow()
}
pendingMusicControlTask = nil
}
}
#endif
}
private func cancelMusicControlWindowSync() {
pendingMusicControlTask?.cancel()
pendingMusicControlTask = nil
}
#if os(macOS)
private func currentMusicControlWindowMetrics() -> MusicControlWindowMetrics {
MusicControlWindowMetrics(
notchHeight: max(vm.closedNotchSize.height, vm.effectiveClosedNotchHeight),
notchWidth: vm.closedNotchSize.width + (isHovering ? 8 : 0),
rightWingWidth: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
cornerRadius: activeCornerRadiusInsets.closed.bottom,
spacing: 36
)
}
private func syncMusicControlWindow(forceRefresh: Bool = false) {
let notchAvailable = vm.effectiveClosedNotchHeight > 0 && vm.closedNotchSize.width > 0
let targetVisible = shouldShowMusicControlWindow() && notchAvailable
if targetVisible {
let metrics = currentMusicControlWindowMetrics()
if !isMusicControlWindowVisible {
let didPresent = MusicControlWindowManager.shared.present(using: vm, metrics: metrics)
isMusicControlWindowVisible = didPresent
} else if forceRefresh {
let didRefresh = MusicControlWindowManager.shared.refresh(using: vm, metrics: metrics)
if !didRefresh {
MusicControlWindowManager.shared.hide()
isMusicControlWindowVisible = false
}
}
} else if isMusicControlWindowVisible {
MusicControlWindowManager.shared.hide()
isMusicControlWindowVisible = false
}
}
private func hideMusicControlWindow() {
if isMusicControlWindowVisible {
MusicControlWindowManager.shared.hide()
isMusicControlWindowVisible = false
}
}
#else
private func syncMusicControlWindow(forceRefresh: Bool = false) {}
private func hideMusicControlWindow() {}
#endif
private func shouldFixSizeForSneakPeek() -> Bool {
guard isSneakPeekVisibleOnCurrentScreen else { return false }
let style = resolvedSneakPeekStyle()
// Check for extension sneak peek
if case .extensionLiveActivity = coordinator.sneakPeek.type {
return vm.notchState == .closed && style == .standard
}
// Original logic for other types
let isMusicSneak = coordinator.sneakPeek.type == .music && vm.notchState == .closed && !vm.hideOnClosed && style == .standard
let isTimerSneak = coordinator.sneakPeek.type == .timer && !vm.hideOnClosed && style == .standard
let isReminderSneak = coordinator.sneakPeek.type == .reminder && !vm.hideOnClosed && style == .standard
let isOtherSneak = coordinator.sneakPeek.type != .music && coordinator.sneakPeek.type != .timer && coordinator.sneakPeek.type != .reminder && vm.notchState == .closed
return isMusicSneak || isTimerSneak || isReminderSneak || isOtherSneak
}
private func resolvedSneakPeekStyle() -> SneakPeekStyle {
if case .extensionLiveActivity = coordinator.sneakPeek.type {
return .standard
}
return coordinator.sneakPeek.styleOverride ?? Defaults[.sneakPeekStyles]
}
}
private enum MusicSecondaryLiveActivity: Equatable {
case timer
case reminder(ReminderLiveActivityManager.ReminderEntry)
case recording
case focus(FocusModeType)
case capsLock(showLabel: Bool)
case extensionPayload(ExtensionLiveActivityPayload)
var id: String {
switch self {
case .timer:
return "timer"
case .reminder(let entry):
return "reminder-\(entry.id)"
case .recording:
return "recording"
case .focus(let mode):
return "focus-\(mode.rawValue)"
case .capsLock(let showLabel):
return showLabel ? "caps-lock-label" : "caps-lock-icon"
case .extensionPayload(let payload):
return "extension-\(payload.id)"
}
}
}
private struct MusicTimerSupplementView: View {
@ObservedObject var timerManager: TimerManager
let accentColor: Color
let showsCountdown: Bool
let showsProgress: Bool
let progressStyle: TimerProgressStyle
let notchHeight: CGFloat
private var clampedProgress: Double {
min(max(timerManager.progress, 0), 1)
}
private var showsRingProgress: Bool {
showsProgress && progressStyle == .ring
}
private var showsBarProgress: Bool {
showsProgress && progressStyle == .bar
}
private var countdownText: String {
timerManager.formattedRemainingTime()
}
private var countdownTextWidth: CGFloat {
max(1, TimerSupplementMetrics.countdownTextWidth(for: countdownText))
}
private var countdownFrameWidth: CGFloat {
TimerSupplementMetrics.countdownFrameWidth(for: countdownText)
}
private var timerNameFrameWidth: CGFloat {
TimerSupplementMetrics.timerNameFrameWidth(for: timerManager.timerName)
}
private var ringDiameter: CGFloat {
max(min(notchHeight - 4, 26), 20)
}
var body: some View {
HStack(spacing: showsRingProgress && showsCountdown ? 8 : 0) {
if showsRingProgress {
ringView
}
if showsCountdown {
countdownStack
} else if showsBarProgress {
standaloneBarView
} else {
timerNameView
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
private var countdownStack: some View {
VStack(alignment: .trailing, spacing: showsBarProgress ? 4 : 0) {
Text(countdownText)
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(timerManager.isOvertime ? .red : .white)
.contentTransition(.numericText())
.animation(.smooth(duration: 0.25), value: timerManager.remainingTime)
.frame(width: countdownFrameWidth, alignment: .trailing)
if showsBarProgress {
barView(width: countdownTextWidth)
}
}
.padding(.trailing, 8)
.frame(maxWidth: .infinity, alignment: .trailing)
}
private var ringView: some View {
ZStack {
Circle()
.stroke(Color.white.opacity(0.18), lineWidth: 3)
Circle()
.trim(from: 0, to: clampedProgress)
.stroke(accentColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.smooth(duration: 0.25), value: clampedProgress)
}
.frame(width: ringDiameter, height: ringDiameter)
.frame(width: max(ringDiameter + 4, 30), height: notchHeight, alignment: .center)
}
private var standaloneBarView: some View {
barView(width: 68)
.frame(maxWidth: .infinity, alignment: .trailing)
}
private var timerNameView: some View {
Text(timerManager.timerName)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
.frame(width: timerNameFrameWidth, alignment: .trailing)
}
private func barView(width: CGFloat) -> some View {
Capsule()
.fill(Color.white.opacity(0.15))
.frame(width: width, height: 4)
.overlay(alignment: .leading) {
Capsule()
.fill(accentColor)
.frame(width: width * max(0, CGFloat(clampedProgress)), height: 4)
.animation(.smooth(duration: 0.25), value: clampedProgress)
}
}
}
private struct MusicReminderSupplementView: View {
let entry: ReminderLiveActivityManager.ReminderEntry
let now: Date
let style: ReminderPresentationStyle
let accent: Color
let notchHeight: CGFloat
var body: some View {
Group {
switch style {
case .ringCountdown:
ringCountdownView
case .digital:
digitalCountdownView
case .minutes:
minutesCountdownView
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
}
private var ringCountdownView: some View {
ZStack {
Circle()
.stroke(Color.white.opacity(0.15), lineWidth: 3)
Circle()
.trim(from: 0, to: progressValue)
.stroke(accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.smooth(duration: 0.25), value: progressValue)
}
.frame(width: ringDiameter, height: ringDiameter)
.frame(width: max(ringDiameter + 4, 26), height: notchHeight, alignment: .center)
}
private var digitalCountdownView: some View {
Text(digitalCountdownText)
.font(.system(size: 15, weight: .semibold, design: .monospaced))
.foregroundColor(accent)
.contentTransition(.numericText())
.animation(.smooth(duration: 0.25), value: digitalCountdownText)
.frame(width: digitalFrameWidth, alignment: .trailing)
.frame(height: notchHeight, alignment: .center)
}
private var minutesCountdownView: some View {
Text(minutesCountdownText)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(accent)
.frame(width: minutesFrameWidth, alignment: .trailing)
.frame(height: notchHeight, alignment: .center)
}
private var progressValue: Double {
guard entry.leadTime > 0 else { return 1 }
let remaining = max(entry.event.start.timeIntervalSince(now), 0)
let elapsed = entry.leadTime - remaining
return min(max(elapsed / entry.leadTime, 0), 1)
}
private var digitalCountdownText: String {
ReminderSupplementMetrics.digitalCountdownText(for: entry, now: now)
}
private var minutesCountdownText: String {
ReminderSupplementMetrics.minutesCountdownText(for: entry, now: now)
}
private var ringDiameter: CGFloat {
ReminderSupplementMetrics.ringDiameter(for: notchHeight)
}
private var digitalFrameWidth: CGFloat {
ReminderSupplementMetrics.digitalFrameWidth(for: digitalCountdownText)
}
private var minutesFrameWidth: CGFloat {
ReminderSupplementMetrics.minutesFrameWidth(for: minutesCountdownText)
}
}
private struct MusicCapsLockLabelView: View {
let color: Color
var body: some View {
Text("Caps Lock")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(color)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .trailing)
.contentTransition(.opacity)
}
}
#if canImport(AppKit)
private typealias MusicSupplementFont = NSFont
#elseif canImport(UIKit)
private typealias MusicSupplementFont = UIFont
#endif
private enum TimerSupplementMetrics {
static func countdownTextWidth(for text: String) -> CGFloat {
musicMeasureText(text, font: MusicSupplementFont.monospacedDigitSystemFont(ofSize: 13, weight: .semibold))
}
static func countdownFrameWidth(for text: String) -> CGFloat {
max(countdownTextWidth(for: text) + 16, 72)
}
static func timerNameFrameWidth(for text: String) -> CGFloat {
guard !text.isEmpty else { return 64 }
let width = musicMeasureText(text, font: MusicSupplementFont.systemFont(ofSize: 12, weight: .medium))
return max(width + 14, 64)
}
}
private enum ReminderSupplementMetrics {
static func digitalCountdownText(for entry: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String {
let remaining = max(entry.event.start.timeIntervalSince(now), 0)
let totalSeconds = Int(remaining.rounded(.down))
let minutes = totalSeconds / 60
let seconds = totalSeconds % 60
return String(format: "%02d:%02d", minutes, seconds)
}
static func minutesCountdownText(for entry: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String {
let remaining = max(entry.event.start.timeIntervalSince(now), 0)
let minutes = max(1, Int(ceil(remaining / 60)))
return minutes == 1 ? "in 1 min" : "in \(minutes) min"
}
static func digitalFrameWidth(for text: String) -> CGFloat {
let width = musicMeasureText(text, font: MusicSupplementFont.monospacedDigitSystemFont(ofSize: 15, weight: .semibold))
return max(width + 18, 76)
}
static func minutesFrameWidth(for text: String) -> CGFloat {
let width = musicMeasureText(text, font: MusicSupplementFont.systemFont(ofSize: 13, weight: .semibold))
return max(width + 18, 88)
}
static func ringDiameter(for notchHeight: CGFloat) -> CGFloat {
max(min(notchHeight - 12, 22), 16)
}
}
private func musicMeasureText(_ text: String, font: MusicSupplementFont) -> CGFloat {
guard !text.isEmpty else { return 0 }
let attributes: [NSAttributedString.Key: Any] = [.font: font]
return CGFloat(ceil(NSAttributedString(string: text, attributes: attributes).size().width))
}
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
}
}