mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
WIP: Settings
This commit is contained in:
parent
105b0466a4
commit
aeed23f950
9 changed files with 749 additions and 3 deletions
21
DynamicIsland/Assets.xcassets/desktop.imageset/Contents.json
vendored
Normal file
21
DynamicIsland/Assets.xcassets/desktop.imageset/Contents.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
DynamicIsland/Assets.xcassets/desktop.imageset/desktop.jpeg
vendored
Normal file
BIN
DynamicIsland/Assets.xcassets/desktop.imageset/desktop.jpeg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 286 KiB |
21
DynamicIsland/Assets.xcassets/glassdesktop.imageset/Contents.json
vendored
Normal file
21
DynamicIsland/Assets.xcassets/glassdesktop.imageset/Contents.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11961,6 +11961,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Hide lock screen preview" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Hide only when NowPlaying app is in fullscreen" : {
|
"Hide only when NowPlaying app is in fullscreen" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -13518,6 +13521,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Loading..." : {
|
||||||
|
"comment" : "A placeholder text displayed while loading the wallpaper.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Lock Screen" : {
|
"Lock Screen" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"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." : {
|
"Opens the AI assistant panel for file analysis and conversation. Default is Cmd+Shift+A. Only works when screen assistant feature is enabled." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -18530,6 +18540,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Preview lock screen widgets" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Preview Zoom" : {
|
"Preview Zoom" : {
|
||||||
"comment" : "A label describing the zoom control for the animation preview.",
|
"comment" : "A label describing the zoom control for the animation preview.",
|
||||||
|
|
|
||||||
|
|
@ -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")),
|
SettingsSearchEntry(tab: .appearance, title: "App icon", keywords: ["app icon", "custom icon"], highlightID: SettingsTab.appearance.highlightID(for: "App icon")),
|
||||||
|
|
||||||
// Lock Screen
|
// 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: "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: "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")),
|
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?
|
private var controller: SettingsLoopingPlayerController?
|
||||||
|
|
||||||
func attach(layer: AVPlayerLayer, url: URL) {
|
func attach(layer: AVPlayerLayer, url: URL) {
|
||||||
controller = SettingsLoopingPlayerController(url: url)
|
controller = SettingsLoopingPlayerController(url: url, autoPlay: true)
|
||||||
layer.player = controller?.player
|
layer.player = controller?.player
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2811,15 +2812,25 @@ private final class SettingsLoopingPlayerController {
|
||||||
let player: AVQueuePlayer
|
let player: AVQueuePlayer
|
||||||
private var looper: AVPlayerLooper?
|
private var looper: AVPlayerLooper?
|
||||||
|
|
||||||
init(url: URL) {
|
init(url: URL, autoPlay: Bool = true) {
|
||||||
let item = AVPlayerItem(url: url)
|
let item = AVPlayerItem(url: url)
|
||||||
player = AVQueuePlayer()
|
player = AVQueuePlayer()
|
||||||
player.isMuted = true
|
player.isMuted = true
|
||||||
player.actionAtItemEnd = .none
|
player.actionAtItemEnd = .none
|
||||||
looper = AVPlayerLooper(player: player, templateItem: item)
|
looper = AVPlayerLooper(player: player, templateItem: item)
|
||||||
|
if autoPlay {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func play() {
|
||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
player.pause()
|
player.pause()
|
||||||
looper = nil
|
looper = nil
|
||||||
|
|
@ -3298,6 +3309,9 @@ struct Appearance: View {
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Slider(value: appearanceMusicVariantBinding, in: liquidVariantRange, step: 1)
|
Slider(value: appearanceMusicVariantBinding, in: liquidVariantRange, step: 1)
|
||||||
|
|
||||||
|
LockScreenGlassVariantPreviewCell(variant: $lockScreenMusicLiquidGlassVariant)
|
||||||
|
.padding(.top, 6)
|
||||||
}
|
}
|
||||||
.settingsHighlight(id: highlightID("Music panel variant (appearance)"))
|
.settingsHighlight(id: highlightID("Music panel variant (appearance)"))
|
||||||
.disabled(!enableLockScreenMediaWidget)
|
.disabled(!enableLockScreenMediaWidget)
|
||||||
|
|
@ -3810,6 +3824,7 @@ struct Appearance: View {
|
||||||
|
|
||||||
struct LockScreenSettings: View {
|
struct LockScreenSettings: View {
|
||||||
@ObservedObject private var calendarManager = CalendarManager.shared
|
@ObservedObject private var calendarManager = CalendarManager.shared
|
||||||
|
@ObservedObject private var previewManager = LockScreenWidgetPreviewManager.shared
|
||||||
@Default(.lockScreenGlassStyle) private var lockScreenGlassStyle
|
@Default(.lockScreenGlassStyle) private var lockScreenGlassStyle
|
||||||
@Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode
|
@Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode
|
||||||
@Default(.lockScreenMusicLiquidGlassVariant) private var lockScreenMusicLiquidGlassVariant
|
@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.")
|
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 {
|
Section {
|
||||||
if #available(macOS 26.0, *) {
|
if #available(macOS 26.0, *) {
|
||||||
Picker("Material", selection: $lockScreenGlassStyle) {
|
Picker("Material", selection: $lockScreenGlassStyle) {
|
||||||
|
|
@ -4400,7 +4427,8 @@ extension LockScreenSettings {
|
||||||
value: Binding<Double>,
|
value: Binding<Double>,
|
||||||
currentValue: Int,
|
currentValue: Int,
|
||||||
isEnabled: Bool,
|
isEnabled: Bool,
|
||||||
highlight: String
|
highlight: String,
|
||||||
|
preview: AnyView? = nil
|
||||||
) -> some View {
|
) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
@ -4411,6 +4439,11 @@ extension LockScreenSettings {
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Slider(value: value, in: liquidVariantRange, step: 1)
|
Slider(value: value, in: liquidVariantRange, step: 1)
|
||||||
|
|
||||||
|
if let preview {
|
||||||
|
preview
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.settingsHighlight(id: highlight)
|
.settingsHighlight(id: highlight)
|
||||||
.disabled(!isEnabled)
|
.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 {
|
private struct LockScreenPositioningControls: View {
|
||||||
@Default(.lockScreenWeatherVerticalOffset) private var weatherOffset
|
@Default(.lockScreenWeatherVerticalOffset) private var weatherOffset
|
||||||
@Default(.lockScreenMusicVerticalOffset) private var musicOffset
|
@Default(.lockScreenMusicVerticalOffset) private var musicOffset
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ class CalendarManager: ObservableObject {
|
||||||
@Published var reminderAuthorizationStatus: EKAuthorizationStatus = .notDetermined
|
@Published var reminderAuthorizationStatus: EKAuthorizationStatus = .notDetermined
|
||||||
@Published var lockScreenEvents: [EventModel] = []
|
@Published var lockScreenEvents: [EventModel] = []
|
||||||
|
|
||||||
|
private var lockScreenPreviewEvents: [EventModel]?
|
||||||
|
|
||||||
private var selectedCalendars: [CalendarModel] = []
|
private var selectedCalendars: [CalendarModel] = []
|
||||||
private let calendarService = CalendarService()
|
private let calendarService = CalendarService()
|
||||||
private var lastEventsFetchDate: Date?
|
private var lastEventsFetchDate: Date?
|
||||||
|
|
@ -253,6 +255,16 @@ class CalendarManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateLockScreenEvents(force: Bool = false) async {
|
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()
|
let now = Date()
|
||||||
|
|
||||||
if !force,
|
if !force,
|
||||||
|
|
@ -320,6 +332,18 @@ class CalendarManager: ObservableObject {
|
||||||
lastLockScreenEventsFetchDate = Date()
|
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() {
|
private func startLockScreenRefreshLoop() {
|
||||||
lockScreenRefreshTask?.cancel()
|
lockScreenRefreshTask?.cancel()
|
||||||
lockScreenRefreshTask = Task { [weak self] in
|
lockScreenRefreshTask = Task { [weak self] in
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ final class LockScreenTimerWidgetManager {
|
||||||
private let timerManager = TimerManager.shared
|
private let timerManager = TimerManager.shared
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var isLocked: Bool = false
|
private var isLocked: Bool = false
|
||||||
|
private var isPreviewing: Bool = false
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
observeTimerState()
|
observeTimerState()
|
||||||
|
|
@ -55,6 +56,12 @@ final class LockScreenTimerWidgetManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setPreviewMode(_ enabled: Bool) {
|
||||||
|
guard isPreviewing != enabled else { return }
|
||||||
|
isPreviewing = enabled
|
||||||
|
updateVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
func refreshPositionForOffsets(animated: Bool) {
|
func refreshPositionForOffsets(animated: Bool) {
|
||||||
LockScreenTimerWidgetPanelManager.shared.refreshPosition(animated: animated)
|
LockScreenTimerWidgetPanelManager.shared.refreshPosition(animated: animated)
|
||||||
}
|
}
|
||||||
|
|
@ -112,6 +119,9 @@ final class LockScreenTimerWidgetManager {
|
||||||
|
|
||||||
private func shouldDisplayWidget() -> Bool {
|
private func shouldDisplayWidget() -> Bool {
|
||||||
guard Defaults[.enableLockScreenTimerWidget] else { return false }
|
guard Defaults[.enableLockScreenTimerWidget] else { return false }
|
||||||
|
if isPreviewing {
|
||||||
|
return timerManager.isTimerActive
|
||||||
|
}
|
||||||
guard isLocked else { return false }
|
guard isLocked else { return false }
|
||||||
return timerManager.hasManualTimerRunning
|
return timerManager.hasManualTimerRunning
|
||||||
}
|
}
|
||||||
|
|
|
||||||
577
DynamicIsland/managers/LockScreenWidgetPreviewManager.swift
Normal file
577
DynamicIsland/managers/LockScreenWidgetPreviewManager.swift
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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<AnyCancellable>()
|
||||||
|
private var startedDemoTimer = false
|
||||||
|
private var previewWindow: NSWindow?
|
||||||
|
private var previewWindowDelegate: PreviewWindowDelegate?
|
||||||
|
private var previewHostingView: NSHostingView<LockScreenPreviewScene>?
|
||||||
|
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<T: Defaults.Serializable>(_ key: Defaults.Key<T>) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue