mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #130 from Ebullioscopic/revamp/essentials
Revamp Lock Screen Timer Widget and Settings
This commit is contained in:
commit
cfa25277ae
7 changed files with 449 additions and 166 deletions
|
|
@ -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" : {
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
|
|
@ -15730,10 +15726,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Right button" : {
|
||||
"comment" : "A label for the right button in the media controls picker.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Rotation:" : {
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -132,8 +126,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)
|
||||
|
|
@ -143,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
|
||||
|
|
@ -153,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"
|
||||
|
|
@ -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 {
|
||||
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)
|
||||
|
|
@ -217,18 +201,29 @@ struct LockScreenTimerWidget: View {
|
|||
}
|
||||
|
||||
private func stopTimer() {
|
||||
guard timerManager.allowsManualInteraction else {
|
||||
timerManager.endExternalTimer(triggerSmoothClose: false)
|
||||
return
|
||||
let allowsManualInteraction = timerManager.allowsManualInteraction
|
||||
|
||||
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 {
|
||||
let icon: String
|
||||
let foreground: Color
|
||||
let background: Color
|
||||
let borderColor: Color?
|
||||
let action: () -> Void
|
||||
let isEnabled: Bool
|
||||
let helpText: String
|
||||
|
|
@ -241,13 +236,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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -908,16 +911,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")
|
||||
|
|
@ -1034,6 +1028,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<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
|
||||
private var availableMediaControllers: [MediaControllerType] {
|
||||
if MusicManager.shared.isNowPlayingDeprecated {
|
||||
|
|
@ -2486,34 +2576,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
|
||||
}
|
||||
}
|
||||
|
|
@ -2766,7 +2856,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)
|
||||
|
|
|
|||
|
|
@ -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<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(.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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -136,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)
|
||||
|
|
|
|||
|
|
@ -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<TimerControlOverlay> {
|
||||
if let hostingView {
|
||||
hostingView.rootView = overlay
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -473,6 +473,7 @@ extension Defaults.Keys {
|
|||
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
|
||||
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", 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)
|
||||
|
||||
// MARK: Battery
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue