diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift index f998c97..41a3735 100644 --- a/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift @@ -7,15 +7,26 @@ import SwiftUI import Defaults -import SkyLightWindow struct LockScreenLiveActivity: View { @EnvironmentObject var vm: DynamicIslandViewModel - // Don't observe lockScreenManager - just show static lock icon without state-driven animations - // This prevents animation conflicts with panel window teardown + @ObservedObject private var lockScreenManager = LockScreenManager.shared @State private var isHovering: Bool = false @State private var gestureProgress: CGFloat = 0 @State private var isExpanded: Bool = false + @Namespace private var lockIconSpace + + private var iconName: String { + lockScreenManager.isLocked ? "lock.fill" : "lock.open.fill" + } + + private var iconColor: Color { + lockScreenManager.isLocked ? .blue : .green + } + + private var glowColor: Color { + lockScreenManager.isLocked ? Color.blue.opacity(0.15) : Color.green.opacity(0.2) + } var body: some View { HStack(spacing: 0) { @@ -26,12 +37,13 @@ struct LockScreenLiveActivity: View { HStack { ZStack { RoundedRectangle(cornerRadius: 6) - .fill(Color.blue.opacity(0.15)) + .fill(glowColor) - Image(systemName: "lock.fill") - .font(.system(size: 12)) - .foregroundColor(.blue) - .modifier(LockPulsingModifier()) + Image(systemName: iconName) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(iconColor) + .modifier(LockPulsingModifier(isUnlocking: !lockScreenManager.isLocked)) + .matchedGeometryEffect(id: "lock-icon", in: lockIconSpace) } .frame(width: vm.effectiveClosedNotchHeight - 12, height: vm.effectiveClosedNotchHeight - 12) } @@ -52,27 +64,46 @@ struct LockScreenLiveActivity: View { .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) .onAppear { // Expand immediately without animation to avoid conflicts - isExpanded = true + withAnimation(.smooth(duration: 0.4)) { + isExpanded = true + } } .onDisappear { // Collapse immediately when removed from hierarchy isExpanded = false } + .onChange(of: lockScreenManager.isLockIdle) { _, newValue in + if newValue { + withAnimation(.smooth(duration: 0.4)) { + isExpanded = false + } + } else { + withAnimation(.smooth(duration: 0.4)) { + isExpanded = true + } + } + } + .animation(.spring(response: 0.5, dampingFraction: 0.85), value: lockScreenManager.isLocked) + .animation(.easeOut(duration: 0.25), value: isExpanded) } } // Pulsing animation modifier for lock indicator struct LockPulsingModifier: ViewModifier { + var isUnlocking: Bool @State private var isPulsing = false func body(content: Content) -> some View { content .scaleEffect(isPulsing ? 1.1 : 1.0) - .opacity(isPulsing ? 0.8 : 1.0) + .opacity(isPulsing ? (isUnlocking ? 1.0 : 0.8) : 1.0) .onAppear { withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) { isPulsing = true } } + .onDisappear { + isPulsing = false + } } } diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift new file mode 100644 index 0000000..3acdb3a --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift @@ -0,0 +1,95 @@ +// +// LockScreenLiveActivityOverlay.swift +// DynamicIsland +// +// Mirrors the closed-notch live activity layout on the macOS lock screen. +// + +import SwiftUI + +enum LockLiveActivityState: Equatable { + case locked + case unlocking +} + +struct LockScreenLiveActivityOverlay: View { + let state: LockLiveActivityState + let notchSize: CGSize + + private var indicatorSize: CGFloat { + max(0, notchSize.height - 12) + } + + private var horizontalPadding: CGFloat { + cornerRadiusInsets.closed.bottom + } + + private var baseWidth: CGFloat { + notchSize.width + (indicatorSize * 2) + } + + private var totalWidth: CGFloat { + baseWidth + (horizontalPadding * 2) + } + + private var iconName: String { + state == .locked ? "lock.fill" : "lock.open.fill" + } + + private var iconColor: Color { + state == .locked ? .blue : .green + } + + private var glowColor: Color { + state == .locked ? Color.blue.opacity(0.15) : Color.green.opacity(0.2) + } + + var body: some View { + HStack(spacing: 0) { + Color.clear + .background { + HStack { + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(glowColor) + + Image(systemName: iconName) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(iconColor) + .modifier(LockPulsingModifier(isUnlocking: state == .unlocking)) + } + .frame(width: indicatorSize, height: indicatorSize) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + .frame(width: indicatorSize, height: notchSize.height) + + Color.clear + .frame(width: notchSize.width, height: notchSize.height) + + Color.clear + .frame(width: indicatorSize, height: notchSize.height) + } + .frame(width: baseWidth, height: notchSize.height) + .padding(.horizontal, horizontalPadding) + .background(Color.black) + .clipShape( + NotchShape( + topCornerRadius: cornerRadiusInsets.closed.top, + bottomCornerRadius: cornerRadiusInsets.closed.bottom + ) + ) + .frame(width: totalWidth, height: notchSize.height) + .animation(.spring(response: 0.45, dampingFraction: 0.85), value: state) + .drawingGroup() + } +} + +#Preview { + VStack(spacing: 24) { + LockScreenLiveActivityOverlay(state: .locked, notchSize: CGSize(width: 220, height: 32)) + LockScreenLiveActivityOverlay(state: .unlocking, notchSize: CGSize(width: 220, height: 32)) + } + .padding() + .background(Color.gray.opacity(0.2)) +} diff --git a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift new file mode 100644 index 0000000..e4aaa35 --- /dev/null +++ b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift @@ -0,0 +1,127 @@ +// +// LockScreenLiveActivityWindowManager.swift +// DynamicIsland +// +// Delegates the lock screen live activity layout to SkyLight so it remains visible when the display is locked. +// + +import AppKit +import Defaults +import SkyLightWindow +import SwiftUI + +@MainActor +class LockScreenLiveActivityWindowManager { + static let shared = LockScreenLiveActivityWindowManager() + + private var window: NSWindow? + private var hasDelegated = false + private var hideTask: Task? + + private init() {} + + private func timestamp() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + return formatter.string(from: Date()) + } + + private func frame(for notchSize: CGSize, on screen: NSScreen) -> NSRect { + let indicatorSize = max(0, notchSize.height - 12) + let horizontalPadding = cornerRadiusInsets.closed.bottom + let totalWidth = notchSize.width + (indicatorSize * 2) + (horizontalPadding * 2) + + let screenFrame = screen.frame + let originX = screenFrame.origin.x + (screenFrame.width / 2) - (totalWidth / 2) + let originY = screenFrame.origin.y + screenFrame.height - notchSize.height + + return NSRect(x: originX, y: originY, width: totalWidth, height: notchSize.height) + } + + private func ensureWindow(notchSize: CGSize, screen: NSScreen) -> NSWindow { + if let window { + return window + } + + let window = NSWindow( + contentRect: frame(for: notchSize, on: screen), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + window.isOpaque = false + window.backgroundColor = .clear + window.isReleasedWhenClosed = false + window.ignoresMouseEvents = true + window.hasShadow = false + window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + window.alphaValue = 0 + + self.window = window + self.hasDelegated = false + return window + } + + private func present(state: LockLiveActivityState) { + guard Defaults[.enableLockScreenLiveActivity] else { + hideImmediately() + return + } + + guard let screen = NSScreen.main else { + print("[\(timestamp())] LockScreenLiveActivityWindowManager: no main screen available") + return + } + + let notchSize = getClosedNotchSize(screen: screen.localizedName) + let window = ensureWindow(notchSize: notchSize, screen: screen) + let targetFrame = frame(for: notchSize, on: screen) + window.setFrame(targetFrame, display: true) + + let overlay = LockScreenLiveActivityOverlay(state: state, notchSize: notchSize) + window.contentView = NSHostingView(rootView: overlay) + + if !hasDelegated { + SkyLightOperator.shared.delegateWindow(window) + hasDelegated = true + } + + window.alphaValue = 1 + window.orderFrontRegardless() + } + + func showLocked() { + hideTask?.cancel() + present(state: .locked) + print("[\(timestamp())] LockScreenLiveActivityWindowManager: showing locked state") + } + + func showUnlockAndScheduleHide() { + hideTask?.cancel() + present(state: .unlocking) + print("[\(timestamp())] LockScreenLiveActivityWindowManager: showing unlock state") + + hideTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(max(Defaults[.waitInterval], 1.0))) + guard let self, !Task.isCancelled else { return } + await MainActor.run { + self.hideImmediately() + } + } + } + + func hideImmediately() { + hideTask?.cancel() + hideTask = nil + + guard let window else { return } + + window.orderOut(nil) + window.alphaValue = 0 + window.contentView = nil + + print("[\(timestamp())] LockScreenLiveActivityWindowManager: HUD hidden") + } +} diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift index 9e63fb6..8e7bef1 100644 --- a/DynamicIsland/managers/LockScreenManager.swift +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -83,6 +83,7 @@ class LockScreenManager: ObservableObject { // Show panel FIRST (creates and shows window on lock screen) print("[\(timestamp())] LockScreenManager: 🎵 Showing lock screen panel") LockScreenPanelManager.shared.showPanel() + LockScreenLiveActivityWindowManager.shared.showLocked() // THEN trigger lock icon in Dynamic Island (only if enabled in settings) if Defaults[.enableLockScreenLiveActivity] { @@ -101,6 +102,7 @@ class LockScreenManager: ObservableObject { // Hide panel window immediately and synchronously print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window") LockScreenPanelManager.shared.hidePanel() + LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide() // Update state immediately if Defaults[.enableLockScreenLiveActivity] {