diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index c493d44..d79dfa8 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -12419,6 +12419,10 @@ }, "shouldTranslate" : false }, + "HUD icon style" : { + "comment" : "A label describing the style of the 3D or symbol-based Bluetooth HUD icon.", + "isCommentAutoGenerated" : true + }, "HUD Position" : { "localizations" : { "pt-BR" : { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index bb30b13..e6fcba9 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1776,6 +1776,7 @@ private struct HUDSelectionCard: View { private struct DevicesSettingsView: View { @Default(.progressBarStyle) var progressBarStyle + @Default(.useBluetoothHUD3DIcon) private var useBluetoothHUD3DIcon private func highlightID(_ title: String) -> String { SettingsTab.devices.highlightID(for: title) @@ -1796,8 +1797,29 @@ private struct DevicesSettingsView: View { .settingsHighlight(id: highlightID("Show battery percentage text in HUD")) Defaults.Toggle("Scroll device name in HUD", key: .showBluetoothDeviceNameMarquee) .settingsHighlight(id: highlightID("Scroll device name in HUD")) - Defaults.Toggle("Use 3D Bluetooth HUD icon", key: .useBluetoothHUD3DIcon) - .settingsHighlight(id: highlightID("Use 3D Bluetooth HUD icon")) + VStack(alignment: .leading, spacing: 12) { + Text("HUD icon style") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + + HStack(spacing: 16) { + Spacer(minLength: 0) + BluetoothHUDIconStyleCard( + style: .symbol, + isSelected: !useBluetoothHUD3DIcon + ) { + useBluetoothHUD3DIcon = false + } + BluetoothHUDIconStyleCard( + style: .threeD, + isSelected: useBluetoothHUD3DIcon + ) { + useBluetoothHUD3DIcon = true + } + Spacer(minLength: 0) + } + } + .settingsHighlight(id: highlightID("Use 3D Bluetooth HUD icon")) } header: { Text("Bluetooth Audio Devices") } footer: { @@ -2669,6 +2691,141 @@ struct About: View { } } +private extension DevicesSettingsView { + enum BluetoothHUDIconStyle: String { + case symbol + case threeD + + var title: String { + switch self { + case .symbol: + return "Symbol" + case .threeD: + return "3D" + } + } + } + + struct BluetoothHUDIconStyleCard: View { + let style: BluetoothHUDIconStyle + let isSelected: Bool + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + VStack(spacing: 8) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(borderColor, lineWidth: isSelected ? 2 : 1) + ) + + preview + } + .frame(width: 90, height: 64) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHovering = hovering + } + } + + Text(style.title) + .font(.caption) + .fontWeight(.medium) + } + .contentShape(Rectangle()) + .onTapGesture { action() } + } + + private var preview: some View { + Group { + switch style { + case .symbol: + Image(systemName: BluetoothAudioDeviceType.airpods.sfSymbol) + .font(.system(size: 24, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + case .threeD: + if let url = Bundle.main.url( + forResource: BluetoothAudioDeviceType.airpods.inlineHUDAnimationBaseName, + withExtension: "mov" + ) { + SettingsLoopingVideoIcon(url: url, size: CGSize(width: 28, height: 28)) + .frame(width: 28, height: 28) + } else { + Image(systemName: BluetoothAudioDeviceType.airpods.sfSymbol) + .font(.system(size: 24, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + } + } + } + } + + private var backgroundColor: Color { + if isSelected { return Color.accentColor.opacity(0.12) } + if isHovering { return Color.primary.opacity(0.05) } + return Color(nsColor: .controlBackgroundColor) + } + + private var borderColor: Color { + if isSelected { return Color.accentColor } + if isHovering { return Color.primary.opacity(0.1) } + return Color.clear + } + } +} + +private struct SettingsLoopingVideoIcon: NSViewRepresentable { + let url: URL + let size: CGSize + + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: NSRect(origin: .zero, size: size)) + view.wantsLayer = true + + let layer = AVPlayerLayer() + layer.videoGravity = .resizeAspect + layer.frame = view.bounds + view.layer?.addSublayer(layer) + context.coordinator.attach(layer: layer, url: url) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) {} + + func makeCoordinator() -> Coordinator { Coordinator() } + + final class Coordinator { + private var controller: SettingsLoopingPlayerController? + + func attach(layer: AVPlayerLayer, url: URL) { + controller = SettingsLoopingPlayerController(url: url) + layer.player = controller?.player + } + } +} + +private final class SettingsLoopingPlayerController { + let player: AVQueuePlayer + private var looper: AVPlayerLooper? + + init(url: URL) { + let item = AVPlayerItem(url: url) + player = AVQueuePlayer() + player.isMuted = true + player.actionAtItemEnd = .none + looper = AVPlayerLooper(player: player, templateItem: item) + player.play() + } + + deinit { + player.pause() + looper = nil + } +} + struct Shelf: View { @Default(.quickShareProvider) var quickShareProvider @Default(.expandedDragDetection) var expandedDragDetection