Merge pull request #130 from Ebullioscopic/revamp/essentials

Revamp Lock Screen Timer Widget and Settings
This commit is contained in:
Hariharan Mudaliar 2025-11-28 23:35:18 +05:30 • committed by GitHub
commit cfa25277ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 449 additions and 166 deletions

View file

@ -10629,10 +10629,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" : { "License key" : {
"localizations" : { "localizations" : {
"fr" : { "fr" : {
@ -15730,10 +15726,6 @@
} }
} }
}, },
"Right button" : {
"comment" : "A label for the right button in the media controls picker.",
"isCommentAutoGenerated" : true
},
"Rotation:" : { "Rotation:" : {
"localizations" : { "localizations" : {
"fr" : { "fr" : {

View file

@ -2,13 +2,13 @@ import SwiftUI
import Defaults import Defaults
struct LockScreenTimerWidget: View { struct LockScreenTimerWidget: View {
static let preferredSize = CGSize(width: 420, height: 104) static let preferredSize = CGSize(width: 420, height: 96)
static let cornerRadius: CGFloat = 26 static let cornerRadius: CGFloat = 26
@ObservedObject private var animator: LockScreenTimerWidgetAnimator @ObservedObject private var animator: LockScreenTimerWidgetAnimator
@ObservedObject private var timerManager = TimerManager.shared @ObservedObject private var timerManager = TimerManager.shared
@Default(.lockScreenGlassStyle) private var glassStyle @Default(.lockScreenGlassStyle) private var glassStyle
@Default(.lockScreenPanelUsesBlur) private var enableBlur @Default(.lockScreenTimerWidgetUsesBlur) private var enableTimerBlur
@Default(.timerPresets) private var timerPresets @Default(.timerPresets) private var timerPresets
@MainActor @MainActor
@ -20,13 +20,11 @@ struct LockScreenTimerWidget: View {
} }
} }
private var clampedProgress: Double { private var countdownFont: Font { displayFont(size: 56) }
min(max(timerManager.progress, 0), 1)
}
private var titleFont: Font { .system(size: 18, weight: .semibold, design: .rounded) } private func displayFont(size: CGFloat) -> Font {
private var statusFont: Font { .system(size: 12, weight: .medium, design: .rounded) } .custom("SF Pro Display", size: size)
private var countdownFont: Font { .system(size: 42, weight: .semibold, design: .default) } }
private var timerLabel: String { private var timerLabel: String {
timerManager.timerName.isEmpty ? "Timer" : timerManager.timerName timerManager.timerName.isEmpty ? "Timer" : timerManager.timerName
@ -46,10 +44,6 @@ struct LockScreenTimerWidget: View {
.ensureMinimumBrightness(factor: 0.75) .ensureMinimumBrightness(factor: 0.75)
} }
private var secondaryAccentColor: Color {
accentColor.ensureMinimumBrightness(factor: 0.55).opacity(0.45)
}
private var accentGradient: LinearGradient { private var accentGradient: LinearGradient {
LinearGradient( LinearGradient(
colors: [ colors: [
@ -70,7 +64,7 @@ struct LockScreenTimerWidget: View {
@ViewBuilder @ViewBuilder
private var widgetBackground: some View { private var widgetBackground: some View {
if enableBlur { if enableTimerBlur {
if usesLiquidGlass { if usesLiquidGlass {
liquidBackground liquidBackground
} else { } else {
@ -110,7 +104,7 @@ struct LockScreenTimerWidget: View {
var body: some View { var body: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
HStack(alignment: .center, spacing: 20) { HStack(alignment: .center, spacing: 18) {
controlButtons controlButtons
titleSection titleSection
@ -132,8 +126,6 @@ struct LockScreenTimerWidget: View {
.overlay(alignment: .topLeading) { .overlay(alignment: .topLeading) {
accentRibbon accentRibbon
} }
.animation(.smooth(duration: 0.22), value: timerManager.isPaused)
.animation(.smooth(duration: 0.22), value: timerManager.remainingTime)
.scaleEffect(animator.isPresented ? 1 : 0.9) .scaleEffect(animator.isPresented ? 1 : 0.9)
.opacity(animator.isPresented ? 1 : 0) .opacity(animator.isPresented ? 1 : 0)
.animation(.spring(response: 0.45, dampingFraction: 0.85, blendDuration: 0.22), value: animator.isPresented) .animation(.spring(response: 0.45, dampingFraction: 0.85, blendDuration: 0.22), value: animator.isPresented)
@ -143,9 +135,8 @@ struct LockScreenTimerWidget: View {
HStack(spacing: 10) { HStack(spacing: 10) {
CircleButton( CircleButton(
icon: pauseIcon, icon: pauseIcon,
foreground: accentColor, foreground: Color.white.opacity(0.95),
background: Color.white.opacity(0.15), background: accentColor.opacity(0.32),
borderColor: accentColor.opacity(0.8),
action: togglePause, action: togglePause,
isEnabled: timerManager.allowsManualInteraction, isEnabled: timerManager.allowsManualInteraction,
helpText: pauseLabel helpText: pauseLabel
@ -153,9 +144,8 @@ struct LockScreenTimerWidget: View {
CircleButton( CircleButton(
icon: "xmark", icon: "xmark",
foreground: Color.white.opacity(0.92), foreground: Color.white.opacity(0.95),
background: Color.white.opacity(0.18), background: Color.black.opacity(0.35),
borderColor: Color.white.opacity(0.35),
action: stopTimer, action: stopTimer,
isEnabled: timerManager.allowsManualInteraction, isEnabled: timerManager.allowsManualInteraction,
helpText: "Stop" helpText: "Stop"
@ -163,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 { private var countdownSection: some View {
VStack(alignment: .trailing, spacing: 6) { VStack(alignment: .trailing, spacing: 0) {
Text(countdownText) Text(countdownText)
.font(countdownFont) .font(countdownFont)
.monospacedDigit()
.foregroundStyle(timerManager.isOvertime ? Color.red : accentColor) .foregroundStyle(timerManager.isOvertime ? Color.red : accentColor)
.contentTransition(.numericText()) .contentTransition(.numericText())
.animation(.smooth(duration: 0.25), value: timerManager.remainingTime) .animation(.smooth(duration: 0.25), value: timerManager.remainingTime)
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
} }
.frame(minWidth: 140, maxWidth: 180, alignment: .trailing) .frame(width: 220, alignment: .center)
.padding(.trailing, 2) .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 { private var accentRibbon: some View {
Capsule() Capsule()
.fill(accentGradient) .fill(accentGradient)
@ -217,18 +201,29 @@ struct LockScreenTimerWidget: View {
} }
private func stopTimer() { private func stopTimer() {
guard timerManager.allowsManualInteraction else { let allowsManualInteraction = timerManager.allowsManualInteraction
timerManager.endExternalTimer(triggerSmoothClose: false)
return LockScreenTimerWidgetPanelManager.shared.hide()
Task.detached(priority: .userInitiated) { [allowsManualInteraction] in
try? await Task.sleep(nanoseconds: LockScreenTimerWidgetPanelManager.hideAnimationDurationNanoseconds)
guard !Task.isCancelled else { return }
await MainActor.run {
if allowsManualInteraction {
TimerManager.shared.stopTimer()
} else {
TimerManager.shared.endExternalTimer(triggerSmoothClose: false)
}
}
} }
timerManager.stopTimer()
} }
private struct CircleButton: View { private struct CircleButton: View {
let icon: String let icon: String
let foreground: Color let foreground: Color
let background: Color let background: Color
let borderColor: Color?
let action: () -> Void let action: () -> Void
let isEnabled: Bool let isEnabled: Bool
let helpText: String let helpText: String
@ -241,13 +236,6 @@ struct LockScreenTimerWidget: View {
.frame(width: 48, height: 48) .frame(width: 48, height: 48)
.background(background.opacity(isEnabled ? 1 : 0.25)) .background(background.opacity(isEnabled ? 1 : 0.25))
.clipShape(Circle()) .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)) .contentTransition(.symbolEffect(.replace))
} }
.buttonStyle(.plain) .buttonStyle(.plain)

View file

@ -179,6 +179,7 @@ struct GeneralSettings: View {
@Default(.lockScreenShowAppIcon) var lockScreenShowAppIcon @Default(.lockScreenShowAppIcon) var lockScreenShowAppIcon
@Default(.lockScreenPanelShowsBorder) var lockScreenPanelShowsBorder @Default(.lockScreenPanelShowsBorder) var lockScreenPanelShowsBorder
@Default(.lockScreenPanelUsesBlur) var lockScreenPanelUsesBlur @Default(.lockScreenPanelUsesBlur) var lockScreenPanelUsesBlur
@Default(.enableLockScreenTimerWidget) var enableLockScreenTimerWidget
@Default(.enableLockScreenWeatherWidget) var enableLockScreenWeatherWidget @Default(.enableLockScreenWeatherWidget) var enableLockScreenWeatherWidget
@Default(.lockScreenWeatherShowsLocation) var lockScreenWeatherShowsLocation @Default(.lockScreenWeatherShowsLocation) var lockScreenWeatherShowsLocation
@Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging @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 media panel", key: .enableLockScreenMediaWidget)
Defaults.Toggle("Show lock screen weather", key: .enableLockScreenWeatherWidget) Defaults.Toggle("Show lock screen weather", key: .enableLockScreenWeatherWidget)
Defaults.Toggle("Show lock screen timer", key: .enableLockScreenTimerWidget) Defaults.Toggle("Show lock screen timer", key: .enableLockScreenTimerWidget)
Defaults.Toggle("Enable timer blur", key: .lockScreenTimerWidgetUsesBlur)
.disabled(!enableLockScreenTimerWidget)
if enableLockScreenWeatherWidget { if enableLockScreenWeatherWidget {
Picker("Widget layout", selection: $lockScreenWeatherWidgetStyle) { Picker("Widget layout", selection: $lockScreenWeatherWidgetStyle) {
ForEach(LockScreenWeatherWidgetStyle.allCases) { style in ForEach(LockScreenWeatherWidgetStyle.allCases) { style in
@ -908,16 +911,7 @@ struct Media: View {
} }
.disabled(!showShuffleAndRepeat) .disabled(!showShuffleAndRepeat)
if showShuffleAndRepeat { if showShuffleAndRepeat {
Picker("Left button", selection: $musicAuxLeftControl) { mediaControlsGrid
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)
}
}
} }
} header: { } header: {
Text("Media controls") Text("Media controls")
@ -1034,6 +1028,102 @@ struct Media: View {
.navigationTitle("Media") .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<MusicAuxiliaryControl>, 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 // Only show controller options that are available on this macOS version
private var availableMediaControllers: [MediaControllerType] { private var availableMediaControllers: [MediaControllerType] {
if MusicManager.shared.isNowPlayingDeprecated { if MusicManager.shared.isNowPlayingDeprecated {
@ -2486,34 +2576,34 @@ struct TimerSettings: View {
let nextIndex = timerPresets.count + 1 let nextIndex = timerPresets.count + 1
let defaultColor = Defaults[.accentColor] let defaultColor = Defaults[.accentColor]
let newPreset = TimerPreset(name: "Preset \(nextIndex)", duration: 5 * 60, color: defaultColor) let newPreset = TimerPreset(name: "Preset \(nextIndex)", duration: 5 * 60, color: defaultColor)
_ = withAnimation(.smooth) { withAnimation(.smooth) {
timerPresets.append(newPreset) timerPresets.append(newPreset)
} }
} }
private func movePresetUp(_ index: Int) { private func movePresetUp(_ index: Int) {
guard index > timerPresets.startIndex else { return } guard index > timerPresets.startIndex else { return }
_ = withAnimation(.smooth) { withAnimation(.smooth) {
timerPresets.swapAt(index, index - 1) timerPresets.swapAt(index, index - 1)
} }
} }
private func movePresetDown(_ index: Int) { private func movePresetDown(_ index: Int) {
guard index < timerPresets.index(before: timerPresets.endIndex) else { return } guard index < timerPresets.index(before: timerPresets.endIndex) else { return }
_ = withAnimation(.smooth) { withAnimation(.smooth) {
timerPresets.swapAt(index, index + 1) timerPresets.swapAt(index, index + 1)
} }
} }
private func removePreset(_ index: Int) { private func removePreset(_ index: Int) {
guard timerPresets.indices.contains(index) else { return } guard timerPresets.indices.contains(index) else { return }
_ = withAnimation(.smooth) { withAnimation(.smooth) {
timerPresets.remove(at: index) timerPresets.remove(at: index)
} }
} }
private func resetPresets() { private func resetPresets() {
_ = withAnimation(.smooth) { withAnimation(.smooth) {
timerPresets = TimerPreset.defaultPresets timerPresets = TimerPreset.defaultPresets
} }
} }
@ -2766,7 +2856,7 @@ struct StatsSettings: View {
Section { Section {
Defaults.Toggle("CPU Usage", key: .showCpuGraph) 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("GPU Usage", key: .showGpuGraph)
Defaults.Toggle("Network Activity", key: .showNetworkGraph) Defaults.Toggle("Network Activity", key: .showNetworkGraph)
Defaults.Toggle("Disk I/O", key: .showDiskGraph) Defaults.Toggle("Disk I/O", key: .showDiskGraph)

View file

@ -19,10 +19,16 @@ typealias PlatformFont = UIFont
struct TimerLiveActivity: View { struct TimerLiveActivity: View {
@EnvironmentObject var vm: DynamicIslandViewModel @EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject var timerManager = TimerManager.shared @ObservedObject var timerManager = TimerManager.shared
@ObservedObject var lockScreenManager = LockScreenManager.shared
@State private var isHovering: Bool = false @State private var isHovering: Bool = false
@State private var showTransientLabel: Bool = false @State private var showTransientLabel: Bool = false
@State private var labelHideTask: DispatchWorkItem? @State private var labelHideTask: DispatchWorkItem?
@State private var isControlWindowVisible: Bool = false @State private var isControlWindowVisible: Bool = false
@State private var pendingControlWindowTask: Task<Void, Never>?
@State private var controlWindowSuppressionTask: Task<Void, Never>?
@State private var hasPendingControlWindowSync: Bool = false
@State private var pendingControlWindowForceRefresh: Bool = false
@State private var isControlWindowSuppressed: Bool = false
@Default(.timerShowsCountdown) private var showsCountdown @Default(.timerShowsCountdown) private var showsCountdown
@Default(.timerShowsProgress) private var showsProgress @Default(.timerShowsProgress) private var showsProgress
@Default(.timerShowsLabel) private var showsLabel @Default(.timerShowsLabel) private var showsLabel
@ -39,6 +45,25 @@ struct TimerLiveActivity: View {
private var wingPadding: CGFloat { 22 } private var wingPadding: CGFloat { 22 }
private var ringStrokeWidth: CGFloat { 3 } private var ringStrokeWidth: CGFloat { 3 }
private var transientLabelDuration: TimeInterval { 4 } 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 { private var ringWrapsIcon: Bool {
showsRingProgress && showsCountdown showsRingProgress && showsCountdown
@ -140,17 +165,48 @@ struct TimerLiveActivity: View {
return timerPresets.first { $0.id == presetId }?.color 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 { private var shouldShowControlWindow: Bool {
#if os(macOS) if !controlWindowEnabled { return false }
let lockSuppressed = !LockScreenManager.shared.currentLockStatus if shouldDisplayLabel { return false }
#else
let lockSuppressed = true let timerEligible = timerManager.isTimerActive && !timerManager.isExternalTimerActive
#endif if !timerEligible { return false }
return controlWindowEnabled
&& !shouldDisplayLabel let notchReady = vm.notchState == .closed && !vm.hideOnClosed
&& timerManager.isTimerActive if !notchReady { return false }
&& !timerManager.isExternalTimerActive
&& lockSuppressed if isControlWindowSuppressed { return false }
if lockScreenManager.isLocked { return false }
return true
} }
private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat { private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat {
@ -176,60 +232,28 @@ struct TimerLiveActivity: View {
} }
var body: some View { var body: some View {
HStack(spacing: 0) { baseTimerLayout
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())
.onHover { hovering in .onHover { hovering in
withAnimation(.smooth(duration: 0.18)) { withAnimation(.smooth(duration: 0.18)) {
isHovering = hovering isHovering = hovering
} }
syncControlWindow(forceRefresh: true) requestControlWindowSync(forceRefresh: true, delay: hovering ? 0.05 : 0)
} }
.onAppear { .onAppear {
syncControlWindow(forceRefresh: true) requestControlWindowSync(forceRefresh: true)
if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime { if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime {
triggerTransientLabel() triggerTransientLabel()
} }
} }
.onDisappear { .onDisappear {
hideControlWindow() hideControlWindow()
cancelControlWindowSync()
cancelControlWindowSuppressionTask()
} }
.onChange(of: timerManager.isTimerActive) { _, isActive in .onChange(of: timerManager.isTimerActive) { _, isActive in
if isActive { if isActive {
if !timerManager.isFinished && !timerManager.isOvertime { if !timerManager.isFinished && !timerManager.isOvertime {
syncControlWindow(forceRefresh: true) requestControlWindowSync(forceRefresh: true)
triggerTransientLabel() triggerTransientLabel()
} }
} else { } else {
@ -237,11 +261,13 @@ struct TimerLiveActivity: View {
cancelTransientLabel() cancelTransientLabel()
showTransientLabel = false showTransientLabel = false
isHovering = false isHovering = false
cancelControlWindowSync()
resetPendingControlWindowFlags()
} }
} }
.onChange(of: timerManager.timerName) { _, _ in .onChange(of: timerManager.timerName) { _, _ in
if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime { if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime {
syncControlWindow(forceRefresh: true) requestControlWindowSync(forceRefresh: true)
triggerTransientLabel() triggerTransientLabel()
} }
} }
@ -252,7 +278,7 @@ struct TimerLiveActivity: View {
showTransientLabel = true showTransientLabel = true
isHovering = false isHovering = false
} }
syncControlWindow() requestControlWindowSync()
} }
} }
.onChange(of: timerManager.isOvertime) { _, overtime in .onChange(of: timerManager.isOvertime) { _, overtime in
@ -262,42 +288,72 @@ struct TimerLiveActivity: View {
showTransientLabel = true showTransientLabel = true
isHovering = false isHovering = false
} }
syncControlWindow() requestControlWindowSync()
} }
} }
.onChange(of: timerManager.isPaused) { _, _ in .onChange(of: timerManager.isPaused) { _, _ in
syncControlWindow(forceRefresh: true) requestControlWindowSync(forceRefresh: true)
} }
.onChange(of: vm.closedNotchSize) { _, _ in .onChange(of: controlWindowSyncKey) { _, _ in
syncControlWindow(forceRefresh: true) requestControlWindowSync(forceRefresh: true)
} }
.onChange(of: vm.screen) { _, _ in .onChange(of: vm.notchState) { _, state in
syncControlWindow(forceRefresh: true) handleControlWindowNotchStateChange(state)
} }
.onChange(of: vm.hideOnClosed) { _, _ in .onChange(of: lockScreenManager.isLocked) { _, locked in
syncControlWindow(forceRefresh: true) handleControlWindowLockStateChange(locked)
} }
.onChange(of: controlWindowEnabled) { _, _ in }
syncControlWindow(forceRefresh: true)
} private var baseTimerLayout: some View {
.onChange(of: showsLabel) { _, _ in HStack(spacing: 0) {
syncControlWindow(forceRefresh: true) leftWingView()
} middleSectionView()
.onChange(of: showTransientLabel) { _, _ in rightWingView()
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)
} }
.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 { private var iconSection: some View {
@ -423,6 +479,139 @@ struct TimerLiveActivity: View {
height: notchContentHeight, alignment: .center) 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() { private func triggerTransientLabel() {
guard !showsLabel else { return } guard !showsLabel else { return }
cancelTransientLabel() cancelTransientLabel()
@ -433,7 +622,7 @@ struct TimerLiveActivity: View {
withAnimation(.smooth) { withAnimation(.smooth) {
showTransientLabel = false showTransientLabel = false
} }
syncControlWindow(forceRefresh: true) requestControlWindowSync(forceRefresh: true)
} }
labelHideTask = task labelHideTask = task
DispatchQueue.main.asyncAfter(deadline: .now() + transientLabelDuration, execute: task) DispatchQueue.main.asyncAfter(deadline: .now() + transientLabelDuration, execute: task)

View file

@ -95,6 +95,7 @@ final class LockScreenTimerWidgetManager {
@MainActor @MainActor
final class LockScreenTimerWidgetPanelManager { final class LockScreenTimerWidgetPanelManager {
static let shared = LockScreenTimerWidgetPanelManager() static let shared = LockScreenTimerWidgetPanelManager()
static let hideAnimationDurationNanoseconds: UInt64 = 360_000_000
private var window: NSWindow? private var window: NSWindow?
private var hasDelegated = false private var hasDelegated = false
@ -120,7 +121,7 @@ final class LockScreenTimerWidgetPanelManager {
hideTask?.cancel() hideTask?.cancel()
animator.isPresented = false animator.isPresented = false
let delay: UInt64 = animated ? 360_000_000 : 0 let delay: UInt64 = animated ? Self.hideAnimationDurationNanoseconds : 0
if delay == 0 { if delay == 0 {
window.orderOut(nil) window.orderOut(nil)
hideTask = nil hideTask = nil
@ -136,6 +137,7 @@ final class LockScreenTimerWidgetPanelManager {
} }
} }
func refreshPosition(animated: Bool) { func refreshPosition(animated: Bool) {
guard let window, window.isVisible, let screen = NSScreen.main else { return } guard let window, window.isVisible, let screen = NSScreen.main else { return }
let frame = targetFrame(on: screen) let frame = targetFrame(on: screen)

View file

@ -4,7 +4,7 @@ import SwiftUI
import SkyLightWindow import SkyLightWindow
import QuartzCore import QuartzCore
struct TimerControlWindowMetrics { struct TimerControlWindowMetrics: Equatable {
let notchHeight: CGFloat let notchHeight: CGFloat
let notchWidth: CGFloat let notchWidth: CGFloat
let rightWingWidth: CGFloat let rightWingWidth: CGFloat
@ -90,15 +90,23 @@ final class TimerControlWindowManager {
guard window != nil else { guard window != nil else {
return present(using: viewModel, metrics: metrics) return present(using: viewModel, metrics: metrics)
} }
if let lastMetrics, lastMetrics == metrics {
return true
}
return present(using: viewModel, metrics: metrics) 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 let window else { return }
guard animated, window.alphaValue > 0.01 else { guard animated, window.alphaValue > 0.01 else {
window.orderOut(nil) window.orderOut(nil)
window.alphaValue = 0 window.alphaValue = 0
if tearDown {
tearDownWindowResources(using: window)
}
return return
} }
@ -110,13 +118,26 @@ final class TimerControlWindowManager {
context.timingFunction = CAMediaTimingFunction(name: .easeIn) context.timingFunction = CAMediaTimingFunction(name: .easeIn)
window.animator().setFrame(retreatFrame, display: true) window.animator().setFrame(retreatFrame, display: true)
window.animator().alphaValue = 0 window.animator().alphaValue = 0
} completionHandler: { } completionHandler: { [weak self] in
window.setFrame(originalFrame, display: false) window.setFrame(originalFrame, display: false)
window.orderOut(nil) window.orderOut(nil)
window.alphaValue = 0 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<TimerControlOverlay> { private func ensureHostingView(with overlay: TimerControlOverlay) -> NSHostingView<TimerControlOverlay> {
if let hostingView { if let hostingView {
hostingView.rootView = overlay hostingView.rootView = overlay

View file

@ -224,7 +224,7 @@ enum MusicAuxiliaryControl: String, CaseIterable, Identifiable, Defaults.Seriali
case .repeatMode: case .repeatMode:
return "repeat" return "repeat"
case .mediaOutput: case .mediaOutput:
return "speaker.wave.2" return "laptopcomputer"
case .lyrics: case .lyrics:
return "quote.bubble" return "quote.bubble"
} }
@ -473,6 +473,7 @@ extension Defaults.Keys {
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false) static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: true) static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: true)
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true) static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true)
static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: true)
static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor) static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor)
// MARK: Battery // MARK: Battery