mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-29 08:04:32 +00:00
Merge remote-tracking branch 'origin/main' into pr-288
This commit is contained in:
commit
61f2570f3c
22 changed files with 4076 additions and 3749 deletions
3
DynamicIsland/BluetoothHUDAnimations/airpods.mov
Executable file
3
DynamicIsland/BluetoothHUDAnimations/airpods.mov
Executable file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:54332f65534c9af667a302e9c2df17e4420bffb94e93cecd0091b11a05e70efc
|
||||||
|
size 351501
|
||||||
3
DynamicIsland/BluetoothHUDAnimations/airpodsGen3.mov
Executable file
3
DynamicIsland/BluetoothHUDAnimations/airpodsGen3.mov
Executable file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:329be57b63f437e3329772b688440b825ae4b27ece16ac33c483d43b3725d0cf
|
||||||
|
size 391452
|
||||||
3
DynamicIsland/BluetoothHUDAnimations/airpodsGen4.mov
Normal file
3
DynamicIsland/BluetoothHUDAnimations/airpodsGen4.mov
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:04b9bce0be1bb910c10d7466b795e0909cbf70d6b62e5f227124d7c67bc9b53a
|
||||||
|
size 352641
|
||||||
3
DynamicIsland/BluetoothHUDAnimations/airpodsMax.mov
Executable file
3
DynamicIsland/BluetoothHUDAnimations/airpodsMax.mov
Executable file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b587c84649e0f3986d59b46fab527a15b2c8146eb9c57a523a2e899afe41ac20
|
||||||
|
size 476065
|
||||||
3
DynamicIsland/BluetoothHUDAnimations/airpodsPro.mov
Executable file
3
DynamicIsland/BluetoothHUDAnimations/airpodsPro.mov
Executable file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c2be0bc5150dd21d85c9d64a18ec5a782279e30980996e726a6c8d3a8cecb775
|
||||||
|
size 463126
|
||||||
3
DynamicIsland/BluetoothHUDAnimations/airpodsPro3.mov
Normal file
3
DynamicIsland/BluetoothHUDAnimations/airpodsPro3.mov
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:df3275c52a9599dbd5768aba279c8904037925bbed6a02a2166a0a01fb730875
|
||||||
|
size 398759
|
||||||
3
DynamicIsland/BluetoothHUDAnimations/beatssolo.mov
Normal file
3
DynamicIsland/BluetoothHUDAnimations/beatssolo.mov
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4c59f43e4f3446a163dde871035971eb1482475051dbc67e5f6a771b5fb04f13
|
||||||
|
size 393932
|
||||||
3
DynamicIsland/BluetoothHUDAnimations/beatsstudio.mov
Normal file
3
DynamicIsland/BluetoothHUDAnimations/beatsstudio.mov
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:835ba4373e8f3dd85e2ff77655eb9464ed07a58687b3b79b51a4535c5febd26a
|
||||||
|
size 443917
|
||||||
|
|
@ -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
|
||||||
//
|
//
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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: ". ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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?) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue