mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
implemneted new huds system and fixed some bugs
This commit is contained in:
parent
44c053a761
commit
d9d3bc937c
12 changed files with 1515 additions and 263 deletions
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 "keyboard"
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue