mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #184 from Ebullioscopic/feature/hud
HUD is now available in multiple modes [BETA]
This commit is contained in:
commit
04f4cdc737
14 changed files with 1689 additions and 299 deletions
|
|
@ -164,11 +164,9 @@ class DynamicIslandViewCoordinator: ObservableObject {
|
|||
resolvedDuration = duration
|
||||
}
|
||||
sneakPeekDuration = resolvedDuration
|
||||
if type != .music && type != .timer && type != .reminder {
|
||||
// close()
|
||||
if !Defaults[.enableSystemHUD] {
|
||||
return
|
||||
}
|
||||
let bypassedTypes: [SneakContentType] = [.music, .timer, .reminder, .bluetoothAudio]
|
||||
if !bypassedTypes.contains(type) && !Defaults[.enableSystemHUD] {
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
withAnimation(.smooth) {
|
||||
|
|
|
|||
|
|
@ -2842,10 +2842,6 @@
|
|||
"comment" : "A label indicating a feature that is still in development.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Alpha Feature Warning" : {
|
||||
"comment" : "The title of an alert that warns users about an alpha feature.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Always hide in fullscreen" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
@ -3671,6 +3667,10 @@
|
|||
"Battery fully charged" : {
|
||||
"comment" : "Battery is full"
|
||||
},
|
||||
"Battery Indicator Styling" : {
|
||||
"comment" : "A section header in the Settings app that lists options for customizing the appearance of the battery indicator.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Battery Information" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
@ -3973,6 +3973,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Behavior & Style" : {
|
||||
|
||||
},
|
||||
"BETA" : {
|
||||
"comment" : "A label indicating a beta version.",
|
||||
|
|
@ -5071,6 +5074,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Choose directly on which side of the screen the vertical bar appears." : {
|
||||
|
||||
},
|
||||
"Choose File" : {
|
||||
"localizations" : {
|
||||
|
|
@ -5597,8 +5603,8 @@
|
|||
"comment" : "An explanatory text that appears when the user tries to enable color-coded color gradients in Segmented progress bars. It instructs the user to switch to Hierarchical or Gradient progress bars to enable these features.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Color-Coded Progress Bars" : {
|
||||
"comment" : "A section header that lists the options for color-coded progress bars.",
|
||||
"Color-coded fills are unavailable in Segmented mode. Switch to Hierarchical or Gradient inside Controls › Dynamic Island to adjust advanced options." : {
|
||||
"comment" : "A message explaining that color-coded battery indicators are unavailable in segmented progress bar style. It also includes a suggestion to switch to a more advanced progress bar style to adjust advanced options.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"ColorPicker Disabled" : {
|
||||
|
|
@ -6325,13 +6331,6 @@
|
|||
"comment" : "A description of the Custom OSD feature, visible only on macOS 15 or later.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Custom OSD is an experimental alpha feature and may contain bugs or unexpected behavior.\n\nThis feature requires macOS 15 or later and is still under active development. It's recommended to keep it disabled unless you want to help test it." : {
|
||||
|
||||
},
|
||||
"Custom OSD is disabled because HUDs are enabled. Disable HUDs in the HUDs tab to use Custom OSD." : {
|
||||
"comment" : "A message displayed below the toggle to inform the user that they cannot enable Custom OSD if HUDs are enabled.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Custom Timer" : {
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
|
|
@ -6832,9 +6831,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Devices" : {
|
||||
"comment" : "The title of the \"Devices\" section in the settings menu.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Diagnostics" : {
|
||||
"comment" : "A section header in the Lock Screen Settings view, describing diagnostics-related functionality.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Dimensions" : {
|
||||
|
||||
},
|
||||
"Disable" : {
|
||||
"extractionState" : "stale",
|
||||
|
|
@ -7061,10 +7067,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Display custom macOS-style OSD windows at the bottom center of the screen when adjusting volume, brightness, or keyboard backlight." : {
|
||||
"comment" : "A description of the functionality of the Custom OSD feature.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Display Mode" : {
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
|
|
@ -7465,6 +7467,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Dynamic Island Progress Bars" : {
|
||||
"comment" : "A section header that lists the options for customizing the appearance of the progress bars displayed by Dynamic Island.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"dynamic.island" : {
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
|
|
@ -7713,10 +7719,6 @@
|
|||
"comment" : "A description of the media panel settings in the lock screen settings view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enable Anyway" : {
|
||||
"comment" : "A button that enables the Custom OSD feature despite the warning.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enable Built-in System HUD" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
|
|
@ -7850,10 +7852,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Enable Custom OSD" : {
|
||||
"comment" : "A toggle that enables or disables the custom OSD feature.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enable customizable controls" : {
|
||||
"comment" : "A toggle that allows users to enable or disable customizable controls for media playback.",
|
||||
"isCommentAutoGenerated" : true
|
||||
|
|
@ -8067,10 +8065,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Enable HUDs" : {
|
||||
"comment" : "A toggle that enables or disables the use of HUDs.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enable media panel blur" : {
|
||||
"comment" : "A toggle switch that enables or disables the blur effect on the media panel.",
|
||||
"isCommentAutoGenerated" : true
|
||||
|
|
@ -9640,12 +9634,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Grant Accessibility permission in System Settings to control macOS HUD replacements from here." : {
|
||||
|
||||
},
|
||||
"Grant Accessibility permission in System Settings to customize the on-screen display replacements here." : {
|
||||
|
||||
},
|
||||
"Graph Visibility" : {
|
||||
"localizations" : {
|
||||
|
|
@ -9666,6 +9654,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Height: %lldpx" : {
|
||||
|
||||
},
|
||||
"Hide" : {
|
||||
"localizations" : {
|
||||
|
|
@ -10130,6 +10121,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"HUD Position" : {
|
||||
|
||||
},
|
||||
"HUD style" : {
|
||||
"localizations" : {
|
||||
|
|
@ -10232,6 +10226,7 @@
|
|||
}
|
||||
},
|
||||
"HUDs" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -10331,10 +10326,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"HUDs are disabled because Custom OSD is enabled. Disable Custom OSD in the Custom OSD tab to use HUDs." : {
|
||||
"comment" : "A message displayed below the toggle to disable HUDs, explaining that it's disabled because Custom OSD is enabled.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Icon & Progress Color" : {
|
||||
"comment" : "A picker that lets users choose the color scheme for the icon and progress bar in the OSD.",
|
||||
"isCommentAutoGenerated" : true
|
||||
|
|
@ -10727,6 +10718,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Interactive (Drag to Change)" : {
|
||||
|
||||
},
|
||||
"IPv4 · %@" : {
|
||||
"comment" : "A label displaying an IP address for a network interface. The text inside the parentheses is the IP address of the interface.",
|
||||
|
|
@ -10896,6 +10890,9 @@
|
|||
},
|
||||
"Layout Preview" : {
|
||||
|
||||
},
|
||||
"Left" : {
|
||||
|
||||
},
|
||||
"License key" : {
|
||||
"localizations" : {
|
||||
|
|
@ -10906,6 +10903,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Line Width: %lldpx" : {
|
||||
|
||||
},
|
||||
"LinkedIn" : {
|
||||
"localizations" : {
|
||||
|
|
@ -15802,10 +15802,6 @@
|
|||
},
|
||||
"Remove from shelf after dragging" : {
|
||||
|
||||
},
|
||||
"Replaces macOS system HUD with Dynamic Island displays for volume, brightness, and keyboard backlight." : {
|
||||
"comment" : "A footer description of the HUDs feature.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Request Access" : {
|
||||
"localizations" : {
|
||||
|
|
@ -15987,6 +15983,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Right" : {
|
||||
|
||||
},
|
||||
"Rotation:" : {
|
||||
"localizations" : {
|
||||
|
|
@ -16177,6 +16176,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Screen Padding: %lldpx" : {
|
||||
|
||||
},
|
||||
"Screen Recording" : {
|
||||
|
||||
|
|
@ -17407,6 +17409,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show Percentage" : {
|
||||
|
||||
},
|
||||
"Show preset list in timer tab" : {
|
||||
"comment" : "A toggle that lets users decide whether to show a list of presets in the timer tab.",
|
||||
|
|
@ -17552,6 +17557,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Size: %lldpx" : {
|
||||
|
||||
},
|
||||
"Skip buttons" : {
|
||||
"comment" : "A section header that lists the skip behaviour options.",
|
||||
|
|
@ -17660,6 +17668,9 @@
|
|||
"Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill." : {
|
||||
"comment" : "A description of how the color-coding feature works.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill. Adjust gradient behavior from Controls › Dynamic Island." : {
|
||||
|
||||
},
|
||||
"Sneak peek duration" : {
|
||||
"comment" : "A label describing the duration of the \"sneak peek\" feature in the calendar settings.",
|
||||
|
|
@ -18458,6 +18469,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Style" : {
|
||||
|
||||
},
|
||||
"Support Us" : {
|
||||
"extractionState" : "stale",
|
||||
|
|
@ -19748,6 +19762,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Use Accent Color" : {
|
||||
|
||||
},
|
||||
"Use music visualizer spectrogram" : {
|
||||
"localizations" : {
|
||||
|
|
@ -20478,6 +20495,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Width: %lldpx" : {
|
||||
|
||||
},
|
||||
"With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : {
|
||||
|
||||
|
|
|
|||
|
|
@ -98,10 +98,12 @@ extension ColorCodedProgressBar {
|
|||
|
||||
// MARK: - Palette Helpers
|
||||
|
||||
private enum ColorCodedPalette {
|
||||
private static let greenComponents: (CGFloat, CGFloat, CGFloat) = (0.2, 0.78, 0.35)
|
||||
private static let yellowComponents: (CGFloat, CGFloat, CGFloat) = (0.93, 0.75, 0.2)
|
||||
private static let redComponents: (CGFloat, CGFloat, CGFloat) = (0.95, 0.36, 0.3)
|
||||
// MARK: - Palette Helpers
|
||||
|
||||
enum ColorCodedPalette {
|
||||
static let greenComponents: (CGFloat, CGFloat, CGFloat) = (0.2, 0.78, 0.35)
|
||||
static let yellowComponents: (CGFloat, CGFloat, CGFloat) = (0.93, 0.75, 0.2)
|
||||
static let redComponents: (CGFloat, CGFloat, CGFloat) = (0.95, 0.36, 0.3)
|
||||
|
||||
static func color(for intensity: CGFloat, smooth: Bool) -> Color {
|
||||
if smooth {
|
||||
|
|
|
|||
157
DynamicIsland/components/OSD/CircularHUDView.swift
Normal file
157
DynamicIsland/components/OSD/CircularHUDView.swift
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
import CoreAudio
|
||||
|
||||
struct CircularHUDView: View {
|
||||
@Binding var type: SneakContentType
|
||||
@Binding var value: CGFloat
|
||||
@Binding var icon: String
|
||||
|
||||
@Default(.circularHUDShowValue) var showValue
|
||||
@Default(.circularHUDSize) var size
|
||||
@Default(.circularHUDStrokeWidth) var strokeWidth
|
||||
@Default(.circularHUDUseAccentColor) var useAccentColor
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@Default(.useColorCodedVolumeDisplay) var useColorCodedVolume
|
||||
@Default(.useSmoothColorGradient) var useSmoothGradient
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background circle
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
Circle()
|
||||
.stroke(.white.opacity(0.1), lineWidth: 1)
|
||||
}
|
||||
|
||||
// Progress ring background
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: strokeWidth)
|
||||
.padding(strokeWidth / 2)
|
||||
|
||||
// Progress ring
|
||||
Circle()
|
||||
.trim(from: 0, to: value)
|
||||
.stroke(
|
||||
strokeStyle,
|
||||
style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.padding(strokeWidth / 2)
|
||||
.animation(.interactiveSpring(response: 0.35, dampingFraction: 0.8, blendDuration: 0), value: value)
|
||||
|
||||
// Content
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: symbolName)
|
||||
.font(.system(size: size * 0.35, weight: .semibold))
|
||||
.foregroundStyle(useAccentColor ? (value > 0.9 ? .white : .primary) : .white)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
|
||||
if showValue {
|
||||
Text("\(Int(value * 100))")
|
||||
.font(.system(size: size * 0.18, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.shadow(color: .black.opacity(0.2), radius: 15, x: 0, y: 5)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var strokeStyle: AnyShapeStyle {
|
||||
if type == .volume && useColorCodedVolume {
|
||||
return ColorCodedProgressBar.shapeStyle(for: value, mode: .volume, smoothGradient: useSmoothGradient)
|
||||
}
|
||||
|
||||
return AnyShapeStyle(useAccentColor ? Color.accentColor : Color.white)
|
||||
}
|
||||
|
||||
private var symbolName: String {
|
||||
if !icon.isEmpty { return icon }
|
||||
|
||||
switch type {
|
||||
case .volume:
|
||||
// Check if headphones/AirPods are connected
|
||||
let deviceInfo = getAudioDeviceInfo()
|
||||
|
||||
if deviceInfo.isAirPods {
|
||||
// Use AirPods icon when AirPods are connected
|
||||
if value < 0.01 { return "airpods" }
|
||||
else { return "airpods" }
|
||||
} else if deviceInfo.isHeadphones {
|
||||
// Use headphone icons when other headphones are connected
|
||||
if value < 0.01 { return "headphones.slash" }
|
||||
else { return "headphones" }
|
||||
} else {
|
||||
// Use speaker icons for built-in speakers
|
||||
if value < 0.01 { return "speaker.slash.fill" }
|
||||
else if value < 0.33 { return "speaker.wave.1.fill" }
|
||||
else if value < 0.66 { return "speaker.wave.2.fill" }
|
||||
else { return "speaker.wave.3.fill" }
|
||||
}
|
||||
case .brightness:
|
||||
return "sun.max.fill"
|
||||
case .backlight:
|
||||
return "keyboard"
|
||||
default:
|
||||
return "questionmark"
|
||||
}
|
||||
}
|
||||
|
||||
private struct AudioDeviceInfo {
|
||||
let isAirPods: Bool
|
||||
let isHeadphones: Bool
|
||||
}
|
||||
|
||||
private func getAudioDeviceInfo() -> AudioDeviceInfo {
|
||||
#if os(macOS)
|
||||
// Get default output device
|
||||
var deviceID = AudioDeviceID()
|
||||
var size = UInt32(MemoryLayout.size(ofValue: deviceID))
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID) == noErr else {
|
||||
return AudioDeviceInfo(isAirPods: false, isHeadphones: false)
|
||||
}
|
||||
|
||||
// Get device name
|
||||
var deviceName: CFString = "" as CFString
|
||||
var nameSize = UInt32(MemoryLayout<CFString>.size)
|
||||
var nameAddress = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyDeviceNameCFString,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
guard AudioObjectGetPropertyData(deviceID, &nameAddress, 0, nil, &nameSize, &deviceName) == noErr else {
|
||||
return AudioDeviceInfo(isAirPods: false, isHeadphones: false)
|
||||
}
|
||||
|
||||
let name = (deviceName as String).lowercased()
|
||||
|
||||
// Check for AirPods specifically
|
||||
let isAirPods = name.contains("airpod")
|
||||
|
||||
// Check for other headphones
|
||||
let isHeadphones = name.contains("headphone") ||
|
||||
name.contains("ear") ||
|
||||
name.contains("buds") ||
|
||||
name.contains("beats")
|
||||
|
||||
return AudioDeviceInfo(isAirPods: isAirPods, isHeadphones: !isAirPods && isHeadphones)
|
||||
#else
|
||||
return AudioDeviceInfo(isAirPods: false, isHeadphones: false)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -21,22 +21,30 @@ struct CustomOSDView: View {
|
|||
var body: some View {
|
||||
VStack(spacing: spacing) {
|
||||
// Icon
|
||||
Image(systemName: symbolName)
|
||||
.font(.system(size: iconSize, weight: .regular))
|
||||
.foregroundStyle(iconColor)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
ZStack {
|
||||
Image(systemName: symbolName)
|
||||
.font(.system(size: iconSize, weight: .regular))
|
||||
.foregroundStyle(iconColor)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
.frame(width: 80, height: 60, alignment: .center) // Fixed container for icon
|
||||
|
||||
// Progress/Value Indicator
|
||||
progressView
|
||||
.frame(width: 140)
|
||||
.padding(.top, 4) // Lower the bar slightly
|
||||
}
|
||||
.frame(width: 200, height: 200)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(.black.opacity(0.1)) // Subtle backing for shadow source
|
||||
.shadow(color: Color.black.opacity(0.25), radius: 24, x: 0, y: 8)
|
||||
)
|
||||
.background(
|
||||
backgroundView
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.shadow(color: Color.black.opacity(0.25), radius: 24, x: 0, y: 8)
|
||||
}
|
||||
|
||||
// MARK: - Symbol Logic
|
||||
|
|
|
|||
229
DynamicIsland/components/OSD/VerticalHUDView.swift
Normal file
229
DynamicIsland/components/OSD/VerticalHUDView.swift
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
import Combine
|
||||
|
||||
struct BumpEvent: Equatable {
|
||||
let direction: Int
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
class VerticalHUDState: ObservableObject {
|
||||
@Published var type: SneakContentType = .volume
|
||||
@Published var value: CGFloat = 0
|
||||
@Published var icon: String = ""
|
||||
@Published var bumpEvent: BumpEvent?
|
||||
|
||||
init(type: SneakContentType = .volume, value: CGFloat = 0, icon: String = "") {
|
||||
self.type = type
|
||||
self.value = value
|
||||
self.icon = icon
|
||||
}
|
||||
}
|
||||
|
||||
struct VerticalHUDView: View {
|
||||
@ObservedObject var state: VerticalHUDState
|
||||
|
||||
@Default(.verticalHUDShowValue) var showValue
|
||||
@Default(.verticalHUDHeight) var hudHeight
|
||||
@Default(.verticalHUDWidth) var hudWidth
|
||||
@Default(.verticalHUDUseAccentColor) var useAccentColor
|
||||
@Default(.verticalHUDInteractive) var isInteractive
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
// Interaction State
|
||||
@State private var isDragging: Bool = false
|
||||
@State private var isHovering: Bool = false
|
||||
@State private var stretchAmount: CGFloat = 0
|
||||
@State private var stretchOffset: CGFloat = 0
|
||||
|
||||
@Default(.useColorCodedVolumeDisplay) var useColorCodedVolume
|
||||
@Default(.useSmoothColorGradient) var useSmoothGradient
|
||||
|
||||
// Constants
|
||||
private let maxStretch: CGFloat = 30 // Reduced max stretch for tighter feel
|
||||
|
||||
var body: some View {
|
||||
// Full Window Container
|
||||
ZStack {
|
||||
// The Actual HUD Pill
|
||||
ZStack {
|
||||
// Background
|
||||
Capsule()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(.white.opacity(0.1), lineWidth: 1)
|
||||
}
|
||||
|
||||
// Fill Bar
|
||||
GeometryReader { geo in
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
Rectangle()
|
||||
.fill(fillStyle)
|
||||
.frame(height: geo.size.height * state.value)
|
||||
// Super Smooth Fill
|
||||
.animation(.interactiveSpring(response: 0.55, dampingFraction: 0.85, blendDuration: 0.3), value: state.value)
|
||||
}
|
||||
}
|
||||
.clipShape(Capsule())
|
||||
|
||||
// Icon & Text overlay
|
||||
VStack {
|
||||
if showValue {
|
||||
Text("\(Int(state.value * 100))%")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(state.value > 0.85 ? (useAccentColor ? .white : .black) : .secondary)
|
||||
.padding(.top, 8 + (stretchOffset < 0 ? abs(stretchOffset/4) : 0))
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: symbolName)
|
||||
.font(.system(size: hudWidth * 0.4, weight: .semibold))
|
||||
.foregroundStyle(state.value > 0.15 ? (useAccentColor ? .white : .black) : .secondary)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.padding(.bottom, hudWidth * 0.35)
|
||||
.offset(y: stretchOffset > 0 ? -stretchOffset/4 : 0)
|
||||
}
|
||||
// Always visible
|
||||
}
|
||||
.frame(width: currentWidth, height: hudHeight + stretchAmount)
|
||||
.offset(y: stretchOffset)
|
||||
.shadow(color: .black.opacity(0.15), radius: 10, x: 0, y: 4)
|
||||
.onHover { hovering in
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
||||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
// Listen for Elastic Bump (Keyboard) - Debounced via Task cancellation
|
||||
.task(id: state.bumpEvent) {
|
||||
guard let event = state.bumpEvent else { return }
|
||||
|
||||
let direction = CGFloat(event.direction)
|
||||
let stretch: CGFloat = 15 // Constant stretch while holding
|
||||
|
||||
// Animate In (Maintain Stretch)
|
||||
// If a new event comes in, this task is cancelled and restarted, keeping it stretched.
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.6)) {
|
||||
stretchAmount = stretch
|
||||
stretchOffset = direction * (-stretch / 2)
|
||||
}
|
||||
|
||||
// Wait for key release (Short debounce window)
|
||||
try? await Task.sleep(nanoseconds: 150 * 1_000_000) // 150ms
|
||||
|
||||
// Animate Back (only if not cancelled)
|
||||
withAnimation(.spring(response: 0.55, dampingFraction: 0.65)) {
|
||||
stretchAmount = 0
|
||||
stretchOffset = 0
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { gesture in
|
||||
guard isInteractive else { return }
|
||||
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) {
|
||||
isDragging = true
|
||||
}
|
||||
|
||||
let currentY = gesture.startLocation.y + gesture.translation.height
|
||||
let percentage = 1.0 - (currentY / hudHeight)
|
||||
|
||||
// Elastic Rubber Banding
|
||||
if percentage > 1.0 {
|
||||
let excess = (percentage - 1.0) * hudHeight
|
||||
let stretch = min(sqrt(abs(excess)) * 1.5, maxStretch) // Reduced multiplier (was 3.0)
|
||||
state.value = 1.0
|
||||
|
||||
withAnimation(.interactiveSpring(response: 0.4, dampingFraction: 0.65)) { // Tighter damping
|
||||
stretchAmount = stretch
|
||||
stretchOffset = -stretch / 2
|
||||
}
|
||||
} else if percentage < 0.0 {
|
||||
let excess = abs(percentage) * hudHeight
|
||||
let stretch = min(sqrt(abs(excess)) * 1.5, maxStretch) // Reduced multiplier
|
||||
state.value = 0.0
|
||||
|
||||
withAnimation(.interactiveSpring(response: 0.4, dampingFraction: 0.65)) {
|
||||
stretchAmount = stretch
|
||||
stretchOffset = stretch / 2
|
||||
}
|
||||
} else {
|
||||
state.value = percentage
|
||||
withAnimation(.interactiveSpring(response: 0.45, dampingFraction: 0.8)) {
|
||||
stretchAmount = 0
|
||||
stretchOffset = 0
|
||||
}
|
||||
}
|
||||
updateSystemLevel(state.value)
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(.spring(response: 0.55, dampingFraction: 0.7)) { // Tighter release
|
||||
isDragging = false
|
||||
stretchAmount = 0
|
||||
stretchOffset = 0
|
||||
if state.value > 1.0 { state.value = 1.0 }
|
||||
if state.value < 0.0 { state.value = 0.0 }
|
||||
}
|
||||
updateSystemLevel(state.value)
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
}
|
||||
|
||||
private var currentWidth: CGFloat {
|
||||
// Only shrink slightly when dragging, otherwise full size
|
||||
return isDragging ? hudWidth * 0.9 : hudWidth
|
||||
}
|
||||
|
||||
private func updateSystemLevel(_ level: CGFloat) {
|
||||
if state.type == .volume {
|
||||
SystemVolumeController.shared.setVolume(Float(level))
|
||||
} else if state.type == .brightness {
|
||||
SystemBrightnessController.shared.setBrightness(Float(level))
|
||||
}
|
||||
}
|
||||
|
||||
private var symbolName: String {
|
||||
if !state.icon.isEmpty { return state.icon }
|
||||
|
||||
switch state.type {
|
||||
case .volume:
|
||||
if state.value < 0.01 { return "speaker.slash.fill" }
|
||||
else if state.value < 0.33 { return "speaker.wave.1.fill" }
|
||||
else if state.value < 0.66 { return "speaker.wave.2.fill" }
|
||||
else { return "speaker.wave.3.fill" }
|
||||
case .brightness:
|
||||
return "sun.max.fill"
|
||||
case .backlight:
|
||||
return state.value >= 0.5 ? "light.max" : "light.min"
|
||||
default:
|
||||
return "questionmark"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Computing Properties
|
||||
|
||||
private var fillStyle: AnyShapeStyle {
|
||||
if state.type == .volume && useColorCodedVolume {
|
||||
let intensity = state.value
|
||||
if useSmoothGradient {
|
||||
let endColor = ColorCodedPalette.color(for: intensity, smooth: true)
|
||||
let startColor = ColorCodedPalette.color(for: max(intensity - 0.15, 0), smooth: true)
|
||||
return AnyShapeStyle(LinearGradient(colors: [startColor, endColor], startPoint: .bottom, endPoint: .top))
|
||||
} else {
|
||||
return AnyShapeStyle(ColorCodedPalette.color(for: intensity, smooth: false))
|
||||
}
|
||||
}
|
||||
|
||||
return AnyShapeStyle(useAccentColor ? Color.accentColor : Color.white)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -23,10 +23,10 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
|||
case appearance
|
||||
case lockScreen
|
||||
case media
|
||||
case devices
|
||||
case timer
|
||||
case calendar
|
||||
case hud
|
||||
case osd
|
||||
case hudAndOSD
|
||||
case battery
|
||||
case stats
|
||||
case clipboard
|
||||
|
|
@ -46,10 +46,10 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
|||
case .appearance: return "Appearance"
|
||||
case .lockScreen: return "Lock Screen"
|
||||
case .media: return "Media"
|
||||
case .devices: return "Devices"
|
||||
case .timer: return "Timer"
|
||||
case .calendar: return "Calendar"
|
||||
case .hud: return "HUDs"
|
||||
case .osd: return "Custom OSD"
|
||||
case .hudAndOSD: return "Controls"
|
||||
case .battery: return "Battery"
|
||||
case .stats: return "Stats"
|
||||
case .clipboard: return "Clipboard"
|
||||
|
|
@ -69,10 +69,10 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
|||
case .appearance: return "paintpalette"
|
||||
case .lockScreen: return "lock.laptopcomputer"
|
||||
case .media: return "play.laptopcomputer"
|
||||
case .devices: return "headphones"
|
||||
case .timer: return "timer"
|
||||
case .calendar: return "calendar"
|
||||
case .hud: return "dial.medium.fill"
|
||||
case .osd: return "square.fill.on.square.fill"
|
||||
case .hudAndOSD: return "dial.medium.fill"
|
||||
case .battery: return "battery.100.bolt"
|
||||
case .stats: return "chart.xyaxis.line"
|
||||
case .clipboard: return "clipboard"
|
||||
|
|
@ -92,10 +92,10 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
|||
case .appearance: return .purple
|
||||
case .lockScreen: return .orange
|
||||
case .media: return .green
|
||||
case .devices: return Color(red: 0.1, green: 0.11, blue: 0.12)
|
||||
case .timer: return .red
|
||||
case .calendar: return .cyan
|
||||
case .hud: return .indigo
|
||||
case .osd: return .teal
|
||||
case .hudAndOSD: return .indigo
|
||||
case .battery: return .yellow
|
||||
case .stats: return .teal
|
||||
case .clipboard: return .mint
|
||||
|
|
@ -260,18 +260,7 @@ struct SettingsView: View {
|
|||
HStack(spacing: 10) {
|
||||
sidebarIcon(for: tab)
|
||||
Text(tab.title)
|
||||
if tab == .osd {
|
||||
Spacer()
|
||||
Text("ALPHA")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.orange)
|
||||
)
|
||||
} else if tab == .downloads {
|
||||
if tab == .downloads || tab == .hudAndOSD {
|
||||
Spacer()
|
||||
Text("BETA")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
|
|
@ -392,10 +381,10 @@ struct SettingsView: View {
|
|||
.appearance,
|
||||
.lockScreen,
|
||||
.media,
|
||||
.devices,
|
||||
.timer,
|
||||
.calendar,
|
||||
.hud,
|
||||
.osd,
|
||||
.hudAndOSD,
|
||||
.battery,
|
||||
.stats,
|
||||
.clipboard,
|
||||
|
|
@ -607,26 +596,26 @@ struct SettingsView: View {
|
|||
SettingsSearchEntry(tab: .battery, title: "Play low battery alert sound", keywords: ["low battery", "alert", "sound"], highlightID: SettingsTab.battery.highlightID(for: "Play low battery alert sound")),
|
||||
|
||||
// HUDs
|
||||
SettingsSearchEntry(tab: .hud, title: "Show Bluetooth device connections", keywords: ["bluetooth", "hud"], highlightID: SettingsTab.hud.highlightID(for: "Show Bluetooth device connections")),
|
||||
SettingsSearchEntry(tab: .hud, title: "Use circular battery indicator", keywords: ["battery", "circular"], highlightID: SettingsTab.hud.highlightID(for: "Use circular battery indicator")),
|
||||
SettingsSearchEntry(tab: .hud, title: "Show battery percentage text in HUD", keywords: ["battery text"], highlightID: SettingsTab.hud.highlightID(for: "Show battery percentage text in HUD")),
|
||||
SettingsSearchEntry(tab: .hud, title: "Scroll device name in HUD", keywords: ["marquee", "device name"], highlightID: SettingsTab.hud.highlightID(for: "Scroll device name in HUD")),
|
||||
SettingsSearchEntry(tab: .hud, title: "Color-coded battery display", keywords: ["color", "battery"], highlightID: SettingsTab.hud.highlightID(for: "Color-coded battery display")),
|
||||
SettingsSearchEntry(tab: .hud, title: "Color-coded volume display", keywords: ["volume", "color"], highlightID: SettingsTab.hud.highlightID(for: "Color-coded volume display")),
|
||||
SettingsSearchEntry(tab: .hud, title: "Smooth color transitions", keywords: ["gradient", "smooth"], highlightID: SettingsTab.hud.highlightID(for: "Smooth color transitions")),
|
||||
SettingsSearchEntry(tab: .hud, title: "Show percentages beside progress bars", keywords: ["percentages", "progress"], highlightID: SettingsTab.hud.highlightID(for: "Show percentages beside progress bars")),
|
||||
SettingsSearchEntry(tab: .hud, title: "HUD style", keywords: ["inline", "compact"], highlightID: SettingsTab.hud.highlightID(for: "HUD style")),
|
||||
SettingsSearchEntry(tab: .hud, title: "Progressbar style", keywords: ["progress", "style"], highlightID: SettingsTab.hud.highlightID(for: "Progressbar style")),
|
||||
SettingsSearchEntry(tab: .hud, title: "Enable glowing effect", keywords: ["glow", "indicator"], highlightID: SettingsTab.hud.highlightID(for: "Enable glowing effect")),
|
||||
SettingsSearchEntry(tab: .hud, title: "Use accent color", keywords: ["accent", "color"], highlightID: SettingsTab.hud.highlightID(for: "Use accent color")),
|
||||
SettingsSearchEntry(tab: .devices, title: "Show Bluetooth device connections", keywords: ["bluetooth", "hud"], highlightID: SettingsTab.devices.highlightID(for: "Show Bluetooth device connections")),
|
||||
SettingsSearchEntry(tab: .devices, title: "Use circular battery indicator", keywords: ["battery", "circular"], highlightID: SettingsTab.devices.highlightID(for: "Use circular battery indicator")),
|
||||
SettingsSearchEntry(tab: .devices, title: "Show battery percentage text in HUD", keywords: ["battery text"], highlightID: SettingsTab.devices.highlightID(for: "Show battery percentage text in HUD")),
|
||||
SettingsSearchEntry(tab: .devices, title: "Scroll device name in HUD", keywords: ["marquee", "device name"], highlightID: SettingsTab.devices.highlightID(for: "Scroll device name in HUD")),
|
||||
SettingsSearchEntry(tab: .devices, title: "Color-coded battery display", keywords: ["color", "battery"], highlightID: SettingsTab.devices.highlightID(for: "Color-coded battery display")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Color-coded volume display", keywords: ["volume", "color"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Color-coded volume display")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Smooth color transitions", keywords: ["gradient", "smooth"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Smooth color transitions")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Show percentages beside progress bars", keywords: ["percentages", "progress"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Show percentages beside progress bars")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "HUD style", keywords: ["inline", "compact"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "HUD style")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Progressbar style", keywords: ["progress", "style"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Progressbar style")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Enable glowing effect", keywords: ["glow", "indicator"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Enable glowing effect")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Use accent color", keywords: ["accent", "color"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Use accent color")),
|
||||
|
||||
// Custom OSD
|
||||
SettingsSearchEntry(tab: .osd, title: "Enable Custom OSD", keywords: ["osd", "on-screen display", "custom osd"], highlightID: SettingsTab.osd.highlightID(for: "Enable Custom OSD")),
|
||||
SettingsSearchEntry(tab: .osd, title: "Volume OSD", keywords: ["volume", "osd"], highlightID: SettingsTab.osd.highlightID(for: "Volume OSD")),
|
||||
SettingsSearchEntry(tab: .osd, title: "Brightness OSD", keywords: ["brightness", "osd"], highlightID: SettingsTab.osd.highlightID(for: "Brightness OSD")),
|
||||
SettingsSearchEntry(tab: .osd, title: "Keyboard Backlight OSD", keywords: ["keyboard", "backlight", "osd"], highlightID: SettingsTab.osd.highlightID(for: "Keyboard Backlight OSD")),
|
||||
SettingsSearchEntry(tab: .osd, title: "Material", keywords: ["material", "frosted", "liquid", "glass", "solid", "osd"], highlightID: SettingsTab.osd.highlightID(for: "Material")),
|
||||
SettingsSearchEntry(tab: .osd, title: "Icon & Progress Color", keywords: ["color", "icon", "white", "black", "gray", "osd"], highlightID: SettingsTab.osd.highlightID(for: "Icon & Progress Color")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Enable Custom OSD", keywords: ["osd", "on-screen display", "custom osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Enable Custom OSD")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Volume OSD", keywords: ["volume", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Volume OSD")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Brightness OSD", keywords: ["brightness", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Brightness OSD")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Keyboard Backlight OSD", keywords: ["keyboard", "backlight", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Keyboard Backlight OSD")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Material", keywords: ["material", "frosted", "liquid", "glass", "solid", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Material")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Icon & Progress Color", keywords: ["color", "icon", "white", "black", "gray", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Icon & Progress Color")),
|
||||
|
||||
// Media
|
||||
SettingsSearchEntry(tab: .media, title: "Music Source", keywords: ["media source", "controller"], highlightID: SettingsTab.media.highlightID(for: "Music Source")),
|
||||
|
|
@ -763,6 +752,10 @@ struct SettingsView: View {
|
|||
SettingsForm(tab: .media) {
|
||||
Media()
|
||||
}
|
||||
case .devices:
|
||||
SettingsForm(tab: .devices) {
|
||||
DevicesSettingsView()
|
||||
}
|
||||
case .timer:
|
||||
SettingsForm(tab: .timer) {
|
||||
TimerSettings()
|
||||
|
|
@ -771,31 +764,9 @@ struct SettingsView: View {
|
|||
SettingsForm(tab: .calendar) {
|
||||
CalendarSettings()
|
||||
}
|
||||
case .hud:
|
||||
SettingsForm(tab: .hud) {
|
||||
HUD()
|
||||
}
|
||||
case .osd:
|
||||
SettingsForm(tab: .osd) {
|
||||
if #available(macOS 15.0, *) {
|
||||
CustomOSDSettings()
|
||||
} else {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
Text("macOS 15 or later required")
|
||||
.font(.headline)
|
||||
|
||||
Text("Custom OSD feature requires macOS 15 or later.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
case .hudAndOSD:
|
||||
SettingsForm(tab: .hudAndOSD) {
|
||||
HUDAndOSDSettingsView()
|
||||
}
|
||||
case .battery:
|
||||
SettingsForm(tab: .battery) {
|
||||
|
|
@ -1212,75 +1183,484 @@ struct Downloads: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct HUD: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@Default(.inlineHUD) var inlineHUD
|
||||
@Default(.progressBarStyle) var progressBarStyle
|
||||
@Default(.enableSystemHUD) var enableSystemHUD
|
||||
@Default(.enableVolumeHUD) var enableVolumeHUD
|
||||
@Default(.enableBrightnessHUD) var enableBrightnessHUD
|
||||
@Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD
|
||||
@Default(.systemHUDSensitivity) var systemHUDSensitivity
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared
|
||||
|
||||
private func highlightID(_ title: String) -> String {
|
||||
SettingsTab.hud.highlightID(for: title)
|
||||
}
|
||||
|
||||
private var hasAccessibilityPermission: Bool {
|
||||
accessibilityPermission.isAuthorized
|
||||
final class HUDPreviewViewModel: ObservableObject {
|
||||
@Published var level: Float = 0
|
||||
@Published var iconName: String = "speaker.wave.3.fill"
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init() {
|
||||
setup()
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
// Ensure controllers are active
|
||||
SystemVolumeController.shared.start()
|
||||
SystemBrightnessController.shared.start()
|
||||
SystemKeyboardBacklightController.shared.start()
|
||||
|
||||
// Initial state from volume
|
||||
let vol = SystemVolumeController.shared.currentVolume
|
||||
self.level = vol
|
||||
if vol <= 0.01 { self.iconName = "speaker.slash.fill" }
|
||||
else if vol < 0.33 { self.iconName = "speaker.wave.1.fill" }
|
||||
else if vol < 0.66 { self.iconName = "speaker.wave.2.fill" }
|
||||
else { self.iconName = "speaker.wave.3.fill" }
|
||||
|
||||
// Listeners
|
||||
NotificationCenter.default.publisher(for: .systemVolumeDidChange)
|
||||
.compactMap { $0.userInfo }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] info in
|
||||
guard let self else { return }
|
||||
if let vol = info["value"] as? Float {
|
||||
self.level = vol
|
||||
if vol <= 0.01 { self.iconName = "speaker.slash.fill" }
|
||||
else if vol < 0.33 { self.iconName = "speaker.wave.1.fill" }
|
||||
else if vol < 0.66 { self.iconName = "speaker.wave.2.fill" }
|
||||
else { self.iconName = "speaker.wave.3.fill" }
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .systemBrightnessDidChange)
|
||||
.compactMap { $0.userInfo }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] info in
|
||||
guard let self else { return }
|
||||
if let val = info["value"] as? Float {
|
||||
self.level = val
|
||||
self.iconName = "sun.max.fill"
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .keyboardBacklightDidChange)
|
||||
.compactMap { $0.userInfo }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] info in
|
||||
guard let self else { return }
|
||||
if let val = info["value"] as? Float {
|
||||
self.level = val
|
||||
self.iconName = val > 0.5 ? "light.max" : "light.min"
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
private struct HUDAndOSDSettingsView: View {
|
||||
@State private var selectedTab: Tab = {
|
||||
if Defaults[.enableSystemHUD] { return .hud }
|
||||
if Defaults[.enableCustomOSD] { return .osd }
|
||||
if Defaults[.enableVerticalHUD] { return .vertical }
|
||||
if Defaults[.enableCircularHUD] { return .circular }
|
||||
return .hud
|
||||
}()
|
||||
@Default(.enableSystemHUD) var enableSystemHUD
|
||||
@Default(.enableCustomOSD) var enableCustomOSD
|
||||
@Default(.enableVerticalHUD) var enableVerticalHUD
|
||||
@Default(.enableCircularHUD) var enableCircularHUD
|
||||
@Default(.verticalHUDPosition) var verticalHUDPosition
|
||||
|
||||
// Vertical HUD Props
|
||||
@Default(.verticalHUDShowValue) var verticalHUDShowValue
|
||||
@Default(.verticalHUDInteractive) var verticalHUDInteractive
|
||||
@Default(.verticalHUDHeight) var verticalHUDHeight
|
||||
@Default(.verticalHUDWidth) var verticalHUDWidth
|
||||
@Default(.verticalHUDPadding) var verticalHUDPadding
|
||||
@Default(.verticalHUDUseAccentColor) var verticalHUDUseAccentColor
|
||||
|
||||
// Circular HUD Props
|
||||
@Default(.circularHUDShowValue) var circularHUDShowValue
|
||||
@Default(.circularHUDSize) var circularHUDSize
|
||||
@Default(.circularHUDStrokeWidth) var circularHUDStrokeWidth
|
||||
@Default(.circularHUDUseAccentColor) var circularHUDUseAccentColor
|
||||
@StateObject private var previewModel = HUDPreviewViewModel()
|
||||
@ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared
|
||||
|
||||
private enum Tab: String, CaseIterable, Identifiable {
|
||||
case hud = "Dynamic Island HUD"
|
||||
case osd = "Custom OSD"
|
||||
case vertical = "Vertical Bar"
|
||||
case circular = "Circular"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
private var paneBackgroundColor: Color {
|
||||
Color(nsColor: .controlBackgroundColor)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
HStack(spacing: 16) {
|
||||
HUDSelectionCard(
|
||||
title: "Dynamic Island",
|
||||
isSelected: selectedTab == .hud,
|
||||
action: {
|
||||
selectedTab = .hud
|
||||
enableSystemHUD = true
|
||||
enableCustomOSD = false
|
||||
enableVerticalHUD = false
|
||||
enableCircularHUD = false
|
||||
}
|
||||
) {
|
||||
VStack {
|
||||
Capsule()
|
||||
.fill(Color.black)
|
||||
.frame(width: 64, height: 20)
|
||||
.shadow(color: .black.opacity(0.3), radius: 4, y: 2)
|
||||
.overlay {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: previewModel.iconName)
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 12)
|
||||
|
||||
GeometryReader { geo in
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.2))
|
||||
.overlay(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color.white)
|
||||
.frame(width: geo.size.width * CGFloat(previewModel.level))
|
||||
.animation(.spring(response: 0.3), value: previewModel.level)
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HUDSelectionCard(
|
||||
title: "Custom OSD",
|
||||
isSelected: selectedTab == .osd,
|
||||
action: {
|
||||
selectedTab = .osd
|
||||
enableCustomOSD = true
|
||||
enableSystemHUD = false
|
||||
enableVerticalHUD = false
|
||||
enableCircularHUD = false
|
||||
}
|
||||
) {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(.white.opacity(0.1), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.1), radius: 3, y: 1)
|
||||
.overlay {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: previewModel.iconName)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(.secondary)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
|
||||
GeometryReader { geo in
|
||||
Capsule()
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
.overlay(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color.primary)
|
||||
.frame(width: geo.size.width * CGFloat(previewModel.level))
|
||||
.animation(.spring(response: 0.3), value: previewModel.level)
|
||||
}
|
||||
}
|
||||
.frame(width: 36, height: 4)
|
||||
}
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
|
||||
HUDSelectionCard(
|
||||
title: "Vertical Bar",
|
||||
isSelected: selectedTab == .vertical,
|
||||
action: {
|
||||
selectedTab = .vertical
|
||||
enableVerticalHUD = true
|
||||
enableSystemHUD = false
|
||||
enableCustomOSD = false
|
||||
enableCircularHUD = false
|
||||
}
|
||||
) {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(.white.opacity(0.1), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.1), radius: 3, y: 1)
|
||||
.overlay {
|
||||
VStack {
|
||||
GeometryReader { geo in
|
||||
VStack {
|
||||
Spacer()
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(Color.white)
|
||||
.frame(height: max(0, geo.size.height * CGFloat(previewModel.level)))
|
||||
.animation(.spring(response: 0.3), value: previewModel.level)
|
||||
}
|
||||
}
|
||||
.mask(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
.padding(.bottom, 2)
|
||||
|
||||
Image(systemName: previewModel.iconName)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(previewModel.level > 0.15 ? .black : .secondary)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
.frame(width: 22, height: 54)
|
||||
}
|
||||
|
||||
HUDSelectionCard(
|
||||
title: "Circular",
|
||||
isSelected: selectedTab == .circular,
|
||||
action: {
|
||||
selectedTab = .circular
|
||||
enableCircularHUD = true
|
||||
enableSystemHUD = false
|
||||
enableCustomOSD = false
|
||||
enableVerticalHUD = false
|
||||
}
|
||||
) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.secondary.opacity(0.2), lineWidth: 4)
|
||||
Circle()
|
||||
.trim(from: 0, to: CGFloat(previewModel.level))
|
||||
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 4, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.spring(response: 0.3), value: previewModel.level)
|
||||
Image(systemName: previewModel.iconName)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(.primary)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
switch selectedTab {
|
||||
case .hud:
|
||||
HUD()
|
||||
case .osd:
|
||||
if #available(macOS 15.0, *) {
|
||||
CustomOSDSettings()
|
||||
} else {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
Text("macOS 15 or later required")
|
||||
.font(.headline)
|
||||
|
||||
Text("Custom OSD feature requires macOS 15 or later.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
case .vertical:
|
||||
Form {
|
||||
if !accessibilityPermission.isAuthorized {
|
||||
Section {
|
||||
SettingsPermissionCallout(
|
||||
message: "Accessibility permission is needed to intercept system controls for the Vertical HUD.",
|
||||
requestAction: {
|
||||
accessibilityPermission.requestAuthorizationPrompt()
|
||||
},
|
||||
openSettingsAction: {
|
||||
accessibilityPermission.openSystemSettings()
|
||||
}
|
||||
)
|
||||
} header: {
|
||||
Text("Accessibility")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Show Percentage", isOn: $verticalHUDShowValue)
|
||||
Toggle("Use Accent Color", isOn: $verticalHUDUseAccentColor)
|
||||
Toggle("Interactive (Drag to Change)", isOn: $verticalHUDInteractive)
|
||||
Defaults.Toggle("Color-coded Volume", key: .useColorCodedVolumeDisplay)
|
||||
if Defaults[.useColorCodedVolumeDisplay] {
|
||||
Defaults.Toggle("Smooth color transitions", key: .useSmoothColorGradient)
|
||||
}
|
||||
} header: {
|
||||
Text("Behavior & Style")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("HUD Position", selection: $verticalHUDPosition) {
|
||||
Text("Left").tag("left")
|
||||
Text("Right").tag("right")
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("Screen Padding: \(Int(verticalHUDPadding))px")
|
||||
Slider(value: $verticalHUDPadding, in: 0...100, step: 4)
|
||||
}
|
||||
} header: {
|
||||
Text("Position")
|
||||
} footer: {
|
||||
Text("Choose directly on which side of the screen the vertical bar appears.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Section {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Width: \(Int(verticalHUDWidth))px")
|
||||
Slider(value: $verticalHUDWidth, in: 24...80, step: 2)
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
Text("Height: \(Int(verticalHUDHeight))px")
|
||||
Slider(value: $verticalHUDHeight, in: 100...500, step: 10)
|
||||
}
|
||||
Button("Reset to Default") {
|
||||
verticalHUDWidth = 36
|
||||
verticalHUDHeight = 160
|
||||
verticalHUDPadding = 24
|
||||
}
|
||||
} header: {
|
||||
Text("Dimensions")
|
||||
}
|
||||
}
|
||||
|
||||
case .circular:
|
||||
Form {
|
||||
if !accessibilityPermission.isAuthorized {
|
||||
Section {
|
||||
SettingsPermissionCallout(
|
||||
message: "Accessibility permission is needed to intercept system controls for the Circular HUD.",
|
||||
requestAction: {
|
||||
accessibilityPermission.requestAuthorizationPrompt()
|
||||
},
|
||||
openSettingsAction: {
|
||||
accessibilityPermission.openSystemSettings()
|
||||
}
|
||||
)
|
||||
} header: {
|
||||
Text("Accessibility")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Show Percentage", isOn: $circularHUDShowValue)
|
||||
Toggle("Use Accent Color", isOn: $circularHUDUseAccentColor)
|
||||
Defaults.Toggle("Color-coded Volume", key: .useColorCodedVolumeDisplay)
|
||||
if Defaults[.useColorCodedVolumeDisplay] {
|
||||
Defaults.Toggle("Smooth color transitions", key: .useSmoothColorGradient)
|
||||
}
|
||||
} header: {
|
||||
Text("Style")
|
||||
}
|
||||
|
||||
Section {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Size: \(Int(circularHUDSize))px")
|
||||
Slider(value: $circularHUDSize, in: 40...200, step: 5)
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
Text("Line Width: \(Int(circularHUDStrokeWidth))px")
|
||||
Slider(value: $circularHUDStrokeWidth, in: 2...16, step: 1)
|
||||
}
|
||||
Button("Reset to Default") {
|
||||
circularHUDSize = 65
|
||||
circularHUDStrokeWidth = 4
|
||||
}
|
||||
} header: {
|
||||
Text("Dimensions")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.background(paneBackgroundColor)
|
||||
.navigationTitle("Controls")
|
||||
}
|
||||
}
|
||||
|
||||
private struct HUDSelectionCard<Preview: View>: View {
|
||||
let title: String
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
@ViewBuilder let preview: Preview
|
||||
|
||||
@State private var isHovering = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
isSelected ? Color.accentColor : Color.clear,
|
||||
lineWidth: 2.5
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.05), radius: 2, y: 1)
|
||||
|
||||
preview
|
||||
}
|
||||
.frame(width: 110, height: 80)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(isSelected ? .primary : .secondary)
|
||||
|
||||
if isSelected {
|
||||
Circle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 4, height: 4)
|
||||
} else {
|
||||
Color.clear
|
||||
.frame(width: 4, height: 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { hovering in
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DevicesSettingsView: View {
|
||||
@Default(.progressBarStyle) var progressBarStyle
|
||||
|
||||
private func highlightID(_ title: String) -> String {
|
||||
SettingsTab.devices.highlightID(for: title)
|
||||
}
|
||||
|
||||
private var colorCodingDisabled: Bool {
|
||||
progressBarStyle == .segmented
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if !hasAccessibilityPermission {
|
||||
Section {
|
||||
SettingsPermissionCallout(
|
||||
message: "Accessibility permission lets Dynamic Island replace the native volume, brightness, and keyboard HUDs.",
|
||||
requestAction: { accessibilityPermission.requestAuthorizationPrompt() },
|
||||
openSettingsAction: { accessibilityPermission.openSystemSettings() }
|
||||
)
|
||||
} header: {
|
||||
Text("Accessibility")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Enable HUDs", isOn: $enableSystemHUD)
|
||||
.disabled(Defaults[.enableCustomOSD] || !hasAccessibilityPermission)
|
||||
} header: {
|
||||
Text("General")
|
||||
} footer: {
|
||||
if Defaults[.enableCustomOSD] {
|
||||
Text("HUDs are disabled because Custom OSD is enabled. Disable Custom OSD in the Custom OSD tab to use HUDs.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
} else if !hasAccessibilityPermission {
|
||||
Text("Grant Accessibility permission in System Settings to control macOS HUD replacements from here.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
} else {
|
||||
Text("Replaces macOS system HUD with Dynamic Island displays for volume, brightness, and keyboard backlight.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
if enableSystemHUD && !Defaults[.enableCustomOSD] && hasAccessibilityPermission {
|
||||
Section {
|
||||
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
||||
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
|
||||
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
|
||||
} header: {
|
||||
Text("Controls")
|
||||
} footer: {
|
||||
Text("Choose which system controls should display HUD notifications.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Defaults.Toggle("Show Bluetooth device connections", key: .showBluetoothDeviceConnections)
|
||||
.settingsHighlight(id: highlightID("Show Bluetooth device connections"))
|
||||
|
|
@ -1297,12 +1677,88 @@ struct HUD: View {
|
|||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
|
||||
Section {
|
||||
let colorCodingDisabled = progressBarStyle == .segmented
|
||||
Defaults.Toggle("Color-coded battery display", key: .useColorCodedBatteryDisplay)
|
||||
.disabled(colorCodingDisabled)
|
||||
.settingsHighlight(id: highlightID("Color-coded battery display"))
|
||||
} header: {
|
||||
Text("Battery Indicator Styling")
|
||||
} footer: {
|
||||
if progressBarStyle == .segmented {
|
||||
Text("Color-coded fills are unavailable in Segmented mode. Switch to Hierarchical or Gradient inside Controls › Dynamic Island to adjust advanced options.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
} else if Defaults[.useSmoothColorGradient] {
|
||||
Text("Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill. Adjust gradient behavior from Controls › Dynamic Island.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
} else {
|
||||
Text("Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%).")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Devices")
|
||||
}
|
||||
}
|
||||
|
||||
struct HUD: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@Default(.inlineHUD) var inlineHUD
|
||||
@Default(.progressBarStyle) var progressBarStyle
|
||||
@Default(.enableSystemHUD) var enableSystemHUD
|
||||
@Default(.enableVolumeHUD) var enableVolumeHUD
|
||||
@Default(.enableBrightnessHUD) var enableBrightnessHUD
|
||||
@Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD
|
||||
@Default(.systemHUDSensitivity) var systemHUDSensitivity
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared
|
||||
|
||||
private func highlightID(_ title: String) -> String {
|
||||
SettingsTab.hudAndOSD.highlightID(for: title)
|
||||
}
|
||||
|
||||
private var hasAccessibilityPermission: Bool {
|
||||
accessibilityPermission.isAuthorized
|
||||
}
|
||||
|
||||
private var colorCodingDisabled: Bool {
|
||||
progressBarStyle == .segmented
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if !hasAccessibilityPermission {
|
||||
Section {
|
||||
SettingsPermissionCallout(
|
||||
message: "Accessibility permission lets Dynamic Island replace the native volume, brightness, and keyboard HUDs.",
|
||||
requestAction: { accessibilityPermission.requestAuthorizationPrompt() },
|
||||
openSettingsAction: { accessibilityPermission.openSystemSettings() }
|
||||
)
|
||||
} header: {
|
||||
Text("Accessibility")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
if enableSystemHUD && !Defaults[.enableCustomOSD] && hasAccessibilityPermission {
|
||||
Section {
|
||||
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
||||
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
|
||||
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
|
||||
} header: {
|
||||
Text("Controls")
|
||||
} footer: {
|
||||
Text("Choose which system controls should display HUD notifications.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Defaults.Toggle("Color-coded volume display", key: .useColorCodedVolumeDisplay)
|
||||
.disabled(colorCodingDisabled)
|
||||
.settingsHighlight(id: highlightID("Color-coded volume display"))
|
||||
|
|
@ -1315,9 +1771,9 @@ struct HUD: View {
|
|||
Defaults.Toggle("Show percentages beside progress bars", key: .showProgressPercentages)
|
||||
.settingsHighlight(id: highlightID("Show percentages beside progress bars"))
|
||||
} header: {
|
||||
Text("Color-Coded Progress Bars")
|
||||
Text("Dynamic Island Progress Bars")
|
||||
} footer: {
|
||||
if progressBarStyle == .segmented {
|
||||
if colorCodingDisabled {
|
||||
Text("Color-coded fills and smooth gradients are unavailable in Segmented mode. Switch to Hierarchical or Gradient to adjust these options.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
|
|
@ -1367,7 +1823,7 @@ struct HUD: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("HUDs")
|
||||
.navigationTitle("Controls")
|
||||
.onAppear {
|
||||
accessibilityPermission.refreshStatus()
|
||||
}
|
||||
|
|
@ -1844,6 +2300,7 @@ struct About: View {
|
|||
.padding(.horizontal, 10)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
}
|
||||
.toolbar {
|
||||
// Button("Welcome window") {
|
||||
|
|
@ -4537,7 +4994,7 @@ struct CustomOSDSettings: View {
|
|||
@State private var previewType: SneakContentType = .volume
|
||||
|
||||
private func highlightID(_ title: String) -> String {
|
||||
SettingsTab.osd.highlightID(for: title)
|
||||
SettingsTab.hudAndOSD.highlightID(for: title)
|
||||
}
|
||||
|
||||
private var hasAccessibilityPermission: Bool {
|
||||
|
|
@ -4558,46 +5015,7 @@ struct CustomOSDSettings: View {
|
|||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Enable Custom OSD", isOn: Binding(
|
||||
get: { enableCustomOSD },
|
||||
set: { newValue in
|
||||
guard hasAccessibilityPermission else {
|
||||
enableCustomOSD = false
|
||||
return
|
||||
}
|
||||
if newValue && !hasSeenOSDAlphaWarning {
|
||||
showAlphaWarning = true
|
||||
} else {
|
||||
enableCustomOSD = newValue
|
||||
if newValue {
|
||||
// Disable System HUD when OSD is enabled
|
||||
enableSystemHUD = false
|
||||
}
|
||||
}
|
||||
}
|
||||
))
|
||||
.settingsHighlight(id: highlightID("Enable Custom OSD"))
|
||||
.disabled(enableSystemHUD || !hasAccessibilityPermission)
|
||||
} header: {
|
||||
Text("General")
|
||||
} footer: {
|
||||
if enableSystemHUD {
|
||||
Text("Custom OSD is disabled because HUDs are enabled. Disable HUDs in the HUDs tab to use Custom OSD.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
} else if !hasAccessibilityPermission {
|
||||
Text("Grant Accessibility permission in System Settings to customize the on-screen display replacements here.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
} else {
|
||||
Text("Display custom macOS-style OSD windows at the bottom center of the screen when adjusting volume, brightness, or keyboard backlight.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
if enableCustomOSD && hasAccessibilityPermission {
|
||||
if hasAccessibilityPermission {
|
||||
Section {
|
||||
Toggle("Volume OSD", isOn: $enableOSDVolume)
|
||||
.settingsHighlight(id: highlightID("Volume OSD"))
|
||||
|
|
@ -4696,18 +5114,6 @@ struct CustomOSDSettings: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.alert("Alpha Feature Warning", isPresented: $showAlphaWarning) {
|
||||
Button("Cancel", role: .cancel) {
|
||||
enableCustomOSD = false
|
||||
}
|
||||
Button("Enable Anyway") {
|
||||
hasSeenOSDAlphaWarning = true
|
||||
enableCustomOSD = true
|
||||
enableSystemHUD = false
|
||||
}
|
||||
} message: {
|
||||
Text("Custom OSD is an experimental alpha feature and may contain bugs or unexpected behavior.\n\nThis feature requires macOS 15 or later and is still under active development. It's recommended to keep it disabled unless you want to help test it.")
|
||||
}
|
||||
.navigationTitle("Custom OSD")
|
||||
.onAppear {
|
||||
accessibilityPermission.refreshStatus()
|
||||
|
|
|
|||
|
|
@ -1456,6 +1456,14 @@ class BluetoothAudioManager: ObservableObject {
|
|||
func refreshConnectedDeviceBatteries() {
|
||||
refreshBatteryLevelsForConnectedDevices()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func activeDeviceIconSymbol() -> String? {
|
||||
if let prioritizedDevice = connectedDevices.last ?? lastConnectedDevice {
|
||||
return prioritizedDevice.deviceType.sfSymbol
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CoreBluetooth Battery Reader
|
||||
|
|
|
|||
151
DynamicIsland/managers/CircularHUDWindowManager.swift
Normal file
151
DynamicIsland/managers/CircularHUDWindowManager.swift
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
#if os(macOS)
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import SkyLightWindow
|
||||
import QuartzCore
|
||||
import Defaults
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
final class CircularHUDWindowManager {
|
||||
static let shared = CircularHUDWindowManager()
|
||||
|
||||
private var windows: [NSScreen: OSDWindow] = [:]
|
||||
private var hideWorkItem: DispatchWorkItem?
|
||||
private let displayDuration: TimeInterval = 2.0
|
||||
private let animationDuration: TimeInterval = 0.2
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// Layout constants removed in favor of dynamic calculation
|
||||
|
||||
private init() {
|
||||
setupSizeObserver()
|
||||
}
|
||||
|
||||
private func setupSizeObserver() {
|
||||
Defaults.publisher(.circularHUDSize, options: []).sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
Task { @MainActor in
|
||||
self.updateWindowLayout()
|
||||
}
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func updateWindowLayout() {
|
||||
guard !windows.isEmpty else { return }
|
||||
|
||||
for (screen, window) in windows {
|
||||
let screenFrame = screen.frame
|
||||
let hudSize = Defaults[.circularHUDSize]
|
||||
let windowSize = hudSize + 80 // Padding
|
||||
|
||||
let x = screenFrame.midX - (windowSize / 2)
|
||||
let y = screenFrame.midY - (windowSize / 2)
|
||||
let newFrame = NSRect(x: x, y: y, width: windowSize, height: windowSize)
|
||||
|
||||
window.nsWindow.setFrame(newFrame, display: true, animate: true)
|
||||
}
|
||||
}
|
||||
|
||||
func show(type: SneakContentType, value: CGFloat, icon: String = "") {
|
||||
guard Defaults[.enableCircularHUD] else { return }
|
||||
|
||||
let screens = NSScreen.screens
|
||||
guard !screens.isEmpty else { return }
|
||||
|
||||
// Show on all screens
|
||||
for screen in screens {
|
||||
let windowContext = ensureWindow(for: type, screen: screen)
|
||||
updateContent(window: windowContext, type: type, value: value, icon: icon)
|
||||
|
||||
// Ensure visible
|
||||
windowContext.nsWindow.orderFrontRegardless()
|
||||
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = animationDuration
|
||||
windowContext.nsWindow.animator().alphaValue = 1
|
||||
}
|
||||
}
|
||||
|
||||
scheduleHide()
|
||||
}
|
||||
|
||||
private func ensureWindow(for type: SneakContentType, screen: NSScreen) -> OSDWindow {
|
||||
if let existing = windows[screen] {
|
||||
return existing
|
||||
}
|
||||
|
||||
let osdView = CircularHUDView(type: .constant(type), value: .constant(0), icon: .constant(""))
|
||||
let hostingView = NSHostingView(rootView: osdView)
|
||||
|
||||
let screenFrame = screen.frame
|
||||
|
||||
let hudSize = Defaults[.circularHUDSize]
|
||||
let windowSize = hudSize + 80 // Padding
|
||||
|
||||
// Position at center of screen
|
||||
let x = screenFrame.midX - (windowSize / 2)
|
||||
let y = screenFrame.midY - (windowSize / 2)
|
||||
let frame = NSRect(x: x, y: y, width: windowSize, height: windowSize)
|
||||
|
||||
let win = NSWindow(
|
||||
contentRect: frame,
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
win.isOpaque = false
|
||||
win.backgroundColor = .clear
|
||||
win.level = .screenSaver
|
||||
win.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary]
|
||||
win.hasShadow = false
|
||||
win.contentView = hostingView
|
||||
win.alphaValue = 0
|
||||
|
||||
SkyLightOperator.shared.delegateWindow(win)
|
||||
|
||||
let windowStruct = OSDWindow(nsWindow: win, hostingView: hostingView, type: type)
|
||||
windows[screen] = windowStruct
|
||||
return windowStruct
|
||||
}
|
||||
|
||||
private func updateContent(window: OSDWindow, type: SneakContentType, value: CGFloat, icon: String) {
|
||||
let osdView = CircularHUDView(type: .constant(type), value: .constant(value), icon: .constant(icon))
|
||||
window.hostingView.rootView = osdView
|
||||
}
|
||||
|
||||
private func scheduleHide() {
|
||||
hideWorkItem?.cancel()
|
||||
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.hideWindow()
|
||||
}
|
||||
}
|
||||
|
||||
hideWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + displayDuration, execute: workItem)
|
||||
}
|
||||
|
||||
private func hideWindow() {
|
||||
for window in windows.values {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = animationDuration
|
||||
window.nsWindow.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
// Keep window around but hidden for faster subsequent shows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper struct
|
||||
private struct OSDWindow {
|
||||
let nsWindow: NSWindow
|
||||
let hostingView: NSHostingView<CircularHUDView>
|
||||
let type: SneakContentType
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -11,9 +11,9 @@ import Defaults
|
|||
final class CustomOSDWindowManager {
|
||||
static let shared = CustomOSDWindowManager()
|
||||
|
||||
private var volumeWindow: OSDWindow?
|
||||
private var brightnessWindow: OSDWindow?
|
||||
private var backlightWindow: OSDWindow?
|
||||
private var volumeWindows: [NSScreen: OSDWindow] = [:]
|
||||
private var brightnessWindows: [NSScreen: OSDWindow] = [:]
|
||||
private var backlightWindows: [NSScreen: OSDWindow] = [:]
|
||||
|
||||
private var hideWorkItem: DispatchWorkItem?
|
||||
private let displayDuration: TimeInterval = 2.0
|
||||
|
|
@ -50,62 +50,61 @@ final class CustomOSDWindowManager {
|
|||
// MARK: - Private Implementation
|
||||
|
||||
private func show(type: SneakContentType, value: CGFloat, icon: String) {
|
||||
guard let screen = NSScreen.main else { return }
|
||||
let screens = NSScreen.screens
|
||||
guard !screens.isEmpty else { return }
|
||||
|
||||
// Close other windows first
|
||||
hideAllWindowsExcept(type: type)
|
||||
|
||||
let window = ensureWindow(for: type)
|
||||
updateContent(window: window, type: type, value: value, icon: icon)
|
||||
|
||||
let targetFrame = calculateFrame(for: screen)
|
||||
|
||||
if window.nsWindow.alphaValue <= 0.01 {
|
||||
// Initial presentation
|
||||
presentWindow(window, targetFrame: targetFrame)
|
||||
} else {
|
||||
// Update existing window
|
||||
window.nsWindow.setFrame(targetFrame, display: true)
|
||||
window.nsWindow.orderFrontRegardless()
|
||||
// Show on all screens
|
||||
for screen in screens {
|
||||
let window = ensureWindow(for: type, screen: screen)
|
||||
updateContent(window: window, type: type, value: value, icon: icon)
|
||||
|
||||
let targetFrame = calculateFrame(for: screen)
|
||||
|
||||
if window.nsWindow.alphaValue <= 0.01 {
|
||||
// Initial presentation
|
||||
presentWindow(window, targetFrame: targetFrame)
|
||||
} else {
|
||||
// Update existing window
|
||||
window.nsWindow.setFrame(targetFrame, display: true)
|
||||
window.nsWindow.orderFrontRegardless()
|
||||
}
|
||||
}
|
||||
|
||||
scheduleHide(for: type)
|
||||
}
|
||||
|
||||
private func ensureWindow(for type: SneakContentType) -> OSDWindow {
|
||||
let window: OSDWindow
|
||||
|
||||
private func ensureWindow(for type: SneakContentType, screen: NSScreen) -> OSDWindow {
|
||||
switch type {
|
||||
case .volume:
|
||||
if let existing = volumeWindow {
|
||||
if let existing = volumeWindows[screen] {
|
||||
return existing
|
||||
}
|
||||
window = createWindow(for: type)
|
||||
volumeWindow = window
|
||||
let window = createWindow(for: type, screen: screen)
|
||||
volumeWindows[screen] = window
|
||||
return window
|
||||
case .brightness:
|
||||
if let existing = brightnessWindow {
|
||||
if let existing = brightnessWindows[screen] {
|
||||
return existing
|
||||
}
|
||||
window = createWindow(for: type)
|
||||
brightnessWindow = window
|
||||
let window = createWindow(for: type, screen: screen)
|
||||
brightnessWindows[screen] = window
|
||||
return window
|
||||
case .backlight:
|
||||
if let existing = backlightWindow {
|
||||
if let existing = backlightWindows[screen] {
|
||||
return existing
|
||||
}
|
||||
window = createWindow(for: type)
|
||||
backlightWindow = window
|
||||
let window = createWindow(for: type, screen: screen)
|
||||
backlightWindows[screen] = window
|
||||
return window
|
||||
default:
|
||||
fatalError("Unsupported OSD type: \(type)")
|
||||
}
|
||||
|
||||
return window
|
||||
}
|
||||
|
||||
private func createWindow(for type: SneakContentType) -> OSDWindow {
|
||||
guard let screen = NSScreen.main else {
|
||||
fatalError("No main screen available")
|
||||
}
|
||||
|
||||
private func createWindow(for type: SneakContentType, screen: NSScreen) -> OSDWindow {
|
||||
let osdView = CustomOSDView(type: .constant(type), value: .constant(0), icon: .constant(""))
|
||||
let hostingView = NSHostingView(rootView: osdView)
|
||||
|
||||
|
|
@ -161,14 +160,20 @@ final class CustomOSDWindowManager {
|
|||
}
|
||||
|
||||
private func hideAllWindowsExcept(type: SneakContentType) {
|
||||
if type != .volume, let volumeWindow = volumeWindow, volumeWindow.nsWindow.alphaValue > 0.01 {
|
||||
hideWindowImmediately(volumeWindow)
|
||||
if type != .volume {
|
||||
for window in volumeWindows.values where window.nsWindow.alphaValue > 0.01 {
|
||||
hideWindowImmediately(window)
|
||||
}
|
||||
}
|
||||
if type != .brightness, let brightnessWindow = brightnessWindow, brightnessWindow.nsWindow.alphaValue > 0.01 {
|
||||
hideWindowImmediately(brightnessWindow)
|
||||
if type != .brightness {
|
||||
for window in brightnessWindows.values where window.nsWindow.alphaValue > 0.01 {
|
||||
hideWindowImmediately(window)
|
||||
}
|
||||
}
|
||||
if type != .backlight, let backlightWindow = backlightWindow, backlightWindow.nsWindow.alphaValue > 0.01 {
|
||||
hideWindowImmediately(backlightWindow)
|
||||
if type != .backlight {
|
||||
for window in backlightWindows.values where window.nsWindow.alphaValue > 0.01 {
|
||||
hideWindowImmediately(window)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,20 +202,20 @@ final class CustomOSDWindowManager {
|
|||
}
|
||||
|
||||
private func hideWindow(for type: SneakContentType) {
|
||||
let window: OSDWindow?
|
||||
let windows: [OSDWindow]
|
||||
|
||||
switch type {
|
||||
case .volume:
|
||||
window = volumeWindow
|
||||
windows = Array(volumeWindows.values)
|
||||
case .brightness:
|
||||
window = brightnessWindow
|
||||
windows = Array(brightnessWindows.values)
|
||||
case .backlight:
|
||||
window = backlightWindow
|
||||
windows = Array(backlightWindows.values)
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
guard let window else { return }
|
||||
for window in windows {
|
||||
|
||||
let currentFrame = window.nsWindow.frame
|
||||
let hideFrame = currentFrame.offsetBy(dx: 0, dy: -20)
|
||||
|
|
@ -223,6 +228,7 @@ final class CustomOSDWindowManager {
|
|||
} completionHandler: {
|
||||
window.nsWindow.orderOut(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
|
@ -231,13 +237,19 @@ final class CustomOSDWindowManager {
|
|||
hideWorkItem?.cancel()
|
||||
hideWorkItem = nil
|
||||
|
||||
volumeWindow?.nsWindow.orderOut(nil)
|
||||
brightnessWindow?.nsWindow.orderOut(nil)
|
||||
backlightWindow?.nsWindow.orderOut(nil)
|
||||
for window in volumeWindows.values {
|
||||
window.nsWindow.orderOut(nil)
|
||||
}
|
||||
for window in brightnessWindows.values {
|
||||
window.nsWindow.orderOut(nil)
|
||||
}
|
||||
for window in backlightWindows.values {
|
||||
window.nsWindow.orderOut(nil)
|
||||
}
|
||||
|
||||
volumeWindow = nil
|
||||
brightnessWindow = nil
|
||||
backlightWindow = nil
|
||||
volumeWindows.removeAll()
|
||||
brightnessWindows.removeAll()
|
||||
backlightWindows.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,14 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
|||
private let keyboardBacklightController = SystemKeyboardBacklightController.shared
|
||||
private let mediaKeyInterceptor = MediaKeyInterceptor.shared
|
||||
|
||||
private static let headsetIconSymbols: Set<String> = [
|
||||
"airpods",
|
||||
"airpodspro",
|
||||
"airpodsmax",
|
||||
"beats.headphones",
|
||||
"headphones"
|
||||
]
|
||||
|
||||
private let standardVolumeStep: Float = 1.0 / 16.0
|
||||
private let standardBrightnessStep: Float = 1.0 / 16.0
|
||||
private let fineStepDivisor: Float = 4.0
|
||||
|
|
@ -30,11 +38,16 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
|||
volumeController.onVolumeChange = { [weak self] volume, muted in
|
||||
guard let self, self.volumeEnabled else { return }
|
||||
let value = muted ? 0 : volume
|
||||
self.sendVolumeNotification(value: value)
|
||||
Task { @MainActor in
|
||||
self.sendVolumeNotification(value: value, isMuted: muted)
|
||||
}
|
||||
}
|
||||
volumeController.onRouteChange = { [weak self] in
|
||||
guard let self, self.volumeEnabled else { return }
|
||||
self.sendVolumeNotification(value: self.volumeController.isMuted ? 0 : self.volumeController.currentVolume)
|
||||
let muted = self.volumeController.isMuted
|
||||
Task { @MainActor in
|
||||
self.sendVolumeNotification(value: muted ? 0 : self.volumeController.currentVolume, isMuted: muted)
|
||||
}
|
||||
}
|
||||
volumeController.start()
|
||||
|
||||
|
|
@ -113,6 +126,17 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
|||
modifiers: NSEvent.ModifierFlags
|
||||
) {
|
||||
guard volumeEnabled else { return }
|
||||
|
||||
// Elastic Limit Detection (Vertical HUD)
|
||||
if Defaults[.enableVerticalHUD] {
|
||||
let volume = volumeController.currentVolume
|
||||
if direction == .up && volume >= 0.99 {
|
||||
Task { @MainActor in VerticalHUDWindowManager.shared.triggerBump(direction: 1) }
|
||||
} else if direction == .down && volume <= 0.01 {
|
||||
Task { @MainActor in VerticalHUDWindowManager.shared.triggerBump(direction: -1) }
|
||||
}
|
||||
}
|
||||
|
||||
let baseStep = stepSize(for: step, base: standardVolumeStep)
|
||||
let delta = direction == .up ? baseStep : -baseStep
|
||||
volumeController.adjust(by: delta)
|
||||
|
|
@ -130,6 +154,16 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
|||
isRepeat: Bool,
|
||||
modifiers: NSEvent.ModifierFlags
|
||||
) {
|
||||
// Elastic Limit Detection (Vertical HUD)
|
||||
if Defaults[.enableVerticalHUD] {
|
||||
let brightness = brightnessController.currentBrightness
|
||||
if direction == .up && brightness >= 0.99 {
|
||||
Task { @MainActor in VerticalHUDWindowManager.shared.triggerBump(direction: 1) }
|
||||
} else if direction == .down && brightness <= 0.01 {
|
||||
Task { @MainActor in VerticalHUDWindowManager.shared.triggerBump(direction: -1) }
|
||||
}
|
||||
}
|
||||
|
||||
let baseStep = stepSize(for: step, base: standardBrightnessStep)
|
||||
let delta = direction == .up ? baseStep : -baseStep
|
||||
if modifiers.contains(.command) && keyboardBacklightEnabled {
|
||||
|
|
@ -141,11 +175,27 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
|||
|
||||
// MARK: - HUD Dispatch
|
||||
|
||||
private func sendVolumeNotification(value: Float) {
|
||||
@MainActor
|
||||
private func sendVolumeNotification(value: Float, isMuted: Bool) {
|
||||
if HUDSuppressionCoordinator.shared.shouldSuppressVolumeHUD {
|
||||
return
|
||||
}
|
||||
|
||||
// Send to Circular HUD if enabled
|
||||
if Defaults[.enableCircularHUD] {
|
||||
Task { @MainActor in
|
||||
CircularHUDWindowManager.shared.show(type: .volume, value: CGFloat(value))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Send to Vertical HUD if enabled
|
||||
if Defaults[.enableVerticalHUD] {
|
||||
let icon = resolvedVolumeIcon(isMuted: isMuted)
|
||||
VerticalHUDWindowManager.shared.show(type: .volume, value: CGFloat(value), icon: icon)
|
||||
return
|
||||
}
|
||||
|
||||
// Send to custom OSD if enabled
|
||||
if Defaults[.enableCustomOSD] && Defaults[.enableOSDVolume] {
|
||||
Task { @MainActor in
|
||||
|
|
@ -153,8 +203,8 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// Send to notch HUD if enabled and OSD is not enabled
|
||||
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableVolumeHUD] {
|
||||
// Send to notch HUD if enabled and OSD/Vertical/Circular is not enabled
|
||||
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] && Defaults[.enableVolumeHUD] {
|
||||
Task { @MainActor in
|
||||
guard let coordinator else { return }
|
||||
coordinator.toggleSneakPeek(
|
||||
|
|
@ -167,7 +217,32 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func resolvedVolumeIcon(isMuted: Bool) -> String {
|
||||
guard let icon = BluetoothAudioManager.shared.activeDeviceIconSymbol() else { return "" }
|
||||
if isMuted && Self.headsetIconSymbols.contains(icon) {
|
||||
return "headphones.slash"
|
||||
}
|
||||
return icon
|
||||
}
|
||||
|
||||
private func sendBrightnessNotification(value: Float) {
|
||||
// Send to Circular HUD if enabled
|
||||
if Defaults[.enableCircularHUD] {
|
||||
Task { @MainActor in
|
||||
CircularHUDWindowManager.shared.show(type: .brightness, value: CGFloat(value))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Send to Vertical HUD if enabled
|
||||
if Defaults[.enableVerticalHUD] {
|
||||
Task { @MainActor in
|
||||
VerticalHUDWindowManager.shared.show(type: .brightness, value: CGFloat(value))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Send to custom OSD if enabled
|
||||
if Defaults[.enableCustomOSD] && Defaults[.enableOSDBrightness] {
|
||||
Task { @MainActor in
|
||||
|
|
@ -175,8 +250,8 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// Send to notch HUD if enabled and OSD is not enabled
|
||||
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableBrightnessHUD] {
|
||||
// Send to notch HUD if enabled and OSD/Vertical/Circular is not enabled
|
||||
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] && Defaults[.enableBrightnessHUD] {
|
||||
Task { @MainActor in
|
||||
guard let coordinator else { return }
|
||||
coordinator.toggleSneakPeek(
|
||||
|
|
@ -190,6 +265,22 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
|||
}
|
||||
|
||||
private func sendKeyboardBacklightNotification(value: Float) {
|
||||
// Send to Circular HUD if enabled
|
||||
if Defaults[.enableCircularHUD] {
|
||||
Task { @MainActor in
|
||||
CircularHUDWindowManager.shared.show(type: .backlight, value: CGFloat(value))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Send to Vertical HUD if enabled
|
||||
if Defaults[.enableVerticalHUD] {
|
||||
Task { @MainActor in
|
||||
VerticalHUDWindowManager.shared.show(type: .backlight, value: CGFloat(value))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Send to custom OSD if enabled
|
||||
if Defaults[.enableCustomOSD] && Defaults[.enableOSDKeyboardBacklight] {
|
||||
Task { @MainActor in
|
||||
|
|
@ -197,8 +288,8 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// Send to notch HUD if enabled and OSD is not enabled
|
||||
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableKeyboardBacklightHUD] {
|
||||
// Send to notch HUD if enabled and OSD/Vertical/Circular is not enabled
|
||||
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] && Defaults[.enableKeyboardBacklightHUD] {
|
||||
Task { @MainActor in
|
||||
guard let coordinator else { return }
|
||||
coordinator.toggleSneakPeek(
|
||||
|
|
|
|||
|
|
@ -23,17 +23,39 @@ class SystemHUDManager {
|
|||
}
|
||||
|
||||
private func setupSettingsObserver() {
|
||||
// Observe Vertical HUD toggle
|
||||
Defaults.publisher(.enableVerticalHUD, options: []).sink { [weak self] change in
|
||||
guard let self = self, self.isSetupComplete else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
if change.newValue {
|
||||
// Always restart to ensure correct flags are applied
|
||||
await self.startSystemObserver()
|
||||
} else if Defaults[.enableSystemHUD] || Defaults[.enableCustomOSD] || Defaults[.enableCircularHUD] {
|
||||
// Restart to apply flags for the remaining active HUD
|
||||
await self.startSystemObserver()
|
||||
} else {
|
||||
await self.stopSystemObserver()
|
||||
}
|
||||
}
|
||||
}.store(in: &cancellables)
|
||||
|
||||
// Observe master HUD toggle
|
||||
Defaults.publisher(.enableSystemHUD, options: []).sink { [weak self] change in
|
||||
guard let self = self, self.isSetupComplete else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
if change.newValue && !Defaults[.enableCustomOSD] {
|
||||
if change.newValue && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] {
|
||||
// Always restart to ensure correct flags are applied
|
||||
await self.startSystemObserver()
|
||||
} else if !Defaults[.enableCustomOSD] {
|
||||
// Only stop if OSD is also disabled
|
||||
} else if !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] {
|
||||
// Only stop if others are also disabled
|
||||
await self.stopSystemObserver()
|
||||
} else if change.newValue == false && (Defaults[.enableCustomOSD] || Defaults[.enableVerticalHUD] || Defaults[.enableCircularHUD]) {
|
||||
// If disabling system HUD but others are active, restart to update flags
|
||||
await self.startSystemObserver()
|
||||
}
|
||||
}
|
||||
}.store(in: &cancellables)
|
||||
|
|
@ -45,17 +67,30 @@ class SystemHUDManager {
|
|||
}
|
||||
Task { @MainActor in
|
||||
if change.newValue {
|
||||
// Start observer for OSD if not already running
|
||||
if self.changesObserver == nil {
|
||||
await self.startSystemObserver()
|
||||
}
|
||||
} else if Defaults[.enableSystemHUD] {
|
||||
// Keep observer running for HUD
|
||||
if self.changesObserver == nil {
|
||||
await self.startSystemObserver()
|
||||
}
|
||||
// Always restart to ensure correct flags are applied
|
||||
await self.startSystemObserver()
|
||||
} else if Defaults[.enableSystemHUD] || Defaults[.enableVerticalHUD] || Defaults[.enableCircularHUD] {
|
||||
// Restart to apply flags for the remaining active HUD
|
||||
await self.startSystemObserver()
|
||||
} else {
|
||||
await self.stopSystemObserver()
|
||||
}
|
||||
}
|
||||
}.store(in: &cancellables)
|
||||
|
||||
// Observe Circular HUD toggle
|
||||
Defaults.publisher(.enableCircularHUD, options: []).sink { [weak self] change in
|
||||
guard let self = self, self.isSetupComplete else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
if change.newValue {
|
||||
// Always restart to ensure correct flags are applied
|
||||
await self.startSystemObserver()
|
||||
} else if Defaults[.enableSystemHUD] || Defaults[.enableCustomOSD] || Defaults[.enableVerticalHUD] {
|
||||
// Restart to apply flags for the remaining active HUD
|
||||
await self.startSystemObserver()
|
||||
} else {
|
||||
// Stop observer if both HUD and OSD are disabled
|
||||
await self.stopSystemObserver()
|
||||
}
|
||||
}
|
||||
|
|
@ -145,8 +180,8 @@ class SystemHUDManager {
|
|||
CustomOSDWindowManager.shared.initialize()
|
||||
}
|
||||
|
||||
// Start observer if either HUD or OSD is enabled
|
||||
if Defaults[.enableSystemHUD] || Defaults[.enableCustomOSD] {
|
||||
// Start observer if any HUD/OSD is enabled
|
||||
if Defaults[.enableSystemHUD] || Defaults[.enableCustomOSD] || Defaults[.enableVerticalHUD] || Defaults[.enableCircularHUD] {
|
||||
Task { @MainActor in
|
||||
await startSystemObserver()
|
||||
self.isSetupComplete = true
|
||||
|
|
@ -170,7 +205,12 @@ class SystemHUDManager {
|
|||
let brightnessEnabled: Bool
|
||||
let keyboardBacklightEnabled: Bool
|
||||
|
||||
if Defaults[.enableCustomOSD] {
|
||||
if Defaults[.enableCircularHUD] || Defaults[.enableVerticalHUD] {
|
||||
// Vertical and Circular HUD support all events
|
||||
volumeEnabled = true
|
||||
brightnessEnabled = true
|
||||
keyboardBacklightEnabled = true
|
||||
} else if Defaults[.enableCustomOSD] {
|
||||
volumeEnabled = Defaults[.enableOSDVolume]
|
||||
brightnessEnabled = Defaults[.enableOSDBrightness]
|
||||
keyboardBacklightEnabled = Defaults[.enableOSDKeyboardBacklight]
|
||||
|
|
@ -186,7 +226,10 @@ class SystemHUDManager {
|
|||
keyboardBacklightEnabled: keyboardBacklightEnabled
|
||||
)
|
||||
|
||||
print("System observer started (HUD: \(Defaults[.enableSystemHUD]), OSD: \(Defaults[.enableCustomOSD]))")
|
||||
// Force disable system HUD to ensure no duplicates
|
||||
SystemOSDManager.disableSystemHUD()
|
||||
|
||||
print("System observer started (HUD: \(Defaults[.enableSystemHUD]), OSD: \(Defaults[.enableCustomOSD]), Vertical: \(Defaults[.enableVerticalHUD]))")
|
||||
isSystemOperationInProgress = false
|
||||
}
|
||||
|
||||
|
|
@ -198,6 +241,9 @@ class SystemHUDManager {
|
|||
changesObserver?.stopObserving()
|
||||
changesObserver = nil
|
||||
|
||||
// Re-enable system HUD when we stop observing
|
||||
SystemOSDManager.enableSystemHUD()
|
||||
|
||||
print("System observer stopped")
|
||||
isSystemOperationInProgress = false
|
||||
}
|
||||
|
|
|
|||
244
DynamicIsland/managers/VerticalHUDWindowManager.swift
Normal file
244
DynamicIsland/managers/VerticalHUDWindowManager.swift
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
#if os(macOS)
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import SkyLightWindow
|
||||
import QuartzCore
|
||||
import Defaults
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
final class VerticalHUDWindowManager {
|
||||
static let shared = VerticalHUDWindowManager()
|
||||
|
||||
// Properties
|
||||
private var windows: [NSScreen: OSDWindow] = [:]
|
||||
private var hideWorkItem: DispatchWorkItem?
|
||||
private let displayDuration: TimeInterval = 2.0
|
||||
private let animationDuration: TimeInterval = 0.2
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private init() {
|
||||
setupPositionObserver()
|
||||
}
|
||||
|
||||
// Helper Struct
|
||||
private struct OSDWindow {
|
||||
let nsWindow: NSWindow
|
||||
let hostingView: NSHostingView<VerticalHUDView>
|
||||
let state: VerticalHUDState
|
||||
}
|
||||
|
||||
private func setupPositionObserver() {
|
||||
// Observe layout keys individually
|
||||
let updateBlock: (Any) -> Void = { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.updateWindowLayout()
|
||||
}
|
||||
}
|
||||
|
||||
Defaults.publisher(.verticalHUDPosition, options: []).sink(receiveValue: updateBlock).store(in: &cancellables)
|
||||
Defaults.publisher(.verticalHUDHeight, options: []).sink(receiveValue: updateBlock).store(in: &cancellables)
|
||||
Defaults.publisher(.verticalHUDWidth, options: []).sink(receiveValue: updateBlock).store(in: &cancellables)
|
||||
Defaults.publisher(.verticalHUDPadding, options: []).sink(receiveValue: updateBlock).store(in: &cancellables)
|
||||
Defaults.publisher(.verticalHUDInteractive, options: []).sink(receiveValue: updateBlock).store(in: &cancellables)
|
||||
|
||||
Defaults.publisher(.enableVerticalHUD, options: []).sink { [weak self] change in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleEnablementChange(change.newValue)
|
||||
}
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func updateWindowLayout() {
|
||||
guard !windows.isEmpty else { return }
|
||||
|
||||
for (screen, window) in windows {
|
||||
let screenFrame = screen.frame
|
||||
let position = Defaults[.verticalHUDPosition]
|
||||
let width = Defaults[.verticalHUDWidth]
|
||||
let height = Defaults[.verticalHUDHeight]
|
||||
let padding = Defaults[.verticalHUDPadding]
|
||||
|
||||
// Add padding for shadow and elastic stretch
|
||||
// We use 120px total padding (60px per side) to absolutely guarantee no clipping.
|
||||
let shadowPadding: CGFloat = 120
|
||||
let totalWidth = width + shadowPadding
|
||||
let totalHeight = height + shadowPadding
|
||||
|
||||
let x: CGFloat
|
||||
if position == "left" {
|
||||
// Left edge of HUD is at (minX + padding).
|
||||
// Window edge = HUD Edge - half shadow padding
|
||||
x = screenFrame.minX + padding - (shadowPadding / 2)
|
||||
} else {
|
||||
// Right edge of HUD is at (maxX - padding).
|
||||
// Left edge of HUD = maxX - padding - width.
|
||||
// Window edge = HUD Edge - half shadow padding
|
||||
x = screenFrame.maxX - width - padding - (shadowPadding / 2)
|
||||
}
|
||||
|
||||
let y = screenFrame.midY - (totalHeight / 2)
|
||||
let newFrame = NSRect(x: x, y: y, width: totalWidth, height: totalHeight)
|
||||
|
||||
window.nsWindow.setFrame(newFrame, display: true, animate: true)
|
||||
applyInteractivity(window)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEnablementChange(_ isEnabled: Bool) {
|
||||
if isEnabled {
|
||||
updateWindowLayout()
|
||||
} else {
|
||||
teardownWindows()
|
||||
}
|
||||
}
|
||||
|
||||
private func applyInteractivity(_ window: OSDWindow, visibleOverride: Bool? = nil) {
|
||||
let isVisible = visibleOverride ?? (window.nsWindow.alphaValue > 0.01)
|
||||
let shouldAllowInteraction = Defaults[.enableVerticalHUD] && Defaults[.verticalHUDInteractive] && isVisible
|
||||
window.nsWindow.ignoresMouseEvents = !shouldAllowInteraction
|
||||
}
|
||||
|
||||
private func updateWindowInteractivity() {
|
||||
for window in windows.values {
|
||||
applyInteractivity(window)
|
||||
}
|
||||
}
|
||||
|
||||
private func teardownWindows() {
|
||||
hideWorkItem?.cancel()
|
||||
hideWorkItem = nil
|
||||
|
||||
for window in windows.values {
|
||||
window.nsWindow.orderOut(nil)
|
||||
window.nsWindow.ignoresMouseEvents = true
|
||||
}
|
||||
|
||||
windows.removeAll()
|
||||
}
|
||||
|
||||
func show(type: SneakContentType, value: CGFloat, icon: String = "") {
|
||||
guard Defaults[.enableVerticalHUD] else { return }
|
||||
|
||||
let screens = NSScreen.screens
|
||||
guard !screens.isEmpty else { return }
|
||||
|
||||
// Show on all screens
|
||||
for screen in screens {
|
||||
let windowContext = ensureWindow(for: type, screen: screen)
|
||||
updateContent(window: windowContext, type: type, value: value, icon: icon)
|
||||
|
||||
// Ensure visible
|
||||
windowContext.nsWindow.orderFrontRegardless()
|
||||
applyInteractivity(windowContext, visibleOverride: true)
|
||||
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = animationDuration
|
||||
windowContext.nsWindow.animator().alphaValue = 1
|
||||
}
|
||||
}
|
||||
|
||||
scheduleHide()
|
||||
}
|
||||
|
||||
func triggerBump(direction: Int) {
|
||||
guard Defaults[.enableVerticalHUD] else { return }
|
||||
Task { @MainActor in
|
||||
for window in windows.values {
|
||||
window.state.bumpEvent = BumpEvent(direction: direction, timestamp: Date())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureWindow(for type: SneakContentType, screen: NSScreen) -> OSDWindow {
|
||||
if let existing = windows[screen] {
|
||||
return existing
|
||||
}
|
||||
|
||||
let state = VerticalHUDState(type: type, value: 0, icon: "")
|
||||
let osdView = VerticalHUDView(state: state)
|
||||
let hostingView = NSHostingView(rootView: osdView)
|
||||
|
||||
// Calculate frame
|
||||
let screenFrame = screen.frame
|
||||
let position = Defaults[.verticalHUDPosition]
|
||||
let width = Defaults[.verticalHUDWidth]
|
||||
let height = Defaults[.verticalHUDHeight]
|
||||
let padding = Defaults[.verticalHUDPadding]
|
||||
|
||||
// Add padding for shadow and elastic stretch
|
||||
// We use 120px total padding (60px per side) to absolutely guarantee no clipping.
|
||||
let shadowPadding: CGFloat = 120
|
||||
let totalWidth = width + shadowPadding
|
||||
let totalHeight = height + shadowPadding
|
||||
|
||||
let x: CGFloat
|
||||
if position == "left" {
|
||||
x = screenFrame.minX + padding - (shadowPadding / 2)
|
||||
} else {
|
||||
x = screenFrame.maxX - width - padding - (shadowPadding / 2)
|
||||
}
|
||||
|
||||
// Center vertically
|
||||
let y = screenFrame.midY - (totalHeight / 2)
|
||||
let frame = NSRect(x: x, y: y, width: totalWidth, height: totalHeight)
|
||||
|
||||
let win = NSWindow(
|
||||
contentRect: frame,
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
win.isOpaque = false
|
||||
win.backgroundColor = NSColor.clear
|
||||
win.level = NSWindow.Level.screenSaver
|
||||
win.collectionBehavior = [NSWindow.CollectionBehavior.canJoinAllSpaces, NSWindow.CollectionBehavior.stationary, NSWindow.CollectionBehavior.ignoresCycle, NSWindow.CollectionBehavior.fullScreenAuxiliary]
|
||||
win.hasShadow = false
|
||||
win.contentView = hostingView
|
||||
win.alphaValue = 0
|
||||
|
||||
// Default to ignoring events until explicitly shown
|
||||
win.ignoresMouseEvents = true
|
||||
|
||||
SkyLightOperator.shared.delegateWindow(win)
|
||||
|
||||
let windowStruct = OSDWindow(nsWindow: win, hostingView: hostingView, state: state)
|
||||
windows[screen] = windowStruct
|
||||
return windowStruct
|
||||
}
|
||||
|
||||
private func updateContent(window: OSDWindow, type: SneakContentType, value: CGFloat, icon: String) {
|
||||
// Update state instead of replacing view
|
||||
window.state.type = type
|
||||
window.state.value = value
|
||||
window.state.icon = icon
|
||||
}
|
||||
|
||||
private func scheduleHide() {
|
||||
hideWorkItem?.cancel()
|
||||
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.hideWindow()
|
||||
}
|
||||
}
|
||||
|
||||
hideWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + displayDuration, execute: workItem)
|
||||
}
|
||||
|
||||
private func hideWindow() {
|
||||
for window in windows.values {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = animationDuration
|
||||
window.nsWindow.animator().alphaValue = 0
|
||||
} completionHandler: { [weak self] in
|
||||
self?.applyInteractivity(window, visibleOverride: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -604,6 +604,24 @@ extension Defaults.Keys {
|
|||
|
||||
// MARK: Custom OSD Window Feature
|
||||
static let enableCustomOSD = Key<Bool>("enableCustomOSD", default: false)
|
||||
static let enableVerticalHUD = Key<Bool>("enableVerticalHUD", default: false)
|
||||
static let enableCircularHUD = Key<Bool>("enableCircularHUD", default: false)
|
||||
static let verticalHUDPosition = Key<String>("verticalHUDPosition", default: "right") // "left" or "right"
|
||||
|
||||
// Vertical HUD Customization
|
||||
static let verticalHUDShowValue = Key<Bool>("verticalHUDShowValue", default: true)
|
||||
static let verticalHUDInteractive = Key<Bool>("verticalHUDInteractive", default: true)
|
||||
static let verticalHUDHeight = Key<CGFloat>("verticalHUDHeight", default: 160)
|
||||
static let verticalHUDWidth = Key<CGFloat>("verticalHUDWidth", default: 36)
|
||||
static let verticalHUDPadding = Key<CGFloat>("verticalHUDPadding", default: 24)
|
||||
static let verticalHUDUseAccentColor = Key<Bool>("verticalHUDUseAccentColor", default: false)
|
||||
|
||||
// Circular HUD Customization
|
||||
static let circularHUDShowValue = Key<Bool>("circularHUDShowValue", default: true)
|
||||
static let circularHUDSize = Key<CGFloat>("circularHUDSize", default: 65)
|
||||
static let circularHUDStrokeWidth = Key<CGFloat>("circularHUDStrokeWidth", default: 4)
|
||||
static let circularHUDUseAccentColor = Key<Bool>("circularHUDUseAccentColor", default: true)
|
||||
|
||||
static let hasSeenOSDAlphaWarning = Key<Bool>("hasSeenOSDAlphaWarning", default: false)
|
||||
static let enableOSDVolume = Key<Bool>("enableOSDVolume", default: true)
|
||||
static let enableOSDBrightness = Key<Bool>("enableOSDBrightness", default: true)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue