Merge remote-tracking branch 'origin/main' into pr-288

This commit is contained in:
Hariharan Mudaliar 2026-02-08 13:41:59 +05:30
commit 61f2570f3c
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
22 changed files with 4076 additions and 3749 deletions

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:54332f65534c9af667a302e9c2df17e4420bffb94e93cecd0091b11a05e70efc
size 351501

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:329be57b63f437e3329772b688440b825ae4b27ece16ac33c483d43b3725d0cf
size 391452

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:04b9bce0be1bb910c10d7466b795e0909cbf70d6b62e5f227124d7c67bc9b53a
size 352641

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b587c84649e0f3986d59b46fab527a15b2c8146eb9c57a523a2e899afe41ac20
size 476065

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c2be0bc5150dd21d85c9d64a18ec5a782279e30980996e726a6c8d3a8cecb775
size 463126

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:df3275c52a9599dbd5768aba279c8904037925bbed6a02a2166a0a01fb730875
size 398759

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c59f43e4f3446a163dde871035971eb1482475051dbc67e5f6a771b5fb04f13
size 393932

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:835ba4373e8f3dd85e2ff77655eb9464ed07a58687b3b79b51a4535c5febd26a
size 443917

View file

@ -583,7 +583,7 @@ struct ContentView: View {
.frame(width: 76, alignment: .trailing) .frame(width: 76, alignment: .trailing)
} }
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
} else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .reminder) && !coordinator.sneakPeek.type.isExtensionPayload && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) { } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .reminder) && !coordinator.sneakPeek.type.isExtensionPayload && ((coordinator.sneakPeek.type != .volume && coordinator.sneakPeek.type != .brightness && coordinator.sneakPeek.type != .backlight) || vm.notchState == .closed) {
InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress)
.transition( .transition(
coordinator.sneakPeek.type == .capsLock coordinator.sneakPeek.type == .capsLock
@ -631,7 +631,7 @@ struct ContentView: View {
} }
if coordinator.sneakPeek.show { if coordinator.sneakPeek.show {
if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && !Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) { if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && !Defaults[.inlineHUD] && ((coordinator.sneakPeek.type != .volume && coordinator.sneakPeek.type != .brightness && coordinator.sneakPeek.type != .backlight) || vm.notchState == .closed) {
SystemEventIndicatorModifier(eventType: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, sendEventBack: { _ in SystemEventIndicatorModifier(eventType: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, sendEventBack: { _ in
// //
}) })

View file

@ -636,8 +636,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let timerWidgetManager = LockScreenTimerWidgetManager.shared let timerWidgetManager = LockScreenTimerWidgetManager.shared
timerWidgetManager.handleLockStateChange(isLocked: LockScreenManager.shared.currentLockStatus) timerWidgetManager.handleLockStateChange(isLocked: LockScreenManager.shared.currentLockStatus)
// Ensure the reminder widget mirrors live activity snapshots as soon as they exist.
_ = LockScreenReminderWidgetManager.shared
} }
private func registerOptionalShortcutHandlers() { private func registerOptionalShortcutHandlers() {

File diff suppressed because it is too large Load diff

View file

@ -21,8 +21,65 @@
*/ */
import SwiftUI import SwiftUI
import AppKit
import AVFoundation
import Defaults import Defaults
// MARK: - Inline HUD looping .mov icon
private final class LoopingPlayerController {
let player: AVQueuePlayer
private var looper: AVPlayerLooper?
init(url: URL) {
let item = AVPlayerItem(url: url)
self.player = AVQueuePlayer()
self.player.isMuted = true
self.player.actionAtItemEnd = .none
self.looper = AVPlayerLooper(player: self.player, templateItem: item)
self.player.play()
}
deinit {
player.pause()
looper = nil
}
}
private struct LoopingVideoIcon: NSViewRepresentable {
let url: URL
let size: CGSize
func makeNSView(context: Context) -> NSView {
let view = NSView(frame: NSRect(origin: .zero, size: size))
view.wantsLayer = true
let layer = AVPlayerLayer()
layer.videoGravity = .resizeAspect
layer.frame = view.bounds
view.layer?.addSublayer(layer)
context.coordinator.attach(layer: layer, url: url)
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
// No-op; the animation loops via AVPlayerLooper.
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator {
private var controller: LoopingPlayerController?
func attach(layer: AVPlayerLayer, url: URL) {
controller = LoopingPlayerController(url: url)
layer.player = controller?.player
}
}
}
struct InlineHUD: View { struct InlineHUD: View {
@EnvironmentObject var vm: DynamicIslandViewModel @EnvironmentObject var vm: DynamicIslandViewModel
@Binding var type: SneakContentType @Binding var type: SneakContentType
@ -39,6 +96,7 @@ struct InlineHUD: View {
@Default(.useCircularBluetoothBatteryIndicator) var useCircularBluetoothBatteryIndicator @Default(.useCircularBluetoothBatteryIndicator) var useCircularBluetoothBatteryIndicator
@Default(.showBluetoothBatteryPercentageText) var showBluetoothBatteryPercentageText @Default(.showBluetoothBatteryPercentageText) var showBluetoothBatteryPercentageText
@Default(.showBluetoothDeviceNameMarquee) var showBluetoothDeviceNameMarquee @Default(.showBluetoothDeviceNameMarquee) var showBluetoothDeviceNameMarquee
@Default(.useBluetoothHUD3DIcon) var useBluetoothHUD3DIcon
@Default(.enableMinimalisticUI) var enableMinimalisticUI @Default(.enableMinimalisticUI) var enableMinimalisticUI
@Default(.showCapsLockLabel) var showCapsLockLabel @Default(.showCapsLockLabel) var showCapsLockLabel
@Default(.capsLockIndicatorTintMode) var capsLockTintMode @Default(.capsLockIndicatorTintMode) var capsLockTintMode
@ -173,10 +231,17 @@ struct InlineHUD: View {
.contentTransition(.interpolate) .contentTransition(.interpolate)
.frame(width: 20, height: 15, alignment: .center) .frame(width: 20, height: 15, alignment: .center)
case .bluetoothAudio: case .bluetoothAudio:
if useBluetoothHUD3DIcon,
let deviceType = bluetoothManager.lastConnectedDevice?.deviceType,
let url = Bundle.main.url(forResource: deviceType.inlineHUDAnimationBaseName, withExtension: "mov") {
LoopingVideoIcon(url: url, size: CGSize(width: 20, height: 20))
.frame(width: 20, height: 20, alignment: .center)
} else {
Image(systemName: icon.isEmpty ? "bluetooth" : icon) Image(systemName: icon.isEmpty ? "bluetooth" : icon)
.symbolRenderingMode(.hierarchical) .symbolRenderingMode(.hierarchical)
.contentTransition(.interpolate) .contentTransition(.interpolate)
.frame(width: 20, height: 15, alignment: .center) .frame(width: 20, height: 15, alignment: .center)
}
case .capsLock: case .capsLock:
Image(systemName: "capslock.fill") Image(systemName: "capslock.fill")
.symbolRenderingMode(.hierarchical) .symbolRenderingMode(.hierarchical)

View file

@ -0,0 +1,79 @@
/*
* 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 SwiftUI
struct LockScreenCalendarEventRow: View {
let line: String
let isVisible: Bool
let renderToken: Int
let font: Font
let alignment: Alignment
let iconName: String
let iconRenderingMode: SymbolRenderingMode
let iconColors: [Color]
var body: some View {
HStack(alignment: .center, spacing: 6) {
calendarIcon
.frame(width: 26, height: 26)
Text(line)
.font(font)
.lineLimit(1)
.truncationMode(.tail)
.minimumScaleFactor(0.85)
.allowsTightening(true)
.layoutPriority(10)
}
.frame(maxWidth: .infinity, alignment: alignment)
.padding(.horizontal, 2)
.id(renderToken)
.opacity(isVisible ? 1 : 0)
.accessibilityHidden(!isVisible)
.allowsHitTesting(false)
}
@ViewBuilder
private var calendarIcon: some View {
let baseImage = Image(systemName: iconName)
.font(.system(size: 20, weight: .semibold))
.id(iconRenderingModeIdentifier)
if iconRenderingModeIdentifier.lowercased().contains("palette"), iconColors.count >= 2 {
baseImage
.symbolRenderingMode(.palette)
.foregroundStyle(iconColors[0], iconColors[1])
} else if iconColors.count >= 2 {
baseImage
.symbolRenderingMode(iconRenderingMode)
.foregroundStyle(iconColors[0], iconColors[1])
} else if iconColors.count == 1 {
baseImage
.symbolRenderingMode(iconRenderingMode)
.foregroundStyle(iconColors[0])
} else {
baseImage
.symbolRenderingMode(iconRenderingMode)
}
}
private var iconRenderingModeIdentifier: String {
String(describing: iconRenderingMode)
}
}

View file

@ -18,14 +18,40 @@
import SwiftUI import SwiftUI
import Defaults import Defaults
import AppKit
import CoreGraphics
struct LockScreenWeatherWidget: View { struct LockScreenWeatherWidget: View {
let snapshot: LockScreenWeatherSnapshot let snapshot: LockScreenWeatherSnapshot
@ObservedObject private var focusManager = DoNotDisturbManager.shared @ObservedObject private var focusManager = DoNotDisturbManager.shared
@ObservedObject private var calendarManager = CalendarManager.shared
@Default(.enableDoNotDisturbDetection) private var focusDetectionEnabled @Default(.enableDoNotDisturbDetection) private var focusDetectionEnabled
@Default(.showDoNotDisturbIndicator) private var focusIndicatorEnabled @Default(.showDoNotDisturbIndicator) private var focusIndicatorEnabled
@Default(.enableLockScreenFocusWidget) private var lockScreenFocusWidgetEnabled @Default(.enableLockScreenFocusWidget) private var lockScreenFocusWidgetEnabled
@Default(.lockScreenShowCalendarCountdown) private var lockScreenShowCalendarCountdown
@Default(.lockScreenShowCalendarEvent) private var lockScreenShowCalendarEvent
@Default(.lockScreenShowCalendarEventEntireDuration) private var lockScreenShowCalendarEventEntireDuration
@Default(.lockScreenShowCalendarEventAfterStartWindow) private var lockScreenShowCalendarEventAfterStartWindow
@Default(.lockScreenShowCalendarTimeRemaining) private var lockScreenShowCalendarTimeRemaining
@Default(.lockScreenShowCalendarStartTimeAfterBegins) private var lockScreenShowCalendarStartTimeAfterBegins
@Default(.lockScreenCalendarEventLookaheadWindow) private var lockScreenCalendarEventLookaheadWindow
@Default(.lockScreenWeatherWidgetRowOrder) private var lockScreenWeatherWidgetRowOrder
@Default(.lockScreenCalendarSelectionMode) private var lockScreenCalendarSelectionMode
@Default(.lockScreenSelectedCalendarIDs) private var lockScreenSelectedCalendarIDs
@Default(.lockScreenShowCalendarEventAfterStartEnabled) private var lockScreenShowCalendarEventAfterStartEnabled
@Default(.lockScreenReminderChipStyle) private var lockScreenReminderChipStyle
@Default(.reminderSneakPeekDuration) private var reminderSneakPeekDuration
@State private var currentTime = Date()
@State private var calendarRowVisible: Bool = false
@State private var lastCalendarLine: String = ""
@State private var calendarRowRenderToken: Int = 0
@State private var widgetWidthRemeasureToken: Int = 0
@State private var lastBadgeLogKey: String = ""
private var currentCalendarEventID: String {
nextCalendarEvent?.id ?? "no_event"
}
private let inlinePrimaryFont = Font.system(size: 22, weight: .semibold, design: .rounded) private let inlinePrimaryFont = Font.system(size: 22, weight: .semibold, design: .rounded)
private let inlineSecondaryFont = Font.system(size: 13, weight: .medium, design: .rounded) private let inlineSecondaryFont = Font.system(size: 13, weight: .medium, design: .rounded)
@ -38,9 +64,422 @@ struct LockScreenWeatherWidget: View {
return formatter return formatter
}() }()
// MARK: - Refresh
/// Refreshes every 15 seconds (general refresh), and optionally also ticks exactly on minute boundaries
/// *only when the screen is locked* and the calendar widget is enabled.
private final class LockAwareTicker: ObservableObject {
@Published var now: Date = Date()
private var refreshTimer: Timer?
private var minuteTimer: Timer?
private var lockObserver: NSObjectProtocol?
private var unlockObserver: NSObjectProtocol?
private var isLocked: Bool = false
private var minuteAlignedEnabled: Bool = false
func start() {
stop()
installLockObservers()
refreshLockStateFromSystem()
startGeneralRefresh()
fireNow()
updateMinuteAlignedTimer()
}
func stop() {
refreshTimer?.invalidate()
refreshTimer = nil
minuteTimer?.invalidate()
minuteTimer = nil
if let lockObserver { DistributedNotificationCenter.default().removeObserver(lockObserver) }
if let unlockObserver { DistributedNotificationCenter.default().removeObserver(unlockObserver) }
lockObserver = nil
unlockObserver = nil
}
func fireNow() {
now = Date()
}
/// Enable/disable the minute-aligned ticker (minute boundary ticks).
/// It will only actually run when the screen is locked.
func setMinuteAlignedEnabled(_ enabled: Bool) {
minuteAlignedEnabled = enabled
updateMinuteAlignedTimer()
}
private func startGeneralRefresh() {
refreshTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: true) { [weak self] _ in
self?.fireNow()
}
refreshTimer?.tolerance = 0
if let refreshTimer {
RunLoop.main.add(refreshTimer, forMode: .common)
}
}
private func refreshLockStateFromSystem() {
if let dict = CGSessionCopyCurrentDictionary() as? [String: Any] {
if let locked = dict["CGSSessionScreenIsLocked"] as? Bool {
isLocked = locked
}
}
}
private func installLockObservers() {
lockObserver = DistributedNotificationCenter.default().addObserver(
forName: NSNotification.Name("com.apple.screenIsLocked"),
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
self.isLocked = true
self.fireNow()
self.updateMinuteAlignedTimer()
}
unlockObserver = DistributedNotificationCenter.default().addObserver(
forName: NSNotification.Name("com.apple.screenIsUnlocked"),
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
self.isLocked = false
self.fireNow()
self.updateMinuteAlignedTimer()
}
}
private func updateMinuteAlignedTimer() {
let shouldRun = isLocked && minuteAlignedEnabled
if !shouldRun {
minuteTimer?.invalidate()
minuteTimer = nil
return
}
if minuteTimer != nil {
return
}
scheduleNextBoundaryAndRepeat()
}
private func scheduleNextBoundaryAndRepeat() {
let current = Date()
let calendar = Calendar.current
var components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: current)
components.second = 0
let thisMinute = calendar.date(from: components) ?? current
let nextMinute = calendar.date(byAdding: .minute, value: 1, to: thisMinute) ?? current.addingTimeInterval(60)
let initialDelay = max(0.0, nextMinute.timeIntervalSince(current))
minuteTimer = Timer.scheduledTimer(withTimeInterval: initialDelay, repeats: false) { [weak self] _ in
guard let self else { return }
self.fireNow()
self.minuteTimer?.invalidate()
self.minuteTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in
self?.fireNow()
}
self.minuteTimer?.tolerance = 0
if let minuteTimer {
RunLoop.main.add(minuteTimer, forMode: .common)
}
}
minuteTimer?.tolerance = 0
if let minuteTimer {
RunLoop.main.add(minuteTimer, forMode: .common)
}
}
deinit {
stop()
}
}
@StateObject private var minuteTicker = LockAwareTicker()
private enum CalendarLookaheadWindow: String {
case mins15 = "15m"
case mins30 = "30m"
case hour1 = "1h"
case hours3 = "3h"
case hours6 = "6h"
case hours12 = "12h"
case restOfDay = "rest_of_day"
case allTime = "all_time"
var minutes: Int? {
switch self {
case .mins15: return 15
case .mins30: return 30
case .hour1: return 60
case .hours3: return 180
case .hours6: return 360
case .hours12: return 720
case .restOfDay, .allTime: return nil
}
}
}
private enum CalendarAfterStartWindow: String {
case min1 = "1m"
case mins5 = "5m"
case mins10 = "10m"
case mins15 = "15m"
case mins30 = "30m"
case mins45 = "45m"
case hour1 = "1h"
case hours2 = "2h"
var minutes: Int {
switch self {
case .min1: return 1
case .mins5: return 5
case .mins10: return 10
case .mins15: return 15
case .mins30: return 30
case .mins45: return 45
case .hour1: return 60
case .hours2: return 120
}
}
}
private var selectedLookaheadWindow: CalendarLookaheadWindow {
let raw = lockScreenCalendarEventLookaheadWindow
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
switch raw {
case "all time", "all_time", "alltime":
return .allTime
case "rest of the day", "rest_of_day", "restofday":
return .restOfDay
case "15m", "15 mins", "15min", "15mins":
return .mins15
case "30m", "30 mins", "30min", "30mins":
return .mins30
case "1h", "1 hour", "1hour":
return .hour1
case "3h", "3 hours", "3hours":
return .hours3
case "6h", "6 hours", "6hours":
return .hours6
case "12h", "12 hours", "12hours":
return .hours12
default:
return CalendarLookaheadWindow(rawValue: lockScreenCalendarEventLookaheadWindow) ?? .hours3
}
}
private var selectedAfterStartWindow: CalendarAfterStartWindow {
let raw = lockScreenShowCalendarEventAfterStartWindow
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
switch raw {
case "1m", "1 min", "1 minute", "1min":
return .min1
case "5m", "5 min", "5 mins", "5 minutes", "5min":
return .mins5
case "10m", "10 min", "10 mins", "10 minutes", "10min":
return .mins10
case "15m", "15 min", "15 mins", "15 minutes", "15min":
return .mins15
case "30m", "30 min", "30 mins", "30 minutes", "30min":
return .mins30
case "45m", "45 min", "45 mins", "45 minutes", "45min":
return .mins45
case "1h", "1 hr", "1 hour", "1hour":
return .hour1
case "2h", "2 hrs", "2 hours", "2hours", "2hour":
return .hours2
default:
return CalendarAfterStartWindow(rawValue: lockScreenShowCalendarEventAfterStartWindow) ?? .mins5
}
}
private func lookaheadEndDate(from now: Date) -> Date? {
switch selectedLookaheadWindow {
case .allTime:
return nil
case .restOfDay:
let calendar = Calendar.current
let startOfToday = calendar.startOfDay(for: now)
return calendar.date(byAdding: .day, value: 1, to: startOfToday)
default:
if let minutes = selectedLookaheadWindow.minutes {
return Calendar.current.date(byAdding: .minute, value: minutes, to: now)
}
return nil
}
}
private func isEventEligibleForLookahead(_ event: EventModel, now: Date) -> Bool {
if event.isAllDay {
return false
}
if lockScreenShowCalendarEventEntireDuration, event.start <= now, event.end >= now {
return true
}
guard event.start > now else { return false }
guard let endDate = lookaheadEndDate(from: now) else {
return true
}
return event.start <= endDate
}
private var lockScreenAllowedCalendarIDs: Set<String>? {
guard lockScreenCalendarSelectionMode != "all" else { return nil }
return lockScreenSelectedCalendarIDs
}
private func passesLockScreenCalendarFilter(_ event: EventModel) -> Bool {
guard let allowed = lockScreenAllowedCalendarIDs else { return true }
guard !allowed.isEmpty else { return false }
return allowed.contains(event.calendar.id)
}
private var nextCalendarEvent: EventModel? {
let now = currentTime
if lockScreenShowCalendarEventEntireDuration {
let candidates = calendarManager.lockScreenEvents
.filter { $0.end >= now }
.filter(passesLockScreenCalendarFilter)
.filter { isEventEligibleForLookahead($0, now: now) }
.sorted { $0.start < $1.start }
return candidates.first
}
let graceMinutes = lockScreenShowCalendarEventAfterStartEnabled
? selectedAfterStartWindow.minutes
: 0
if graceMinutes > 0 {
let graceEndForEvent: (EventModel) -> Date = { event in
Calendar.current.date(byAdding: .minute, value: graceMinutes, to: event.start) ?? event.start
}
let activeCandidates = calendarManager.lockScreenEvents
.filter { !$0.isAllDay }
.filter(passesLockScreenCalendarFilter)
.filter { $0.start <= now && $0.end >= now }
.filter { now <= graceEndForEvent($0) }
.sorted { $0.start < $1.start }
if let active = activeCandidates.first {
return active
}
}
let upcoming = calendarManager.lockScreenEvents
.filter { $0.start > now }
.filter(passesLockScreenCalendarFilter)
.filter { isEventEligibleForLookahead($0, now: now) }
.sorted { $0.start < $1.start }
return upcoming.first
}
private var shouldShowCalendarRow: Bool {
lockScreenShowCalendarEvent && nextCalendarEvent != nil
}
private var shouldShowFocusInCalendarSlot: Bool {
shouldShowFocusWidget && !shouldShowCalendarRow
}
private var shouldCombineFocusAndCalendar: Bool {
shouldShowFocusWidget && shouldShowCalendarRow
}
private enum RowKind {
case weather
case focus
case calendar
}
private var orderedRowKinds: [RowKind] {
switch lockScreenWeatherWidgetRowOrder {
case "weather_focus_calendar":
return [.weather, .focus, .calendar]
case "weather_calendar_focus":
return [.weather, .calendar, .focus]
case "focus_weather_calendar":
return [.focus, .weather, .calendar]
case "focus_calendar_weather":
return [.focus, .calendar, .weather]
case "calendar_weather_focus":
return [.calendar, .weather, .focus]
case "calendar_focus_weather":
return [.calendar, .focus, .weather]
default:
return [.weather, .calendar, .focus]
}
}
private var enabledRowKinds: [RowKind] {
var enabled: Set<RowKind> = [.weather]
if lockScreenShowCalendarEvent { enabled.insert(.calendar) }
if shouldShowFocusWidget { enabled.insert(.focus) }
if shouldShowFocusInCalendarSlot { enabled.insert(.calendar); enabled.remove(.focus) }
if shouldCombineFocusAndCalendar { enabled.remove(.focus) }
return orderedRowKinds.filter { enabled.contains($0) }
}
private var fullRowOrder: [RowKind] {
orderedRowKinds
}
private func isRowActive(_ kind: RowKind) -> Bool {
switch kind {
case .weather:
return true
case .focus:
return shouldShowFocusWidget
case .calendar:
return nextCalendarEvent != nil
}
}
private var shouldCollapseGap: Bool {
fullRowOrder.count == 3
}
private var collapseDistance: CGFloat {
26 + focusWidgetSpacing
}
private var collapseOffsetForBottomRow: CGFloat {
guard shouldCollapseGap else { return 0 }
let middleKind = fullRowOrder[1]
let bottomKind = fullRowOrder[2]
let middleActive = isRowActive(middleKind)
let bottomActive = isRowActive(bottomKind)
return (!middleActive && bottomActive) ? -collapseDistance : 0
}
private var isInline: Bool { snapshot.widgetStyle == .inline } private var isInline: Bool { snapshot.widgetStyle == .inline }
private var stackAlignment: VerticalAlignment { isInline ? .firstTextBaseline : .top } private var stackAlignment: VerticalAlignment { isInline ? .firstTextBaseline : .top }
private var stackSpacing: CGFloat { isInline ? 14 : 22 } private var stackSpacing: CGFloat { isInline ? 14 : 22 }
private var mainRowAlignment: Alignment {
if isInline {
return shouldCombineFocusAndCalendar ? .center : .leading
}
return .center
}
private var secondaryRowAlignment: Alignment { .center }
private var focusRowAlignment: Alignment { .center }
private var gaugeDiameter: CGFloat { 64 } private var gaugeDiameter: CGFloat { 64 }
private var topPadding: CGFloat { isInline ? 6 : 22 } private var topPadding: CGFloat { isInline ? 6 : 22 }
private var bottomPadding: CGFloat { isInline ? 6 : 10 } private var bottomPadding: CGFloat { isInline ? 6 : 10 }
@ -51,11 +490,13 @@ struct LockScreenWeatherWidget: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: focusWidgetSpacing) { VStack(alignment: .leading, spacing: focusWidgetSpacing) {
if shouldShowFocusWidget { ForEach(Array(enabledRowKinds.enumerated()), id: \.offset) { index, kind in
focusWidget rowView(for: kind)
.offset(y: offsetForRow(index: index))
.animation(.easeInOut(duration: 0.3), value: collapseOffsetForBottomRow)
} }
mainWidgetRow
} }
.id(widgetWidthRemeasureToken)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(Color.white.opacity(0.65)) .foregroundStyle(Color.white.opacity(0.65))
.padding(.horizontal, 10) .padding(.horizontal, 10)
@ -63,10 +504,125 @@ struct LockScreenWeatherWidget: View {
.padding(.bottom, bottomPadding) .padding(.bottom, bottomPadding)
.background(Color.clear) .background(Color.clear)
.shadow(color: .black.opacity(0.35), radius: 8, x: 0, y: 3) .shadow(color: .black.opacity(0.35), radius: 8, x: 0, y: 3)
.onReceive(minuteTicker.$now) { currentTime = $0 }
.onAppear {
minuteTicker.start()
minuteTicker.setMinuteAlignedEnabled(lockScreenShowCalendarEvent)
currentTime = Date()
calendarRowVisible = shouldShowCalendarRow
Task {
await calendarManager.checkCalendarAuthorization()
await calendarManager.checkReminderAuthorization()
await calendarManager.updateLockScreenEvents(force: true)
}
if let event = nextCalendarEvent {
lastCalendarLine = eventLineText(for: event)
logBadgeColor(event: event, reason: "on-appear")
}
}
.onChange(of: lockScreenShowCalendarEvent) { _, enabled in
minuteTicker.setMinuteAlignedEnabled(enabled)
}
.onChange(of: lockScreenCalendarEventLookaheadWindow) { _, _ in
Task { await calendarManager.updateLockScreenEvents(force: true) }
}
.onChange(of: lockScreenCalendarSelectionMode) { _, _ in
Task { await calendarManager.updateLockScreenEvents(force: true) }
}
.onChange(of: lockScreenSelectedCalendarIDs) { _, _ in
Task { await calendarManager.updateLockScreenEvents(force: true) }
}
.onDisappear { minuteTicker.stop() }
.onChange(of: currentCalendarEventID) { _, _ in
calendarRowRenderToken &+= 1
if nextCalendarEvent != nil {
widgetWidthRemeasureToken &+= 1
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.20) {
calendarRowRenderToken &+= 1
if nextCalendarEvent != nil {
widgetWidthRemeasureToken &+= 1
}
}
}
.onChange(of: currentCalendarEventID) { _, _ in
if let event = nextCalendarEvent {
let newLine = eventLineText(for: event)
if !newLine.isEmpty {
lastCalendarLine = newLine
}
logBadgeColor(event: event, reason: "event-change")
}
}
.onChange(of: lockScreenReminderChipStyle) { _, _ in
if let event = nextCalendarEvent {
logBadgeColor(event: event, reason: "chip-style-change")
}
}
.onChange(of: shouldShowCalendarRow) { _, newValue in
withAnimation(.easeInOut(duration: 0.25)) {
calendarRowVisible = newValue
}
}
.accessibilityElement(children: .ignore) .accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel) .accessibilityLabel(accessibilityLabel)
} }
private func offsetForRow(index: Int) -> CGFloat {
if enabledRowKinds.count != 3 { return 0 }
if index == 2 {
return collapseOffsetForBottomRow
}
return 0
}
@ViewBuilder
private func rowView(for kind: RowKind) -> some View {
switch kind {
case .weather:
mainWidgetRow
case .calendar:
if shouldCombineFocusAndCalendar {
combinedFocusCalendarRow
} else if shouldShowFocusInCalendarSlot {
focusRowInCalendarSlot
} else {
nextEventRow
}
case .focus:
if shouldCombineFocusAndCalendar {
EmptyView()
} else {
focusWidget
.opacity(shouldShowFocusWidget ? 1 : 0)
.accessibilityHidden(!shouldShowFocusWidget)
.allowsHitTesting(false)
}
}
}
private var focusRowInCalendarSlot: some View {
HStack(alignment: .center, spacing: 8) {
focusIcon
.font(.system(size: 20, weight: .semibold))
.frame(width: 26, height: 26)
Text(focusDisplayName)
.font(inlinePrimaryFont)
.lineLimit(1)
.minimumScaleFactor(0.85)
.allowsTightening(true)
}
.frame(maxWidth: .infinity, alignment: secondaryRowAlignment)
.padding(.horizontal, 2)
.opacity(shouldShowFocusWidget ? 1 : 0)
.accessibilityLabel("Focus active: \(focusDisplayName)")
.allowsHitTesting(false)
}
private var mainWidgetRow: some View { private var mainWidgetRow: some View {
HStack(alignment: stackAlignment, spacing: stackSpacing) { HStack(alignment: stackAlignment, spacing: stackSpacing) {
if let charging = snapshot.charging { if let charging = snapshot.charging {
@ -95,6 +651,214 @@ struct LockScreenWeatherWidget: View {
locationSegment locationSegment
} }
} }
.frame(maxWidth: .infinity, alignment: mainRowAlignment)
}
private var nextEventRow: some View {
let event = nextCalendarEvent
let line = shouldShowCalendarRow ? eventLineText(for: event) : lastCalendarLine
let iconConfig = calendarRowIconConfig(for: event)
return LockScreenCalendarEventRow(
line: line,
isVisible: calendarRowVisible,
renderToken: calendarRowRenderToken,
font: inlinePrimaryFont,
alignment: secondaryRowAlignment,
iconName: iconConfig.name,
iconRenderingMode: iconConfig.renderingMode,
iconColors: iconConfig.colors
)
}
private var combinedFocusCalendarRow: some View {
let event = nextCalendarEvent
let line = shouldShowCalendarRow ? eventLineText(for: event) : lastCalendarLine
let iconConfig = calendarRowIconConfig(for: event)
return HStack(alignment: .center, spacing: 10) {
HStack(alignment: .center, spacing: 6) {
calendarIconView(iconConfig)
.frame(width: 26, height: 26)
Text(line)
.font(inlinePrimaryFont)
.lineLimit(1)
.minimumScaleFactor(0.85)
.allowsTightening(true)
}
.layoutPriority(2)
Text("•")
.font(inlinePrimaryFont)
.foregroundStyle(secondaryLabelColor)
.accessibilityHidden(true)
HStack(alignment: .center, spacing: 8) {
focusIcon
.font(.system(size: 20, weight: .semibold))
.frame(width: 26, height: 26)
Text(focusDisplayName)
.font(inlinePrimaryFont)
.lineLimit(1)
.minimumScaleFactor(0.85)
.allowsTightening(true)
}
.layoutPriority(1)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, 2)
.id(calendarRowRenderToken)
.opacity(shouldShowFocusWidget && shouldShowCalendarRow ? 1 : 0)
.accessibilityLabel("\(line). Focus active: \(focusDisplayName)")
.allowsHitTesting(false)
}
@ViewBuilder
private func calendarIconView(_ iconConfig: (name: String, renderingMode: SymbolRenderingMode, colors: [Color])) -> some View {
let image = Image(systemName: iconConfig.name)
.font(.system(size: 20, weight: .semibold))
let isPalette = String(describing: iconConfig.renderingMode)
.lowercased()
.contains("palette")
if isPalette, iconConfig.colors.count >= 2 {
image
.symbolRenderingMode(.palette)
.foregroundStyle(iconConfig.colors[0], iconConfig.colors[1])
} else if iconConfig.colors.count >= 2 {
image
.symbolRenderingMode(iconConfig.renderingMode)
.foregroundStyle(iconConfig.colors[0], iconConfig.colors[1])
} else if iconConfig.colors.count == 1 {
image
.symbolRenderingMode(iconConfig.renderingMode)
.foregroundStyle(iconConfig.colors[0])
} else {
image
.symbolRenderingMode(iconConfig.renderingMode)
}
}
private func calendarRowIconConfig(for event: EventModel?) -> (name: String, renderingMode: SymbolRenderingMode, colors: [Color]) {
guard let event else {
return (name: "calendar.badge", renderingMode: .hierarchical, colors: [Color.white.opacity(0.9)])
}
let badgeColor = reminderChipColor(for: event)
logBadgeColor(event: event, reason: "icon-config")
if event.type.isReminder || event.calendar.isReminder {
if isReminderCritical(event) {
return (name: "exclamationmark.triangle.fill", renderingMode: .multicolor, colors: [])
}
return (
name: "calendar.badge",
renderingMode: .palette,
colors: [badgeColor, Color.white.opacity(0.6)]
)
}
let eventBadgeColor = eventAccentColor(for: event)
return (
name: "calendar.badge",
renderingMode: .palette,
colors: [eventBadgeColor, Color.white.opacity(0.6)]
)
}
private func logBadgeColor(event: EventModel, reason: String) {
let key = "\(event.id)|\(lockScreenReminderChipStyle.rawValue)|\(isReminderCritical(event))|\(reason)"
lastBadgeLogKey = key
let color = reminderChipColor(for: event)
let nsColor = NSColor(color).usingColorSpace(.deviceRGB)
if let nsColor {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
nsColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
let hex = String(format: "#%02X%02X%02X", Int(red * 255), Int(green * 255), Int(blue * 255))
let alphaText = String(format: "%.2f", alpha)
NSLog("LockScreenCalendarRow badge color: %@ alpha=%@ style=%@ critical=%@ reason=%@", hex, alphaText, lockScreenReminderChipStyle.rawValue, String(isReminderCritical(event)), reason)
} else {
NSLog("LockScreenCalendarRow badge color: <unavailable> style=%@ critical=%@ reason=%@", lockScreenReminderChipStyle.rawValue, String(isReminderCritical(event)), reason)
}
}
private func reminderChipColor(for event: EventModel) -> Color {
if isReminderCritical(event) { return .red }
switch lockScreenReminderChipStyle {
case .monochrome:
return Color.white.opacity(0.85)
case .eventColor:
let color = reminderAccentColor(for: event)
return color.ensureMinimumBrightness(factor: 0.7)
}
}
private func reminderAccentColor(for event: EventModel) -> Color {
Color(event.calendar.color)
}
private func eventAccentColor(for event: EventModel) -> Color {
Color(event.calendar.color).ensureMinimumBrightness(factor: 0.7)
}
private func isReminderCritical(_ event: EventModel) -> Bool {
guard event.type.isReminder || event.calendar.isReminder else { return false }
let window = TimeInterval(reminderSneakPeekDuration)
guard window > 0 else { return false }
let remaining = event.start.timeIntervalSince(currentTime)
return remaining > 0 && remaining <= window
}
private func eventLineText(for event: EventModel?) -> String {
guard let event else { return "" }
let now = currentTime
if event.isAllDay {
return "All day • \(event.title)"
}
let timeString = event.start.formatted(date: .omitted, time: .shortened)
if event.start <= now && event.end >= now {
let leading = lockScreenShowCalendarStartTimeAfterBegins ? "\(timeString) • " : ""
let suffix: String
if lockScreenShowCalendarTimeRemaining {
let secondsLeft = event.end.timeIntervalSince(now)
let minutesLeft = max(0, Int(ceil(secondsLeft / 60.0)))
suffix = " • \(countdownText(fromMinutes: minutesLeft)) left"
} else {
suffix = ""
}
return "\(leading)\(event.title)\(suffix)"
}
guard lockScreenShowCalendarCountdown else {
return "\(timeString) • \(event.title)"
}
let secondsUntilStart = event.start.timeIntervalSince(now)
let totalMinutes = max(0, Int(ceil(secondsUntilStart / 60.0)))
return "\(timeString) • \(event.title) • in \(countdownText(fromMinutes: totalMinutes))"
}
private func countdownText(fromMinutes minutes: Int) -> String {
if minutes < 60 {
return "\(minutes)m"
}
let hours = minutes / 60
let mins = minutes % 60
if mins == 0 {
return "\(hours)h"
}
return "\(hours)h \(mins)m"
} }
private var sunriseTimeText: String? { private var sunriseTimeText: String? {
@ -399,7 +1163,7 @@ struct LockScreenWeatherWidget: View {
} }
private var focusWidgetSpacing: CGFloat { private var focusWidgetSpacing: CGFloat {
guard shouldShowFocusWidget else { return 0 } guard lockScreenShowCalendarEvent || lockScreenFocusWidgetEnabled else { return 0 }
return isInline ? 14 : 20 return isInline ? 14 : 20
} }
@ -447,8 +1211,7 @@ struct LockScreenWeatherWidget: View {
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.85) .minimumScaleFactor(0.85)
} }
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: focusRowAlignment)
.multilineTextAlignment(.center)
.padding(.horizontal, 2) .padding(.horizontal, 2)
.accessibilityLabel("Focus active: \(focusDisplayName)") .accessibilityLabel("Focus active: \(focusDisplayName)")
} }
@ -670,6 +1433,13 @@ struct LockScreenWeatherWidget: View {
components.append("Focus active: \(focusDisplayName)") components.append("Focus active: \(focusDisplayName)")
} }
if lockScreenShowCalendarEvent, let event = nextCalendarEvent {
let line = eventLineText(for: event)
if !line.isEmpty {
components.append("Calendar: \(line)")
}
}
return components.joined(separator: ". ") return components.joined(separator: ". ")
} }

View file

@ -35,6 +35,9 @@ struct MinimalisticMusicPlayerView: View {
@ObservedObject private var reminderManager = ReminderLiveActivityManager.shared @ObservedObject private var reminderManager = ReminderLiveActivityManager.shared
@ObservedObject private var timerManager = TimerManager.shared @ObservedObject private var timerManager = TimerManager.shared
@ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared
@State private var hudValue: Double = 0
@State private var hudDragging: Bool = false
@State private var hudLastDragged: Date = .distantPast
@Default(.enableReminderLiveActivity) private var enableReminderLiveActivity @Default(.enableReminderLiveActivity) private var enableReminderLiveActivity
@Default(.enableLyrics) private var enableLyrics @Default(.enableLyrics) private var enableLyrics
@Default(.timerPresets) private var timerPresets @Default(.timerPresets) private var timerPresets
@ -85,8 +88,13 @@ struct MinimalisticMusicPlayerView: View {
.padding(.top, 6) .padding(.top, 6)
// Compact playback controls // Compact playback controls
if shouldShowControlHUDRow {
controlHUDRow
.padding(.top, 4)
} else {
playbackControls playbackControls
.padding(.top, 4) .padding(.top, 4)
}
if enableLyrics { if enableLyrics {
lyricsView lyricsView
@ -673,6 +681,122 @@ private struct MinimalisticReminderDetailsView: View {
.padding(.top, 2) .padding(.top, 2)
} }
private var shouldShowControlHUDRow: Bool {
guard vm.notchState == .open else { return false }
guard coordinator.sneakPeek.show else { return false }
guard Defaults[.enableSystemHUD] else { return false }
guard !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] else { return false }
switch coordinator.sneakPeek.type {
case .volume:
return Defaults[.enableVolumeHUD]
case .brightness:
return Defaults[.enableBrightnessHUD]
case .backlight:
return Defaults[.enableKeyboardBacklightHUD]
default:
return false
}
}
private var controlHUDRow: some View {
HStack(alignment: .center, spacing: 10) {
if !controlLeftIconName.isEmpty {
Image(systemName: controlLeftIconName)
.font(.system(size: 16, weight: .semibold))
.frame(width: 22, height: 22, alignment: .center)
}
controlHUDSlider
if !controlRightIconName.isEmpty {
Image(systemName: controlRightIconName)
.font(.system(size: 16, weight: .semibold))
.frame(width: 22, height: 22, alignment: .center)
}
}
.frame(maxWidth: .infinity, alignment: .center)
.frame(height: 60, alignment: .center)
.onAppear { syncHUDValueIfNeeded(force: true) }
.onChange(of: coordinator.sneakPeek.value) { _, _ in
syncHUDValueIfNeeded(force: false)
}
}
private var controlHUDSlider: some View {
VStack(spacing: 0) {
Spacer(minLength: 0)
CustomSlider(
value: Binding(
get: { hudValue },
set: { newValue in
hudValue = newValue
updateControlHUDValue(newValue)
}
),
range: 0...1,
color: .white,
dragging: $hudDragging,
lastDragged: $hudLastDragged,
onValueChange: { newValue in
updateControlHUDValue(newValue)
},
thumbSize: 10,
restingTrackHeight: 4,
draggingTrackHeight: 7
)
.frame(height: 7)
Spacer(minLength: 0)
}
.frame(height: 22)
}
private func syncHUDValueIfNeeded(force: Bool) {
guard shouldShowControlHUDRow else { return }
guard force || !hudDragging else { return }
hudValue = Double(coordinator.sneakPeek.value)
}
private func updateControlHUDValue(_ newValue: Double) {
let clamped = max(0, min(1, newValue))
switch coordinator.sneakPeek.type {
case .volume:
SystemVolumeController.shared.setVolume(Float(clamped))
case .brightness:
SystemBrightnessController.shared.setBrightness(Float(clamped))
case .backlight:
SystemKeyboardBacklightController.shared.setLevel(Float(clamped))
default:
break
}
}
private var controlLeftIconName: String {
switch coordinator.sneakPeek.type {
case .volume:
return SystemVolumeController.shared.isMuted ? "speaker.slash" : "speaker.wave.1"
case .brightness:
return "sun.min.fill"
case .backlight:
return "light.min"
default:
return ""
}
}
private var controlRightIconName: String {
switch coordinator.sneakPeek.type {
case .volume:
return SystemVolumeController.shared.isMuted ? "" : "speaker.wave.3"
case .brightness:
return "sun.max.fill"
case .backlight:
return "light.max"
default:
return ""
}
}
private var playPauseButton: some View { private var playPauseButton: some View {
MinimalisticSquircircleButton( MinimalisticSquircircleButton(
icon: musicManager.isPlaying ? (musicManager.isLiveStream ? "stop.fill" : "pause.fill") : "play.fill", icon: musicManager.isPlaying ? (musicManager.isLiveStream ? "stop.fill" : "pause.fill") : "play.fill",

View file

@ -129,10 +129,15 @@ struct AlbumArtView: View {
} }
struct MusicControlsView: View { struct MusicControlsView: View {
@EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject var musicManager = MusicManager.shared @ObservedObject var musicManager = MusicManager.shared
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@State private var sliderValue: Double = 0 @State private var sliderValue: Double = 0
@State private var dragging: Bool = false @State private var dragging: Bool = false
@State private var lastDragged: Date = .distantPast @State private var lastDragged: Date = .distantPast
@State private var hudValue: Double = 0
@State private var hudDragging: Bool = false
@State private var hudLastDragged: Date = .distantPast
@Default(.showShuffleAndRepeat) private var showCustomControls @Default(.showShuffleAndRepeat) private var showCustomControls
@Default(.musicControlSlots) private var slotConfig @Default(.musicControlSlots) private var slotConfig
@Default(.showMediaOutputControl) private var showMediaOutputControl @Default(.showMediaOutputControl) private var showMediaOutputControl
@ -144,8 +149,12 @@ struct MusicControlsView: View {
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
songInfoAndSlider songInfoAndSlider
if shouldShowControlHUDRow {
controlHUDRow
} else {
playbackControls playbackControls
} }
}
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
@ -238,6 +247,121 @@ struct MusicControlsView: View {
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} }
private var shouldShowControlHUDRow: Bool {
guard vm.notchState == .open else { return false }
guard coordinator.sneakPeek.show else { return false }
guard Defaults[.enableSystemHUD] else { return false }
guard !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] else { return false }
switch coordinator.sneakPeek.type {
case .volume:
return Defaults[.enableVolumeHUD]
case .brightness:
return Defaults[.enableBrightnessHUD]
case .backlight:
return Defaults[.enableKeyboardBacklightHUD]
default:
return false
}
}
private var controlHUDRow: some View {
HStack(alignment: .center, spacing: 10) {
if !controlLeftIconName.isEmpty {
Image(systemName: controlLeftIconName)
.font(.system(size: 16, weight: .semibold))
.frame(width: 22, height: 22, alignment: .center)
}
controlHUDSlider
if !controlRightIconName.isEmpty {
Image(systemName: controlRightIconName)
.font(.system(size: 16, weight: .semibold))
.frame(width: 22, height: 22, alignment: .center)
}
}
.frame(maxWidth: .infinity, alignment: .center)
.onAppear { syncHUDValueIfNeeded(force: true) }
.onChange(of: coordinator.sneakPeek.value) { _, _ in
syncHUDValueIfNeeded(force: false)
}
}
private var controlHUDSlider: some View {
VStack(spacing: 0) {
Spacer(minLength: 0)
CustomSlider(
value: Binding(
get: { hudValue },
set: { newValue in
hudValue = newValue
updateControlHUDValue(newValue)
}
),
range: 0...1,
color: .white,
dragging: $hudDragging,
lastDragged: $hudLastDragged,
onValueChange: { newValue in
updateControlHUDValue(newValue)
},
thumbSize: 10,
restingTrackHeight: 4,
draggingTrackHeight: 7
)
.frame(height: 7)
Spacer(minLength: 0)
}
.frame(height: 22)
}
private func syncHUDValueIfNeeded(force: Bool) {
guard shouldShowControlHUDRow else { return }
guard force || !hudDragging else { return }
hudValue = Double(coordinator.sneakPeek.value)
}
private func updateControlHUDValue(_ newValue: Double) {
let clamped = max(0, min(1, newValue))
switch coordinator.sneakPeek.type {
case .volume:
SystemVolumeController.shared.setVolume(Float(clamped))
case .brightness:
SystemBrightnessController.shared.setBrightness(Float(clamped))
case .backlight:
SystemKeyboardBacklightController.shared.setLevel(Float(clamped))
default:
break
}
}
private var controlLeftIconName: String {
switch coordinator.sneakPeek.type {
case .volume:
return SystemVolumeController.shared.isMuted ? "speaker.slash" : "speaker.wave.1"
case .brightness:
return "sun.min.fill"
case .backlight:
return "light.min"
default:
return ""
}
}
private var controlRightIconName: String {
switch coordinator.sneakPeek.type {
case .volume:
return SystemVolumeController.shared.isMuted ? "" : "speaker.wave.3"
case .brightness:
return "sun.max.fill"
case .backlight:
return "light.max"
default:
return ""
}
}
private var brandAccentColor: Color { private var brandAccentColor: Color {
musicManager.brandAccentColor musicManager.brandAccentColor
} }

View file

@ -43,6 +43,25 @@ class BluetoothAudioManager: ObservableObject {
private let batteryReader = BluetoothLEBatteryReader() private let batteryReader = BluetoothLEBatteryReader()
private var isLiveBatteryRefreshInFlight = false private var isLiveBatteryRefreshInFlight = false
private let appleVendorID: UInt16 = 0x05AC
private let devicePIDMap: [UInt16: BluetoothAudioDeviceType] = [
0x2002: .airpods, // Gen 1 AirPods
0x200F: .airpods, // Gen 2 AirPods
0x2013: .airpodsGen3, // Gen 3 AirPods
0x2019: .airpodsGen4, // Gen 4 AirPods
0x201B: .airpodsGen4, // Gen 4 AirPods ANC
0x200A: .airpodsMax, // AirPods Max Lightning
0x201F: .airpodsMax, // AirPods Max USB-C
0x200E: .airpodsPro, // AirPods Pro Gen 1
0x2014: .airpodsPro, // AirPods Pro Gen 2 Lightning
0x2024: .airpodsPro, // AirPods Pro Gen 2 USB-C
0x2027: .airpodsPro3, // AirPods Pro Gen 3
0x2017: .beatsstudio, // Beats Studio Pro
0x2009: .beatsstudio, // Beats Studio 3
0x2006: .beatssolo, // Beats Solo 3
0x200C: .beatssolo // Beats Solo Pro
]
@Published private(set) var batteryStatus: [String: String] = [:] @Published private(set) var batteryStatus: [String: String] = [:]
private var batteryStatusByAddress: [String: Int] = [:] private var batteryStatusByAddress: [String: Int] = [:]
@ -397,23 +416,246 @@ class BluetoothAudioManager: ObservableObject {
return nil return nil
} }
// MARK: - PID-based device detection
/// Extract a UInt16 from common payload formats (Int/NSNumber/String including hex like "0x201B").
private func extractUInt16(from payload: [String: Any], keys: [String]) -> UInt16? {
for key in keys {
guard let raw = payload[key] else { continue }
if let number = raw as? NSNumber {
return UInt16(truncatingIfNeeded: number.uint16Value)
}
if let intValue = raw as? Int {
return UInt16(truncatingIfNeeded: intValue)
}
if let string = raw as? String {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if trimmed.hasPrefix("0x") {
let hex = trimmed.dropFirst(2)
if let value = UInt16(hex, radix: 16) { return value }
} else if let value = UInt16(trimmed, radix: 10) {
return value
}
}
}
return nil
}
/// Recursively search nested dictionaries/arrays for the first UInt16 value whose key matches `predicate`.
private func deepSearchUInt16(in value: Any, predicate: (String) -> Bool) -> UInt16? {
if let dict = value as? [String: Any] {
for (key, entry) in dict {
if predicate(key) {
if let found = extractUInt16(from: dict, keys: [key]) {
return found
}
if let number = entry as? NSNumber { return UInt16(truncatingIfNeeded: number.uint16Value) }
if let intValue = entry as? Int { return UInt16(truncatingIfNeeded: intValue) }
if let string = entry as? String {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if trimmed.hasPrefix("0x"), let value = UInt16(trimmed.dropFirst(2), radix: 16) { return value }
if let value = UInt16(trimmed, radix: 10) { return value }
}
}
}
for entry in dict.values {
if let found = deepSearchUInt16(in: entry, predicate: predicate) { return found }
}
return nil
}
if let array = value as? [Any] {
for entry in array {
if let found = deepSearchUInt16(in: entry, predicate: predicate) { return found }
}
return nil
}
return nil
}
/// Fallback: attempt to get VendorID/ProductID from system_profiler SPBluetoothDataType JSON.
private func vendorProductIDsFromSystemProfiler(forNormalizedAddress target: String) -> (vendor: UInt16, product: UInt16)? {
guard !target.isEmpty else { return nil }
guard let root = systemProfilerBluetoothDictionary() else { return nil }
guard let deviceConnected = root["device_connected"] as? [Any] else { return nil }
func pidFromPayload(_ payload: [String: Any]) -> UInt16? {
if let raw = payload["device_productID"] as? String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if trimmed.hasPrefix("0x"), let value = UInt16(trimmed.dropFirst(2), radix: 16) { return value }
if let value = UInt16(trimmed, radix: 16) { return value }
}
let productKeys = ["device_productID", "ProductID", "product_id", "productID", "DeviceProductID", "ProductId", "Product ID"]
return extractUInt16(from: payload, keys: productKeys)
?? deepSearchUInt16(in: payload) { $0.lowercased().contains("productid") }
}
func vidFromPayload(_ payload: [String: Any]) -> UInt16? {
let vendorKeys = ["device_vendorID", "VendorID", "vendor_id", "vendorID", "DeviceVendorID", "VendorId", "Vendor ID"]
return extractUInt16(from: payload, keys: vendorKeys)
?? deepSearchUInt16(in: payload) { $0.lowercased().contains("vendorid") }
}
for item in deviceConnected {
guard let dict = item as? [String: Any],
let nameKey = dict.keys.first,
let infoAny = dict[nameKey],
let payload = infoAny as? [String: Any] else {
continue
}
if let address = payload["device_address"] as? String {
if normalizeBluetoothIdentifier(address) != target { continue }
} else {
let candidates = profilerAddressCandidates(from: payload).map(normalizeBluetoothIdentifier)
if !candidates.contains(target) { continue }
}
if let pid = pidFromPayload(payload) {
if let vid = vidFromPayload(payload) {
return (vendor: vid, product: pid)
}
if devicePIDMap[pid] != nil {
return (vendor: appleVendorID, product: pid)
}
}
}
return nil
}
/// Attempts to find VendorID/ProductID for a device using Bluetooth caches.
private func vendorProductIDs(for device: IOBluetoothDevice) -> (vendor: UInt16, product: UInt16)? {
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite),
let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else {
return nil
}
let target = normalizeBluetoothIdentifier(device.addressString ?? "")
guard !target.isEmpty else { return nil }
let vendorKeys = [
"VendorID", "vendor_id", "vendorID",
"device_vendorID", "DeviceVendorID", "device_vendor_id",
"device_vendorId", "DeviceVendorId",
"VendorId", "Vendor ID",
"VendorIDSource", "VendorIDSourceLocal", "VendorIDSourceRemote"
]
let productKeys = [
"ProductID", "product_id", "productID",
"device_productID", "DeviceProductID", "device_product_id",
"device_productId", "DeviceProductId",
"ProductId", "Product ID",
"ProductIDSource", "ProductIDSourceLocal", "ProductIDSourceRemote"
]
for (key, value) in deviceCache {
guard let payload = value as? [String: Any] else { continue }
if matchesBluetoothIdentifier(target, key: key, payload: payload) {
let vendor = extractUInt16(from: payload, keys: vendorKeys)
?? deepSearchUInt16(in: payload) { $0.lowercased().contains("vendorid") }
let product = extractUInt16(from: payload, keys: productKeys)
?? deepSearchUInt16(in: payload) { $0.lowercased().contains("productid") }
if let product {
if let vendor {
return (vendor: vendor, product: product)
}
if devicePIDMap[product] != nil {
return (vendor: appleVendorID, product: product)
}
}
}
}
if let coreCache = preferences.object(forKey: "CoreBluetoothCache") as? [String: [String: Any]] {
for payload in coreCache.values {
if let addressValue = payload["DeviceAddress"]
?? payload["Address"]
?? payload["BD_ADDR"]
?? payload["device_address"],
let address = normalizeBluetoothIdentifier(from: addressValue),
address == target {
let vendor = extractUInt16(from: payload, keys: vendorKeys)
?? deepSearchUInt16(in: payload) { $0.lowercased().contains("vendorid") }
let product = extractUInt16(from: payload, keys: productKeys)
?? deepSearchUInt16(in: payload) { $0.lowercased().contains("productid") }
if let product {
if let vendor {
return (vendor: vendor, product: product)
}
if devicePIDMap[product] != nil {
return (vendor: appleVendorID, product: product)
}
}
}
}
}
if let fromProfiler = vendorProductIDsFromSystemProfiler(forNormalizedAddress: target) {
return fromProfiler
}
return nil
}
/// Attempts to detect AirPods/Beats type using vendor/product IDs.
private func airPodsTypeFromPID(_ device: IOBluetoothDevice) -> BluetoothAudioDeviceType? {
if let ids = vendorProductIDs(for: device) {
return devicePIDMap[ids.product]
}
return nil
}
/// Detects the type of audio device based on name and properties /// Detects the type of audio device based on name and properties
private func detectDeviceType(from device: IOBluetoothDevice, name: String) -> BluetoothAudioDeviceType { private func detectDeviceType(from device: IOBluetoothDevice, name: String) -> BluetoothAudioDeviceType {
let lowercaseName = name.lowercased() let lowercaseName = name.lowercased()
if let pidBasedType = airPodsTypeFromPID(device) {
return pidBasedType
}
// Check for specific AirPods models // Check for specific AirPods models
if lowercaseName.contains("airpods") { if lowercaseName.contains("airpods") {
if lowercaseName.contains("max") { if lowercaseName.contains("max") {
return .airpodsMax return .airpodsMax
} else if lowercaseName.contains("pro") { } else if lowercaseName.contains("pro") {
if lowercaseName.contains("3") || lowercaseName.contains("gen 3") || lowercaseName.contains("gen3") {
return .airpodsPro3
}
return .airpodsPro return .airpodsPro
} else if lowercaseName.contains("gen 4")
|| lowercaseName.contains("gen4")
|| lowercaseName.contains("4th")
|| lowercaseName.contains("airpods 4")
|| lowercaseName.contains("airpods4") {
return .airpodsGen4
} else if lowercaseName.contains("gen 3")
|| lowercaseName.contains("gen3")
|| lowercaseName.contains("3rd")
|| lowercaseName.contains("third")
|| lowercaseName.contains("airpods 3")
|| lowercaseName.contains("airpods3") {
return .airpodsGen3
} }
return .airpods return .airpods
} }
// Check for other brands // Check for other brands
if lowercaseName.contains("beats") { if lowercaseName.contains("beats") {
return .beats if lowercaseName.contains("studio") {
return .beatsstudio
}
if lowercaseName.contains("solo") {
return .beatssolo
}
return .beatssolo
} else if lowercaseName.contains("speaker") || lowercaseName.contains("boombox") { } else if lowercaseName.contains("speaker") || lowercaseName.contains("boombox") {
return .speaker return .speaker
} else if lowercaseName.contains("headphone") || lowercaseName.contains("headset") || } else if lowercaseName.contains("headphone") || lowercaseName.contains("headset") ||
@ -1770,9 +2012,14 @@ extension BluetoothAudioDevice {
enum BluetoothAudioDeviceType { enum BluetoothAudioDeviceType {
case airpods case airpods
case airpodsGen3
case airpodsGen4
case airpodsPro case airpodsPro
case airpodsPro3
case airpodsMax case airpodsMax
case beats case beats
case beatsstudio
case beatssolo
case headphones case headphones
case speaker case speaker
case generic case generic
@ -1781,12 +2028,22 @@ enum BluetoothAudioDeviceType {
switch self { switch self {
case .airpods: case .airpods:
return "airpods" return "airpods"
case .airpodsGen3:
return "airpods.gen3"
case .airpodsGen4:
return "airpods.gen4"
case .airpodsPro: case .airpodsPro:
return "airpodspro" return "airpods.pro"
case .airpodsPro3:
return "airpods.pro"
case .airpodsMax: case .airpodsMax:
return "airpodsmax" return "airpodsmax"
case .beats: case .beats:
return "beats.headphones" return "beats.headphones"
case .beatsstudio:
return "beats.headphones"
case .beatssolo:
return "beats.headphones"
case .headphones: case .headphones:
return "headphones" return "headphones"
case .speaker: case .speaker:
@ -1799,14 +2056,24 @@ enum BluetoothAudioDeviceType {
var displayName: String { var displayName: String {
switch self { switch self {
case .airpods: return "AirPods" case .airpods: return "AirPods"
case .airpodsGen3: return "AirPods (Gen 3)"
case .airpodsGen4: return "AirPods (Gen 4)"
case .airpodsPro: return "AirPods Pro" case .airpodsPro: return "AirPods Pro"
case .airpodsPro3: return "AirPods Pro 3"
case .airpodsMax: return "AirPods Max" case .airpodsMax: return "AirPods Max"
case .beats: return "Beats" case .beats: return "Beats"
case .beatsstudio: return "Beats Studio"
case .beatssolo: return "Beats Solo"
case .headphones: return "Headphones" case .headphones: return "Headphones"
case .speaker: return "Speaker" case .speaker: return "Speaker"
case .generic: return "Bluetooth Device" case .generic: return "Bluetooth Device"
} }
} }
/// Inline HUD only: base filename (no extension) for a looping .mov animation.
var inlineHUDAnimationBaseName: String {
String(describing: self)
}
} }
// MARK: - Notification Name Constants // MARK: - Notification Name Constants

View file

@ -38,6 +38,7 @@ class CalendarManager: ObservableObject {
@Published var selectedCalendarIDs: Set<String> = [] @Published var selectedCalendarIDs: Set<String> = []
@Published var calendarAuthorizationStatus: EKAuthorizationStatus = .notDetermined @Published var calendarAuthorizationStatus: EKAuthorizationStatus = .notDetermined
@Published var reminderAuthorizationStatus: EKAuthorizationStatus = .notDetermined @Published var reminderAuthorizationStatus: EKAuthorizationStatus = .notDetermined
@Published var lockScreenEvents: [EventModel] = []
private var selectedCalendars: [CalendarModel] = [] private var selectedCalendars: [CalendarModel] = []
private let calendarService = CalendarService() private let calendarService = CalendarService()
@ -50,6 +51,9 @@ class CalendarManager: ObservableObject {
private let eventStoreChangeThrottle: TimeInterval = 20 private let eventStoreChangeThrottle: TimeInterval = 20
private let selfInducedChangeSuppression: TimeInterval = 6 private let selfInducedChangeSuppression: TimeInterval = 6
private let eventFetchLimiter = EventFetchLimiter() private let eventFetchLimiter = EventFetchLimiter()
private var lastLockScreenEventsFetchDate: Date?
private let lockScreenRefreshInterval: TimeInterval = 15
private var lockScreenRefreshTask: Task<Void, Never>?
var hasCalendarAccess: Bool { isAuthorized(calendarAuthorizationStatus) } var hasCalendarAccess: Bool { isAuthorized(calendarAuthorizationStatus) }
var hasReminderAccess: Bool { isAuthorized(reminderAuthorizationStatus) } var hasReminderAccess: Bool { isAuthorized(reminderAuthorizationStatus) }
@ -57,6 +61,7 @@ class CalendarManager: ObservableObject {
private init() { private init() {
currentWeekStartDate = CalendarManager.startOfDay(Date()) currentWeekStartDate = CalendarManager.startOfDay(Date())
setupEventStoreChangedObserver() setupEventStoreChangedObserver()
startLockScreenRefreshLoop()
Task { Task {
await reloadCalendarAndReminderLists() await reloadCalendarAndReminderLists()
} }
@ -67,6 +72,7 @@ class CalendarManager: ObservableObject {
NotificationCenter.default.removeObserver(observer) NotificationCenter.default.removeObserver(observer)
} }
pendingEventStoreRefreshTask?.cancel() pendingEventStoreRefreshTask?.cancel()
lockScreenRefreshTask?.cancel()
} }
private func setupEventStoreChangedObserver() { private func setupEventStoreChangedObserver() {
@ -111,6 +117,7 @@ class CalendarManager: ObservableObject {
pendingEventStoreRefreshTask = nil pendingEventStoreRefreshTask = nil
await reloadCalendarAndReminderLists() await reloadCalendarAndReminderLists()
await maybeRefreshEventsAfterReload() await maybeRefreshEventsAfterReload()
await updateLockScreenEvents(force: true)
nextAllowedEventStoreRefresh = Date().addingTimeInterval(eventStoreChangeThrottle) nextAllowedEventStoreRefresh = Date().addingTimeInterval(eventStoreChangeThrottle)
ignoreEventStoreChangesUntil = Date().addingTimeInterval(selfInducedChangeSuppression) ignoreEventStoreChangesUntil = Date().addingTimeInterval(selfInducedChangeSuppression)
} }
@ -154,12 +161,14 @@ class CalendarManager: ObservableObject {
if granted { if granted {
await reloadCalendarAndReminderLists() await reloadCalendarAndReminderLists()
await updateEvents(force: true) await updateEvents(force: true)
await updateLockScreenEvents(force: true)
} }
case .restricted, .denied: case .restricted, .denied:
NSLog("Calendar access denied or restricted") NSLog("Calendar access denied or restricted")
case .authorized, .fullAccess: case .authorized, .fullAccess:
await reloadCalendarAndReminderLists() await reloadCalendarAndReminderLists()
await updateEvents(force: true) await updateEvents(force: true)
await updateLockScreenEvents(force: true)
case .writeOnly: case .writeOnly:
NSLog("Calendar write only") NSLog("Calendar write only")
@unknown default: @unknown default:
@ -230,6 +239,7 @@ class CalendarManager: ObservableObject {
Defaults[.calendarSelectionState] = selectionState Defaults[.calendarSelectionState] = selectionState
updateSelectedCalendars() updateSelectedCalendars()
await updateEvents(force: true) await updateEvents(force: true)
await updateLockScreenEvents(force: true)
} }
static func startOfDay(_ date: Date) -> Date { static func startOfDay(_ date: Date) -> Date {
@ -239,6 +249,88 @@ class CalendarManager: ObservableObject {
func updateCurrentDate(_ date: Date) async { func updateCurrentDate(_ date: Date) async {
currentWeekStartDate = Calendar.current.startOfDay(for: date) currentWeekStartDate = Calendar.current.startOfDay(for: date)
await updateEvents(force: true) await updateEvents(force: true)
await updateLockScreenEvents(force: true)
}
func updateLockScreenEvents(force: Bool = false) async {
let now = Date()
if !force,
let lastFetch = lastLockScreenEventsFetchDate,
now.timeIntervalSince(lastFetch) < lockScreenRefreshInterval {
return
}
let lookaheadRaw = Defaults[.lockScreenCalendarEventLookaheadWindow]
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
let isAllTimeLookahead =
lookaheadRaw == "all_time" ||
lookaheadRaw == "all time" ||
lookaheadRaw == "alltime"
let isRestOfDay =
lookaheadRaw == "rest_of_day" ||
lookaheadRaw == "rest of the day" ||
lookaheadRaw == "restofday"
func lookaheadMinutes(from raw: String) -> Int? {
switch raw {
case "15m", "15 min", "15 mins", "15min", "15mins": return 15
case "30m", "30 min", "30 mins", "30min", "30mins": return 30
case "1h", "1 hr", "1 hour", "1hour": return 60
case "3h", "3 hr", "3 hours", "3hours": return 180
case "6h", "6 hr", "6 hours", "6hours": return 360
case "12h", "12 hr", "12 hours", "12hours": return 720
default: return nil
}
}
let calendar = Calendar.current
let startOfToday = calendar.startOfDay(for: now)
let startDate = calendar.date(byAdding: .day, value: -1, to: startOfToday) ?? startOfToday
let endDate: Date
if isAllTimeLookahead {
endDate = calendar.date(byAdding: .day, value: 365, to: now) ?? now.addingTimeInterval(365 * 24 * 3600)
} else if isRestOfDay {
endDate = calendar.date(byAdding: .day, value: 1, to: startOfToday) ?? startOfToday.addingTimeInterval(24 * 3600)
} else if let minutes = lookaheadMinutes(from: lookaheadRaw) {
endDate = calendar.date(byAdding: .minute, value: minutes, to: now) ?? now.addingTimeInterval(TimeInterval(minutes * 60))
} else {
endDate = calendar.date(byAdding: .minute, value: 180, to: now) ?? now.addingTimeInterval(180 * 60)
}
let calendarIDs = allCalendars.map { $0.id }
let service = calendarService
let fetched = await eventFetchLimiter.run {
await service.events(from: startDate, to: endDate, calendars: calendarIDs)
}
if lockScreenEvents == fetched {
lastLockScreenEventsFetchDate = Date()
return
}
withAnimation(.smooth(duration: 0.25)) {
lockScreenEvents = fetched
}
lastLockScreenEventsFetchDate = Date()
}
private func startLockScreenRefreshLoop() {
lockScreenRefreshTask?.cancel()
lockScreenRefreshTask = Task { [weak self] in
guard let self else { return }
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 60 * 1_000_000_000)
if Task.isCancelled { break }
guard self.hasCalendarAccess else { continue }
await self.updateLockScreenEvents(force: false)
}
}
} }
private func updateEvents(force: Bool = false) async { private func updateEvents(force: Bool = false) async {

View file

@ -94,6 +94,18 @@ final class LockScreenReminderWidgetManager: ObservableObject {
self.handleSnapshotUpdate(self.reminderManager.lockScreenSnapshot) self.handleSnapshotUpdate(self.reminderManager.lockScreenSnapshot)
} }
.store(in: &cancellables) .store(in: &cancellables)
Defaults.publisher(.lockScreenReminderWidgetHorizontalAlignment, options: [])
.sink { _ in
LockScreenReminderWidgetPanelManager.shared.refreshPosition(animated: true)
}
.store(in: &cancellables)
Defaults.publisher(.lockScreenReminderWidgetVerticalOffset, options: [])
.sink { _ in
LockScreenReminderWidgetPanelManager.shared.refreshPosition(animated: true)
}
.store(in: &cancellables)
} }
private func handleSnapshotUpdate(_ newSnapshot: LockScreenReminderWidgetSnapshot?) { private func handleSnapshotUpdate(_ newSnapshot: LockScreenReminderWidgetSnapshot?) {

View file

@ -20,6 +20,7 @@ import AppKit
import SwiftUI import SwiftUI
import SkyLightWindow import SkyLightWindow
import QuartzCore import QuartzCore
import Defaults
@MainActor @MainActor
final class LockScreenReminderWidgetPanelManager { final class LockScreenReminderWidgetPanelManager {
@ -119,7 +120,18 @@ final class LockScreenReminderWidgetPanelManager {
private func frame(for size: CGSize, on screen: NSScreen) -> NSRect { private func frame(for size: CGSize, on screen: NSScreen) -> NSRect {
let screenFrame = screen.frame let screenFrame = screen.frame
let originX = screenFrame.midX - (size.width / 2) let horizontalPadding: CGFloat = 24
let alignment = Defaults[.lockScreenReminderWidgetHorizontalAlignment].lowercased()
let originX: CGFloat
switch alignment {
case "leading", "left":
originX = screenFrame.minX + horizontalPadding
case "trailing", "right":
originX = screenFrame.maxX - size.width - horizontalPadding
default:
originX = screenFrame.midX - (size.width / 2)
}
let musicTop = LockScreenPanelManager.shared.latestFrame?.maxY ?? (screenFrame.midY - 32) let musicTop = LockScreenPanelManager.shared.latestFrame?.maxY ?? (screenFrame.midY - 32)
let timerFrame = LockScreenTimerWidgetPanelManager.shared.latestFrame let timerFrame = LockScreenTimerWidgetPanelManager.shared.latestFrame
@ -148,7 +160,9 @@ final class LockScreenReminderWidgetPanelManager {
proposedY = (lowerBound + clampedUpperBound) / 2 proposedY = (lowerBound + clampedUpperBound) / 2
} }
proposedY = max(lowerBound, min(proposedY, clampedUpperBound)) let rawOffset = CGFloat(Defaults[.lockScreenReminderWidgetVerticalOffset])
let clampedOffset = min(max(rawOffset, -160), 160)
proposedY = max(lowerBound, min(proposedY + clampedOffset, clampedUpperBound))
return NSRect(x: originX, y: proposedY, width: size.width, height: size.height) return NSRect(x: originX, y: proposedY, width: size.width, height: size.height)
} }

View file

@ -31,6 +31,7 @@ final class LockScreenWeatherPanelManager {
private(set) var latestFrame: NSRect? private(set) var latestFrame: NSRect?
private var lastSnapshot: LockScreenWeatherSnapshot? private var lastSnapshot: LockScreenWeatherSnapshot?
private var lastContentSize: CGSize? private var lastContentSize: CGSize?
private var lastInlineBaselineHeight: CGFloat = 0
private var screenChangeObserver: NSObjectProtocol? private var screenChangeObserver: NSObjectProtocol?
private var workspaceObservers: [NSObjectProtocol] = [] private var workspaceObservers: [NSObjectProtocol] = []
@ -64,6 +65,9 @@ final class LockScreenWeatherPanelManager {
let view = LockScreenWeatherWidget(snapshot: snapshot) let view = LockScreenWeatherWidget(snapshot: snapshot)
let hostingView = NSHostingView(rootView: view) let hostingView = NSHostingView(rootView: view)
let fittingSize = hostingView.fittingSize let fittingSize = hostingView.fittingSize
if snapshot.widgetStyle == .inline {
lastInlineBaselineHeight = max(lastInlineBaselineHeight, fittingSize.height)
}
hostingView.frame = NSRect(origin: .zero, size: fittingSize) hostingView.frame = NSRect(origin: .zero, size: fittingSize)
let targetFrame = frame(for: fittingSize, snapshot: snapshot, on: screen) let targetFrame = frame(for: fittingSize, snapshot: snapshot, on: screen)
@ -114,14 +118,23 @@ final class LockScreenWeatherPanelManager {
let screenFrame = screen.frame let screenFrame = screen.frame
let originX = screenFrame.midX - (size.width / 2) let originX = screenFrame.midX - (size.width / 2)
let verticalOffset = screenFrame.height * 0.15 let verticalOffset = screenFrame.height * 0.15
let maxY = screenFrame.maxY - size.height - 48 let isCircular = snapshot.widgetStyle == .circular
let topMargin: CGFloat = isCircular ? 120 : 48
let inlineBaselineHeight: CGFloat = max(lastInlineBaselineHeight, 80)
let positionHeight = snapshot.widgetStyle == .inline
? max(size.height, inlineBaselineHeight)
: size.height
let maxY = screenFrame.maxY - positionHeight - topMargin
let baseY = min(maxY, screenFrame.midY + verticalOffset) let baseY = min(maxY, screenFrame.midY + verticalOffset)
let loweredY = baseY - 36 let loweredY = baseY - 36
let inlineLift: CGFloat = snapshot.widgetStyle == .inline ? 44 : 0 let inlineLift: CGFloat = snapshot.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 userOffset = CGFloat(Defaults[.lockScreenWeatherVerticalOffset])
let clampedOffset = min(max(userOffset, -160), 160) let clampedOffset = min(max(userOffset, -160), 160)
let adjustedY = loweredY + inlineLift + clampedOffset let adjustedY = loweredY + inlineLift + clampedOffset - circularDrop - sizeDrop
let upperClampedY = min(maxY, adjustedY) let upperClampedY = min(maxY, adjustedY)
let clampedY = max(screenFrame.minY + 80, upperClampedY) let clampedY = max(screenFrame.minY + 80, upperClampedY)
return NSRect(x: originX, y: clampedY, width: size.width, height: size.height) return NSRect(x: originX, y: clampedY, width: size.width, height: size.height)

View file

@ -800,6 +800,19 @@ extension Defaults.Keys {
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true) static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true)
static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false) static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false)
static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor) static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor)
static let lockScreenReminderWidgetHorizontalAlignment = Key<String>("lockScreenReminderWidgetHorizontalAlignment", default: "center")
static let lockScreenReminderWidgetVerticalOffset = Key<Double>("lockScreenReminderWidgetVerticalOffset", default: 0)
static let lockScreenShowCalendarEvent = Key<Bool>("lockScreenShowCalendarEvent", default: true)
static let lockScreenCalendarEventLookaheadWindow = Key<String>("lockScreenCalendarEventLookaheadWindow", default: "3h")
static let lockScreenCalendarSelectionMode = Key<String>("lockScreenCalendarSelectionMode", default: "all")
static let lockScreenSelectedCalendarIDs = Key<Set<String>>("lockScreenSelectedCalendarIDs", default: [])
static let lockScreenShowCalendarCountdown = Key<Bool>("lockScreenShowCalendarCountdown", default: true)
static let lockScreenShowCalendarEventEntireDuration = Key<Bool>("lockScreenShowCalendarEventEntireDuration", default: true)
static let lockScreenShowCalendarEventAfterStartEnabled = Key<Bool>("lockScreenShowCalendarEventAfterStartEnabled", default: false)
static let lockScreenShowCalendarEventAfterStartWindow = Key<String>("lockScreenShowCalendarEventAfterStartWindow", default: "5m")
static let lockScreenShowCalendarTimeRemaining = Key<Bool>("lockScreenShowCalendarTimeRemaining", default: true)
static let lockScreenShowCalendarStartTimeAfterBegins = Key<Bool>("lockScreenShowCalendarStartTimeAfterBegins", default: true)
static let lockScreenWeatherWidgetRowOrder = Key<String>("lockScreenWeatherWidgetRowOrder", default: "weather_calendar_focus")
// MARK: Battery // MARK: Battery
static let showPowerStatusNotifications = Key<Bool>("showPowerStatusNotifications", default: true) static let showPowerStatusNotifications = Key<Bool>("showPowerStatusNotifications", default: true)
@ -864,6 +877,7 @@ extension Defaults.Keys {
static let useCircularBluetoothBatteryIndicator = Key<Bool>("useCircularBluetoothBatteryIndicator", default: true) static let useCircularBluetoothBatteryIndicator = Key<Bool>("useCircularBluetoothBatteryIndicator", default: true)
static let showBluetoothBatteryPercentageText = Key<Bool>("showBluetoothBatteryPercentageText", default: false) static let showBluetoothBatteryPercentageText = Key<Bool>("showBluetoothBatteryPercentageText", default: false)
static let showBluetoothDeviceNameMarquee = Key<Bool>("showBluetoothDeviceNameMarquee", default: false) static let showBluetoothDeviceNameMarquee = Key<Bool>("showBluetoothDeviceNameMarquee", default: false)
static let useBluetoothHUD3DIcon = Key<Bool>("useBluetoothHUD3DIcon", default: true)
// MARK: Stats Feature // MARK: Stats Feature
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false) static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)