mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-28 15:44:32 +00:00
working some stuff
This commit is contained in:
parent
a135c8a590
commit
9677be3f68
8 changed files with 582 additions and 12 deletions
|
|
@ -200,14 +200,14 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
.onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in
|
.onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
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()
|
vm.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in
|
.onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
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()
|
vm.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -215,7 +215,7 @@ struct ContentView: View {
|
||||||
.onChange(of: vm.shouldRecheckHover) { _, _ in
|
.onChange(of: vm.shouldRecheckHover) { _, _ in
|
||||||
// Recheck hover state when popovers are closed
|
// Recheck hover state when popovers are closed
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
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()
|
vm.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -751,10 +751,11 @@ struct ContentView: View {
|
||||||
|
|
||||||
// Helper function to check if any popovers are active
|
// Helper function to check if any popovers are active
|
||||||
private func hasAnyActivePopovers() -> Bool {
|
private func hasAnyActivePopovers() -> Bool {
|
||||||
return vm.isBatteryPopoverActive ||
|
return vm.isBatteryPopoverActive ||
|
||||||
vm.isClipboardPopoverActive ||
|
vm.isClipboardPopoverActive ||
|
||||||
vm.isColorPickerPopoverActive ||
|
vm.isColorPickerPopoverActive ||
|
||||||
vm.isStatsPopoverActive
|
vm.isStatsPopoverActive ||
|
||||||
|
vm.isMediaOutputPopoverActive
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to prevent rapid haptic feedback
|
// Helper to prevent rapid haptic feedback
|
||||||
|
|
|
||||||
|
|
@ -10257,6 +10257,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Media output" : {
|
||||||
|
"comment" : "Accessibility label describing the control that opens the media output selector.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Media inactivity timeout" : {
|
"Media inactivity timeout" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -13751,8 +13755,24 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"No audio outputs available" : {
|
||||||
|
"comment" : "Displayed when no audio output devices can be listed in the media output selector.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
"Reminders" : {
|
"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." : {
|
"Replaces system HUD notifications with Dynamic Island displays." : {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import Defaults
|
||||||
struct MinimalisticMusicPlayerView: View {
|
struct MinimalisticMusicPlayerView: View {
|
||||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
let albumArtNamespace: Namespace.ID
|
let albumArtNamespace: Namespace.ID
|
||||||
|
@Default(.showMediaOutputControl) var showMediaOutputControl
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|
@ -182,8 +183,12 @@ struct MinimalisticMusicPlayerView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
if Defaults[.showShuffleAndRepeat] {
|
if Defaults[.showShuffleAndRepeat] {
|
||||||
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
|
if showMediaOutputControl {
|
||||||
Task { await musicManager.toggleRepeat() }
|
MinimalisticMediaOutputButton()
|
||||||
|
} else {
|
||||||
|
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
|
||||||
|
Task { await musicManager.toggleRepeat() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -216,7 +221,61 @@ struct MinimalisticMusicPlayerView: View {
|
||||||
action: action
|
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 {
|
private var repeatIcon: String {
|
||||||
switch musicManager.repeatMode {
|
switch musicManager.repeatMode {
|
||||||
case .off: return "repeat"
|
case .off: return "repeat"
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ struct MusicControlsView: View {
|
||||||
@State private var dragging: Bool = false
|
@State private var dragging: Bool = false
|
||||||
@State private var lastDragged: Date = .distantPast
|
@State private var lastDragged: Date = .distantPast
|
||||||
let showShuffleAndRepeat: Bool
|
let showShuffleAndRepeat: Bool
|
||||||
|
@Default(.showMediaOutputControl) private var showMediaOutputControl
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
|
|
@ -192,8 +193,12 @@ struct MusicControlsView: View {
|
||||||
MusicManager.shared.nextTrack()
|
MusicManager.shared.nextTrack()
|
||||||
}
|
}
|
||||||
if showShuffleAndRepeat {
|
if showShuffleAndRepeat {
|
||||||
HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) {
|
if showMediaOutputControl {
|
||||||
MusicManager.shared.toggleRepeat()
|
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<AnyCancellable> = []
|
||||||
|
|
||||||
|
init(controller: SystemVolumeController = .shared) {
|
||||||
|
self.controller = controller
|
||||||
|
controller.start()
|
||||||
|
level = controller.currentVolume
|
||||||
|
isMuted = controller.isMuted
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .systemVolumeDidChange)
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.sink { [weak self] notification in
|
||||||
|
guard let self,
|
||||||
|
let value = notification.userInfo?["value"] as? Float,
|
||||||
|
let muted = notification.userInfo?["muted"] as? Bool else { return }
|
||||||
|
self.level = value
|
||||||
|
self.isMuted = muted
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .systemAudioRouteDidChange)
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.syncFromController()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVolume(_ value: Float) {
|
||||||
|
level = value
|
||||||
|
if value > 0 {
|
||||||
|
isMuted = false
|
||||||
|
}
|
||||||
|
controller.setVolume(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleMute() {
|
||||||
|
isMuted.toggle()
|
||||||
|
controller.toggleMute()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncFromController() {
|
||||||
|
level = controller.currentVolume
|
||||||
|
isMuted = controller.isMuted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
NotchHomeView(
|
NotchHomeView(
|
||||||
albumArtNamespace: Namespace().wrappedValue
|
albumArtNamespace: Namespace().wrappedValue
|
||||||
|
|
|
||||||
|
|
@ -747,6 +747,7 @@ struct Media: View {
|
||||||
@Default(.enableSneakPeek) private var enableSneakPeek
|
@Default(.enableSneakPeek) private var enableSneakPeek
|
||||||
@Default(.sneakPeekStyles) var sneakPeekStyles
|
@Default(.sneakPeekStyles) var sneakPeekStyles
|
||||||
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
||||||
|
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
|
|
@ -787,6 +788,10 @@ struct Media: View {
|
||||||
customBadge(text: "Beta")
|
customBadge(text: "Beta")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Defaults.Toggle(key: .showMediaOutputControl) {
|
||||||
|
Text("Replace repeat button with media output control")
|
||||||
|
}
|
||||||
|
.disabled(!showShuffleAndRepeat)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Media controls")
|
Text("Media controls")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
245
DynamicIsland/managers/AudioRouteManager.swift
Normal file
245
DynamicIsland/managers/AudioRouteManager.swift
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
import Combine
|
||||||
|
import CoreAudio
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct AudioOutputDevice: Identifiable, Equatable {
|
||||||
|
let id: AudioDeviceID
|
||||||
|
let name: String
|
||||||
|
let transportType: UInt32
|
||||||
|
|
||||||
|
var iconName: String {
|
||||||
|
let normalizedName = name.lowercased()
|
||||||
|
|
||||||
|
if normalizedName.contains("airpods") {
|
||||||
|
return "airpodspro"
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalizedName.contains("macbook") {
|
||||||
|
return "laptopcomputer"
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalizedName.contains("headphone") || normalizedName.contains("headset") {
|
||||||
|
return "headphones"
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalizedName.contains("beats") {
|
||||||
|
return "headphones"
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalizedName.contains("homepod") {
|
||||||
|
return "hifispeaker.2"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch transportType {
|
||||||
|
case kAudioDeviceTransportTypeBluetooth:
|
||||||
|
if normalizedName.contains("speaker") {
|
||||||
|
return "speaker.wave.2"
|
||||||
|
}
|
||||||
|
return "headphones"
|
||||||
|
case kAudioDeviceTransportTypeAirPlay:
|
||||||
|
return "airplayaudio"
|
||||||
|
case kAudioDeviceTransportTypeDisplayPort, kAudioDeviceTransportTypeHDMI:
|
||||||
|
return "tv"
|
||||||
|
case kAudioDeviceTransportTypeUSB, kAudioDeviceTransportTypeFireWire:
|
||||||
|
return "hifispeaker.2"
|
||||||
|
case kAudioDeviceTransportTypePCI, kAudioDeviceTransportTypeVirtual:
|
||||||
|
return "speaker.wave.2"
|
||||||
|
case kAudioDeviceTransportTypeBuiltIn:
|
||||||
|
return normalizedName.contains("display") ? "tv" : "speaker.wave.2"
|
||||||
|
default:
|
||||||
|
return "speaker.wave.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AudioRouteManager: ObservableObject {
|
||||||
|
static let shared = AudioRouteManager()
|
||||||
|
|
||||||
|
@Published private(set) var devices: [AudioOutputDevice] = []
|
||||||
|
@Published private(set) var activeDeviceID: AudioDeviceID = 0
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "com.dynamicisland.audio-route", qos: .userInitiated)
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
refreshDevices()
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleRouteChange),
|
||||||
|
name: .systemAudioRouteDidChange,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeDevice: AudioOutputDevice? {
|
||||||
|
devices.first { $0.id == activeDeviceID }
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshDevices() {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let defaultID = self.fetchDefaultOutputDevice()
|
||||||
|
let deviceInfos = self.fetchOutputDeviceIDs().compactMap(self.makeDeviceInfo)
|
||||||
|
let sortedDevices = deviceInfos.sorted { lhs, rhs in
|
||||||
|
if lhs.id == defaultID { return true }
|
||||||
|
if rhs.id == defaultID { return false }
|
||||||
|
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.activeDeviceID = defaultID
|
||||||
|
self.devices = sortedDevices
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func select(device: AudioOutputDevice) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.setDefaultOutputDevice(device.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
@objc private func handleRouteChange() {
|
||||||
|
refreshDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchOutputDeviceIDs() -> [AudioDeviceID] {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
|
||||||
|
var dataSize: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&address,
|
||||||
|
0,
|
||||||
|
nil,
|
||||||
|
&dataSize
|
||||||
|
) == noErr else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let deviceCount = Int(dataSize) / MemoryLayout<AudioDeviceID>.size
|
||||||
|
var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount)
|
||||||
|
let status = AudioObjectGetPropertyData(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&address,
|
||||||
|
0,
|
||||||
|
nil,
|
||||||
|
&dataSize,
|
||||||
|
&deviceIDs
|
||||||
|
)
|
||||||
|
if status != noErr {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return deviceIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeDeviceInfo(for deviceID: AudioDeviceID) -> AudioOutputDevice? {
|
||||||
|
guard deviceHasOutputChannels(deviceID) else { return nil }
|
||||||
|
let name = deviceName(for: deviceID) ?? "Unknown Device"
|
||||||
|
let transport = transportType(for: deviceID)
|
||||||
|
return AudioOutputDevice(id: deviceID, name: name, transportType: transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deviceHasOutputChannels(_ deviceID: AudioDeviceID) -> Bool {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||||
|
mScope: kAudioDevicePropertyScopeOutput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
|
||||||
|
var dataSize: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &dataSize) == noErr else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer = UnsafeMutableRawPointer.allocate(byteCount: Int(dataSize), alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||||
|
defer { buffer.deallocate() }
|
||||||
|
|
||||||
|
guard AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, buffer) == noErr else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let audioBufferListPointer = buffer.assumingMemoryBound(to: AudioBufferList.self)
|
||||||
|
let audioBuffers = UnsafeMutableAudioBufferListPointer(audioBufferListPointer)
|
||||||
|
let channelCount = audioBuffers.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||||
|
return channelCount > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deviceName(for deviceID: AudioDeviceID) -> String? {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioObjectPropertyName,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
|
||||||
|
var name: CFString = "" as CFString
|
||||||
|
var dataSize = UInt32(MemoryLayout<CFString?>.size)
|
||||||
|
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, &name)
|
||||||
|
guard status == noErr else { return nil }
|
||||||
|
return name as String
|
||||||
|
}
|
||||||
|
|
||||||
|
private func transportType(for deviceID: AudioDeviceID) -> UInt32 {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyTransportType,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
|
||||||
|
var type: UInt32 = kAudioDeviceTransportTypeUnknown
|
||||||
|
var dataSize = UInt32(MemoryLayout<UInt32>.size)
|
||||||
|
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, &type)
|
||||||
|
return status == noErr ? type : kAudioDeviceTransportTypeUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchDefaultOutputDevice() -> AudioDeviceID {
|
||||||
|
var deviceID = AudioDeviceID()
|
||||||
|
var dataSize = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
let status = AudioObjectGetPropertyData(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&address,
|
||||||
|
0,
|
||||||
|
nil,
|
||||||
|
&dataSize,
|
||||||
|
&deviceID
|
||||||
|
)
|
||||||
|
return status == noErr ? deviceID : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setDefaultOutputDevice(_ deviceID: AudioDeviceID) {
|
||||||
|
var target = deviceID
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
let status = AudioObjectSetPropertyData(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&address,
|
||||||
|
0,
|
||||||
|
nil,
|
||||||
|
UInt32(MemoryLayout<AudioDeviceID>.size),
|
||||||
|
&target
|
||||||
|
)
|
||||||
|
if status == noErr {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.activeDeviceID = deviceID
|
||||||
|
}
|
||||||
|
refreshDevices()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -334,6 +334,7 @@ extension Defaults.Keys {
|
||||||
static let enableFullscreenMediaDetection = Key<Bool>("enableFullscreenMediaDetection", default: true)
|
static let enableFullscreenMediaDetection = Key<Bool>("enableFullscreenMediaDetection", default: true)
|
||||||
static let waitInterval = Key<Double>("waitInterval", default: 3)
|
static let waitInterval = Key<Double>("waitInterval", default: 3)
|
||||||
static let showShuffleAndRepeat = Key<Bool>("showShuffleAndRepeat", default: false)
|
static let showShuffleAndRepeat = Key<Bool>("showShuffleAndRepeat", default: false)
|
||||||
|
static let showMediaOutputControl = Key<Bool>("showMediaOutputControl", default: false)
|
||||||
// Enable lock screen media widget (shows the standalone panel when screen is locked)
|
// Enable lock screen media widget (shows the standalone panel when screen is locked)
|
||||||
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
|
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
|
||||||
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
|
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
@Published var isClipboardPopoverActive: Bool = false
|
@Published var isClipboardPopoverActive: Bool = false
|
||||||
@Published var isColorPickerPopoverActive: Bool = false
|
@Published var isColorPickerPopoverActive: Bool = false
|
||||||
@Published var isStatsPopoverActive: Bool = false
|
@Published var isStatsPopoverActive: Bool = false
|
||||||
|
@Published var isMediaOutputPopoverActive: Bool = false
|
||||||
@Published var shouldRecheckHover: Bool = false
|
@Published var shouldRecheckHover: Bool = false
|
||||||
|
|
||||||
let webcamManager = WebcamManager.shared
|
let webcamManager = WebcamManager.shared
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue