mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +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
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -754,7 +754,8 @@ struct ContentView: View {
|
|||
return vm.isBatteryPopoverActive ||
|
||||
vm.isClipboardPopoverActive ||
|
||||
vm.isColorPickerPopoverActive ||
|
||||
vm.isStatsPopoverActive
|
||||
vm.isStatsPopoverActive ||
|
||||
vm.isMediaOutputPopoverActive
|
||||
}
|
||||
|
||||
// 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" : {
|
||||
"localizations" : {
|
||||
"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" : {
|
||||
|
||||
},
|
||||
"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." : {
|
||||
|
||||
|
|
|
|||
|
|
@ -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,11 +183,15 @@ struct MinimalisticMusicPlayerView: View {
|
|||
}
|
||||
|
||||
if Defaults[.showShuffleAndRepeat] {
|
||||
if showMediaOutputControl {
|
||||
MinimalisticMediaOutputButton()
|
||||
} else {
|
||||
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
|
||||
Task { await musicManager.toggleRepeat() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
|
@ -216,6 +221,60 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -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,11 +193,15 @@ struct MusicControlsView: View {
|
|||
MusicManager.shared.nextTrack()
|
||||
}
|
||||
if showShuffleAndRepeat {
|
||||
if showMediaOutputControl {
|
||||
MediaOutputPickerButton()
|
||||
} else {
|
||||
HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) {
|
||||
MusicManager.shared.toggleRepeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
|
|
@ -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 {
|
||||
NotchHomeView(
|
||||
albumArtNamespace: Namespace().wrappedValue
|
||||
|
|
|
|||
|
|
@ -747,6 +747,7 @@ struct Media: View {
|
|||
@Default(.enableSneakPeek) private var enableSneakPeek
|
||||
@Default(.sneakPeekStyles) var sneakPeekStyles
|
||||
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
||||
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
|
|
@ -787,6 +788,10 @@ struct Media: View {
|
|||
customBadge(text: "Beta")
|
||||
}
|
||||
}
|
||||
Defaults.Toggle(key: .showMediaOutputControl) {
|
||||
Text("Replace repeat button with media output control")
|
||||
}
|
||||
.disabled(!showShuffleAndRepeat)
|
||||
} header: {
|
||||
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 waitInterval = Key<Double>("waitInterval", default: 3)
|
||||
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)
|
||||
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", 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 isColorPickerPopoverActive: Bool = false
|
||||
@Published var isStatsPopoverActive: Bool = false
|
||||
@Published var isMediaOutputPopoverActive: Bool = false
|
||||
@Published var shouldRecheckHover: Bool = false
|
||||
|
||||
let webcamManager = WebcamManager.shared
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue