Merge pull request #128 from Ebullioscopic/feature/timer

Timer Lock Screen Widget
This commit is contained in:
Hariharan Mudaliar 2025-11-27 14:08:02 +05:30 • committed by GitHub
commit 231ee29123
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 636 additions and 7 deletions

View file

@ -618,6 +618,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
await LockScreenWeatherManager.shared.refresh(force: true) await LockScreenWeatherManager.shared.refresh(force: true)
} }
} }
// Warm up the lock screen timer widget manager so it can observe timer/default
// changes immediately instead of waiting for the first lock event.
let timerWidgetManager = LockScreenTimerWidgetManager.shared
timerWidgetManager.handleLockStateChange(isLocked: LockScreenManager.shared.currentLockStatus)
} }
func playWelcomeSound() { func playWelcomeSound() {

View file

@ -1357,6 +1357,10 @@
"comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"00:05:00" : {
"comment" : "A time displayed in the timer panel on a lock screen.",
"isCommentAutoGenerated" : true
},
"1x" : { "1x" : {
"localizations" : { "localizations" : {
"fr" : { "fr" : {

View file

@ -0,0 +1,269 @@
import SwiftUI
import Defaults
struct LockScreenTimerWidget: View {
static let preferredSize = CGSize(width: 420, height: 104)
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(.timerPresets) private var timerPresets
@MainActor
init(animator: LockScreenTimerWidgetAnimator? = nil) {
if let animator {
_animator = ObservedObject(wrappedValue: animator)
} else {
_animator = ObservedObject(wrappedValue: LockScreenTimerWidgetAnimator(isPresented: true))
}
}
private var clampedProgress: Double {
min(max(timerManager.progress, 0), 1)
}
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 var timerLabel: String {
timerManager.timerName.isEmpty ? "Timer" : timerManager.timerName
}
private var countdownText: String {
timerManager.formattedRemainingTime()
}
private var activePresetColor: Color? {
guard let presetId = timerManager.activePresetId else { return nil }
return timerPresets.first { $0.id == presetId }?.color
}
private var accentColor: Color {
(activePresetColor ?? timerManager.timerColor)
.ensureMinimumBrightness(factor: 0.75)
}
private var secondaryAccentColor: Color {
accentColor.ensureMinimumBrightness(factor: 0.55).opacity(0.45)
}
private var accentGradient: LinearGradient {
LinearGradient(
colors: [
accentColor.opacity(0.35),
accentColor.ensureMinimumBrightness(factor: 0.5)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
private var usesLiquidGlass: Bool {
if #available(macOS 26.0, *) {
return glassStyle == .liquid
}
return false
}
@ViewBuilder
private var widgetBackground: some View {
if enableBlur {
if usesLiquidGlass {
liquidBackground
} else {
frostedBackground
}
} else {
RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)
.fill(Color.black.opacity(0.65))
}
}
@ViewBuilder
private var liquidBackground: some View {
if #available(macOS 26.0, *) {
RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)
.glassEffect(
.clear.tint(accentColor.opacity(0.18)).interactive(),
in: .rect(cornerRadius: Self.cornerRadius)
)
} else {
frostedBackground
}
}
private var frostedBackground: some View {
RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)
.fill(.ultraThinMaterial)
}
private var pauseIcon: String {
timerManager.isPaused ? "play.fill" : "pause.fill"
}
private var pauseLabel: String {
timerManager.isPaused ? "Resume" : "Pause"
}
var body: some View {
VStack(spacing: 16) {
HStack(alignment: .center, spacing: 20) {
controlButtons
titleSection
.frame(maxWidth: .infinity)
countdownSection
}
}
.padding(.horizontal, 24)
.padding(.vertical, 18)
.frame(width: Self.preferredSize.width, height: Self.preferredSize.height)
.background(widgetBackground)
.clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)
.stroke(Color.white.opacity(0.08), lineWidth: 1)
}
.shadow(color: Color.black.opacity(0.25), radius: 18, x: 0, y: 16)
.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)
}
private var controlButtons: some View {
HStack(spacing: 10) {
CircleButton(
icon: pauseIcon,
foreground: accentColor,
background: Color.white.opacity(0.15),
borderColor: accentColor.opacity(0.8),
action: togglePause,
isEnabled: timerManager.allowsManualInteraction,
helpText: pauseLabel
)
CircleButton(
icon: "xmark",
foreground: Color.white.opacity(0.92),
background: Color.white.opacity(0.18),
borderColor: Color.white.opacity(0.35),
action: stopTimer,
isEnabled: timerManager.allowsManualInteraction,
helpText: "Stop"
)
}
}
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) {
Text(countdownText)
.font(countdownFont)
.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)
.padding(.trailing, 2)
}
private var accentRibbon: some View {
Capsule()
.fill(accentGradient)
.frame(width: 110, height: 26)
.blur(radius: 12)
.offset(x: 18, y: -6)
.opacity(0.45)
}
private func togglePause() {
guard timerManager.allowsManualInteraction else { return }
if timerManager.isPaused {
timerManager.resumeTimer()
} else {
timerManager.pauseTimer()
}
}
private func stopTimer() {
guard timerManager.allowsManualInteraction else {
timerManager.endExternalTimer(triggerSmoothClose: false)
return
}
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
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(foreground)
.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)
.help(helpText)
.disabled(!isEnabled)
.opacity(isEnabled ? 1 : 0.4)
}
}
}
#Preview {
LockScreenTimerWidget()
.frame(width: LockScreenTimerWidget.preferredSize.width, height: LockScreenTimerWidget.preferredSize.height)
.padding()
.background(Color.black)
.onAppear {
TimerManager.shared.startDemoTimer(duration: 1783)
}
}

View file

@ -379,6 +379,7 @@ struct GeneralSettings: View {
Defaults.Toggle("Enable blur", key: .lockScreenPanelUsesBlur) Defaults.Toggle("Enable blur", key: .lockScreenPanelUsesBlur)
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)
if enableLockScreenWeatherWidget { if enableLockScreenWeatherWidget {
Picker("Widget layout", selection: $lockScreenWeatherWidgetStyle) { Picker("Widget layout", selection: $lockScreenWeatherWidgetStyle) {
ForEach(LockScreenWeatherWidgetStyle.allCases) { style in ForEach(LockScreenWeatherWidgetStyle.allCases) { style in
@ -1723,6 +1724,7 @@ struct Appearance: View {
private struct LockScreenPositioningControls: View { private struct LockScreenPositioningControls: View {
@Default(.lockScreenWeatherVerticalOffset) private var weatherOffset @Default(.lockScreenWeatherVerticalOffset) private var weatherOffset
@Default(.lockScreenMusicVerticalOffset) private var musicOffset @Default(.lockScreenMusicVerticalOffset) private var musicOffset
@Default(.lockScreenTimerVerticalOffset) private var timerOffset
private let offsetRange: ClosedRange<Double> = -160...160 private let offsetRange: ClosedRange<Double> = -160...160
var body: some View { var body: some View {
@ -1738,6 +1740,17 @@ private struct LockScreenPositioningControls: View {
} }
) )
let timerBinding = Binding<Double>(
get: { timerOffset },
set: { newValue in
let clampedValue = clamp(newValue)
if timerOffset != clampedValue {
timerOffset = clampedValue
}
propagateTimerOffsetChange(animated: false)
}
)
let musicBinding = Binding<Double>( let musicBinding = Binding<Double>(
get: { musicOffset }, get: { musicOffset },
set: { newValue in set: { newValue in
@ -1749,7 +1762,7 @@ private struct LockScreenPositioningControls: View {
} }
) )
LockScreenPositioningPreview(weatherOffset: weatherBinding, musicOffset: musicBinding) LockScreenPositioningPreview(weatherOffset: weatherBinding, timerOffset: timerBinding, musicOffset: musicBinding)
.frame(height: 260) .frame(height: 260)
.padding(.vertical, 8) .padding(.vertical, 8)
@ -1761,6 +1774,16 @@ private struct LockScreenPositioningControls: View {
resetAction: resetWeatherOffset resetAction: resetWeatherOffset
) )
Divider()
.frame(height: 64)
offsetColumn(
title: "Timer",
value: timerOffset,
resetTitle: "Reset Timer",
resetAction: resetTimerOffset
)
Divider() Divider()
.frame(height: 64) .frame(height: 64)
@ -1790,6 +1813,11 @@ private struct LockScreenPositioningControls: View {
propagateWeatherOffsetChange(animated: true) propagateWeatherOffsetChange(animated: true)
} }
private func resetTimerOffset() {
timerOffset = 0
propagateTimerOffsetChange(animated: true)
}
private func resetMusicOffset() { private func resetMusicOffset() {
musicOffset = 0 musicOffset = 0
propagateMusicOffsetChange(animated: true) propagateMusicOffsetChange(animated: true)
@ -1801,6 +1829,12 @@ private struct LockScreenPositioningControls: View {
} }
} }
private func propagateTimerOffsetChange(animated: Bool) {
Task { @MainActor in
LockScreenTimerWidgetManager.shared.refreshPositionForOffsets(animated: animated)
}
}
private func propagateMusicOffsetChange(animated: Bool) { private func propagateMusicOffsetChange(animated: Bool) {
Task { @MainActor in Task { @MainActor in
LockScreenPanelManager.shared.applyOffsetAdjustment(animated: animated) LockScreenPanelManager.shared.applyOffsetAdjustment(animated: animated)
@ -1831,11 +1865,14 @@ private struct LockScreenPositioningControls: View {
private struct LockScreenPositioningPreview: View { private struct LockScreenPositioningPreview: View {
@Binding var weatherOffset: Double @Binding var weatherOffset: Double
@Binding var timerOffset: Double
@Binding var musicOffset: Double @Binding var musicOffset: Double
@State private var weatherStartOffset: Double = 0 @State private var weatherStartOffset: Double = 0
@State private var timerStartOffset: Double = 0
@State private var musicStartOffset: Double = 0 @State private var musicStartOffset: Double = 0
@State private var isWeatherDragging = false @State private var isWeatherDragging = false
@State private var isTimerDragging = false
@State private var isMusicDragging = false @State private var isMusicDragging = false
private let offsetRange: ClosedRange<Double> = -160...160 private let offsetRange: ClosedRange<Double> = -160...160
@ -1852,8 +1889,10 @@ private struct LockScreenPositioningPreview: View {
) )
let centerX = screenRect.midX let centerX = screenRect.midX
let weatherBaseY = screenRect.minY + (screenRect.height * 0.28) let weatherBaseY = screenRect.minY + (screenRect.height * 0.28)
let musicBaseY = screenRect.minY + (screenRect.height * 0.68) let timerBaseY = screenRect.minY + (screenRect.height * 0.5)
let musicBaseY = screenRect.minY + (screenRect.height * 0.78)
let weatherSize = CGSize(width: screenRect.width * 0.42, height: screenRect.height * 0.22) let weatherSize = CGSize(width: screenRect.width * 0.42, height: screenRect.height * 0.22)
let timerSize = CGSize(width: screenRect.width * 0.5, height: screenRect.height * 0.2)
let musicSize = CGSize(width: screenRect.width * 0.56, height: screenRect.height * 0.34) let musicSize = CGSize(width: screenRect.width * 0.56, height: screenRect.height * 0.34)
ZStack { ZStack {
@ -1871,6 +1910,10 @@ private struct LockScreenPositioningPreview: View {
.position(x: centerX, y: weatherBaseY - CGFloat(weatherOffset)) .position(x: centerX, y: weatherBaseY - CGFloat(weatherOffset))
.gesture(weatherDragGesture(in: screenRect, baseY: weatherBaseY, panelSize: weatherSize)) .gesture(weatherDragGesture(in: screenRect, baseY: weatherBaseY, panelSize: weatherSize))
timerPanel(size: timerSize)
.position(x: centerX, y: timerBaseY - CGFloat(timerOffset))
.gesture(timerDragGesture(in: screenRect, baseY: timerBaseY, panelSize: timerSize))
musicPanel(size: musicSize) musicPanel(size: musicSize)
.position(x: centerX, y: musicBaseY - CGFloat(musicOffset)) .position(x: centerX, y: musicBaseY - CGFloat(musicOffset))
.gesture(musicDragGesture(in: screenRect, baseY: musicBaseY, panelSize: musicSize)) .gesture(musicDragGesture(in: screenRect, baseY: musicBaseY, panelSize: musicSize))
@ -1928,6 +1971,29 @@ private struct LockScreenPositioningPreview: View {
.shadow(color: Color.purple.opacity(0.24), radius: 12, x: 0, y: 9) .shadow(color: Color.purple.opacity(0.24), radius: 12, x: 0, y: 9)
} }
private func timerPanel(size: CGSize) -> some View {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(
LinearGradient(
colors: [Color.orange.opacity(0.75), Color.purple.opacity(0.55)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: size.width, height: size.height)
.overlay {
VStack(spacing: 6) {
Text("Timer")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
Text("00:05:00")
.font(.system(size: 18, weight: .bold, design: .monospaced))
.foregroundStyle(.white)
}
}
.shadow(color: Color.orange.opacity(0.3), radius: 12, x: 0, y: 8)
}
private func weatherDragGesture(in screenRect: CGRect, baseY: CGFloat, panelSize: CGSize) -> some Gesture { private func weatherDragGesture(in screenRect: CGRect, baseY: CGFloat, panelSize: CGSize) -> some Gesture {
DragGesture(minimumDistance: 0) DragGesture(minimumDistance: 0)
.onChanged { value in .onChanged { value in
@ -1970,6 +2036,27 @@ private struct LockScreenPositioningPreview: View {
} }
} }
private func timerDragGesture(in screenRect: CGRect, baseY: CGFloat, panelSize: CGSize) -> some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
if !isTimerDragging {
isTimerDragging = true
timerStartOffset = timerOffset
}
let proposed = timerStartOffset - Double(value.translation.height)
timerOffset = clampedOffset(
proposed,
baseCenterY: baseY,
panelHeight: panelSize.height,
screenRect: screenRect
)
}
.onEnded { _ in
isTimerDragging = false
}
}
private func clampedOffset( private func clampedOffset(
_ proposed: Double, _ proposed: Double,
baseCenterY: CGFloat, baseCenterY: CGFloat,

View file

@ -140,9 +140,18 @@ struct TimerLiveActivity: View {
return timerPresets.first { $0.id == presetId }?.color return timerPresets.first { $0.id == presetId }?.color
} }
private var shouldShowControlWindow: Bool { private var shouldShowControlWindow: Bool {
controlWindowEnabled && !shouldDisplayLabel && timerManager.isTimerActive && !timerManager.isExternalTimerActive #if os(macOS)
} let lockSuppressed = !LockScreenManager.shared.currentLockStatus
#else
let lockSuppressed = true
#endif
return controlWindowEnabled
&& !shouldDisplayLabel
&& timerManager.isTimerActive
&& !timerManager.isExternalTimerActive
&& lockSuppressed
}
private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat { private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat {
let attributes: [NSAttributedString.Key: Any] = [.font: font] let attributes: [NSAttributedString.Key: Any] = [.font: font]

View file

@ -108,6 +108,8 @@ class LockScreenManager: ObservableObject {
LockScreenLiveActivityWindowManager.shared.showLocked() LockScreenLiveActivityWindowManager.shared.showLocked()
LockScreenWeatherManager.shared.showWeatherWidget() LockScreenWeatherManager.shared.showWeatherWidget()
LockScreenReminderWidgetManager.shared.showReminderWidget() LockScreenReminderWidgetManager.shared.showReminderWidget()
LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: true)
TimerControlWindowManager.shared.hide(animated: false)
// THEN trigger lock icon in Dynamic Island (only if enabled in settings) // THEN trigger lock icon in Dynamic Island (only if enabled in settings)
if Defaults[.enableLockScreenLiveActivity] { if Defaults[.enableLockScreenLiveActivity] {
@ -130,6 +132,7 @@ class LockScreenManager: ObservableObject {
LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide() LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide()
LockScreenWeatherManager.shared.hideWeatherWidget() LockScreenWeatherManager.shared.hideWeatherWidget()
LockScreenReminderWidgetManager.shared.hideReminderWidget() LockScreenReminderWidgetManager.shared.hideReminderWidget()
LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: false)
// Update state immediately // Update state immediately
if Defaults[.enableLockScreenLiveActivity] { if Defaults[.enableLockScreenLiveActivity] {

View file

@ -92,6 +92,7 @@ class LockScreenPanelManager {
latestFrame = targetFrame latestFrame = targetFrame
hideTask?.cancel() hideTask?.cancel()
panelAnimator.isPresented = false panelAnimator.isPresented = false
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: false)
let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator)) let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator))
hosting.frame = NSRect(origin: .zero, size: targetFrame.size) hosting.frame = NSRect(origin: .zero, size: targetFrame.size)
@ -144,6 +145,8 @@ class LockScreenPanelManager {
latestFrame = targetFrame latestFrame = targetFrame
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated)
// Update corner radius to match the SwiftUI panel's style // Update corner radius to match the SwiftUI panel's style
let targetRadius = expanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius let targetRadius = expanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius
if animated { if animated {
@ -167,6 +170,7 @@ class LockScreenPanelManager {
guard panelWindow != nil else { return } guard panelWindow != nil else { return }
updatePanelSize(expanded: isPanelExpanded, additionalHeight: currentAdditionalHeight, animated: animated) updatePanelSize(expanded: isPanelExpanded, additionalHeight: currentAdditionalHeight, animated: animated)
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated)
} }
func hidePanel() { func hidePanel() {
@ -197,9 +201,10 @@ class LockScreenPanelManager {
let collapsedSize = LockScreenMusicPanel.collapsedSize let collapsedSize = LockScreenMusicPanel.collapsedSize
let originX = screenFrame.midX - (collapsedSize.width / 2) let originX = screenFrame.midX - (collapsedSize.width / 2)
let baseOriginY = screenFrame.origin.y + (screenFrame.height / 2) - collapsedSize.height - 32 let baseOriginY = screenFrame.origin.y + (screenFrame.height / 2) - collapsedSize.height - 32
let defaultLowering: CGFloat = -28
let userOffset = CGFloat(Defaults[.lockScreenMusicVerticalOffset]) let userOffset = CGFloat(Defaults[.lockScreenMusicVerticalOffset])
let clampedOffset = min(max(userOffset, -160), 160) let clampedOffset = min(max(userOffset, -160), 160)
let originY = baseOriginY + clampedOffset let originY = baseOriginY + defaultLowering + clampedOffset
return NSRect(x: originX, y: originY, width: collapsedSize.width, height: collapsedSize.height) return NSRect(x: originX, y: originY, width: collapsedSize.width, height: collapsedSize.height)
} }
} }

View file

@ -0,0 +1,240 @@
import Foundation
import Combine
import Defaults
import SwiftUI
import AppKit
import SkyLightWindow
import QuartzCore
@MainActor
final class LockScreenTimerWidgetAnimator: ObservableObject {
@Published var isPresented: Bool
init(isPresented: Bool = false) {
self.isPresented = isPresented
}
}
@MainActor
final class LockScreenTimerWidgetManager {
static let shared = LockScreenTimerWidgetManager()
private let timerManager = TimerManager.shared
private var cancellables = Set<AnyCancellable>()
private var isLocked: Bool = false
private init() {
observeTimerState()
observeDefaults()
}
func handleLockStateChange(isLocked: Bool) {
self.isLocked = isLocked
if isLocked {
updateVisibility()
} else {
LockScreenTimerWidgetPanelManager.shared.hide()
}
}
func refreshPositionForOffsets(animated: Bool) {
LockScreenTimerWidgetPanelManager.shared.refreshPosition(animated: animated)
}
func notifyMusicPanelFrameChanged(animated: Bool) {
LockScreenTimerWidgetPanelManager.shared.refreshRelativeToMusicPanel(animated: animated)
}
private func observeTimerState() {
timerManager.$isTimerActive
.combineLatest(timerManager.$activeSource)
.receive(on: RunLoop.main)
.sink { [weak self] _, _ in
self?.updateVisibility()
}
.store(in: &cancellables)
}
private func observeDefaults() {
Defaults.publisher(.enableLockScreenTimerWidget, options: [])
.receive(on: RunLoop.main)
.sink { [weak self] change in
guard let self else { return }
if change.newValue {
self.updateVisibility()
} else {
LockScreenTimerWidgetPanelManager.shared.hide()
}
}
.store(in: &cancellables)
Defaults.publisher(.lockScreenTimerVerticalOffset, options: [])
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self else { return }
self.refreshPositionForOffsets(animated: true)
}
.store(in: &cancellables)
}
private func updateVisibility() {
guard shouldDisplayWidget() else {
LockScreenTimerWidgetPanelManager.shared.hide()
return
}
LockScreenTimerWidgetPanelManager.shared.showWidget()
}
private func shouldDisplayWidget() -> Bool {
guard Defaults[.enableLockScreenTimerWidget] else { return false }
guard isLocked else { return false }
return timerManager.hasManualTimerRunning
}
}
@MainActor
final class LockScreenTimerWidgetPanelManager {
static let shared = LockScreenTimerWidgetPanelManager()
private var window: NSWindow?
private var hasDelegated = false
private let animator = LockScreenTimerWidgetAnimator()
private var hideTask: Task<Void, Never>?
private init() {}
func showWidget() {
guard let screen = NSScreen.main else { return }
let window = ensureWindow()
let frame = targetFrame(on: screen)
window.setFrame(frame, display: true)
window.alphaValue = 1
window.orderFrontRegardless()
hideTask?.cancel()
hideTask = nil
animator.isPresented = true
}
func hide(animated: Bool = true) {
guard let window else { return }
hideTask?.cancel()
animator.isPresented = false
let delay: UInt64 = animated ? 360_000_000 : 0
if delay == 0 {
window.orderOut(nil)
hideTask = nil
return
}
hideTask = Task { [weak window, weak self] in
try? await Task.sleep(nanoseconds: delay)
await MainActor.run {
window?.orderOut(nil)
self?.hideTask = nil
}
}
}
func refreshPosition(animated: Bool) {
guard let window, window.isVisible, let screen = NSScreen.main else { return }
let frame = targetFrame(on: screen)
if animated {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.22
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
window.animator().setFrame(frame, display: true)
}
} else {
window.setFrame(frame, display: true)
}
}
func refreshRelativeToMusicPanel(animated: Bool) {
guard window?.isVisible == true else { return }
refreshPosition(animated: animated)
}
private func ensureWindow() -> NSWindow {
if let window {
if window.contentView == nil {
window.contentView = hostingView()
}
return window
}
let frame = NSRect(origin: .zero, size: LockScreenTimerWidget.preferredSize)
let newWindow = NSWindow(
contentRect: frame,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
newWindow.isReleasedWhenClosed = false
newWindow.isOpaque = false
newWindow.backgroundColor = .clear
newWindow.hasShadow = false
newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))
newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
newWindow.ignoresMouseEvents = false
newWindow.isMovable = false
newWindow.contentView = hostingView()
ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface)
window = newWindow
if !hasDelegated {
SkyLightOperator.shared.delegateWindow(newWindow)
hasDelegated = true
}
return newWindow
}
private func hostingView() -> NSHostingView<LockScreenTimerWidget> {
let view = LockScreenTimerWidget(animator: animator)
let hosting = NSHostingView(rootView: view)
hosting.frame = NSRect(origin: .zero, size: LockScreenTimerWidget.preferredSize)
hosting.wantsLayer = true
hosting.layer?.masksToBounds = true
hosting.layer?.cornerRadius = LockScreenTimerWidget.cornerRadius
return hosting
}
private func targetFrame(on screen: NSScreen) -> NSRect {
let size = LockScreenTimerWidget.preferredSize
let originX = screen.frame.midX - (size.width / 2)
let defaultLowering: CGFloat = -18
var baseY = screen.frame.midY + 24 + defaultLowering
if let musicFrame = LockScreenPanelManager.shared.latestFrame {
baseY = musicFrame.maxY + 28 + defaultLowering
}
let offset = CGFloat(clampedTimerOffset())
var originY = baseY + offset
if let musicFrame = LockScreenPanelManager.shared.latestFrame {
originY = max(originY, musicFrame.maxY + 12)
}
if let weatherFrame = LockScreenWeatherPanelManager.shared.latestFrame {
originY = min(originY, weatherFrame.minY - size.height - 20)
} else {
let topLimit = screen.frame.maxY - size.height - 72
originY = min(originY, topLimit)
}
let minY = screen.frame.minY + 100
originY = max(originY, minY)
return NSRect(x: originX, y: originY, width: size.width, height: size.height)
}
private func clampedTimerOffset() -> Double {
let raw = Defaults[.lockScreenTimerVerticalOffset]
return min(max(raw, -160), 160)
}
}

View file

@ -25,6 +25,10 @@ final class TimerControlWindowManager {
@discardableResult @discardableResult
func present(using viewModel: DynamicIslandViewModel, metrics: TimerControlWindowMetrics) -> Bool { func present(using viewModel: DynamicIslandViewModel, metrics: TimerControlWindowMetrics) -> Bool {
guard !LockScreenManager.shared.currentLockStatus else {
hide(animated: false)
return false
}
guard let screen = resolveScreen(from: viewModel) else { return false } guard let screen = resolveScreen(from: viewModel) else { return false }
guard viewModel.effectiveClosedNotchHeight > 0, viewModel.closedNotchSize.width > 0 else { guard viewModel.effectiveClosedNotchHeight > 0, viewModel.closedNotchSize.width > 0 else {
hide() hide()

View file

@ -395,7 +395,8 @@ class TimerManager: ObservableObject {
// Fall back to default sound if no custom sound or custom sound doesn't exist // Fall back to default sound if no custom sound or custom sound doesn't exist
if soundURL == nil { if soundURL == nil {
soundURL = Bundle.main.url(forResource: "dynamic", withExtension: "m4a") soundURL = Bundle.main.url(forResource: "timer", withExtension: "mp3")
?? Bundle.main.url(forResource: "dynamic", withExtension: "m4a")
} }
guard let finalSoundURL = soundURL else { guard let finalSoundURL = soundURL else {

View file

@ -452,6 +452,7 @@ extension Defaults.Keys {
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true) static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true) static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
static let enableLockScreenReminderWidget = Key<Bool>("enableLockScreenReminderWidget", default: true) static let enableLockScreenReminderWidget = Key<Bool>("enableLockScreenReminderWidget", default: true)
static let enableLockScreenTimerWidget = Key<Bool>("enableLockScreenTimerWidget", default: true)
static let lockScreenWeatherRefreshInterval = Key<TimeInterval>("lockScreenWeatherRefreshInterval", default: 30 * 60) static let lockScreenWeatherRefreshInterval = Key<TimeInterval>("lockScreenWeatherRefreshInterval", default: 30 * 60)
static let lockScreenWeatherShowsLocation = Key<Bool>("lockScreenWeatherShowsLocation", default: true) static let lockScreenWeatherShowsLocation = Key<Bool>("lockScreenWeatherShowsLocation", default: true)
static let lockScreenWeatherShowsCharging = Key<Bool>("lockScreenWeatherShowsCharging", default: true) static let lockScreenWeatherShowsCharging = Key<Bool>("lockScreenWeatherShowsCharging", default: true)
@ -467,6 +468,7 @@ extension Defaults.Keys {
static let lockScreenWeatherProviderSource = Key<LockScreenWeatherProviderSource>("lockScreenWeatherProviderSource", default: .openMeteo) static let lockScreenWeatherProviderSource = Key<LockScreenWeatherProviderSource>("lockScreenWeatherProviderSource", default: .openMeteo)
static let lockScreenWeatherVerticalOffset = Key<Double>("lockScreenWeatherVerticalOffset", default: 0) static let lockScreenWeatherVerticalOffset = Key<Double>("lockScreenWeatherVerticalOffset", default: 0)
static let lockScreenMusicVerticalOffset = Key<Double>("lockScreenMusicVerticalOffset", default: 0) static let lockScreenMusicVerticalOffset = Key<Double>("lockScreenMusicVerticalOffset", default: 0)
static let lockScreenTimerVerticalOffset = Key<Double>("lockScreenTimerVerticalOffset", default: 0)
static let lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid) static let lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid)
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)

BIN
DynamicIsland/timer.mp3 Normal file

Binary file not shown.