diff --git a/DynamicIsland/Assets.xcassets/desktop.imageset/Contents.json b/DynamicIsland/Assets.xcassets/desktop.imageset/Contents.json new file mode 100644 index 0000000..a0602d5 --- /dev/null +++ b/DynamicIsland/Assets.xcassets/desktop.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "desktop.jpeg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DynamicIsland/Assets.xcassets/desktop.imageset/desktop.jpeg b/DynamicIsland/Assets.xcassets/desktop.imageset/desktop.jpeg new file mode 100644 index 0000000..8a68f43 Binary files /dev/null and b/DynamicIsland/Assets.xcassets/desktop.imageset/desktop.jpeg differ diff --git a/DynamicIsland/Assets.xcassets/glassdesktop.imageset/6EDAFE63-F31B-44D9-A167-0D7EC8B02C66_1_201_a.jpeg b/DynamicIsland/Assets.xcassets/glassdesktop.imageset/6EDAFE63-F31B-44D9-A167-0D7EC8B02C66_1_201_a.jpeg new file mode 100644 index 0000000..2cdaa7d Binary files /dev/null and b/DynamicIsland/Assets.xcassets/glassdesktop.imageset/6EDAFE63-F31B-44D9-A167-0D7EC8B02C66_1_201_a.jpeg differ diff --git a/DynamicIsland/Assets.xcassets/glassdesktop.imageset/Contents.json b/DynamicIsland/Assets.xcassets/glassdesktop.imageset/Contents.json new file mode 100644 index 0000000..f47328b --- /dev/null +++ b/DynamicIsland/Assets.xcassets/glassdesktop.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "6EDAFE63-F31B-44D9-A167-0D7EC8B02C66_1_201_a.jpeg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index d79dfa8..c1acf0f 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -11961,6 +11961,9 @@ } } } + }, + "Hide lock screen preview" : { + }, "Hide only when NowPlaying app is in fullscreen" : { "localizations" : { @@ -13518,6 +13521,10 @@ } } }, + "Loading..." : { + "comment" : "A placeholder text displayed while loading the wallpaper.", + "isCommentAutoGenerated" : true + }, "Lock Screen" : { "localizations" : { "fr" : { @@ -18047,6 +18054,9 @@ } } } + }, + "Opens a transparent preview window with mock data that mirrors the current lock screen widget configuration." : { + }, "Opens the AI assistant panel for file analysis and conversation. Default is Cmd+Shift+A. Only works when screen assistant feature is enabled." : { "localizations" : { @@ -18530,6 +18540,9 @@ } } } + }, + "Preview lock screen widgets" : { + }, "Preview Zoom" : { "comment" : "A label describing the zoom control for the animation preview.", diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index e6fcba9..a5c5e66 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -700,6 +700,7 @@ struct SettingsView: View { SettingsSearchEntry(tab: .appearance, title: "App icon", keywords: ["app icon", "custom icon"], highlightID: SettingsTab.appearance.highlightID(for: "App icon")), // Lock Screen + SettingsSearchEntry(tab: .lockScreen, title: "Preview lock screen widgets", keywords: ["preview", "lock screen", "widgets"], highlightID: SettingsTab.lockScreen.highlightID(for: "Preview lock screen widgets")), SettingsSearchEntry(tab: .lockScreen, title: "Enable lock screen live activity", keywords: ["lock screen", "live activity"], highlightID: SettingsTab.lockScreen.highlightID(for: "Enable lock screen live activity")), SettingsSearchEntry(tab: .lockScreen, title: "Play lock/unlock sounds", keywords: ["chime", "sound"], highlightID: SettingsTab.lockScreen.highlightID(for: "Play lock/unlock sounds")), SettingsSearchEntry(tab: .lockScreen, title: "Material", keywords: ["glass", "frosted", "liquid"], highlightID: SettingsTab.lockScreen.highlightID(for: "Material")), @@ -2801,7 +2802,7 @@ private struct SettingsLoopingVideoIcon: NSViewRepresentable { private var controller: SettingsLoopingPlayerController? func attach(layer: AVPlayerLayer, url: URL) { - controller = SettingsLoopingPlayerController(url: url) + controller = SettingsLoopingPlayerController(url: url, autoPlay: true) layer.player = controller?.player } } @@ -2811,15 +2812,25 @@ private final class SettingsLoopingPlayerController { let player: AVQueuePlayer private var looper: AVPlayerLooper? - init(url: URL) { + init(url: URL, autoPlay: Bool = true) { let item = AVPlayerItem(url: url) player = AVQueuePlayer() player.isMuted = true player.actionAtItemEnd = .none looper = AVPlayerLooper(player: player, templateItem: item) + if autoPlay { + player.play() + } + } + + func play() { player.play() } + func pause() { + player.pause() + } + deinit { player.pause() looper = nil @@ -3298,6 +3309,9 @@ struct Appearance: View { .foregroundStyle(.secondary) } Slider(value: appearanceMusicVariantBinding, in: liquidVariantRange, step: 1) + + LockScreenGlassVariantPreviewCell(variant: $lockScreenMusicLiquidGlassVariant) + .padding(.top, 6) } .settingsHighlight(id: highlightID("Music panel variant (appearance)")) .disabled(!enableLockScreenMediaWidget) @@ -3810,6 +3824,7 @@ struct Appearance: View { struct LockScreenSettings: View { @ObservedObject private var calendarManager = CalendarManager.shared + @ObservedObject private var previewManager = LockScreenWidgetPreviewManager.shared @Default(.lockScreenGlassStyle) private var lockScreenGlassStyle @Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode @Default(.lockScreenMusicLiquidGlassVariant) private var lockScreenMusicLiquidGlassVariant @@ -3935,6 +3950,18 @@ struct LockScreenSettings: View { Text("Controls whether Dynamic Island mirrors lock/unlock events with its own live activity and audible chimes.") } + Section { + Button(previewManager.isPreviewVisible ? "Hide lock screen preview" : "Preview lock screen widgets") { + previewManager.togglePreview() + } + .buttonStyle(.borderedProminent) + .settingsHighlight(id: highlightID("Preview lock screen widgets")) + } header: { + Text("Preview") + } footer: { + Text("Opens a transparent preview window with mock data that mirrors the current lock screen widget configuration.") + } + Section { if #available(macOS 26.0, *) { Picker("Material", selection: $lockScreenGlassStyle) { @@ -4400,7 +4427,8 @@ extension LockScreenSettings { value: Binding, currentValue: Int, isEnabled: Bool, - highlight: String + highlight: String, + preview: AnyView? = nil ) -> some View { VStack(alignment: .leading, spacing: 6) { HStack { @@ -4411,6 +4439,11 @@ extension LockScreenSettings { .foregroundStyle(.secondary) } Slider(value: value, in: liquidVariantRange, step: 1) + + if let preview { + preview + .padding(.top, 6) + } } .settingsHighlight(id: highlight) .disabled(!isEnabled) @@ -4418,6 +4451,53 @@ extension LockScreenSettings { } } +private struct LockScreenGlassVariantPreviewCell: View { + @Binding var variant: LiquidGlassVariant + + private let cornerRadius: CGFloat = 16 + private let previewCornerRadius: CGFloat = 14 + private let previewSize = CGSize(width: 190, height: 96) + + var body: some View { + ZStack { + Image("glassdesktop") + .resizable() + .scaledToFill() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + + liquidGlassPreview + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity) + .frame(height: 120) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + .padding(.vertical, 6) + .allowsHitTesting(false) + } + + private var liquidGlassPreview: some View { + TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { context in + let phase = context.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1.0) + let opacityJitter = phase < 0.5 ? 1.0 : 0.999999 + LiquidGlassBackground( + variant: variant, + cornerRadius: previewCornerRadius, + trigger: context.date.timeIntervalSinceReferenceDate + ) { + Color.white.opacity(0.04) + } + .frame(width: previewSize.width, height: previewSize.height) + .opacity(opacityJitter) + } + .shadow(color: Color.black.opacity(0.2), radius: 10, x: 0, y: 6) + } +} + private struct LockScreenPositioningControls: View { @Default(.lockScreenWeatherVerticalOffset) private var weatherOffset @Default(.lockScreenMusicVerticalOffset) private var musicOffset diff --git a/DynamicIsland/managers/CalendarManager.swift b/DynamicIsland/managers/CalendarManager.swift index 80b47fd..1d84c0b 100644 --- a/DynamicIsland/managers/CalendarManager.swift +++ b/DynamicIsland/managers/CalendarManager.swift @@ -40,6 +40,8 @@ class CalendarManager: ObservableObject { @Published var reminderAuthorizationStatus: EKAuthorizationStatus = .notDetermined @Published var lockScreenEvents: [EventModel] = [] + private var lockScreenPreviewEvents: [EventModel]? + private var selectedCalendars: [CalendarModel] = [] private let calendarService = CalendarService() private var lastEventsFetchDate: Date? @@ -253,6 +255,16 @@ class CalendarManager: ObservableObject { } func updateLockScreenEvents(force: Bool = false) async { + if let previewEvents = lockScreenPreviewEvents { + if lockScreenEvents != previewEvents { + withAnimation(.smooth(duration: 0.25)) { + lockScreenEvents = previewEvents + } + } + lastLockScreenEventsFetchDate = Date() + return + } + let now = Date() if !force, @@ -320,6 +332,18 @@ class CalendarManager: ObservableObject { lastLockScreenEventsFetchDate = Date() } + func setLockScreenPreviewEvents(_ events: [EventModel]?) { + lockScreenPreviewEvents = events + guard let events else { + Task { await updateLockScreenEvents(force: true) } + return + } + withAnimation(.smooth(duration: 0.25)) { + lockScreenEvents = events + } + lastLockScreenEventsFetchDate = Date() + } + private func startLockScreenRefreshLoop() { lockScreenRefreshTask?.cancel() lockScreenRefreshTask = Task { [weak self] in diff --git a/DynamicIsland/managers/LockScreenTimerWidgetManager.swift b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift index 937c73e..ff672f4 100644 --- a/DynamicIsland/managers/LockScreenTimerWidgetManager.swift +++ b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift @@ -40,6 +40,7 @@ final class LockScreenTimerWidgetManager { private let timerManager = TimerManager.shared private var cancellables = Set() private var isLocked: Bool = false + private var isPreviewing: Bool = false private init() { observeTimerState() @@ -55,6 +56,12 @@ final class LockScreenTimerWidgetManager { } } + func setPreviewMode(_ enabled: Bool) { + guard isPreviewing != enabled else { return } + isPreviewing = enabled + updateVisibility() + } + func refreshPositionForOffsets(animated: Bool) { LockScreenTimerWidgetPanelManager.shared.refreshPosition(animated: animated) } @@ -112,6 +119,9 @@ final class LockScreenTimerWidgetManager { private func shouldDisplayWidget() -> Bool { guard Defaults[.enableLockScreenTimerWidget] else { return false } + if isPreviewing { + return timerManager.isTimerActive + } guard isLocked else { return false } return timerManager.hasManualTimerRunning } diff --git a/DynamicIsland/managers/LockScreenWidgetPreviewManager.swift b/DynamicIsland/managers/LockScreenWidgetPreviewManager.swift new file mode 100644 index 0000000..f5fc25d --- /dev/null +++ b/DynamicIsland/managers/LockScreenWidgetPreviewManager.swift @@ -0,0 +1,577 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Combine +import Defaults +import SwiftUI + +@MainActor +final class LockScreenWidgetPreviewManager: ObservableObject { + static let shared = LockScreenWidgetPreviewManager() + + @Published private(set) var isPreviewVisible = false + + private var cancellables = Set() + private var startedDemoTimer = false + private var previewWindow: NSWindow? + private var previewWindowDelegate: PreviewWindowDelegate? + private var previewHostingView: NSHostingView? + private var cachedFocusState: FocusPreviewState? + + private init() { + observeDefaults() + } + + func togglePreview() { + if isPreviewVisible { + hidePreview() + } else { + showPreview() + } + } + + func showPreview() { + guard !isPreviewVisible else { return } + isPreviewVisible = true + ensurePreviewWindow() + applyPreviewState() + renderPreview() + } + + func hidePreview() { + guard isPreviewVisible else { return } + isPreviewVisible = false + hideAllWidgets() + restorePreviewState() + closePreviewWindow() + } + + private func renderPreview() { + guard isPreviewVisible else { return } + + ensurePreviewWindow() + + if Defaults[.enableLockScreenTimerWidget] { + ensureDemoTimerIfNeeded() + } else { + stopDemoTimerIfNeeded() + } + + updatePreviewContent() + } + + private func hideAllWidgets() { + stopDemoTimerIfNeeded() + } + + private func ensureDemoTimerIfNeeded() { + let timerManager = TimerManager.shared + guard !timerManager.isTimerActive else { return } + timerManager.startDemoTimer(duration: 1783) + startedDemoTimer = true + } + + private func stopDemoTimerIfNeeded() { + guard startedDemoTimer else { return } + TimerManager.shared.forceStopTimer() + startedDemoTimer = false + } + + private func observeDefaults() { + observeKey(.enableLockScreenMediaWidget) + observeKey(.enableLockScreenWeatherWidget) + observeKey(.enableLockScreenTimerWidget) + observeKey(.lockScreenWeatherWidgetStyle) + observeKey(.lockScreenWeatherProviderSource) + observeKey(.lockScreenWeatherTemperatureUnit) + observeKey(.lockScreenWeatherShowsLocation) + observeKey(.lockScreenWeatherShowsSunrise) + observeKey(.lockScreenWeatherShowsAQI) + observeKey(.lockScreenWeatherAQIScale) + observeKey(.lockScreenBatteryShowsCharging) + observeKey(.lockScreenBatteryShowsChargingPercentage) + observeKey(.lockScreenBatteryShowsBatteryGauge) + observeKey(.lockScreenBatteryShowsBluetooth) + observeKey(.lockScreenBatteryUsesLaptopSymbol) + observeKey(.lockScreenWeatherUsesGaugeTint) + observeKey(.lockScreenReminderChipStyle) + } + + private func observeKey(_ key: Defaults.Key) { + Defaults.publisher(key, options: []) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.renderPreview() + } + .store(in: &cancellables) + } + + private func mockWeatherSnapshot() -> LockScreenWeatherSnapshot { + let unit = Defaults[.lockScreenWeatherTemperatureUnit] + let temperatureValue: Double = unit == .celsius ? 21 : 72 + let temperatureText = "\(Int(round(temperatureValue)))°" + let temperatureInfo = LockScreenWeatherSnapshot.TemperatureInfo( + current: temperatureValue, + minimum: temperatureValue - 5, + maximum: temperatureValue + 6, + unitSymbol: unit.symbol + ) + + let chargingInfo: LockScreenWeatherSnapshot.ChargingInfo? = Defaults[.lockScreenBatteryShowsCharging] + ? LockScreenWeatherSnapshot.ChargingInfo( + minutesRemaining: 42, + isCharging: true, + isPluggedIn: true, + batteryLevel: 78 + ) + : nil + + let bluetoothInfo: LockScreenWeatherSnapshot.BluetoothInfo? = Defaults[.lockScreenBatteryShowsBluetooth] + ? LockScreenWeatherSnapshot.BluetoothInfo( + deviceName: "AirPods Pro", + batteryLevel: 74, + iconName: "airpodspro" + ) + : nil + + let batteryInfo: LockScreenWeatherSnapshot.BatteryInfo? = Defaults[.lockScreenBatteryShowsBatteryGauge] + ? LockScreenWeatherSnapshot.BatteryInfo( + batteryLevel: 63, + usesLaptopSymbol: Defaults[.lockScreenBatteryUsesLaptopSymbol] + ) + : nil + + let widgetStyle = Defaults[.lockScreenWeatherWidgetStyle] + let showLocation = widgetStyle == .inline && Defaults[.lockScreenWeatherShowsLocation] + let providerSource = Defaults[.lockScreenWeatherProviderSource] + let showsAQI = Defaults[.lockScreenWeatherShowsAQI] && providerSource.supportsAirQuality + let scale = Defaults[.lockScreenWeatherAQIScale] + let airQualityIndex = scale == .us ? 42 : 28 + let airQuality = showsAQI + ? LockScreenWeatherSnapshot.AirQualityInfo( + index: airQualityIndex, + category: .init(index: airQualityIndex, scale: scale), + scale: scale + ) + : nil + + let showsSunrise = Defaults[.lockScreenWeatherShowsSunrise] && widgetStyle == .inline + let sunCycle = LockScreenWeatherSnapshot.SunCycleInfo( + sunrise: Date().addingTimeInterval(2 * 3600), + sunset: Date().addingTimeInterval(9 * 3600) + ) + + return LockScreenWeatherSnapshot( + temperatureText: temperatureText, + symbolName: "cloud.sun.fill", + description: "Partly Cloudy", + locationName: "Cupertino", + charging: chargingInfo, + bluetooth: bluetoothInfo, + battery: batteryInfo, + showsLocation: showLocation, + airQuality: airQuality, + widgetStyle: widgetStyle, + showsChargingPercentage: Defaults[.lockScreenBatteryShowsChargingPercentage], + temperatureInfo: temperatureInfo, + usesGaugeTint: Defaults[.lockScreenWeatherUsesGaugeTint], + sunCycle: sunCycle, + showsSunrise: showsSunrise + ) + } + + private func ensurePreviewWindow() { + guard let screen = NSScreen.main else { return } + + if let window = previewWindow { + window.makeKeyAndOrderFront(nil) + updatePreviewContent() + return + } + + let frame = screen.frame + let window = NSWindow( + contentRect: frame, + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + + window.title = "Lock Screen Preview" + window.isReleasedWhenClosed = false + window.backgroundColor = .clear + window.center() + + let scene = LockScreenPreviewScene( + screenSize: screen.frame.size, + weatherSnapshot: mockWeatherSnapshot(), + showsWeather: Defaults[.enableLockScreenWeatherWidget], + showsTimer: Defaults[.enableLockScreenTimerWidget], + showsMediaPanel: Defaults[.enableLockScreenMediaWidget] + ) + let hosting = NSHostingView(rootView: scene) + hosting.frame = NSRect(origin: .zero, size: frame.size) + hosting.autoresizingMask = [.width, .height] + previewHostingView = hosting + window.contentView = hosting + + let delegate = PreviewWindowDelegate { [weak self] in + self?.hidePreview() + } + previewWindowDelegate = delegate + window.delegate = delegate + + previewWindow = window + window.makeKeyAndOrderFront(nil) + } + + private func updatePreviewContent() { + guard let screen = NSScreen.main else { return } + let scene = LockScreenPreviewScene( + screenSize: screen.frame.size, + weatherSnapshot: mockWeatherSnapshot(), + showsWeather: Defaults[.enableLockScreenWeatherWidget], + showsTimer: Defaults[.enableLockScreenTimerWidget], + showsMediaPanel: Defaults[.enableLockScreenMediaWidget] + ) + + if let hosting = previewHostingView { + hosting.rootView = scene + } else if let window = previewWindow { + let hosting = NSHostingView(rootView: scene) + hosting.frame = NSRect(origin: .zero, size: window.frame.size) + hosting.autoresizingMask = [.width, .height] + previewHostingView = hosting + window.contentView = hosting + } + } + + private func closePreviewWindow() { + previewWindow?.orderOut(nil) + } + + private func applyPreviewState() { + applyFocusPreviewState() + applyCalendarPreviewEvents() + } + + private func restorePreviewState() { + restoreFocusPreviewState() + CalendarManager.shared.setLockScreenPreviewEvents(nil) + } + + private func applyFocusPreviewState() { + let manager = DoNotDisturbManager.shared + if cachedFocusState == nil { + cachedFocusState = FocusPreviewState( + isActive: manager.isDoNotDisturbActive, + name: manager.currentFocusModeName, + identifier: manager.currentFocusModeIdentifier + ) + } + manager.isDoNotDisturbActive = true + manager.currentFocusModeName = "Do Not Disturb" + manager.currentFocusModeIdentifier = "com.apple.donotdisturb" + } + + private func restoreFocusPreviewState() { + guard let cachedFocusState else { return } + let manager = DoNotDisturbManager.shared + manager.isDoNotDisturbActive = cachedFocusState.isActive + manager.currentFocusModeName = cachedFocusState.name + manager.currentFocusModeIdentifier = cachedFocusState.identifier + self.cachedFocusState = nil + } + + private func applyCalendarPreviewEvents() { + let now = Date() + let reminderCalendar = CalendarModel( + id: "preview.reminders", + title: "Reminders", + color: .systemBlue, + isSubscribed: false, + isReminder: true + ) + let reminderEvent = EventModel( + id: "preview.reminder.pay-rent", + start: now.addingTimeInterval(12 * 60), + end: now.addingTimeInterval(42 * 60), + title: "Pay rent", + location: nil, + notes: nil, + url: nil, + isAllDay: false, + type: .reminder(completed: false), + calendar: reminderCalendar, + participants: [], + timeZone: TimeZone.current, + hasRecurrenceRules: false, + priority: .high, + conferenceURL: nil + ) + + let eventCalendar = CalendarModel( + id: "preview.calendar", + title: "Calendar", + color: .systemOrange, + isSubscribed: false, + isReminder: false + ) + let event = EventModel( + id: "preview.event.standup", + start: now.addingTimeInterval(60 * 60), + end: now.addingTimeInterval(90 * 60), + title: "Design Review", + location: "Studio", + notes: nil, + url: nil, + isAllDay: false, + type: .event(.accepted), + calendar: eventCalendar, + participants: [], + timeZone: TimeZone.current, + hasRecurrenceRules: false, + priority: nil, + conferenceURL: nil + ) + + CalendarManager.shared.setLockScreenPreviewEvents([reminderEvent, event]) + } +} + +private struct FocusPreviewState { + let isActive: Bool + let name: String + let identifier: String +} + +private final class PreviewWindowDelegate: NSObject, NSWindowDelegate { + private let onClose: () -> Void + + init(onClose: @escaping () -> Void) { + self.onClose = onClose + } + + func windowWillClose(_ notification: Notification) { + onClose() + } +} + +private struct LockScreenPreviewScene: View { + let screenSize: CGSize + let weatherSnapshot: LockScreenWeatherSnapshot + let showsWeather: Bool + let showsTimer: Bool + let showsMediaPanel: Bool + + @State private var weatherSize: CGSize = .zero + @State private var musicSize: CGSize = .zero + @State private var inlineBaselineHeight: CGFloat = 0 + @StateObject private var panelAnimator = LockScreenPanelAnimator() + + var body: some View { + GeometryReader { proxy in + let scale = min(proxy.size.width / screenSize.width, proxy.size.height / screenSize.height) + let contentSize = CGSize(width: screenSize.width * scale, height: screenSize.height * scale) + let origin = CGPoint( + x: (proxy.size.width - contentSize.width) / 2, + y: (proxy.size.height - contentSize.height) / 2 + ) + + ZStack(alignment: .topLeading) { + WallpaperView() + .frame(width: proxy.size.width, height: proxy.size.height) + .clipped() + + ZStack(alignment: .topLeading) { + if showsMediaPanel { + LockScreenMusicPanel(animator: panelAnimator) + .fixedSize() + .background(PreviewSizeReader(size: $musicSize)) + .position(centerPoint(for: swiftUIFrame(for: mediaFrame))) + .onAppear { + panelAnimator.isPresented = true + } + } + + if showsTimer { + LockScreenTimerWidget() + .frame(width: timerFrame.width, height: timerFrame.height) + .position(centerPoint(for: swiftUIFrame(for: timerFrame))) + } + + if showsWeather { + LockScreenWeatherWidget(snapshot: weatherSnapshot) + .fixedSize() + .background(PreviewSizeReader(size: $weatherSize)) + .position(centerPoint(for: swiftUIFrame(for: weatherFrame))) + .onChange(of: weatherSize) { _, newSize in + if weatherSnapshot.widgetStyle == .inline { + inlineBaselineHeight = max(inlineBaselineHeight, newSize.height) + } + } + } + } + .frame(width: screenSize.width, height: screenSize.height, alignment: .topLeading) + .scaleEffect(scale, anchor: .topLeading) + .offset(x: origin.x, y: origin.y) + } + } + .ignoresSafeArea() + } + + private var mediaFrame: CGRect { + let size = musicSize == .zero ? LockScreenMusicPanel.collapsedSize : musicSize + let screenFrame = CGRect(origin: .zero, size: screenSize) + let originX = screenFrame.midX - (size.width / 2) + let baseOriginY = screenFrame.origin.y + (screenFrame.height / 2) - size.height - 32 + let defaultLowering: CGFloat = -28 + let userOffset = CGFloat(Defaults[.lockScreenMusicVerticalOffset]) + let clampedOffset = min(max(userOffset, -160), 160) + let originY = baseOriginY + defaultLowering + clampedOffset + return CGRect(x: originX, y: originY, width: size.width, height: size.height) + } + + private var timerFrame: CGRect { + let size = LockScreenTimerWidget.preferredSize + let screenFrame = CGRect(origin: .zero, size: screenSize) + let originX = screenFrame.midX - (size.width / 2) + let defaultLowering: CGFloat = -18 + var baseY = screenFrame.midY + 24 + defaultLowering + + if showsMediaPanel { + baseY = mediaFrame.maxY + 28 + defaultLowering + } + + let offset = CGFloat(min(max(Defaults[.lockScreenTimerVerticalOffset], -160), 160)) + var originY = baseY + offset + + if showsMediaPanel { + originY = max(originY, mediaFrame.maxY + 12) + } + + if showsWeather { + originY = min(originY, weatherFrame.minY - size.height - 20) + } else { + let topLimit = screenFrame.maxY - size.height - 72 + originY = min(originY, topLimit) + } + + let minY = screenFrame.minY + 100 + originY = max(originY, minY) + + return CGRect(x: originX, y: originY, width: size.width, height: size.height) + } + + private var weatherFrame: CGRect { + let size = weatherSize == .zero ? CGSize(width: 220, height: 60) : weatherSize + let screenFrame = CGRect(origin: .zero, size: screenSize) + let originX = screenFrame.midX - (size.width / 2) + let verticalOffset = screenFrame.height * 0.15 + let isCircular = weatherSnapshot.widgetStyle == .circular + let topMargin: CGFloat = isCircular ? 120 : 48 + let inlineBaseline = max(inlineBaselineHeight, 80) + let positionHeight = weatherSnapshot.widgetStyle == .inline + ? max(size.height, inlineBaseline) + : size.height + let maxY = screenFrame.maxY - positionHeight - topMargin + let baseY = min(maxY, screenFrame.midY + verticalOffset) + let loweredY = baseY - 36 + + let inlineLift: CGFloat = weatherSnapshot.widgetStyle == .inline ? 44 : 0 + let circularDrop: CGFloat = isCircular ? 28 : 0 + let sizeDropHeight = positionHeight + let sizeDrop = max(0, sizeDropHeight - 80) * 0.35 + let userOffset = CGFloat(Defaults[.lockScreenWeatherVerticalOffset]) + let clampedOffset = min(max(userOffset, -160), 160) + let adjustedY = loweredY + inlineLift + clampedOffset - circularDrop - sizeDrop + let upperClampedY = min(maxY, adjustedY) + let clampedY = max(screenFrame.minY + 80, upperClampedY) + + return CGRect(x: originX, y: clampedY, width: size.width, height: size.height) + } + + private func swiftUIFrame(for appKitFrame: CGRect) -> CGRect { + let originY = screenSize.height - appKitFrame.maxY + return CGRect(x: appKitFrame.origin.x, y: originY, width: appKitFrame.width, height: appKitFrame.height) + } + + private func centerPoint(for frame: CGRect) -> CGPoint { + CGPoint(x: frame.midX, y: frame.midY) + } +} + +private struct PreviewSizeReader: View { + @Binding var size: CGSize + + var body: some View { + GeometryReader { proxy in + Color.clear + .onAppear { size = proxy.size } + .onChange(of: proxy.size) { _, newSize in + size = newSize + } + } + } +} + +struct WallpaperView: View { + @State private var wallpaperImage: NSImage? + + var body: some View { + ZStack { + if let image = wallpaperImage { + Image(nsImage: image) + .resizable() + .scaledToFill() + } else { + Color.black + Text("Loading...") + .foregroundStyle(.secondary) + } + } + .onAppear(perform: loadWallpaper) + } + + func loadWallpaper() { + if let url = Bundle.main.url(forResource: "desktop", withExtension: "jpeg"), + let image = NSImage(contentsOf: url), + image.size.width > 0, + image.size.height > 0 { + self.wallpaperImage = image + return + } + + if let image = NSImage(named: "desktop"), + image.size.width > 0, + image.size.height > 0 { + self.wallpaperImage = image + return + } + + if let screen = NSScreen.main, + let url = NSWorkspace.shared.desktopImageURL(for: screen), + let image = NSImage(contentsOf: url), + image.size.width > 0, + image.size.height > 0 { + self.wallpaperImage = image + return + } + } +}