From cdc973a45bd27b87ada63678d38bd120aef568b5 Mon Sep 17 00:00:00 2001 From: delli Date: Sat, 22 Nov 2025 19:10:10 +0100 Subject: [PATCH 1/6] a little upgrade of the settings view --- DynamicIsland/Localizable.xcstrings | 8 -- .../components/Settings/SettingsView.swift | 119 +++++++++++++++--- DynamicIsland/models/Constants.swift | 2 +- 3 files changed, 104 insertions(+), 25 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index ac86707..b4cc2f3 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -10497,10 +10497,6 @@ } } }, - "Left button" : { - "comment" : "A label for a picker that lets the user select a control for the left button on a media player.", - "isCommentAutoGenerated" : true - }, "License key" : { "localizations" : { "fr" : { @@ -15582,10 +15578,6 @@ } } }, - "Right button" : { - "comment" : "A label for the right button in the media controls picker.", - "isCommentAutoGenerated" : true - }, "Rotation:" : { "localizations" : { "fr" : { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index d7aa82f..30c98cd 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -904,16 +904,7 @@ struct Media: View { } .disabled(!showShuffleAndRepeat) if showShuffleAndRepeat { - Picker("Left button", selection: $musicAuxLeftControl) { - ForEach(MusicAuxiliaryControl.allCases) { control in - Text(control.displayName).tag(control) - } - } - Picker("Right button", selection: $musicAuxRightControl) { - ForEach(MusicAuxiliaryControl.allCases) { control in - Text(control.displayName).tag(control) - } - } + mediaControlsGrid } } header: { Text("Media controls") @@ -1010,6 +1001,102 @@ struct Media: View { .navigationTitle("Media") } + private var mediaControlsGrid: some View { + VStack(alignment: .leading, spacing: 20) { + mediaControlsRow(title: "Left button", selection: $musicAuxLeftControl, otherSelection: musicAuxRightControl) + mediaControlsRow(title: "Right button", selection: $musicAuxRightControl, otherSelection: musicAuxLeftControl) + } + .padding(.vertical, 12) + } + + private func mediaControlsRow(title: String, selection: Binding, otherSelection: MusicAuxiliaryControl) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + + HStack(spacing: 16) { + ForEach([MusicAuxiliaryControl.mediaOutput, MusicAuxiliaryControl.repeatMode, MusicAuxiliaryControl.lyrics, MusicAuxiliaryControl.shuffle], id: \.self) { control in + MediaControlButton( + control: control, + isSelected: selection.wrappedValue == control, + disabled: control == otherSelection + ) { + selection.wrappedValue = control + } + } + } + } + } + + struct MediaControlButton: View { + let control: MusicAuxiliaryControl + let isSelected: Bool + let disabled: Bool + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + VStack(spacing: 8) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(borderColor, lineWidth: isSelected ? 2 : 1) + ) + + Image(systemName: control.symbolName) + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(iconColor) + } + .frame(width: 80, height: 60) + .onHover { hovering in + if !disabled { + withAnimation(.easeInOut(duration: 0.2)) { + isHovering = hovering + } + } + } + + Text(control.displayName) + .font(.caption) + .fontWeight(.medium) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(width: 100) + .foregroundStyle(disabled ? .secondary : .primary) + } + .contentShape(Rectangle()) + .onTapGesture { + if !disabled { + action() + } + } + .opacity(disabled ? 0.5 : 1.0) + } + + private var backgroundColor: Color { + if disabled { return Color(nsColor: .controlBackgroundColor) } + if isSelected { return Color.accentColor.opacity(0.1) } + if isHovering { return Color.primary.opacity(0.05) } + return Color(nsColor: .controlBackgroundColor) + } + + private var borderColor: Color { + if isSelected { return Color.accentColor } + if isHovering { return Color.primary.opacity(0.1) } + return Color.clear + } + + private var iconColor: Color { + if disabled { return .secondary } + if isSelected { return .accentColor } + return .primary + } + } + // Only show controller options that are available on this macOS version private var availableMediaControllers: [MediaControllerType] { if MusicManager.shared.isNowPlayingDeprecated { @@ -2106,34 +2193,34 @@ struct TimerSettings: View { let nextIndex = timerPresets.count + 1 let defaultColor = Defaults[.accentColor] let newPreset = TimerPreset(name: "Preset \(nextIndex)", duration: 5 * 60, color: defaultColor) - _ = withAnimation(.smooth) { + withAnimation(.smooth) { timerPresets.append(newPreset) } } private func movePresetUp(_ index: Int) { guard index > timerPresets.startIndex else { return } - _ = withAnimation(.smooth) { + withAnimation(.smooth) { timerPresets.swapAt(index, index - 1) } } private func movePresetDown(_ index: Int) { guard index < timerPresets.index(before: timerPresets.endIndex) else { return } - _ = withAnimation(.smooth) { + withAnimation(.smooth) { timerPresets.swapAt(index, index + 1) } } private func removePreset(_ index: Int) { guard timerPresets.indices.contains(index) else { return } - _ = withAnimation(.smooth) { + withAnimation(.smooth) { timerPresets.remove(at: index) } } private func resetPresets() { - _ = withAnimation(.smooth) { + withAnimation(.smooth) { timerPresets = TimerPreset.defaultPresets } } @@ -2386,7 +2473,7 @@ struct StatsSettings: View { Section { Defaults.Toggle("CPU Usage", key: .showCpuGraph) - Defaults.Toggle("Memory Usage", key: .showMemoryGraph) + Defaults.Toggle("Memory Usage", key: .showMemoryGraph) Defaults.Toggle("GPU Usage", key: .showGpuGraph) Defaults.Toggle("Network Activity", key: .showNetworkGraph) Defaults.Toggle("Disk I/O", key: .showDiskGraph) diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 55cf754..a9be1d3 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -224,7 +224,7 @@ enum MusicAuxiliaryControl: String, CaseIterable, Identifiable, Defaults.Seriali case .repeatMode: return "repeat" case .mediaOutput: - return "speaker.wave.2" + return "laptopcomputer" case .lyrics: return "quote.bubble" } From 2ae9c896051403185583263ad3667169968b3a17 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 27 Nov 2025 23:55:27 +0530 Subject: [PATCH 2/6] Timer UI Fix --- .../components/LockScreen/LockScreenTimerWidget.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift index bb3d41c..73e17a7 100644 --- a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift @@ -132,8 +132,6 @@ struct LockScreenTimerWidget: View { .overlay(alignment: .topLeading) { accentRibbon } - .animation(.smooth(duration: 0.22), value: timerManager.isPaused) - .animation(.smooth(duration: 0.22), value: timerManager.remainingTime) .scaleEffect(animator.isPresented ? 1 : 0.9) .opacity(animator.isPresented ? 1 : 0) .animation(.spring(response: 0.45, dampingFraction: 0.85, blendDuration: 0.22), value: animator.isPresented) @@ -222,6 +220,7 @@ struct LockScreenTimerWidget: View { return } timerManager.stopTimer() + LockScreenTimerWidgetPanelManager.shared.hide() } private struct CircleButton: View { From 33a854dffb6ff8f174ed7ec24073ad2414532359 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 28 Nov 2025 18:30:49 +0530 Subject: [PATCH 3/6] Resolve Timer Decoupling --- .../LockScreen/LockScreenTimerWidget.swift | 82 +++++++------------ .../components/Settings/SettingsView.swift | 3 + DynamicIsland/models/Constants.swift | 1 + 3 files changed, 34 insertions(+), 52 deletions(-) diff --git a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift index 73e17a7..076c992 100644 --- a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift @@ -2,13 +2,13 @@ import SwiftUI import Defaults struct LockScreenTimerWidget: View { - static let preferredSize = CGSize(width: 420, height: 104) + static let preferredSize = CGSize(width: 420, height: 96) static let cornerRadius: CGFloat = 26 @ObservedObject private var animator: LockScreenTimerWidgetAnimator @ObservedObject private var timerManager = TimerManager.shared @Default(.lockScreenGlassStyle) private var glassStyle - @Default(.lockScreenPanelUsesBlur) private var enableBlur + @Default(.lockScreenTimerWidgetUsesBlur) private var enableTimerBlur @Default(.timerPresets) private var timerPresets @MainActor @@ -20,13 +20,11 @@ struct LockScreenTimerWidget: View { } } - private var clampedProgress: Double { - min(max(timerManager.progress, 0), 1) - } + private var countdownFont: Font { displayFont(size: 56) } - private var titleFont: Font { .system(size: 18, weight: .semibold, design: .rounded) } - private var statusFont: Font { .system(size: 12, weight: .medium, design: .rounded) } - private var countdownFont: Font { .system(size: 42, weight: .semibold, design: .default) } + private func displayFont(size: CGFloat) -> Font { + .custom("SF Pro Display", size: size) + } private var timerLabel: String { timerManager.timerName.isEmpty ? "Timer" : timerManager.timerName @@ -46,10 +44,6 @@ struct LockScreenTimerWidget: View { .ensureMinimumBrightness(factor: 0.75) } - private var secondaryAccentColor: Color { - accentColor.ensureMinimumBrightness(factor: 0.55).opacity(0.45) - } - private var accentGradient: LinearGradient { LinearGradient( colors: [ @@ -70,7 +64,7 @@ struct LockScreenTimerWidget: View { @ViewBuilder private var widgetBackground: some View { - if enableBlur { + if enableTimerBlur { if usesLiquidGlass { liquidBackground } else { @@ -110,7 +104,7 @@ struct LockScreenTimerWidget: View { var body: some View { VStack(spacing: 16) { - HStack(alignment: .center, spacing: 20) { + HStack(alignment: .center, spacing: 18) { controlButtons titleSection @@ -141,9 +135,8 @@ struct LockScreenTimerWidget: View { HStack(spacing: 10) { CircleButton( icon: pauseIcon, - foreground: accentColor, - background: Color.white.opacity(0.15), - borderColor: accentColor.opacity(0.8), + foreground: Color.white.opacity(0.95), + background: accentColor.opacity(0.32), action: togglePause, isEnabled: timerManager.allowsManualInteraction, helpText: pauseLabel @@ -151,9 +144,8 @@ struct LockScreenTimerWidget: View { CircleButton( icon: "xmark", - foreground: Color.white.opacity(0.92), - background: Color.white.opacity(0.18), - borderColor: Color.white.opacity(0.35), + foreground: Color.white.opacity(0.95), + background: Color.black.opacity(0.35), action: stopTimer, isEnabled: timerManager.allowsManualInteraction, helpText: "Stop" @@ -161,41 +153,35 @@ struct LockScreenTimerWidget: View { } } - private var titleSection: some View { - Group { - if timerManager.timerName.count > 18 { - MarqueeText( - .constant(timerLabel), - font: .system(size: 18, weight: .semibold, design: .rounded), - nsFont: .title3, - textColor: accentColor, - minDuration: 0.18, - frameWidth: Self.preferredSize.width * 0.4 - ) - } else { - Text(timerLabel) - .font(titleFont) - .foregroundStyle(accentColor) - .lineLimit(1) - .minimumScaleFactor(0.85) - } - } - .frame(maxWidth: .infinity, alignment: .center) - } - private var countdownSection: some View { - VStack(alignment: .trailing, spacing: 6) { + VStack(alignment: .trailing, spacing: 0) { Text(countdownText) .font(countdownFont) + .monospacedDigit() .foregroundStyle(timerManager.isOvertime ? Color.red : accentColor) .contentTransition(.numericText()) .animation(.smooth(duration: 0.25), value: timerManager.remainingTime) .frame(maxWidth: .infinity, alignment: .trailing) } - .frame(minWidth: 140, maxWidth: 180, alignment: .trailing) + .frame(width: 220, alignment: .center) .padding(.trailing, 2) } + private var titleSection: some View { + VStack(alignment: .center, spacing: 0) { + MarqueeText( + .constant(timerLabel), + font: displayFont(size: 18), + nsFont: .title3, + textColor: accentColor, + minDuration: 0.18, + frameWidth: Self.preferredSize.width * 0.35 + ) + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + private var accentRibbon: some View { Capsule() .fill(accentGradient) @@ -227,7 +213,6 @@ struct LockScreenTimerWidget: View { let icon: String let foreground: Color let background: Color - let borderColor: Color? let action: () -> Void let isEnabled: Bool let helpText: String @@ -240,13 +225,6 @@ struct LockScreenTimerWidget: View { .frame(width: 48, height: 48) .background(background.opacity(isEnabled ? 1 : 0.25)) .clipShape(Circle()) - .overlay { - if let borderColor { - Circle() - .stroke(borderColor.opacity(isEnabled ? 1 : 0.35), lineWidth: 1) - } - } - .shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4) .contentTransition(.symbolEffect(.replace)) } .buttonStyle(.plain) diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 2647f37..f77e8dc 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -179,6 +179,7 @@ struct GeneralSettings: View { @Default(.lockScreenShowAppIcon) var lockScreenShowAppIcon @Default(.lockScreenPanelShowsBorder) var lockScreenPanelShowsBorder @Default(.lockScreenPanelUsesBlur) var lockScreenPanelUsesBlur + @Default(.enableLockScreenTimerWidget) var enableLockScreenTimerWidget @Default(.enableLockScreenWeatherWidget) var enableLockScreenWeatherWidget @Default(.lockScreenWeatherShowsLocation) var lockScreenWeatherShowsLocation @Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging @@ -380,6 +381,8 @@ struct GeneralSettings: View { Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget) Defaults.Toggle("Show lock screen weather", key: .enableLockScreenWeatherWidget) Defaults.Toggle("Show lock screen timer", key: .enableLockScreenTimerWidget) + Defaults.Toggle("Enable timer blur", key: .lockScreenTimerWidgetUsesBlur) + .disabled(!enableLockScreenTimerWidget) if enableLockScreenWeatherWidget { Picker("Widget layout", selection: $lockScreenWeatherWidgetStyle) { ForEach(LockScreenWeatherWidgetStyle.allCases) { style in diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 179682d..216159a 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -473,6 +473,7 @@ extension Defaults.Keys { static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false) static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: true) static let lockScreenPanelUsesBlur = Key("lockScreenPanelUsesBlur", default: true) + static let lockScreenTimerWidgetUsesBlur = Key("lockScreenTimerWidgetUsesBlur", default: true) static let lockScreenReminderChipStyle = Key("lockScreenReminderChipStyle", default: .eventColor) // MARK: Battery From 2db2fb20a542cc200829b3afef1f92be4a3488d0 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 28 Nov 2025 19:13:14 +0530 Subject: [PATCH 4/6] Fix Frame Drop in button animation --- .../components/Timer/TimerLiveActivity.swift | 347 ++++++++++++++---- 1 file changed, 268 insertions(+), 79 deletions(-) diff --git a/DynamicIsland/components/Timer/TimerLiveActivity.swift b/DynamicIsland/components/Timer/TimerLiveActivity.swift index fa9da86..a19524d 100644 --- a/DynamicIsland/components/Timer/TimerLiveActivity.swift +++ b/DynamicIsland/components/Timer/TimerLiveActivity.swift @@ -19,10 +19,16 @@ typealias PlatformFont = UIFont struct TimerLiveActivity: View { @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject var timerManager = TimerManager.shared + @ObservedObject var lockScreenManager = LockScreenManager.shared @State private var isHovering: Bool = false @State private var showTransientLabel: Bool = false @State private var labelHideTask: DispatchWorkItem? @State private var isControlWindowVisible: Bool = false + @State private var pendingControlWindowTask: Task? + @State private var controlWindowSuppressionTask: Task? + @State private var hasPendingControlWindowSync: Bool = false + @State private var pendingControlWindowForceRefresh: Bool = false + @State private var isControlWindowSuppressed: Bool = false @Default(.timerShowsCountdown) private var showsCountdown @Default(.timerShowsProgress) private var showsProgress @Default(.timerShowsLabel) private var showsLabel @@ -39,6 +45,25 @@ struct TimerLiveActivity: View { private var wingPadding: CGFloat { 22 } private var ringStrokeWidth: CGFloat { 3 } private var transientLabelDuration: TimeInterval { 4 } + private let controlWindowResumeDelay: TimeInterval = 0.22 + + private struct ControlWindowSyncKey: Equatable { + var isTimerActive: Bool + var timerNameSignature: Int + var isFinished: Bool + var isOvertime: Bool + var isPaused: Bool + var closedNotchSize: CGSize + var screenName: String? + var hideOnClosed: Bool + var controlWindowEnabled: Bool + var showsLabel: Bool + var showTransientLabel: Bool + var showsCountdown: Bool + var showsProgress: Bool + var progressStyleIdentifier: String + var activeSourceIdentifier: String + } private var ringWrapsIcon: Bool { showsRingProgress && showsCountdown @@ -140,17 +165,48 @@ struct TimerLiveActivity: View { return timerPresets.first { $0.id == presetId }?.color } + private var middleSectionWidth: CGFloat { + vm.closedNotchSize.width + (isHovering ? 8 : 0) + } + + private var adjustedNotchHeight: CGFloat { + vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0) + } + + private var controlWindowSyncKey: ControlWindowSyncKey { + ControlWindowSyncKey( + isTimerActive: timerManager.isTimerActive, + timerNameSignature: timerManager.timerName.hashValue, + isFinished: timerManager.isFinished, + isOvertime: timerManager.isOvertime, + isPaused: timerManager.isPaused, + closedNotchSize: vm.closedNotchSize, + screenName: vm.screen, + hideOnClosed: vm.hideOnClosed, + controlWindowEnabled: controlWindowEnabled, + showsLabel: showsLabel, + showTransientLabel: showTransientLabel, + showsCountdown: showsCountdown, + showsProgress: showsProgress, + progressStyleIdentifier: progressStyle.rawValue, + activeSourceIdentifier: timerManager.activeSource.rawValue + ) + } + private var shouldShowControlWindow: Bool { - #if os(macOS) - let lockSuppressed = !LockScreenManager.shared.currentLockStatus - #else - let lockSuppressed = true - #endif - return controlWindowEnabled - && !shouldDisplayLabel - && timerManager.isTimerActive - && !timerManager.isExternalTimerActive - && lockSuppressed + if !controlWindowEnabled { return false } + if shouldDisplayLabel { return false } + + let timerEligible = timerManager.isTimerActive && !timerManager.isExternalTimerActive + if !timerEligible { return false } + + let notchReady = vm.notchState == .closed && !vm.hideOnClosed + if !notchReady { return false } + + if isControlWindowSuppressed { return false } + if lockScreenManager.isLocked { return false } + + return true } private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat { @@ -176,60 +232,28 @@ struct TimerLiveActivity: View { } var body: some View { - HStack(spacing: 0) { - Color.clear - .frame(width: leftWingWidth, height: notchContentHeight) - .background(alignment: .leading) { - HStack(spacing: showsInfoSection ? 8 : 0) { - iconSection - if showsInfoSection { - infoSection - } - } - .padding(.leading, wingPadding / 2) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - } - - Rectangle() - .fill(.black) - .frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0), height: notchContentHeight) - - Color.clear - .frame(width: rightWingWidth, height: notchContentHeight) - .background(alignment: .trailing) { - HStack(spacing: ringOnRight && showsCountdown ? 8 : 0) { - if ringOnRight { - ringSection - } - if showsCountdown { - countdownSection - } - } - .padding(.trailing, wingPadding / 2) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) - } - } - .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) - .contentShape(Rectangle()) + baseTimerLayout .onHover { hovering in withAnimation(.smooth(duration: 0.18)) { isHovering = hovering } - syncControlWindow(forceRefresh: true) + requestControlWindowSync(forceRefresh: true, delay: hovering ? 0.05 : 0) } .onAppear { - syncControlWindow(forceRefresh: true) + requestControlWindowSync(forceRefresh: true) if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime { triggerTransientLabel() } } .onDisappear { hideControlWindow() + cancelControlWindowSync() + cancelControlWindowSuppressionTask() } .onChange(of: timerManager.isTimerActive) { _, isActive in if isActive { if !timerManager.isFinished && !timerManager.isOvertime { - syncControlWindow(forceRefresh: true) + requestControlWindowSync(forceRefresh: true) triggerTransientLabel() } } else { @@ -237,11 +261,13 @@ struct TimerLiveActivity: View { cancelTransientLabel() showTransientLabel = false isHovering = false + cancelControlWindowSync() + resetPendingControlWindowFlags() } } .onChange(of: timerManager.timerName) { _, _ in if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime { - syncControlWindow(forceRefresh: true) + requestControlWindowSync(forceRefresh: true) triggerTransientLabel() } } @@ -252,7 +278,7 @@ struct TimerLiveActivity: View { showTransientLabel = true isHovering = false } - syncControlWindow() + requestControlWindowSync() } } .onChange(of: timerManager.isOvertime) { _, overtime in @@ -262,42 +288,72 @@ struct TimerLiveActivity: View { showTransientLabel = true isHovering = false } - syncControlWindow() + requestControlWindowSync() } } .onChange(of: timerManager.isPaused) { _, _ in - syncControlWindow(forceRefresh: true) + requestControlWindowSync(forceRefresh: true) } - .onChange(of: vm.closedNotchSize) { _, _ in - syncControlWindow(forceRefresh: true) + .onChange(of: controlWindowSyncKey) { _, _ in + requestControlWindowSync(forceRefresh: true) } - .onChange(of: vm.screen) { _, _ in - syncControlWindow(forceRefresh: true) + .onChange(of: vm.notchState) { _, state in + handleControlWindowNotchStateChange(state) } - .onChange(of: vm.hideOnClosed) { _, _ in - syncControlWindow(forceRefresh: true) + .onChange(of: lockScreenManager.isLocked) { _, locked in + handleControlWindowLockStateChange(locked) } - .onChange(of: controlWindowEnabled) { _, _ in - syncControlWindow(forceRefresh: true) - } - .onChange(of: showsLabel) { _, _ in - syncControlWindow(forceRefresh: true) - } - .onChange(of: showTransientLabel) { _, _ in - syncControlWindow(forceRefresh: true) - } - .onChange(of: showsCountdown) { _, _ in - syncControlWindow(forceRefresh: true) - } - .onChange(of: showsProgress) { _, _ in - syncControlWindow(forceRefresh: true) - } - .onChange(of: progressStyle) { _, _ in - syncControlWindow(forceRefresh: true) - } - .onChange(of: timerManager.activeSource) { _, _ in - syncControlWindow(forceRefresh: true) + } + + private var baseTimerLayout: some View { + HStack(spacing: 0) { + leftWingView() + middleSectionView() + rightWingView() } + .frame(height: adjustedNotchHeight, alignment: .center) + .contentShape(Rectangle()) + } + + @ViewBuilder + private func leftWingView() -> some View { + Color.clear + .frame(width: leftWingWidth, height: notchContentHeight) + .background(alignment: .leading) { + HStack(spacing: showsInfoSection ? 8 : 0) { + iconSection + if showsInfoSection { + infoSection + } + } + .padding(.leading, wingPadding / 2) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + } + + @ViewBuilder + private func middleSectionView() -> some View { + Rectangle() + .fill(.black) + .frame(width: middleSectionWidth, height: notchContentHeight) + } + + @ViewBuilder + private func rightWingView() -> some View { + Color.clear + .frame(width: rightWingWidth, height: notchContentHeight) + .background(alignment: .trailing) { + HStack(spacing: ringOnRight && showsCountdown ? 8 : 0) { + if ringOnRight { + ringSection + } + if showsCountdown { + countdownSection + } + } + .padding(.trailing, wingPadding / 2) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } } private var iconSection: some View { @@ -423,6 +479,139 @@ struct TimerLiveActivity: View { height: notchContentHeight, alignment: .center) } + private func requestControlWindowSync(forceRefresh: Bool = false, delay: TimeInterval = 0) { + enqueueControlWindowSync(forceRefresh: forceRefresh, delay: delay) + } + + private func enqueueControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0) { + if shouldDeferControlWindowSync() { + hasPendingControlWindowSync = true + if forceRefresh { + pendingControlWindowForceRefresh = true + } + return + } + + scheduleControlWindowSync(forceRefresh: forceRefresh, delay: delay) + } + + private func scheduleControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0, bypassSuppression: Bool = false) { + cancelControlWindowSync() + + guard shouldShowControlWindow else { + resetPendingControlWindowFlags() + hideControlWindow() + return + } + + if !bypassSuppression && shouldDeferControlWindowSync() { + hasPendingControlWindowSync = true + if forceRefresh { + pendingControlWindowForceRefresh = true + } + return + } + + resetPendingControlWindowFlags() + + let syncDelay = max(0, delay) + + pendingControlWindowTask = Task.detached(priority: .userInitiated) { [syncDelay, forceRefresh] 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 shouldShowControlWindow { + syncControlWindow(forceRefresh: forceRefresh) + } else { + hideControlWindow() + } + + pendingControlWindowTask = nil + } + } + } + + private func cancelControlWindowSync() { + pendingControlWindowTask?.cancel() + pendingControlWindowTask = nil + } + + private func resetPendingControlWindowFlags() { + hasPendingControlWindowSync = false + pendingControlWindowForceRefresh = false + } + + private func shouldDeferControlWindowSync() -> Bool { + vm.notchState != .closed || lockScreenManager.isLocked || isControlWindowSuppressed + } + + private func triggerPendingControlWindowSyncIfNeeded() { + guard hasPendingControlWindowSync else { return } + let shouldForce = pendingControlWindowForceRefresh + resetPendingControlWindowFlags() + scheduleControlWindowSync(forceRefresh: shouldForce, delay: 0, bypassSuppression: true) + } + + private func suppressControlWindowUpdates() { + isControlWindowSuppressed = true + cancelControlWindowSuppressionTask() + } + + private func releaseControlWindowUpdates(after delay: TimeInterval) { + cancelControlWindowSuppressionTask() + controlWindowSuppressionTask = 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 { + isControlWindowSuppressed = false + triggerPendingControlWindowSyncIfNeeded() + } else { + isControlWindowSuppressed = true + } + + controlWindowSuppressionTask = nil + } + } + } + + private func cancelControlWindowSuppressionTask() { + controlWindowSuppressionTask?.cancel() + controlWindowSuppressionTask = nil + } + + private func handleControlWindowNotchStateChange(_ state: NotchState) { + if state == .open { + suppressControlWindowUpdates() + cancelControlWindowSync() + hideControlWindow() + } else { + releaseControlWindowUpdates(after: controlWindowResumeDelay) + requestControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + + private func handleControlWindowLockStateChange(_ locked: Bool) { + if locked { + suppressControlWindowUpdates() + cancelControlWindowSync() + hideControlWindow() + } else { + releaseControlWindowUpdates(after: controlWindowResumeDelay) + requestControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + private func triggerTransientLabel() { guard !showsLabel else { return } cancelTransientLabel() @@ -433,7 +622,7 @@ struct TimerLiveActivity: View { withAnimation(.smooth) { showTransientLabel = false } - syncControlWindow(forceRefresh: true) + requestControlWindowSync(forceRefresh: true) } labelHideTask = task DispatchQueue.main.asyncAfter(deadline: .now() + transientLabelDuration, execute: task) From f9a8887879a31fd2bb0ef222fd835f3e340a0830 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 28 Nov 2025 19:33:51 +0530 Subject: [PATCH 5/6] Smoothened dismiss timer --- .../LockScreen/LockScreenTimerWidget.swift | 21 ++++++++++++++----- .../LockScreenTimerWidgetManager.swift | 3 ++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift index 076c992..d21c36a 100644 --- a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift @@ -201,12 +201,23 @@ struct LockScreenTimerWidget: View { } private func stopTimer() { - guard timerManager.allowsManualInteraction else { - timerManager.endExternalTimer(triggerSmoothClose: false) - return + let stopAction: @MainActor () -> Void = { + if timerManager.allowsManualInteraction { + timerManager.stopTimer() + } else { + timerManager.endExternalTimer(triggerSmoothClose: false) + } + } + + Task { @MainActor in + // Trigger the scale-out animation first so the dismissal mirrors the unlock flow. + LockScreenTimerWidgetPanelManager.shared.hide() + + try? await Task.sleep(nanoseconds: LockScreenTimerWidgetPanelManager.hideAnimationDurationNanoseconds) + + guard !Task.isCancelled else { return } + stopAction() } - timerManager.stopTimer() - LockScreenTimerWidgetPanelManager.shared.hide() } private struct CircleButton: View { diff --git a/DynamicIsland/managers/LockScreenTimerWidgetManager.swift b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift index b8efbe3..50fee0d 100644 --- a/DynamicIsland/managers/LockScreenTimerWidgetManager.swift +++ b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift @@ -95,6 +95,7 @@ final class LockScreenTimerWidgetManager { @MainActor final class LockScreenTimerWidgetPanelManager { static let shared = LockScreenTimerWidgetPanelManager() + static let hideAnimationDurationNanoseconds: UInt64 = 360_000_000 private var window: NSWindow? private var hasDelegated = false @@ -120,7 +121,7 @@ final class LockScreenTimerWidgetPanelManager { hideTask?.cancel() animator.isPresented = false - let delay: UInt64 = animated ? 360_000_000 : 0 + let delay: UInt64 = animated ? Self.hideAnimationDurationNanoseconds : 0 if delay == 0 { window.orderOut(nil) hideTask = nil From a08540c589aced6be62f9f6f841ff95e63896740 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 28 Nov 2025 20:04:51 +0530 Subject: [PATCH 6/6] Fully fixed: Timer Lock Screen Widget --- .../LockScreen/LockScreenTimerWidget.swift | 22 +++++++-------- .../LockScreenTimerWidgetManager.swift | 1 + .../managers/TimerControlWindowManager.swift | 27 ++++++++++++++++--- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift index d21c36a..ab2145d 100644 --- a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift @@ -201,22 +201,22 @@ struct LockScreenTimerWidget: View { } private func stopTimer() { - let stopAction: @MainActor () -> Void = { - if timerManager.allowsManualInteraction { - timerManager.stopTimer() - } else { - timerManager.endExternalTimer(triggerSmoothClose: false) - } - } + let allowsManualInteraction = timerManager.allowsManualInteraction - Task { @MainActor in - // Trigger the scale-out animation first so the dismissal mirrors the unlock flow. - LockScreenTimerWidgetPanelManager.shared.hide() + LockScreenTimerWidgetPanelManager.shared.hide() + Task.detached(priority: .userInitiated) { [allowsManualInteraction] in try? await Task.sleep(nanoseconds: LockScreenTimerWidgetPanelManager.hideAnimationDurationNanoseconds) guard !Task.isCancelled else { return } - stopAction() + + await MainActor.run { + if allowsManualInteraction { + TimerManager.shared.stopTimer() + } else { + TimerManager.shared.endExternalTimer(triggerSmoothClose: false) + } + } } } diff --git a/DynamicIsland/managers/LockScreenTimerWidgetManager.swift b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift index 50fee0d..bfc2b65 100644 --- a/DynamicIsland/managers/LockScreenTimerWidgetManager.swift +++ b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift @@ -137,6 +137,7 @@ final class LockScreenTimerWidgetPanelManager { } } + func refreshPosition(animated: Bool) { guard let window, window.isVisible, let screen = NSScreen.main else { return } let frame = targetFrame(on: screen) diff --git a/DynamicIsland/managers/TimerControlWindowManager.swift b/DynamicIsland/managers/TimerControlWindowManager.swift index 860feac..0803eb7 100644 --- a/DynamicIsland/managers/TimerControlWindowManager.swift +++ b/DynamicIsland/managers/TimerControlWindowManager.swift @@ -4,7 +4,7 @@ import SwiftUI import SkyLightWindow import QuartzCore -struct TimerControlWindowMetrics { +struct TimerControlWindowMetrics: Equatable { let notchHeight: CGFloat let notchWidth: CGFloat let rightWingWidth: CGFloat @@ -90,15 +90,23 @@ final class TimerControlWindowManager { guard window != nil else { return present(using: viewModel, metrics: metrics) } + + if let lastMetrics, lastMetrics == metrics { + return true + } + return present(using: viewModel, metrics: metrics) } - func hide(animated: Bool = true) { + func hide(animated: Bool = true, tearDown: Bool = true) { guard let window else { return } guard animated, window.alphaValue > 0.01 else { window.orderOut(nil) window.alphaValue = 0 + if tearDown { + tearDownWindowResources(using: window) + } return } @@ -110,13 +118,26 @@ final class TimerControlWindowManager { context.timingFunction = CAMediaTimingFunction(name: .easeIn) window.animator().setFrame(retreatFrame, display: true) window.animator().alphaValue = 0 - } completionHandler: { + } completionHandler: { [weak self] in window.setFrame(originalFrame, display: false) window.orderOut(nil) window.alphaValue = 0 + if tearDown { + self?.tearDownWindowResources(using: window) + } } } + private func tearDownWindowResources(using window: NSWindow? = nil) { + let targetWindow = window ?? self.window + targetWindow?.contentView = nil + targetWindow?.orderOut(nil) + hostingView = nil + lastMetrics = nil + self.window = nil + hasDelegated = false + } + private func ensureHostingView(with overlay: TimerControlOverlay) -> NSHostingView { if let hostingView { hostingView.rootView = overlay