mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
2579 lines
111 KiB
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
|
|
}
|
|
}
|