diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..d41a940
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+*.gif filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/assets/lockscreen.gif b/.github/assets/lockscreen.gif
new file mode 100644
index 0000000..e735751
--- /dev/null
+++ b/.github/assets/lockscreen.gif
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:88e4c5da1649734ce4b9f02745e9c467df60166ebd8a187f09c452a100063220
+size 90875738
diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj
index e6c6510..7566c97 100644
--- a/DynamicIsland.xcodeproj/project.pbxproj
+++ b/DynamicIsland.xcodeproj/project.pbxproj
@@ -401,6 +401,8 @@
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = DynamicIsland/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Atoll;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
@@ -412,7 +414,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- MARKETING_VERSION = "1.0.1-beta";
+ MARKETING_VERSION = "1.0.2-beta";
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -449,6 +451,8 @@
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = DynamicIsland/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Atoll;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
@@ -460,7 +464,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- MARKETING_VERSION = "1.0.1-beta";
+ MARKETING_VERSION = "1.0.2-beta";
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift
index e5c6517..b908f3f 100644
--- a/DynamicIsland/ContentView.swift
+++ b/DynamicIsland/ContentView.swift
@@ -200,14 +200,14 @@ struct ContentView: View {
}
.onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
- if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isStatsPopoverActive {
+ if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive {
vm.close()
}
}
}
.onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
- if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive {
+ if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isMediaOutputPopoverActive {
vm.close()
}
}
@@ -215,7 +215,7 @@ struct ContentView: View {
.onChange(of: vm.shouldRecheckHover) { _, _ in
// Recheck hover state when popovers are closed
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
- if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive && !isHovering {
+ if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive && !isHovering {
vm.close()
}
}
@@ -320,10 +320,9 @@ struct ContentView: View {
}
.sensoryFeedback(.alignment, trigger: haptics)
.contextMenu {
- SettingsLink(label: {
- Text("Settings")
- })
- .keyboardShortcut(KeyEquivalent(","), modifiers: .command)
+ Button("Settings") {
+ SettingsWindowController.shared.showWindow()
+ }
// Button("Edit") { // Doesnt work....
// let dn = DynamicNotch(content: EditPanelView())
// dn.toggle()
@@ -404,7 +403,7 @@ struct ContentView: View {
.frame(width: 76, alignment: .trailing)
}
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
- } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) {
+ } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) {
InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress)
.transition(.opacity)
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed && !lockScreenManager.isLocked {
@@ -429,7 +428,7 @@ struct ContentView: View {
}
if coordinator.sneakPeek.show {
- if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && !Defaults[.inlineHUD] {
+ if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && !Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) {
SystemEventIndicatorModifier(eventType: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, sendEventBack: { _ in
//
})
@@ -751,10 +750,11 @@ struct ContentView: View {
// Helper function to check if any popovers are active
private func hasAnyActivePopovers() -> Bool {
- return vm.isBatteryPopoverActive ||
- vm.isClipboardPopoverActive ||
- vm.isColorPickerPopoverActive ||
- vm.isStatsPopoverActive
+ return vm.isBatteryPopoverActive ||
+ vm.isClipboardPopoverActive ||
+ vm.isColorPickerPopoverActive ||
+ vm.isStatsPopoverActive ||
+ vm.isMediaOutputPopoverActive
}
// Helper to prevent rapid haptic feedback
diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift
index 384401b..b756100 100644
--- a/DynamicIsland/DynamicIslandApp.swift
+++ b/DynamicIsland/DynamicIslandApp.swift
@@ -70,6 +70,15 @@ struct DynamicNotchApp: App {
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)
}
+
+ var commands: some Commands {
+ CommandGroup(replacing: .appSettings) {
+ Button("Settings…") {
+ SettingsWindowController.shared.showWindow()
+ }
+ .keyboardShortcut(",", modifiers: .command)
+ }
+ }
}
class AppDelegate: NSObject, NSApplicationDelegate {
@@ -404,6 +413,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
Defaults.publisher(.showDiskGraph, options: []).sink { [weak self] _ in
self?.debouncedUpdateWindowSize()
}.store(in: &cancellables)
+
+ Defaults.publisher(.enableShortcuts, options: []).sink { change in
+ KeyboardShortcuts.isEnabled = change.newValue
+ }.store(in: &cancellables)
// Observe minimalistic UI setting changes - trigger window resize
Defaults.publisher(.enableMinimalisticUI, options: []).sink { [weak self] change in
@@ -477,12 +490,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
self, selector: #selector(onScreenUnlocked(_:)),
name: NSNotification.Name(rawValue: "com.apple.screenIsUnlocked"), object: nil)
- KeyboardShortcuts.onKeyDown(for: .toggleSneakPeek) { [weak self] in
+ _ = KeyboardShortcuts.onKeyDown(for: .toggleSneakPeek) { [weak self] in
guard let self = self else { return }
-
- // Only execute if shortcuts are enabled
guard Defaults[.enableShortcuts] else { return }
-
+
self.coordinator.toggleSneakPeek(
status: !self.coordinator.sneakPeek.show,
type: .music,
@@ -490,14 +501,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
)
}
- KeyboardShortcuts.onKeyDown(for: .toggleNotchOpen) { [weak self] in
+ _ = KeyboardShortcuts.onKeyDown(for: .toggleNotchOpen) { [weak self] in
guard let self = self else { return }
-
- // Only execute if shortcuts are enabled
guard Defaults[.enableShortcuts] else { return }
-
+
let mouseLocation = NSEvent.mouseLocation
-
+
var viewModel = self.vm
if Defaults[.showOnAllDisplays] {
@@ -510,131 +519,91 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
}
-
+
self.closeNotchWorkItem?.cancel()
self.closeNotchWorkItem = nil
-
+
switch viewModel.notchState {
case .closed:
viewModel.open()
-
+
let workItem = DispatchWorkItem { [weak viewModel] in
viewModel?.close()
}
self.closeNotchWorkItem = workItem
-
+
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: workItem)
case .open:
viewModel.close()
}
}
-
- KeyboardShortcuts.onKeyDown(for: .startDemoTimer) { [weak self] in
- guard let self = self else { return }
-
- // Only execute if shortcuts are enabled
+
+ _ = KeyboardShortcuts.onKeyDown(for: .startDemoTimer) {
guard Defaults[.enableShortcuts] else { return }
-
- // Only start timer if the timer feature is enabled
guard Defaults[.enableTimerFeature] else { return }
-
- // Start a 5-minute demo timer
TimerManager.shared.startDemoTimer(duration: 300)
}
-
- KeyboardShortcuts.onKeyDown(for: .clipboardHistoryPanel) { [weak self] in
+
+ _ = KeyboardShortcuts.onKeyDown(for: .clipboardHistoryPanel) { [weak self] in
guard let self = self else { return }
-
- // Only execute if shortcuts are enabled
guard Defaults[.enableShortcuts] else { return }
-
- // Only open clipboard if the feature is enabled
guard Defaults[.enableClipboardManager] else { return }
-
- // Start clipboard monitoring if not already running
+
if !ClipboardManager.shared.isMonitoring {
ClipboardManager.shared.startMonitoring()
}
-
- // Handle keyboard shortcut based on display mode
+
switch Defaults[.clipboardDisplayMode] {
case .panel:
ClipboardPanelManager.shared.toggleClipboardPanel()
case .popover:
- // For popover mode, first ensure notch is open, then toggle popover
-
- // If notch is closed, open it first
if self.vm.notchState == .closed {
self.vm.open()
- // Wait a moment for the notch to open, then show popover
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil)
}
} else {
- // Notch is already open, toggle popover immediately
NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil)
}
}
}
-
- KeyboardShortcuts.onKeyDown(for: .colorPickerPanel) { [weak self] in
- guard let self = self else { return }
-
- // Only execute if shortcuts are enabled
+
+ _ = KeyboardShortcuts.onKeyDown(for: .colorPickerPanel) {
guard Defaults[.enableShortcuts] else { return }
-
- // Only open color picker panel if the feature is enabled
guard Defaults[.enableColorPickerFeature] else { return }
-
- // Toggle color picker panel
ColorPickerPanelManager.shared.toggleColorPickerPanel()
}
-
- KeyboardShortcuts.onKeyDown(for: .screenAssistantPanel) { [weak self] in
+
+ _ = KeyboardShortcuts.onKeyDown(for: .screenAssistantPanel) { [weak self] in
guard let self = self else { return }
-
- // Only execute if shortcuts are enabled
guard Defaults[.enableShortcuts] else { return }
-
- // Only open screen assistant if the feature is enabled
guard Defaults[.enableScreenAssistant] else { return }
-
- // Handle keyboard shortcut based on display mode
+
switch Defaults[.screenAssistantDisplayMode] {
case .panel:
ScreenAssistantPanelManager.shared.toggleScreenAssistantPanel()
case .popover:
- // For popover mode, first ensure notch is open, then toggle popover
-
- // If notch is closed, open it first
if self.vm.notchState == .closed {
self.vm.open()
- // Wait a moment for the notch to open, then show popover
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil)
}
} else {
- // Notch is already open, toggle popover immediately
NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil)
}
}
}
-
- KeyboardShortcuts.onKeyDown(for: .statsPanel) { [weak self] in
- guard let self = self else { return }
-
- // Only execute if shortcuts are enabled
+
+ KeyboardShortcuts.isEnabled = Defaults[.enableShortcuts]
+
+ _ = KeyboardShortcuts.onKeyDown(for: .statsPanel) {
guard Defaults[.enableShortcuts] else { return }
-
- // Only open stats panel if both stats feature and panel are enabled
guard Defaults[.enableStatsFeature] && Defaults[.showStatsPanel] else { return }
-
- // Start stats monitoring if not already running
+
if !StatsManager.shared.isMonitoring {
StatsManager.shared.startMonitoring()
}
-
- // Toggle stats panel
+
StatsPanelManager.shared.toggleStatsPanel()
}
diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings
index a9f0a7d..c83114d 100644
--- a/DynamicIsland/Localizable.xcstrings
+++ b/DynamicIsland/Localizable.xcstrings
@@ -3157,6 +3157,15 @@
}
}
},
+ "Battery charging" : {
+ "comment" : "Battery charging without estimate"
+ },
+ "Battery charging, %@ remaining" : {
+ "comment" : "Charging time remaining"
+ },
+ "Battery fully charged" : {
+ "comment" : "Battery is full"
+ },
"Battery Information" : {
"localizations" : {
"ar" : {
@@ -3464,6 +3473,17 @@
"comment" : "A section header that lists Bluetooth audio devices.",
"isCommentAutoGenerated" : true
},
+ "Bluetooth device %@ at %d percent" : {
+ "comment" : "Bluetooth device battery",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Bluetooth device %1$@ at %2$d percent"
+ }
+ }
+ }
+ },
"Boost your productivity with Clipboard Manager" : {
"localizations" : {
"ar" : {
@@ -4609,8 +4629,9 @@
"Color Picker Panel:" : {
},
- "Color-coded displays are only available in Gradient mode. Switch Progressbar style to Gradient to enable this feature." : {
-
+ "Color-coded fills and smooth gradients are unavailable in Segmented mode. Switch to Hierarchical or Gradient to adjust these options." : {
+ "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.",
@@ -4721,9 +4742,6 @@
},
"Configuration" : {
- },
- "Connected" : {
-
},
"Continue" : {
"localizations" : {
@@ -5800,8 +5818,8 @@
}
}
},
- "Discrete: Battery/Volume uses 3 distinct colors only\nBattery: Green (high), Yellow (medium), Red (low)\nVolume: Green (quiet), Yellow (medium), Red (loud)" : {
- "comment" : "A description of the \"Discrete\" color-coded progress bar style, including the colors used for battery and volume levels.",
+ "Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%)." : {
+ "comment" : "A description of how the color-coded progress bars work when not using smooth transitions.",
"isCommentAutoGenerated" : true
},
"Disk Read" : {
@@ -10356,6 +10374,10 @@
}
}
},
+ "Media output" : {
+ "comment" : "Accessibility label describing the control that opens the media output selector.",
+ "isCommentAutoGenerated" : true
+ },
"Media playback live activity" : {
"localizations" : {
"ar" : {
@@ -10761,6 +10783,7 @@
}
},
"Mic %@" : {
+ "extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -10860,6 +10883,14 @@
}
}
},
+ "Mic muted" : {
+ "comment" : "A description of the state when the microphone is muted.",
+ "isCommentAutoGenerated" : true
+ },
+ "Mic unmuted" : {
+ "comment" : "A description of the state when the microphone is unmuted.",
+ "isCommentAutoGenerated" : true
+ },
"Microphone" : {
"comment" : "A label displayed next to the microphone icon in the privacy indicator detail view.",
"isCommentAutoGenerated" : true
@@ -11805,6 +11836,10 @@
"comment" : "A message displayed when there are no active network interfaces to display.",
"isCommentAutoGenerated" : true
},
+ "No audio outputs available" : {
+ "comment" : "Displayed when no audio output devices can be listed in the media output selector.",
+ "isCommentAutoGenerated" : true
+ },
"No clipboard history" : {
},
@@ -13005,6 +13040,14 @@
},
"Opens the detailed system performance monitor panel. Default is Cmd+Shift+S. Only works when stats feature is enabled." : {
+ },
+ "Output devices" : {
+ "comment" : "Section title for the list of available audio output devices in the media output selector.",
+ "isCommentAutoGenerated" : true
+ },
+ "Output volume" : {
+ "comment" : "Label describing the system volume control inside the media output selector.",
+ "isCommentAutoGenerated" : true
},
"Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button." : {
@@ -13743,6 +13786,10 @@
},
"Reminders" : {
+ },
+ "Replace repeat button with media output control" : {
+ "comment" : "A toggle that allows users to replace the repeat button with a media output control.",
+ "isCommentAutoGenerated" : true
},
"Replaces system HUD notifications with Dynamic Island displays." : {
@@ -14465,6 +14512,10 @@
}
}
},
+ "Settings…" : {
+ "comment" : "A command that shows the settings window.",
+ "isCommentAutoGenerated" : true
+ },
"Shelf" : {
"localizations" : {
"ar" : {
@@ -15307,8 +15358,8 @@
}
}
},
- "Smooth: Battery/Volume gradually transitions through all colors\nBattery: Green (high) → Yellow-Green → Yellow → Orange → Red (low)\nVolume: Green (quiet) → Yellow → Orange → Red (loud)" : {
- "comment" : "A detailed description of the \"Smooth\" color-coded gradient display feature.",
+ "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
},
"Sneak Peek shows the media title and artist under the notch for a few seconds." : {
diff --git a/DynamicIsland/components/ColorCodedProgressBar.swift b/DynamicIsland/components/ColorCodedProgressBar.swift
index 6698aae..8d620bc 100644
--- a/DynamicIsland/components/ColorCodedProgressBar.swift
+++ b/DynamicIsland/components/ColorCodedProgressBar.swift
@@ -9,16 +9,31 @@
import SwiftUI
import Defaults
+enum ColorCodingMode {
+ case volume
+ case battery
+
+ func intensity(for value: CGFloat) -> CGFloat {
+ let clamped = min(max(value, 0), 1)
+ switch self {
+ case .volume:
+ return clamped
+ case .battery:
+ return 1 - clamped
+ }
+ }
+}
+
struct ColorCodedProgressBar: View {
let value: CGFloat // 0.0 to 1.0
- let reversed: Bool // If true: green at low, red at high (for volume)
+ let mode: ColorCodingMode
let width: CGFloat
let height: CGFloat
let smoothGradient: Bool // If true: smooth gradient, if false: discrete colors
- init(value: CGFloat, reversed: Bool = false, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) {
+ init(value: CGFloat, mode: ColorCodingMode = .battery, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) {
self.value = min(max(value, 0), 1) // Clamp between 0 and 1
- self.reversed = reversed
+ self.mode = mode
self.width = width
self.height = height
self.smoothGradient = smoothGradient
@@ -34,100 +49,19 @@ struct ColorCodedProgressBar: View {
// Filled track with color gradient or solid color
RoundedRectangle(cornerRadius: height / 2)
- .fill(smoothGradient ?
- AnyShapeStyle(LinearGradient(
- gradient: gradientForValue,
- startPoint: .leading,
- endPoint: .trailing
- )) :
- AnyShapeStyle(colorForPercentage(value, reversed: reversed))
- )
+ .fill(fillStyle)
.frame(width: width * value, height: height)
}
}
.frame(width: width, height: height)
}
- /// Generates a gradient based on the current value
- private var gradientForValue: Gradient {
- let color = colorForPercentage(value, reversed: reversed)
-
- // Create a gradient that transitions smoothly
- let startColor = colorForPercentage(max(value - 0.1, 0), reversed: reversed)
- let endColor = color
-
- return Gradient(colors: [startColor, endColor])
- }
-
- /// Calculates discrete color for a given percentage (no smooth transitions)
- /// - Parameters:
- /// - percentage: Value from 0.0 to 1.0
- /// - reversed: If true, reverses the color scale (green at low, red at high)
- /// - Returns: Discrete color (red, yellow, or green only)
- private func discreteColorForPercentage(_ percentage: CGFloat, reversed: Bool) -> Color {
- let normalized = reversed ? (1.0 - percentage) : percentage
-
- switch normalized {
- case 0.0..<0.33:
- // Red zone (critical/low)
- return Color(red: 1.0, green: 0.23, blue: 0.19)
- case 0.33..<0.67:
- // Yellow zone (medium)
- return Color(red: 1.0, green: 0.8, blue: 0.0)
- default:
- // Green zone (healthy/high)
- return Color(red: 0.2, green: 0.78, blue: 0.35)
- }
- }
-
- /// Calculates color for a given percentage with smooth transitions
- /// - Parameters:
- /// - percentage: Value from 0.0 to 1.0
- /// - reversed: If true, reverses the color scale (green at low, red at high)
- /// - Returns: Color with smooth gradient transitions
- private func colorForPercentage(_ percentage: CGFloat, reversed: Bool) -> Color {
- let normalized = reversed ? (1.0 - percentage) : percentage
-
- switch normalized {
- case 0.0..<0.2:
- // Pure Red zone (critical/low)
- return Color(red: 1.0, green: 0.23, blue: 0.19)
-
- case 0.2..<0.35:
- // Red → Orange transition
- let t = (normalized - 0.2) / 0.15
- let red: CGFloat = 1.0
- let green: CGFloat = 0.23 + (0.37 * t) // 0.23 → 0.6
- let blue: CGFloat = 0.19
- return Color(red: red, green: green, blue: blue)
-
- case 0.35..<0.5:
- // Orange → Yellow transition
- let t = (normalized - 0.35) / 0.15
- let red: CGFloat = 1.0
- let green: CGFloat = 0.6 + (0.4 * t) // 0.6 → 1.0
- let blue: CGFloat = 0.19
- return Color(red: red, green: green, blue: blue)
-
- case 0.5..<0.65:
- // Yellow → Yellow-Green transition
- let t = (normalized - 0.5) / 0.15
- let red: CGFloat = 1.0 - (0.4 * t) // 1.0 → 0.6
- let green: CGFloat = 1.0
- let blue: CGFloat = 0.19 + (0.4 * t) // 0.19 → 0.59
- return Color(red: red, green: green, blue: blue)
-
- case 0.65..<0.8:
- // Yellow-Green → Green transition
- let t = (normalized - 0.65) / 0.15
- let red: CGFloat = 0.6 - (0.4 * t) // 0.6 → 0.2
- let green: CGFloat = 1.0 - (0.22 * t) // 1.0 → 0.78
- let blue: CGFloat = 0.59 - (0.24 * t) // 0.59 → 0.35
- return Color(red: red, green: green, blue: blue)
-
- default:
- // Pure Green zone (healthy/high)
- return Color(red: 0.2, green: 0.78, blue: 0.35)
+ private var fillStyle: AnyShapeStyle {
+ let intensity = mode.intensity(for: value)
+ if smoothGradient {
+ return AnyShapeStyle(ColorCodedPalette.gradient(for: intensity))
+ } else {
+ return AnyShapeStyle(ColorCodedPalette.discreteColor(for: intensity))
}
}
}
@@ -137,12 +71,92 @@ struct ColorCodedProgressBar: View {
extension ColorCodedProgressBar {
/// Creates a battery-style progress bar (green at high, red at low)
static func battery(value: CGFloat, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) -> some View {
- ColorCodedProgressBar(value: value, reversed: false, width: width, height: height, smoothGradient: smoothGradient)
+ ColorCodedProgressBar(value: value, mode: .battery, width: width, height: height, smoothGradient: smoothGradient)
}
/// Creates a volume-style progress bar (red at high, green at low)
static func volume(value: CGFloat, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) -> some View {
- ColorCodedProgressBar(value: value, reversed: true, width: width, height: height, smoothGradient: smoothGradient)
+ ColorCodedProgressBar(value: value, mode: .volume, width: width, height: height, smoothGradient: smoothGradient)
+ }
+
+ /// Shared helper for other progress bar implementations that need the same palette.
+ static func shapeStyle(for value: CGFloat, mode: ColorCodingMode, smoothGradient: Bool) -> AnyShapeStyle {
+ let intensity = mode.intensity(for: min(max(value, 0), 1))
+ if smoothGradient {
+ return AnyShapeStyle(ColorCodedPalette.gradient(for: intensity))
+ }
+ return AnyShapeStyle(ColorCodedPalette.discreteColor(for: intensity))
+ }
+
+ /// Returns the palette color that matches the configured thresholds. Useful for non-linear indicators.
+ static func paletteColor(for value: CGFloat, mode: ColorCodingMode, smoothGradient: Bool) -> Color {
+ let normalized = min(max(value, 0), 1)
+ let intensity = mode.intensity(for: normalized)
+ return ColorCodedPalette.color(for: intensity, smooth: smoothGradient)
+ }
+}
+
+// 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)
+
+ static func color(for intensity: CGFloat, smooth: Bool) -> Color {
+ if smooth {
+ return smoothColor(for: intensity)
+ }
+ return discreteColor(for: intensity)
+ }
+
+ static func discreteColor(for intensity: CGFloat) -> Color {
+ let clamped = min(max(intensity, 0), 1)
+ switch clamped {
+ case ..<0.6:
+ return color(from: greenComponents)
+ case ..<0.85:
+ return color(from: yellowComponents)
+ default:
+ return color(from: redComponents)
+ }
+ }
+
+ static func gradient(for intensity: CGFloat) -> LinearGradient {
+ let endColor = smoothColor(for: intensity)
+ let startColor = smoothColor(for: max(intensity - 0.15, 0))
+ return LinearGradient(colors: [startColor, endColor], startPoint: .leading, endPoint: .trailing)
+ }
+
+ private static func smoothColor(for intensity: CGFloat) -> Color {
+ let clamped = min(max(intensity, 0), 1)
+ switch clamped {
+ case ..<0.6:
+ // Stay green within the comfort zone
+ return color(from: greenComponents)
+ case ..<0.85:
+ let t = (clamped - 0.6) / 0.25
+ return blend(from: greenComponents, to: yellowComponents, fraction: t)
+ default:
+ let t = (clamped - 0.85) / 0.15
+ return blend(from: yellowComponents, to: redComponents, fraction: t)
+ }
+ }
+
+ private static func blend(from start: (CGFloat, CGFloat, CGFloat), to end: (CGFloat, CGFloat, CGFloat), fraction: CGFloat) -> Color {
+ let t = min(max(fraction, 0), 1)
+ let red = lerp(start.0, end.0, t)
+ let green = lerp(start.1, end.1, t)
+ let blue = lerp(start.2, end.2, t)
+ return color(from: (red, green, blue))
+ }
+
+ private static func lerp(_ a: CGFloat, _ b: CGFloat, _ t: CGFloat) -> CGFloat {
+ a + (b - a) * t
+ }
+
+ private static func color(from components: (CGFloat, CGFloat, CGFloat)) -> Color {
+ Color(red: Double(components.0), green: Double(components.1), blue: Double(components.2))
}
}
diff --git a/DynamicIsland/components/Live activities/InlineHUD.swift b/DynamicIsland/components/Live activities/InlineHUD.swift
index 13d6c6d..e721a41 100644
--- a/DynamicIsland/components/Live activities/InlineHUD.swift
+++ b/DynamicIsland/components/Live activities/InlineHUD.swift
@@ -20,12 +20,42 @@ struct InlineHUD: View {
@Default(.useColorCodedVolumeDisplay) var useColorCodedVolumeDisplay
@Default(.useSmoothColorGradient) var useSmoothColorGradient
@Default(.progressBarStyle) var progressBarStyle
+ @Default(.showProgressPercentages) var showProgressPercentages
+ @Default(.useCircularBluetoothBatteryIndicator) var useCircularBluetoothBatteryIndicator
+ @Default(.showBluetoothBatteryPercentageText) var showBluetoothBatteryPercentageText
+ @Default(.showBluetoothDeviceNameMarquee) var showBluetoothDeviceNameMarquee
@ObservedObject var bluetoothManager = BluetoothAudioManager.shared
@State private var displayName: String = ""
var body: some View {
- HStack {
+ let useCircularIndicator = useCircularBluetoothBatteryIndicator
+ let hasBatteryLevel = value > 0
+
+ let baseInfoWidth: CGFloat = {
+ if type == .bluetoothAudio {
+ return showBluetoothDeviceNameMarquee ? 100 : 52
+ }
+ return 100
+ }()
+ let infoWidth = baseInfoWidth - (hoverAnimation ? 0 : 12) + gestureProgress / 2
+ let baseTrailingWidth: CGFloat = {
+ if type == .bluetoothAudio {
+ if !hasBatteryLevel {
+ return showBluetoothDeviceNameMarquee ? 100 : 70
+ }
+
+ if useCircularIndicator {
+ return showBluetoothBatteryPercentageText ? 100 : 70
+ } else {
+ return showBluetoothBatteryPercentageText ? 112 : 82
+ }
+ }
+ return 100
+ }()
+ let trailingWidth = baseTrailingWidth - (hoverAnimation ? 0 : 12) + gestureProgress / 2
+
+ return HStack {
HStack(spacing: 5) {
Group {
switch (type) {
@@ -77,22 +107,15 @@ struct InlineHUD: View {
// Use marquee text for device names to handle long names
if type == .bluetoothAudio {
- MarqueeText(
- $displayName,
- font: .system(size: 13, weight: .medium),
- nsFont: .body,
- textColor: .white,
- minDuration: 0.5,
- frameWidth: 85 - (hoverAnimation ? 0 : 12) + gestureProgress / 2
- )
- .onAppear {
- displayName = Type2Name(type)
- }
- .onChange(of: type) { _, _ in
- displayName = Type2Name(type)
- }
- .onChange(of: bluetoothManager.lastConnectedDevice?.name) { _, _ in
- displayName = Type2Name(type)
+ if showBluetoothDeviceNameMarquee {
+ MarqueeText(
+ $displayName,
+ font: .system(size: 13, weight: .medium),
+ nsFont: .body,
+ textColor: .white,
+ minDuration: 0.2,
+ frameWidth: infoWidth
+ )
}
} else {
Text(Type2Name(type))
@@ -103,7 +126,7 @@ struct InlineHUD: View {
.contentTransition(.numericText())
}
}
- .frame(width: 100 - (hoverAnimation ? 0 : 12) + gestureProgress / 2, height: vm.notchSize.height - (hoverAnimation ? 0 : 12), alignment: .leading)
+ .frame(width: infoWidth, height: vm.notchSize.height - (hoverAnimation ? 0 : 12), alignment: .leading)
Rectangle()
.fill(.black)
@@ -127,62 +150,147 @@ struct InlineHUD: View {
.frame(maxWidth: .infinity, alignment: .trailing)
.contentTransition(.interpolate)
} else if (type == .bluetoothAudio) {
- // Bluetooth device battery display
- HStack(spacing: 4) {
- if value > 0 {
- // Show color-coded battery bar if battery info available
- // Color-coding disabled for segmented or hierarchical mode
- if useColorCodedBatteryDisplay && progressBarStyle == .gradient {
- ColorCodedProgressBar.battery(value: value, width: 60, height: 4, smoothGradient: useSmoothColorGradient)
+ if hasBatteryLevel {
+ HStack(spacing: useCircularIndicator ? 8 : 6) {
+ if useCircularIndicator {
+ CircularBatteryIndicator(
+ value: value,
+ useColorCoding: useColorCodedBatteryDisplay && progressBarStyle != .segmented,
+ smoothGradient: useSmoothColorGradient
+ )
+ .allowsHitTesting(false)
} else {
- DraggableProgressBar(value: .constant(value))
- .frame(width: 60)
+ LinearBatteryIndicator(
+ value: value,
+ useColorCoding: useColorCodedBatteryDisplay && progressBarStyle != .segmented,
+ smoothGradient: useSmoothColorGradient
+ )
+ .allowsHitTesting(false)
}
- Text("\(Int(value * 100))%")
- .font(.caption)
- .fontWeight(.medium)
- .foregroundStyle(.white)
- .lineLimit(1)
+
+ if showBluetoothBatteryPercentageText {
+ Text("\(Int(value * 100))%")
+ .font(.caption)
+ .fontWeight(.medium)
+ .foregroundStyle(.white)
+ .lineLimit(1)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ }
+ } else {
+ // Volume and brightness displays
+ Group {
+ if type == .volume {
+ Group {
+ if value.isZero {
+ Text("muted")
+ .font(.caption)
+ .fontWeight(.medium)
+ .foregroundStyle(.gray)
+ .lineLimit(1)
+ .allowsTightening(true)
+ .multilineTextAlignment(.trailing)
+ .contentTransition(.numericText())
+ } else {
+ HStack(spacing: 6) {
+ DraggableProgressBar(value: $value, colorMode: .volume)
+ PercentageLabel(value: value, isVisible: showProgressPercentages)
+ }
+ .transition(.opacity.combined(with: .scale))
+ }
+ }
+ .animation(.smooth(duration: 0.2), value: value.isZero)
} else {
- // No battery info available
- Text("Connected")
- .font(.caption)
- .fontWeight(.medium)
- .foregroundStyle(.gray)
- .lineLimit(1)
+ HStack(spacing: 6) {
+ DraggableProgressBar(value: $value)
+ PercentageLabel(value: value, isVisible: showProgressPercentages)
+ }
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
- } else {
- // Volume and brightness displays
- HStack {
- // Color-coding only available in gradient mode, not hierarchical or segmented
- if type == .volume && useColorCodedVolumeDisplay && progressBarStyle == .gradient {
- // Color-coded volume (reversed: red at high, green at low)
- ColorCodedProgressBar.volume(value: value, width: 70, height: 4, smoothGradient: useSmoothColorGradient)
- } else {
- // Default draggable progress bar
- DraggableProgressBar(value: $value)
- }
-
- if (type == .volume && value.isZero) {
- Text("muted")
- .font(.caption)
- .fontWeight(.medium)
- .foregroundStyle(.gray)
- .lineLimit(1)
- .allowsTightening(true)
- .multilineTextAlignment(.trailing)
- }
- }
}
}
.padding(.trailing, 4)
- .frame(width: 100 - (hoverAnimation ? 0 : 12) + gestureProgress / 2, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center)
+ .frame(width: trailingWidth, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center)
}
.frame(height: vm.closedNotchSize.height + (hoverAnimation ? 8 : 0), alignment: .center)
+ .onAppear {
+ displayName = Type2Name(type)
+ }
+ .onChange(of: type) { _, _ in
+ displayName = Type2Name(type)
+ }
+ .onChange(of: bluetoothManager.lastConnectedDevice?.name) { _, _ in
+ displayName = Type2Name(type)
+ }
}
+ private struct CircularBatteryIndicator: View {
+ let value: CGFloat
+ let useColorCoding: Bool
+ let smoothGradient: Bool
+
+ private var clampedValue: CGFloat {
+ min(max(value, 0), 1)
+ }
+
+ private var indicatorColor: Color {
+ if useColorCoding {
+ return ColorCodedProgressBar.paletteColor(for: clampedValue, mode: .battery, smoothGradient: smoothGradient)
+ }
+ return .white
+ }
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .stroke(Color.white.opacity(0.18), lineWidth: 2.6)
+
+ Circle()
+ .trim(from: 0, to: max(clampedValue, 0.015))
+ .rotation(.degrees(-90))
+ .stroke(indicatorColor, style: StrokeStyle(lineWidth: 2.8, lineCap: .round))
+ }
+ .frame(width: 22, height: 22)
+ .animation(.smooth(duration: 0.18), value: clampedValue)
+ }
+ }
+
+ private struct LinearBatteryIndicator: View {
+ let value: CGFloat
+ let useColorCoding: Bool
+ let smoothGradient: Bool
+
+ private let trackWidth: CGFloat = 54
+ private let trackHeight: CGFloat = 6
+
+ private var clampedValue: CGFloat {
+ min(max(value, 0), 1)
+ }
+
+ private var fillColor: Color {
+ if useColorCoding {
+ return ColorCodedProgressBar.paletteColor(for: clampedValue, mode: .battery, smoothGradient: smoothGradient)
+ }
+ return .white
+ }
+
+ var body: some View {
+ ZStack(alignment: .leading) {
+ Capsule()
+ .fill(Color.white.opacity(0.18))
+ .frame(width: trackWidth, height: trackHeight)
+
+ Capsule()
+ .fill(fillColor)
+ .frame(width: trackWidth * clampedValue, height: trackHeight)
+ }
+ .frame(width: trackWidth, height: trackHeight)
+ .animation(.smooth(duration: 0.18), value: clampedValue)
+ }
+ }
+
func SpeakerSymbol(_ value: CGFloat) -> String {
switch(value) {
case 0:
diff --git a/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift b/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift
index 911293b..97cceab 100644
--- a/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift
+++ b/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift
@@ -22,6 +22,7 @@ struct SystemEventIndicatorModifier: View {
@Binding var icon: String
let showSlider: Bool = false
var sendEventBack: (CGFloat) -> Void
+ @Default(.showProgressPercentages) private var showProgressPercentages
var body: some View {
HStack(spacing: 14) {
@@ -58,13 +59,21 @@ struct SystemEventIndicatorModifier: View {
default:
EmptyView()
}
- if (eventType != .mic) {
- DraggableProgressBar(value: $value)
- } else {
- Text("Mic \(value > 0 ? "unmuted" : "muted")")
+ switch eventType {
+ case .mic:
+ Text(value > 0 ? "Mic unmuted" : "Mic muted")
.foregroundStyle(.gray)
.lineLimit(1)
.allowsTightening(true)
+ .contentTransition(.numericText())
+ case .volume:
+ VolumeProgressSection(value: $value, showPercentages: showProgressPercentages)
+ case .brightness:
+ ProgressSection(value: $value, showPercentages: showProgressPercentages)
+ case .backlight:
+ ProgressSection(value: $value, showPercentages: showProgressPercentages)
+ default:
+ ProgressSection(value: $value, showPercentages: showProgressPercentages)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -88,18 +97,106 @@ struct SystemEventIndicatorModifier: View {
}
}
+struct ProgressSection: View {
+ @Binding var value: CGFloat
+ var showPercentages: Bool
+ var colorMode: ProgressColorMode? = nil
+
+ var body: some View {
+ HStack(spacing: 8) {
+ DraggableProgressBar(value: $value, colorMode: colorMode)
+ PercentageLabel(value: value, isVisible: showPercentages)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+struct VolumeProgressSection: View {
+ @Binding var value: CGFloat
+ var showPercentages: Bool
+
+ var body: some View {
+ Group {
+ if value.isZero {
+ Text("muted")
+ .font(.caption)
+ .fontWeight(.medium)
+ .foregroundStyle(.gray)
+ .contentTransition(.numericText())
+ } else {
+ ProgressSection(value: $value, showPercentages: showPercentages, colorMode: .volume)
+ .transition(.opacity.combined(with: .scale))
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .animation(.smooth(duration: 0.2), value: value.isZero)
+ }
+}
+
+struct PercentageLabel: View {
+ let value: CGFloat
+ let isVisible: Bool
+ @Default(.progressBarStyle) private var progressBarStyle
+
+ var body: some View {
+ Group {
+ if shouldShow {
+ Text(formattedValue)
+ .font(.caption)
+ .fontWeight(.semibold)
+ .monospacedDigit()
+ .foregroundStyle(.white.opacity(0.9))
+ .contentTransition(.numericText())
+ .frame(width: 28, alignment: .trailing)
+ }
+ }
+ }
+
+ private var shouldShow: Bool {
+ isVisible && progressBarStyle != .segmented
+ }
+
+ private var formattedValue: String {
+ let raw = max(0, min(100, Int(round(value * 100))))
+ return String(format: "%3d", raw)
+ }
+}
+
+enum ProgressColorMode {
+ case volume
+ case battery
+}
+
+private extension ProgressColorMode {
+ var colorCodingMode: ColorCodingMode {
+ switch self {
+ case .volume:
+ return .volume
+ case .battery:
+ return .battery
+ }
+ }
+}
+
struct DraggableProgressBar: View {
@EnvironmentObject var vm: DynamicIslandViewModel
@Binding var value: CGFloat
+ var colorMode: ProgressColorMode? = nil
@State private var isDragging = false
- @State private var dragOffset: CGFloat = 0
+ @Default(.progressBarStyle) private var progressBarStyle
+ @Default(.useColorCodedVolumeDisplay) private var useColorCodedVolumeDisplay
+ @Default(.useColorCodedBatteryDisplay) private var useColorCodedBatteryDisplay
+ @Default(.useSmoothColorGradient) private var useSmoothColorGradient
+ @Default(.systemEventIndicatorUseAccent) private var useAccentColor
+ @Default(.systemEventIndicatorShadow) private var useShadow
+ @Default(.inlineHUD) private var inlineHUD
var body: some View {
VStack {
GeometryReader { geo in
Group {
- if Defaults[.progressBarStyle] == .segmented {
+ if progressBarStyle == .segmented {
// Segmented progress bar - completely different layout
SegmentedProgressContent(value: value, geometry: geo)
} else {
@@ -107,23 +204,8 @@ struct DraggableProgressBar: View {
ZStack(alignment: .leading) {
Capsule()
.fill(.tertiary)
- Group {
- switch Defaults[.progressBarStyle] {
- case .gradient:
- Capsule()
- .fill(LinearGradient(colors: Defaults[.systemEventIndicatorUseAccent] ? [Defaults[.accentColor], Defaults[.accentColor].ensureMinimumBrightness(factor: 0.2)] : [.white, .white.opacity(0.2)], startPoint: .trailing, endPoint: .leading))
- .frame(width: max(0, min(geo.size.width * value, geo.size.width)))
- .shadow(color: Defaults[.systemEventIndicatorShadow] ? Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) : .white : .clear, radius: 8, x: 3)
- case .hierarchical:
- Capsule()
- .fill(Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor] : .white)
- .frame(width: max(0, min(geo.size.width * value, geo.size.width)))
- .shadow(color: Defaults[.systemEventIndicatorShadow] ? Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) : .white : .clear, radius: 8, x: 3)
- case .segmented:
- EmptyView() // This case won't be reached due to the outer if condition
- }
- }
- .opacity(value.isZero ? 0 : 1)
+ progressFill(width: geo.size.width)
+ .opacity(value.isZero ? 0 : 1)
}
}
}
@@ -142,7 +224,7 @@ struct DraggableProgressBar: View {
}
)
}
- .frame(height: Defaults[.inlineHUD] ? isDragging ? 8 : 5 : isDragging ? 9 : 6)
+ .frame(height: inlineHUD ? (isDragging ? 8 : 5) : (isDragging ? 9 : 6))
}
}
@@ -152,6 +234,59 @@ struct DraggableProgressBar: View {
value = max(0, min(newValue, 1))
}
+
+ private func progressFill(width: CGFloat) -> some View {
+ let clampedWidth = max(0, min(width * value, width))
+ let accentColor = Defaults[.accentColor]
+ let shadowColor: Color = {
+ guard useShadow else { return .clear }
+ if useAccentColor {
+ return accentColor.ensureMinimumBrightness(factor: 0.7)
+ }
+ return .white
+ }()
+
+ return Group {
+ if shouldUseColorCoding, let mode = colorMode?.colorCodingMode {
+ Capsule()
+ .fill(ColorCodedProgressBar.shapeStyle(for: value, mode: mode, smoothGradient: useSmoothColorGradient))
+ .frame(width: clampedWidth)
+ .shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3)
+ } else {
+ switch progressBarStyle {
+ case .gradient:
+ Capsule()
+ .fill(LinearGradient(
+ colors: useAccentColor
+ ? [accentColor, accentColor.ensureMinimumBrightness(factor: 0.2)]
+ : [.white, .white.opacity(0.2)],
+ startPoint: .trailing,
+ endPoint: .leading
+ ))
+ .frame(width: clampedWidth)
+ .shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3)
+ case .hierarchical:
+ Capsule()
+ .fill(useAccentColor ? accentColor : .white)
+ .frame(width: clampedWidth)
+ .shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3)
+ case .segmented:
+ EmptyView()
+ }
+ }
+ }
+ }
+
+ private var shouldUseColorCoding: Bool {
+ guard let colorMode else { return false }
+ guard progressBarStyle != .segmented else { return false }
+ switch colorMode {
+ case .volume:
+ return useColorCodedVolumeDisplay
+ case .battery:
+ return useColorCodedBatteryDisplay
+ }
+ }
}
struct SegmentedProgressContent: View {
@@ -164,8 +299,8 @@ struct SegmentedProgressContent: View {
var body: some View {
let spacing: CGFloat = 1.5
- let computed = (geometry.size.width - CGFloat(segmentCount - 1) * spacing) / CGFloat(segmentCount)
- let barWidth = max(3.0, min(6.0, computed))
+ let computed = (geometry.size.width - CGFloat(segmentCount - 1) * spacing) / CGFloat(segmentCount)
+ let barWidth = max(3.0, computed)
let activeCount = Int(round(value * CGFloat(segmentCount)))
HStack(spacing: spacing) {
diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift
index b539d8b..a351e66 100644
--- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift
+++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift
@@ -13,15 +13,19 @@ struct LockScreenMusicPanel: View {
static let expandedSize = CGSize(width: 720, height: 340)
@ObservedObject var musicManager = MusicManager.shared
+ @ObservedObject private var routeManager = AudioRouteManager.shared
+ @StateObject private var volumeModel = MediaOutputVolumeViewModel()
@State private var sliderValue: Double = 0
@State private var dragging: Bool = false
@State private var isActive = true
@State private var isExpanded = false
+ @State private var isVolumeSliderVisible = false
@State private var collapseWorkItem: DispatchWorkItem?
@Default(.lockScreenGlassStyle) var lockScreenGlassStyle
@Default(.lockScreenShowAppIcon) var showAppIcon
@Default(.lockScreenPanelShowsBorder) var showPanelBorder
@Default(.lockScreenPanelUsesBlur) var enableBlur
+ @Default(.showMediaOutputControl) var showMediaOutputControl
private let collapsedPanelCornerRadius: CGFloat = 28
private let expandedPanelCornerRadius: CGFloat = 52
@@ -29,9 +33,12 @@ struct LockScreenMusicPanel: View {
private let expandedAlbumArtCornerRadius: CGFloat = 60
private let expandedContentSpacing: CGFloat = 40
private let collapseTimeout: TimeInterval = 5
+ private let collapsedSliderExtraHeight: CGFloat = 72
+ private let expandedSliderExtraHeight: CGFloat = 88
private var currentSize: CGSize {
- isExpanded ? Self.expandedSize : Self.collapsedSize
+ let base = isExpanded ? Self.expandedSize : Self.collapsedSize
+ return CGSize(width: base.width, height: base.height + sliderExtraHeight)
}
private var panelCornerRadius: CGFloat {
@@ -58,18 +65,39 @@ struct LockScreenMusicPanel: View {
panelCore
.frame(width: currentSize.width, height: currentSize.height)
.animation(.spring(response: 0.48, dampingFraction: 0.82, blendDuration: 0.18), value: isExpanded)
+ .animation(.spring(response: 0.45, dampingFraction: 0.85, blendDuration: 0.18), value: shouldShowVolumeSlider)
.onAppear {
sliderValue = musicManager.elapsedTime
isActive = true
logPanelAppearance()
- LockScreenPanelManager.shared.updatePanelSize(expanded: false, animated: false)
+ LockScreenPanelManager.shared.updatePanelSize(
+ expanded: isExpanded,
+ additionalHeight: sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider),
+ animated: false
+ )
+ routeManager.refreshDevices()
}
.onDisappear {
isActive = false
cancelCollapseTimer()
+ isVolumeSliderVisible = false
}
.onChange(of: isExpanded) { _, expanded in
- LockScreenPanelManager.shared.updatePanelSize(expanded: expanded)
+ LockScreenPanelManager.shared.updatePanelSize(
+ expanded: expanded,
+ additionalHeight: sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider)
+ )
+ }
+ .onChange(of: showMediaOutputControl) { _, enabled in
+ if !enabled {
+ withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
+ isVolumeSliderVisible = false
+ }
+ }
+ LockScreenPanelManager.shared.updatePanelSize(
+ expanded: isExpanded,
+ additionalHeight: sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
+ )
}
}
@@ -332,29 +360,45 @@ struct LockScreenMusicPanel: View {
private func playbackControls(alignment: Alignment) -> some View {
let spacing: CGFloat = isExpanded ? 24 : 20
- return HStack(spacing: spacing) {
- // Always show shuffle on lock screen
- controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
- musicManager.toggleShuffle()
- }
-
- controlButton(icon: "backward.fill", size: 18) {
- musicManager.previousTrack()
- }
-
- playPauseButton
-
- controlButton(icon: "forward.fill", size: 18) {
- musicManager.nextTrack()
- }
-
- // Always show repeat on lock screen
- controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
- musicManager.toggleRepeat()
+ return VStack(spacing: shouldShowVolumeSlider ? 14 : 10) {
+ controlsRow(alignment: alignment, spacing: spacing)
+
+ if shouldShowVolumeSlider {
+ volumeSlider
+ .frame(maxWidth: .infinity, alignment: alignment)
+ .transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
}
}
.frame(maxWidth: .infinity, alignment: alignment)
.padding(.top, isExpanded ? 6 : 2)
+ .animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowVolumeSlider)
+ }
+
+ private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
+ HStack(spacing: spacing) {
+ controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
+ musicManager.toggleShuffle()
+ }
+
+ controlButton(icon: "backward.fill", size: 18) {
+ musicManager.previousTrack()
+ }
+
+ playPauseButton
+
+ controlButton(icon: "forward.fill", size: 18) {
+ musicManager.nextTrack()
+ }
+
+ if showMediaOutputControl {
+ mediaOutputControlButton
+ } else {
+ controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
+ musicManager.toggleRepeat()
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: alignment)
}
private var playPauseButton: some View {
@@ -391,6 +435,21 @@ struct LockScreenMusicPanel: View {
.buttonStyle(PlainButtonStyle())
}
+ private var mediaOutputControlButton: some View {
+ let frameSize: CGFloat = isExpanded ? 56 : 32
+ let iconSize: CGFloat = isExpanded ? 26 : 18
+
+ return Button(action: toggleVolumeSlider) {
+ Image(systemName: mediaOutputIcon)
+ .font(.system(size: iconSize, weight: .medium))
+ .foregroundColor(shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8))
+ .frame(width: frameSize, height: frameSize)
+ .contentShape(Rectangle())
+ }
+ .accessibilityLabel("Media output")
+ .buttonStyle(PlainButtonStyle())
+ }
+
private var repeatIcon: String {
switch musicManager.repeatMode {
case .off: return "repeat"
@@ -399,6 +458,87 @@ struct LockScreenMusicPanel: View {
}
}
+ private var mediaOutputIcon: String {
+ routeManager.activeDevice?.iconName ?? "speaker.wave.2"
+ }
+
+ private var volumeSlider: some View {
+ HStack(spacing: 14) {
+ Image(systemName: volumeIconName)
+ .font(.system(size: isExpanded ? 16 : 14, weight: .semibold))
+ .foregroundColor(.white.opacity(0.8))
+
+ Slider(
+ value: Binding(
+ get: { Double(volumeModel.level) },
+ set: { newValue in
+ registerInteraction()
+ volumeModel.setVolume(Float(newValue))
+ }
+ ),
+ in: 0 ... 1
+ )
+ .tint(sliderColor)
+
+ Text(volumePercentage)
+ .font(.system(size: isExpanded ? 12 : 11, weight: .medium, design: .monospaced))
+ .foregroundColor(.white.opacity(0.7))
+ .frame(width: 48, alignment: .trailing)
+ }
+ .padding(.vertical, 8)
+ .padding(.horizontal, 12)
+ .background(
+ RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous)
+ .fill(Color.white.opacity(0.08))
+ )
+ }
+
+ private var shouldShowVolumeSlider: Bool {
+ showMediaOutputControl && isVolumeSliderVisible
+ }
+
+ private var sliderExtraHeight: CGFloat {
+ sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
+ }
+
+ private var volumeIconName: String {
+ if volumeModel.isMuted || volumeModel.level <= 0.001 {
+ return "speaker.slash.fill"
+ } else if volumeModel.level < 0.33 {
+ return "speaker.wave.1.fill"
+ } else if volumeModel.level < 0.66 {
+ return "speaker.wave.2.fill"
+ }
+ return "speaker.wave.3.fill"
+ }
+
+ private var volumePercentage: String {
+ "\(Int(round(volumeModel.level * 100)))%"
+ }
+
+ private func toggleVolumeSlider() {
+ registerInteraction()
+ let newState = !isVolumeSliderVisible
+ if newState {
+ routeManager.refreshDevices()
+ }
+
+ withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
+ isVolumeSliderVisible = newState
+ }
+
+ let targetHeight = sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
+ LockScreenPanelManager.shared.updatePanelSize(
+ expanded: isExpanded,
+ additionalHeight: targetHeight
+ )
+ }
+
+ private func sliderHeight(forExpanded expanded: Bool, visible: Bool) -> CGFloat {
+ guard visible else { return 0 }
+ return expanded ? expandedSliderExtraHeight : collapsedSliderExtraHeight
+ }
+
@ViewBuilder
private var panelBackground: some View {
if enableBlur {
diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift
index 9aa2bee..07fdb19 100644
--- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift
+++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift
@@ -3,22 +3,25 @@ import SwiftUI
struct LockScreenWeatherWidget: View {
let snapshot: LockScreenWeatherSnapshot
+ private let primaryFont = Font.system(size: 22, weight: .semibold, design: .rounded)
+
var body: some View {
- HStack(spacing: 8) {
- Image(systemName: snapshot.symbolName)
- .font(.system(size: 26, weight: .medium))
- .symbolRenderingMode(.hierarchical)
- Text(snapshot.temperatureText)
- .font(.system(size: 24, weight: .semibold, design: .rounded))
- .kerning(-0.4)
- if let locationName = snapshot.locationName, !locationName.isEmpty {
- Text("•")
- .font(.system(size: 22, weight: .semibold, design: .rounded))
- Text(locationName)
- .font(.system(size: 20, weight: .medium, design: .rounded))
- .foregroundStyle(Color.white.opacity(0.65))
+ HStack(alignment: .firstTextBaseline, spacing: 16) {
+ if let charging = snapshot.charging {
+ chargingSegment(for: charging)
+ }
+
+ if let bluetooth = snapshot.bluetooth {
+ bluetoothSegment(for: bluetooth)
+ }
+
+ weatherSegment
+
+ if shouldShowLocation {
+ locationSegment
}
}
+ .frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(Color.white.opacity(0.65))
.padding(.horizontal, 10)
.padding(.vertical, 6)
@@ -28,20 +31,151 @@ struct LockScreenWeatherWidget: View {
.accessibilityLabel(accessibilityLabel)
}
+ private var weatherSegment: some View {
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ Image(systemName: snapshot.symbolName)
+ .font(.system(size: 26, weight: .medium))
+ .symbolRenderingMode(.hierarchical)
+ Text(snapshot.temperatureText)
+ .font(primaryFont)
+ .kerning(-0.3)
+ .lineLimit(1)
+ .minimumScaleFactor(0.9)
+ .layoutPriority(2)
+ }
+ }
+
+ @ViewBuilder
+ private func chargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
+ if let iconName = chargingIconName(for: info) {
+ HStack(spacing: 4) {
+ Image(systemName: iconName)
+ .font(.system(size: 20, weight: .semibold))
+ .symbolRenderingMode(.hierarchical)
+
+ if let text = formattedChargingTime(for: info) {
+ Text(text)
+ .font(primaryFont)
+ .lineLimit(1)
+ .minimumScaleFactor(0.75)
+ .layoutPriority(1.2)
+ }
+ }
+ .layoutPriority(1)
+ }
+ }
+
+ @ViewBuilder
+ private func bluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
+ HStack(spacing: 4) {
+ Image(systemName: info.iconName)
+ .font(.system(size: 20, weight: .semibold))
+ .symbolRenderingMode(.hierarchical)
+ Text("\(clampedBatteryLevel(info.batteryLevel))%")
+ .font(primaryFont)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .minimumScaleFactor(0.85)
+ .layoutPriority(1.4)
+ }
+ }
+
+ @ViewBuilder
+ private var locationSegment: some View {
+ if let locationName = snapshot.locationName {
+ Text(locationName)
+ .font(primaryFont)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .minimumScaleFactor(0.75)
+ .layoutPriority(0.7)
+ }
+ }
+
+ private var shouldShowLocation: Bool {
+ snapshot.showsLocation && (snapshot.locationName?.isEmpty == false)
+ }
+
+ private func chargingIconName(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? {
+ let icon = info.iconName
+ return icon.isEmpty ? nil : icon
+ }
+
+ private func formattedChargingTime(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? {
+ guard let minutes = info.minutesRemaining, minutes > 0 else {
+ return nil
+ }
+
+ let hours = minutes / 60
+ let remainingMinutes = minutes % 60
+
+ if hours > 0 {
+ return "\(hours)h \(remainingMinutes)m"
+ }
+ return "\(remainingMinutes)m"
+ }
+
+ private func clampedBatteryLevel(_ level: Int) -> Int {
+ min(max(level, 0), 100)
+ }
+
private var accessibilityLabel: String {
- if let locationName = snapshot.locationName, !locationName.isEmpty {
- return String(
- format: NSLocalizedString("Weather: %@ %@ in %@", comment: "Weather description, temperature, and location"),
- snapshot.description,
- snapshot.temperatureText,
- locationName
+ var components: [String] = []
+
+ if snapshot.showsLocation, let locationName = snapshot.locationName, !locationName.isEmpty {
+ components.append(
+ String(
+ format: NSLocalizedString("Weather: %@ %@ in %@", comment: "Weather description, temperature, and location"),
+ snapshot.description,
+ snapshot.temperatureText,
+ locationName
+ )
+ )
+ } else {
+ components.append(
+ String(
+ format: NSLocalizedString("Weather: %@ %@", comment: "Weather description and temperature"),
+ snapshot.description,
+ snapshot.temperatureText
+ )
)
}
+ if let charging = snapshot.charging {
+ components.append(accessibilityChargingText(for: charging))
+ }
+
+ if let bluetooth = snapshot.bluetooth {
+ components.append(accessibilityBluetoothText(for: bluetooth))
+ }
+
+ return components.joined(separator: ". ")
+ }
+
+ private func accessibilityChargingText(for charging: LockScreenWeatherSnapshot.ChargingInfo) -> String {
+ if let minutes = charging.minutesRemaining, minutes > 0 {
+ let formatter = DateComponentsFormatter()
+ formatter.allowedUnits = [.hour, .minute]
+ formatter.unitsStyle = .full
+ let duration = formatter.string(from: TimeInterval(minutes * 60)) ?? "\(minutes) minutes"
+ return String(
+ format: NSLocalizedString("Battery charging, %@ remaining", comment: "Charging time remaining"),
+ duration
+ )
+ }
+
+ if charging.isPluggedIn && !charging.isCharging {
+ return NSLocalizedString("Battery fully charged", comment: "Battery is full")
+ }
+
+ return NSLocalizedString("Battery charging", comment: "Battery charging without estimate")
+ }
+
+ private func accessibilityBluetoothText(for bluetooth: LockScreenWeatherSnapshot.BluetoothInfo) -> String {
return String(
- format: NSLocalizedString("Weather: %@ %@", comment: "Weather description and temperature"),
- snapshot.description,
- snapshot.temperatureText
+ format: NSLocalizedString("Bluetooth device %@ at %d percent", comment: "Bluetooth device battery"),
+ bluetooth.deviceName,
+ bluetooth.batteryLevel
)
}
}
diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift
index a18243f..515fc79 100644
--- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift
+++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift
@@ -11,6 +11,7 @@ import Defaults
struct MinimalisticMusicPlayerView: View {
@EnvironmentObject var vm: DynamicIslandViewModel
let albumArtNamespace: Namespace.ID
+ @Default(.showMediaOutputControl) var showMediaOutputControl
var body: some View {
VStack(spacing: 0) {
@@ -182,8 +183,12 @@ struct MinimalisticMusicPlayerView: View {
}
if Defaults[.showShuffleAndRepeat] {
- controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
- Task { await musicManager.toggleRepeat() }
+ if showMediaOutputControl {
+ MinimalisticMediaOutputButton()
+ } else {
+ controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
+ Task { await musicManager.toggleRepeat() }
+ }
}
}
}
@@ -216,7 +221,61 @@ struct MinimalisticMusicPlayerView: View {
action: action
)
}
-
+ private struct MinimalisticMediaOutputButton: View {
+ @ObservedObject private var routeManager = AudioRouteManager.shared
+ @StateObject private var volumeModel = MediaOutputVolumeViewModel()
+ @EnvironmentObject private var vm: DynamicIslandViewModel
+ @State private var isPopoverPresented = false
+ @State private var isHoveringPopover = false
+
+ var body: some View {
+ MinimalisticSquircircleButton(
+ icon: routeManager.activeDevice?.iconName ?? "speaker.wave.2",
+ fontSize: 18,
+ fontWeight: .medium,
+ frameSize: CGSize(width: 40, height: 40),
+ cornerRadius: 16,
+ foregroundColor: .white.opacity(0.85)
+ ) {
+ isPopoverPresented.toggle()
+ if isPopoverPresented {
+ routeManager.refreshDevices()
+ }
+ }
+ .accessibilityLabel("Media output")
+ .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
+ MediaOutputSelectorPopover(
+ routeManager: routeManager,
+ volumeModel: volumeModel,
+ onHoverChanged: { hovering in
+ isHoveringPopover = hovering
+ updateActivity()
+ }
+ ) {
+ isPopoverPresented = false
+ isHoveringPopover = false
+ updateActivity()
+ }
+ }
+ .onChange(of: isPopoverPresented) { _, presented in
+ if !presented {
+ isHoveringPopover = false
+ }
+ updateActivity()
+ }
+ .onAppear {
+ routeManager.refreshDevices()
+ }
+ .onDisappear {
+ vm.isMediaOutputPopoverActive = false
+ }
+ }
+
+ private func updateActivity() {
+ vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
+ }
+ }
+
private var repeatIcon: String {
switch musicManager.repeatMode {
case .off: return "repeat"
diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift
index f999438..78fd7c8 100644
--- a/DynamicIsland/components/Notch/NotchHomeView.swift
+++ b/DynamicIsland/components/Notch/NotchHomeView.swift
@@ -116,6 +116,7 @@ struct MusicControlsView: View {
@State private var dragging: Bool = false
@State private var lastDragged: Date = .distantPast
let showShuffleAndRepeat: Bool
+ @Default(.showMediaOutputControl) private var showMediaOutputControl
var body: some View {
VStack(alignment: .leading) {
@@ -192,8 +193,12 @@ struct MusicControlsView: View {
MusicManager.shared.nextTrack()
}
if showShuffleAndRepeat {
- HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) {
- MusicManager.shared.toggleRepeat()
+ if showMediaOutputControl {
+ MediaOutputPickerButton()
+ } else {
+ HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) {
+ MusicManager.shared.toggleRepeat()
+ }
}
}
}
@@ -382,6 +387,239 @@ struct CustomSlider: View {
}
}
+private struct MediaOutputPickerButton: View {
+ @ObservedObject private var routeManager = AudioRouteManager.shared
+ @StateObject private var volumeModel = MediaOutputVolumeViewModel()
+ @State private var isPopoverPresented = false
+ @State private var isHoveringPopover = false
+ @EnvironmentObject private var vm: DynamicIslandViewModel
+
+ var body: some View {
+ HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) {
+ isPopoverPresented.toggle()
+ if isPopoverPresented {
+ routeManager.refreshDevices()
+ }
+ }
+ .accessibilityLabel("Media output")
+ .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
+ MediaOutputSelectorPopover(
+ routeManager: routeManager,
+ volumeModel: volumeModel,
+ onHoverChanged: { hovering in
+ isHoveringPopover = hovering
+ updatePopoverActivity()
+ }
+ ) {
+ isPopoverPresented = false
+ isHoveringPopover = false
+ updatePopoverActivity()
+ }
+ }
+ .onAppear {
+ routeManager.refreshDevices()
+ }
+ .onChange(of: isPopoverPresented) { _, presented in
+ if !presented {
+ isHoveringPopover = false
+ }
+ updatePopoverActivity()
+ }
+ .onDisappear {
+ vm.isMediaOutputPopoverActive = false
+ }
+ }
+
+ private var buttonIcon: String {
+ routeManager.activeDevice?.iconName ?? "speaker.wave.2"
+ }
+
+ private func updatePopoverActivity() {
+ vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
+ }
+}
+
+struct MediaOutputSelectorPopover: View {
+ @ObservedObject var routeManager: AudioRouteManager
+ @ObservedObject var volumeModel: MediaOutputVolumeViewModel
+ var onHoverChanged: (Bool) -> Void
+ var dismiss: () -> Void
+
+ var body: some View {
+ VStack(spacing: 16) {
+ volumeSection
+ Divider()
+ devicesSection
+ }
+ .frame(width: 240)
+ .padding(16)
+ .onHover { hovering in
+ onHoverChanged(hovering)
+ }
+ .onDisappear {
+ onHoverChanged(false)
+ }
+ }
+
+ private var volumeSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 12) {
+ Button {
+ volumeModel.toggleMute()
+ } label: {
+ Image(systemName: volumeIconName)
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundColor(.primary)
+ .frame(width: 28, height: 28)
+ .background(
+ Circle()
+ .fill(Color.secondary.opacity(0.18))
+ )
+ .contentShape(Circle())
+ }
+ .buttonStyle(.plain)
+
+ Slider(
+ value: Binding(
+ get: { Double(volumeModel.level) },
+ set: { newValue in
+ volumeModel.setVolume(Float(newValue))
+ }
+ ),
+ in: 0 ... 1
+ )
+ .tint(.accentColor)
+ }
+
+ HStack {
+ Text("Output volume")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Spacer()
+ Text(volumePercentage)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+
+ private var devicesSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Output devices")
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ if routeManager.devices.isEmpty {
+ Text("No audio outputs available")
+ .font(.callout)
+ .foregroundColor(.secondary)
+ .padding(.vertical, 12)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else {
+ ScrollView {
+ VStack(spacing: 6) {
+ ForEach(routeManager.devices) { device in
+ Button {
+ routeManager.select(device: device)
+ dismiss()
+ } label: {
+ HStack(spacing: 8) {
+ Image(systemName: device.iconName)
+ .font(.system(size: 14, weight: .medium))
+ Text(device.name)
+ .foregroundColor(.primary)
+ .lineLimit(1)
+ Spacer()
+ if device.id == routeManager.activeDeviceID {
+ Image(systemName: "checkmark")
+ .font(.system(size: 12, weight: .bold))
+ }
+ }
+ .padding(.vertical, 6)
+ .padding(.horizontal, 8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(device.id == routeManager.activeDeviceID ? Color.primary.opacity(0.12) : .clear)
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .frame(maxHeight: 180)
+ }
+ }
+ }
+
+ private var volumeIconName: String {
+ if volumeModel.isMuted || volumeModel.level <= 0.001 {
+ return "speaker.slash.fill"
+ } else if volumeModel.level < 0.33 {
+ return "speaker.wave.1.fill"
+ } else if volumeModel.level < 0.66 {
+ return "speaker.wave.2.fill"
+ }
+ return "speaker.wave.3.fill"
+ }
+
+ private var volumePercentage: String {
+ "\(Int(round(volumeModel.level * 100)))%"
+ }
+}
+
+final class MediaOutputVolumeViewModel: ObservableObject {
+ @Published var level: Float
+ @Published var isMuted: Bool
+
+ private let controller: SystemVolumeController
+ private var cancellables: Set
+ Join our Discord community — 1,200+ members +
> **Project rename:** DynamicIsland is now called **Atoll**. Visit the new repository at [github.com/Ebullioscopic/Atoll](https://github.com/Ebullioscopic/Atoll). # Atoll @@ -57,6 +75,16 @@ The application provides a comprehensive interface for monitoring system perform
+### LockScreen Widgets
+- **Weather**
+- **Media Player**
+- **Device Charging status**
+- **Bluetooth Peripheral Battery status**
+
+
+
+