diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 867f899..c459e14 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -34,7 +34,7 @@ struct ContentView: View { @Default(.showNetworkGraph) var showNetworkGraph @Default(.showDiskGraph) var showDiskGraph - // Dynamic sizing based on view type and graph count with smooth transitions + // Dynamic sizing based on view type and graph count with smooth transitions var dynamicNotchSize: CGSize { // Use minimalistic or normal size based on settings let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize @@ -107,6 +107,8 @@ struct ContentView: View { } var body: some View { + let interactionsEnabled = !lockScreenManager.isLocked + ZStack(alignment: .top) { let mainLayout = NotchLayout() .frame(alignment: .top) @@ -149,12 +151,12 @@ struct ContentView: View { .animation(notchStateAnimation, value: vm.notchState) .animation(viewTransitionAnimation, value: coordinator.currentView) } - .conditionalModifier(Defaults[.openNotchOnHover]) { view in + .conditionalModifier(Defaults[.openNotchOnHover] && interactionsEnabled) { view in view.onHover { hovering in handleHover(hovering) } } - .conditionalModifier(!Defaults[.openNotchOnHover]) { view in + .conditionalModifier(!Defaults[.openNotchOnHover] && interactionsEnabled) { view in view .onHover { hovering in handleSimpleHover(hovering) @@ -165,14 +167,14 @@ struct ContentView: View { } doOpen() } - .conditionalModifier(Defaults[.enableGestures]) { view in + .conditionalModifier(Defaults[.enableGestures] && interactionsEnabled) { view in view .panGesture(direction: .down) { translation, phase in handleDownGesture(translation: translation, phase: phase) } } } - .conditionalModifier(Defaults[.closeGestureEnabled] && Defaults[.enableGestures]) { view in + .conditionalModifier(Defaults[.closeGestureEnabled] && Defaults[.enableGestures] && interactionsEnabled) { view in view .panGesture(direction: .up) { translation, phase in handleUpGesture(translation: translation, phase: phase) @@ -417,7 +419,7 @@ struct ContentView: View { } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) { InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) .transition(.opacity) - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed { + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed && !lockScreenManager.isLocked { MusicLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { TimerLiveActivity() @@ -611,7 +613,9 @@ struct ContentView: View { @ViewBuilder var dragDetector: some View { - if Defaults[.dynamicShelf] && !Defaults[.enableMinimalisticUI] { + if lockScreenManager.isLocked { + EmptyView() + } else if Defaults[.dynamicShelf] && !Defaults[.enableMinimalisticUI] { Color.clear .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 4c490fd..418f97e 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -434,6 +434,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { coordinator.setupWorkersNotificationObservers() + LockScreenLiveActivityWindowManager.shared.configure(viewModel: vm) + LockScreenManager.shared.configure(viewModel: vm) // Migrate legacy progress bar settings Defaults.Keys.migrateProgressBarStyle() diff --git a/DynamicIsland/components/LockScreen/LockScreenDynamicIslandReplica.swift b/DynamicIsland/components/LockScreen/LockScreenDynamicIslandReplica.swift new file mode 100644 index 0000000..bf766c3 --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenDynamicIslandReplica.swift @@ -0,0 +1 @@ +// Legacy placeholder file superseded by LockScreenLiveActivityOverlay.swift. diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift index 9a3699e..b690ce0 100644 --- a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift @@ -1,89 +1,57 @@ -// -// LockScreenLiveActivityOverlay.swift -// DynamicIsland -// -// Mirrors the closed-notch live activity layout on the macOS lock screen. -// - import SwiftUI -enum LockLiveActivityState: Equatable { - case locked - case unlocking -} - -final class LockScreenLiveActivityOverlayViewModel: ObservableObject { - @Published var expansion: CGFloat = 1 +final class LockScreenLiveActivityOverlayModel: ObservableObject { + @Published var iconName: String = "lock.fill" + @Published var scale: CGFloat = 0.6 + @Published var opacity: Double = 0 } struct LockScreenLiveActivityOverlay: View { - let state: LockLiveActivityState - let notchSize: CGSize - @ObservedObject var viewModel: LockScreenLiveActivityOverlayViewModel + @ObservedObject var model: LockScreenLiveActivityOverlayModel + let notchSize: CGSize - private var indicatorSize: CGFloat { - max(0, notchSize.height - 12) - } + private var indicatorSize: CGFloat { + max(0, notchSize.height - 12) + } - private var horizontalPadding: CGFloat { - cornerRadiusInsets.closed.bottom - } + private var horizontalPadding: CGFloat { + cornerRadiusInsets.closed.bottom + } - private var baseWidth: CGFloat { - notchSize.width + (indicatorSize * 2) - } + private var totalWidth: CGFloat { + notchSize.width + (indicatorSize * 2) + (horizontalPadding * 2) + } - private var totalWidth: CGFloat { - baseWidth + (horizontalPadding * 2) - } + var body: some View { + HStack(spacing: 0) { + Color.clear + .overlay(alignment: .leading) { + Image(systemName: model.iconName) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: indicatorSize, height: indicatorSize) + } + .frame(width: indicatorSize, height: notchSize.height) - private var iconName: String { - state == .locked ? "lock.fill" : "lock.open.fill" - } + Rectangle() + .fill(.black) + .frame(width: notchSize.width, height: notchSize.height) - private var iconColor: Color { .white } - - var body: some View { - HStack(alignment: .top, spacing: 0) { - Color.clear - .overlay(alignment: .leading) { - Image(systemName: iconName) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(iconColor) - .frame(width: indicatorSize, height: indicatorSize) - } - .frame(width: indicatorSize, height: notchSize.height, alignment: .top) - - Color.clear - .frame(width: notchSize.width, height: notchSize.height, alignment: .top) - - Color.clear - .frame(width: indicatorSize, height: notchSize.height, alignment: .top) - } - .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) - .scaleEffect(x: max(0.05, viewModel.expansion), y: 1, anchor: .center) - .opacity(min(1, max(0.2, viewModel.expansion * 5))) - .animation(.spring(response: 0.45, dampingFraction: 0.85), value: state) - .animation(.spring(response: 0.5, dampingFraction: 0.85), value: viewModel.expansion) - .drawingGroup() - } -} - -#Preview { - let model = LockScreenLiveActivityOverlayViewModel() - return VStack(spacing: 24) { - LockScreenLiveActivityOverlay(state: .locked, notchSize: CGSize(width: 220, height: 32), viewModel: model) - LockScreenLiveActivityOverlay(state: .unlocking, notchSize: CGSize(width: 220, height: 32), viewModel: model) - } - .padding() - .background(Color.gray.opacity(0.2)) + Color.clear + .frame(width: indicatorSize, height: notchSize.height) + } + .frame(width: notchSize.width + (indicatorSize * 2), 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) + .scaleEffect(x: model.scale, y: 1, anchor: .center) + .opacity(model.opacity) + .drawingGroup() + } } diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 0e9bec5..75ace95 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -15,6 +15,10 @@ struct LockScreenMusicPanel: View { @State private var lastDragged: Date = .distantPast @State private var isActive = true + private let panelWidth: CGFloat = 460 + private let panelHeight: CGFloat = 180 + private let cornerRadius: CGFloat = 28 + var body: some View { if isActive { panelContent @@ -23,14 +27,14 @@ struct LockScreenMusicPanel: View { } } else { Color.clear - .frame(width: 420, height: 150) + .frame(width: panelWidth, height: panelHeight) } } private var panelContent: some View { - VStack(spacing: 0) { + VStack(spacing: 12) { // Header area with album art - HStack(alignment: .center, spacing: 10) { + HStack(alignment: .center, spacing: 16) { // Album art Button { musicManager.openMusicApp() @@ -38,8 +42,8 @@ struct LockScreenMusicPanel: View { Image(nsImage: musicManager.albumArt) .resizable() .aspectRatio(contentMode: .fill) - .frame(width: 50, height: 50) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 16)) } .buttonStyle(PlainButtonStyle()) .opacity(musicManager.isPlaying ? 1 : 0.4) @@ -71,30 +75,30 @@ struct LockScreenMusicPanel: View { .frame(width: 20, height: 16) } } - .frame(height: 50) + .frame(height: 60) // Progress bar progressBar - .padding(.top, 6) + .padding(.top, 4) // Playback controls playbackControls .padding(.top, 4) } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(width: 420, height: 150) + .padding(.horizontal, 20) + .padding(.vertical, 16) + .frame(width: panelWidth, height: panelHeight) .background { if #available(macOS 26.0, *) { - RoundedRectangle(cornerRadius: 20) - .glassEffect(in: .rect(cornerRadius: 20)) + RoundedRectangle(cornerRadius: cornerRadius) + .glassEffect(in: .rect(cornerRadius: cornerRadius)) .onAppear { let formatter = DateFormatter() formatter.dateFormat = "HH:mm:ss.SSS" print("[\(formatter.string(from: Date()))] LockScreenMusicPanel: ✨ Using macOS 26.0+ Liquid Glass effect") } } else { - RoundedRectangle(cornerRadius: 20) + RoundedRectangle(cornerRadius: cornerRadius) .fill(.ultraThinMaterial) .onAppear { let formatter = DateFormatter() @@ -103,6 +107,10 @@ struct LockScreenMusicPanel: View { } } } + .overlay { + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color.white.opacity(0.35), lineWidth: 1.4) + } .shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10) .onAppear { sliderValue = musicManager.elapsedTime @@ -233,7 +241,9 @@ struct LockScreenMusicPanel: View { Image(systemName: musicManager.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 24, weight: .semibold)) .foregroundColor(.white) - .frame(width: 50, height: 50) + .frame(width: 54, height: 54) + .background(Color.white.opacity(0.15)) + .clipShape(Circle()) } .buttonStyle(PlainButtonStyle()) } diff --git a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift index 7ddd400..2b8814e 100644 --- a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift +++ b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift @@ -18,7 +18,8 @@ class LockScreenLiveActivityWindowManager { private var hasDelegated = false private var hideTask: Task? private var hostingView: NSHostingView? - private let overlayModel = LockScreenLiveActivityOverlayViewModel() + private let overlayModel = LockScreenLiveActivityOverlayModel() + private weak var viewModel: DynamicIslandViewModel? private init() {} @@ -28,25 +29,30 @@ class LockScreenLiveActivityWindowManager { return formatter.string(from: Date()) } - private func frame(for notchSize: CGSize, on screen: NSScreen) -> NSRect { - let indicatorSize = max(0, notchSize.height - 12) + private func windowSize(for notchSize: CGSize) -> CGSize { + let indicatorWidth = 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 + let totalWidth = notchSize.width + (indicatorWidth * 2) + (horizontalPadding * 2) - return NSRect(x: originX, y: originY, width: totalWidth, height: notchSize.height) + return CGSize(width: totalWidth, height: notchSize.height) } - private func ensureWindow(notchSize: CGSize, screen: NSScreen) -> NSWindow { + private func frame(for windowSize: CGSize, on screen: NSScreen) -> NSRect { + let screenFrame = screen.frame + let originX = screenFrame.origin.x + (screenFrame.width / 2) - (windowSize.width / 2) + let originY = screenFrame.origin.y + screenFrame.height - windowSize.height + + return NSRect(x: originX, y: originY, width: windowSize.width, height: windowSize.height) + } + + private func ensureWindow(windowSize: CGSize, screen: NSScreen) -> NSWindow { if let window { return window } let window = NSWindow( - contentRect: frame(for: notchSize, on: screen), + contentRect: frame(for: windowSize, on: screen), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false @@ -67,29 +73,43 @@ class LockScreenLiveActivityWindowManager { return window } - private func present(state: LockLiveActivityState) { + private func lockContext() -> (notchSize: CGSize, screen: NSScreen)? { + guard let screen = NSScreen.main else { + print("[\(timestamp())] LockScreenLiveActivityWindowManager: no main screen available") + return nil + } + + guard let viewModel else { + print("[\(timestamp())] LockScreenLiveActivityWindowManager: no view model configured") + return nil + } + + var notchSize = viewModel.closedNotchSize + if notchSize.width <= 0 || notchSize.height <= 0 { + notchSize = getClosedNotchSize(screen: screen.localizedName) + } + + return (notchSize, screen) + } + + private func present(notchSize: CGSize, on screen: NSScreen) { 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) + let windowSize = windowSize(for: notchSize) + let window = ensureWindow(windowSize: windowSize, screen: screen) + let targetFrame = frame(for: windowSize, on: screen) window.setFrame(targetFrame, display: true) - let overlay = LockScreenLiveActivityOverlay(state: state, notchSize: notchSize, viewModel: overlayModel) + let overlayView = LockScreenLiveActivityOverlay(model: overlayModel, notchSize: notchSize) if let hostingView { - hostingView.rootView = overlay + hostingView.rootView = overlayView hostingView.frame = CGRect(origin: .zero, size: targetFrame.size) } else { - let view = NSHostingView(rootView: overlay) + let view = NSHostingView(rootView: overlayView) view.frame = CGRect(origin: .zero, size: targetFrame.size) hostingView = view window.contentView = view @@ -107,40 +127,55 @@ class LockScreenLiveActivityWindowManager { } window.orderFrontRegardless() - - if state == .locked { - overlayModel.expansion = 0.2 - withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) { - overlayModel.expansion = 1 - } - } else { - overlayModel.expansion = 1 - } - - if window.alphaValue < 1 { - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.12 - window.animator().alphaValue = 1 - } - } + window.alphaValue = 1 } func showLocked() { hideTask?.cancel() - present(state: .locked) + guard let context = lockContext() else { return } + + overlayModel.iconName = "lock.fill" + overlayModel.scale = 0.6 + overlayModel.opacity = 0 + + present(notchSize: context.notchSize, on: context.screen) + + DispatchQueue.main.async { + withAnimation(.spring(response: 0.45, dampingFraction: 0.82)) { + self.overlayModel.scale = 1 + } + withAnimation(.easeOut(duration: 0.18)) { + self.overlayModel.opacity = 1 + } + } + print("[\(timestamp())] LockScreenLiveActivityWindowManager: showing locked state") } func showUnlockAndScheduleHide() { hideTask?.cancel() - present(state: .unlocking) - print("[\(timestamp())] LockScreenLiveActivityWindowManager: showing unlock state") + guard let context = lockContext() else { return } + + overlayModel.iconName = "lock.open.fill" + overlayModel.scale = 0.7 + overlayModel.opacity = 0 + + present(notchSize: context.notchSize, on: context.screen) + + DispatchQueue.main.async { + withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { + self.overlayModel.scale = 1 + } + withAnimation(.easeOut(duration: 0.16)) { + self.overlayModel.opacity = 1 + } + } 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() + self.hideWithAnimation() } } } @@ -149,17 +184,30 @@ class LockScreenLiveActivityWindowManager { hideTask?.cancel() hideTask = nil + hideWithAnimation() + } + + private func hideWithAnimation() { guard let window else { return } - withAnimation(.easeOut(duration: 0.1)) { - overlayModel.expansion = 0.2 + withAnimation(.easeInOut(duration: 0.18)) { + overlayModel.opacity = 0 + overlayModel.scale = 0.7 } NSAnimationContext.runAnimationGroup { context in - context.duration = 0.1 + context.duration = 0.2 window.animator().alphaValue = 0 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { + window.orderOut(nil) + } + print("[\(timestamp())] LockScreenLiveActivityWindowManager: HUD hidden") } + + func configure(viewModel: DynamicIslandViewModel) { + self.viewModel = viewModel + } } diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift index 8e7bef1..00c3b87 100644 --- a/DynamicIsland/managers/LockScreenManager.swift +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -18,6 +18,7 @@ class LockScreenManager: ObservableObject { // MARK: - Coordinator private let coordinator = DynamicIslandViewCoordinator.shared + private weak var viewModel: DynamicIslandViewModel? // MARK: - Published Properties @Published var isLocked: Bool = false @@ -79,6 +80,17 @@ class LockScreenManager: ObservableObject { // Set locked state immediately without animation wrapper isLocked = true + + viewModel?.closeForLockScreen() + + if coordinator.expandingView.show { + let currentType = coordinator.expandingView.type + coordinator.toggleExpandingView(status: false, type: currentType) + } + + if coordinator.sneakPeek.show { + coordinator.toggleSneakPeek(status: false, type: coordinator.sneakPeek.type) + } // Show panel FIRST (creates and shows window on lock screen) print("[\(timestamp())] LockScreenManager: 🎵 Showing lock screen panel") @@ -143,6 +155,10 @@ class LockScreenManager: ObservableObject { // MARK: - Extensions extension LockScreenManager { + func configure(viewModel: DynamicIslandViewModel) { + self.viewModel = viewModel + } + /// Get current lock status without async var currentLockStatus: Bool { return isLocked diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index 7e89df8..06fff41 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -41,11 +41,11 @@ class LockScreenPanelManager { return } - let panelWidth: CGFloat = 420 - let panelHeight: CGFloat = 150 + let panelWidth: CGFloat = 460 + let panelHeight: CGFloat = 180 let screenFrame = screen.frame let originX = screenFrame.origin.x + (screenFrame.width / 2) - (panelWidth / 2) - let originY = screenFrame.origin.y + (screenFrame.height / 2) - panelHeight - 40 + let originY = screenFrame.origin.y + (screenFrame.height / 2) - panelHeight - 32 let targetFrame = NSRect(x: originX, y: originY, width: panelWidth, height: panelHeight) let window: NSWindow diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index d1e5237..ae057bf 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -174,6 +174,15 @@ class DynamicIslandViewModel: NSObject, ObservableObject { } } + func closeForLockScreen() { + let targetSize = getClosedNotchSize(screen: screen) + withAnimation(.none) { + notchSize = targetSize + closedNotchSize = targetSize + notchState = .closed + } + } + func closeHello() { DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in self?.coordinator.firstLaunch = false diff --git a/DynamicIsland/utils/Environment+LockScreenReplica.swift b/DynamicIsland/utils/Environment+LockScreenReplica.swift new file mode 100644 index 0000000..7a6fb9f --- /dev/null +++ b/DynamicIsland/utils/Environment+LockScreenReplica.swift @@ -0,0 +1 @@ +// Deprecated: lockScreenReplica environment key now lives in ContentView.swift.