diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 80944c4..876f07c 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -618,6 +618,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { 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() { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 515e229..418be4c 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1357,6 +1357,10 @@ "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", "isCommentAutoGenerated" : true }, + "00:05:00" : { + "comment" : "A time displayed in the timer panel on a lock screen.", + "isCommentAutoGenerated" : true + }, "1x" : { "localizations" : { "fr" : { diff --git a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift new file mode 100644 index 0000000..bb3d41c --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift @@ -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) + } +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index c51894e..e7148d8 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -379,6 +379,7 @@ struct GeneralSettings: View { Defaults.Toggle("Enable blur", key: .lockScreenPanelUsesBlur) 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) if enableLockScreenWeatherWidget { Picker("Widget layout", selection: $lockScreenWeatherWidgetStyle) { ForEach(LockScreenWeatherWidgetStyle.allCases) { style in @@ -1723,6 +1724,7 @@ struct Appearance: View { private struct LockScreenPositioningControls: View { @Default(.lockScreenWeatherVerticalOffset) private var weatherOffset @Default(.lockScreenMusicVerticalOffset) private var musicOffset + @Default(.lockScreenTimerVerticalOffset) private var timerOffset private let offsetRange: ClosedRange = -160...160 var body: some View { @@ -1738,6 +1740,17 @@ private struct LockScreenPositioningControls: View { } ) + let timerBinding = Binding( + get: { timerOffset }, + set: { newValue in + let clampedValue = clamp(newValue) + if timerOffset != clampedValue { + timerOffset = clampedValue + } + propagateTimerOffsetChange(animated: false) + } + ) + let musicBinding = Binding( get: { musicOffset }, 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) .padding(.vertical, 8) @@ -1761,6 +1774,16 @@ private struct LockScreenPositioningControls: View { resetAction: resetWeatherOffset ) + Divider() + .frame(height: 64) + + offsetColumn( + title: "Timer", + value: timerOffset, + resetTitle: "Reset Timer", + resetAction: resetTimerOffset + ) + Divider() .frame(height: 64) @@ -1790,6 +1813,11 @@ private struct LockScreenPositioningControls: View { propagateWeatherOffsetChange(animated: true) } + private func resetTimerOffset() { + timerOffset = 0 + propagateTimerOffsetChange(animated: true) + } + private func resetMusicOffset() { musicOffset = 0 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) { Task { @MainActor in LockScreenPanelManager.shared.applyOffsetAdjustment(animated: animated) @@ -1831,11 +1865,14 @@ private struct LockScreenPositioningControls: View { private struct LockScreenPositioningPreview: View { @Binding var weatherOffset: Double + @Binding var timerOffset: Double @Binding var musicOffset: Double @State private var weatherStartOffset: Double = 0 + @State private var timerStartOffset: Double = 0 @State private var musicStartOffset: Double = 0 @State private var isWeatherDragging = false + @State private var isTimerDragging = false @State private var isMusicDragging = false private let offsetRange: ClosedRange = -160...160 @@ -1852,8 +1889,10 @@ private struct LockScreenPositioningPreview: View { ) let centerX = screenRect.midX 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 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) ZStack { @@ -1871,6 +1910,10 @@ private struct LockScreenPositioningPreview: View { .position(x: centerX, y: weatherBaseY - CGFloat(weatherOffset)) .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) .position(x: centerX, y: musicBaseY - CGFloat(musicOffset)) .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) } + 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 { DragGesture(minimumDistance: 0) .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( _ proposed: Double, baseCenterY: CGFloat, diff --git a/DynamicIsland/components/Timer/TimerLiveActivity.swift b/DynamicIsland/components/Timer/TimerLiveActivity.swift index e14508d..fa9da86 100644 --- a/DynamicIsland/components/Timer/TimerLiveActivity.swift +++ b/DynamicIsland/components/Timer/TimerLiveActivity.swift @@ -140,9 +140,18 @@ struct TimerLiveActivity: View { return timerPresets.first { $0.id == presetId }?.color } - private var shouldShowControlWindow: Bool { - controlWindowEnabled && !shouldDisplayLabel && timerManager.isTimerActive && !timerManager.isExternalTimerActive - } + 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 + } private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat { let attributes: [NSAttributedString.Key: Any] = [.font: font] diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift index e36adcf..2d48c4d 100644 --- a/DynamicIsland/managers/LockScreenManager.swift +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -108,6 +108,8 @@ class LockScreenManager: ObservableObject { LockScreenLiveActivityWindowManager.shared.showLocked() LockScreenWeatherManager.shared.showWeatherWidget() 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) if Defaults[.enableLockScreenLiveActivity] { @@ -130,6 +132,7 @@ class LockScreenManager: ObservableObject { LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide() LockScreenWeatherManager.shared.hideWeatherWidget() LockScreenReminderWidgetManager.shared.hideReminderWidget() + LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: false) // Update state immediately if Defaults[.enableLockScreenLiveActivity] { diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index e30d8bd..56bb800 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -92,6 +92,7 @@ class LockScreenPanelManager { latestFrame = targetFrame hideTask?.cancel() panelAnimator.isPresented = false + LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: false) let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator)) hosting.frame = NSRect(origin: .zero, size: targetFrame.size) @@ -144,6 +145,8 @@ class LockScreenPanelManager { latestFrame = targetFrame + LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated) + // Update corner radius to match the SwiftUI panel's style let targetRadius = expanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius if animated { @@ -167,6 +170,7 @@ class LockScreenPanelManager { guard panelWindow != nil else { return } updatePanelSize(expanded: isPanelExpanded, additionalHeight: currentAdditionalHeight, animated: animated) + LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated) } func hidePanel() { @@ -197,9 +201,10 @@ class LockScreenPanelManager { let collapsedSize = LockScreenMusicPanel.collapsedSize let originX = screenFrame.midX - (collapsedSize.width / 2) let baseOriginY = screenFrame.origin.y + (screenFrame.height / 2) - collapsedSize.height - 32 + let defaultLowering: CGFloat = -28 let userOffset = CGFloat(Defaults[.lockScreenMusicVerticalOffset]) 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) } } diff --git a/DynamicIsland/managers/LockScreenTimerWidgetManager.swift b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift new file mode 100644 index 0000000..b8efbe3 --- /dev/null +++ b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift @@ -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() + 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? + + 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 { + 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) + } +} diff --git a/DynamicIsland/managers/TimerControlWindowManager.swift b/DynamicIsland/managers/TimerControlWindowManager.swift index eeb2378..860feac 100644 --- a/DynamicIsland/managers/TimerControlWindowManager.swift +++ b/DynamicIsland/managers/TimerControlWindowManager.swift @@ -25,6 +25,10 @@ final class TimerControlWindowManager { @discardableResult 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 viewModel.effectiveClosedNotchHeight > 0, viewModel.closedNotchSize.width > 0 else { hide() diff --git a/DynamicIsland/managers/TimerManager.swift b/DynamicIsland/managers/TimerManager.swift index b49654e..af8762f 100644 --- a/DynamicIsland/managers/TimerManager.swift +++ b/DynamicIsland/managers/TimerManager.swift @@ -395,7 +395,8 @@ class TimerManager: ObservableObject { // Fall back to default sound if no custom sound or custom sound doesn't exist 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 { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 9d7dc3c..7725b83 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -452,6 +452,7 @@ extension Defaults.Keys { static let enableLockScreenMediaWidget = Key("enableLockScreenMediaWidget", default: true) static let enableLockScreenWeatherWidget = Key("enableLockScreenWeatherWidget", default: true) static let enableLockScreenReminderWidget = Key("enableLockScreenReminderWidget", default: true) + static let enableLockScreenTimerWidget = Key("enableLockScreenTimerWidget", default: true) static let lockScreenWeatherRefreshInterval = Key("lockScreenWeatherRefreshInterval", default: 30 * 60) static let lockScreenWeatherShowsLocation = Key("lockScreenWeatherShowsLocation", default: true) static let lockScreenWeatherShowsCharging = Key("lockScreenWeatherShowsCharging", default: true) @@ -467,6 +468,7 @@ extension Defaults.Keys { static let lockScreenWeatherProviderSource = Key("lockScreenWeatherProviderSource", default: .openMeteo) static let lockScreenWeatherVerticalOffset = Key("lockScreenWeatherVerticalOffset", default: 0) static let lockScreenMusicVerticalOffset = Key("lockScreenMusicVerticalOffset", default: 0) + static let lockScreenTimerVerticalOffset = Key("lockScreenTimerVerticalOffset", default: 0) static let lockScreenGlassStyle = Key("lockScreenGlassStyle", default: .liquid) static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false) static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: true) diff --git a/DynamicIsland/timer.mp3 b/DynamicIsland/timer.mp3 new file mode 100644 index 0000000..41293e2 Binary files /dev/null and b/DynamicIsland/timer.mp3 differ