From 9677be3f6859b366ef968d45c94a04802cba532f Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 26 Oct 2025 15:26:41 +0530 Subject: [PATCH] working some stuff --- DynamicIsland/ContentView.swift | 15 +- DynamicIsland/Localizable.xcstrings | 20 ++ .../Notch/MinimalisticMusicPlayerView.swift | 65 ++++- .../components/Notch/NotchHomeView.swift | 242 ++++++++++++++++- .../components/Settings/SettingsView.swift | 5 + .../managers/AudioRouteManager.swift | 245 ++++++++++++++++++ DynamicIsland/models/Constants.swift | 1 + .../models/DynamicIslandViewModel.swift | 1 + 8 files changed, 582 insertions(+), 12 deletions(-) create mode 100644 DynamicIsland/managers/AudioRouteManager.swift diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index e5c6517..dd02b54 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() } } @@ -751,10 +751,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/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 8aa023a..ef5b7ba 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -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." : { 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 = [] + + 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 diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 0d1802b..684fcca 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -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") } diff --git a/DynamicIsland/managers/AudioRouteManager.swift b/DynamicIsland/managers/AudioRouteManager.swift new file mode 100644 index 0000000..a481a2b --- /dev/null +++ b/DynamicIsland/managers/AudioRouteManager.swift @@ -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.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.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.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.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.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.size), + &target + ) + if status == noErr { + DispatchQueue.main.async { [weak self] in + self?.activeDeviceID = deviceID + } + refreshDevices() + } + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index d7c7edc..b4c3eef 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -334,6 +334,7 @@ extension Defaults.Keys { static let enableFullscreenMediaDetection = Key("enableFullscreenMediaDetection", default: true) static let waitInterval = Key("waitInterval", default: 3) static let showShuffleAndRepeat = Key("showShuffleAndRepeat", default: false) + static let showMediaOutputControl = Key("showMediaOutputControl", default: false) // Enable lock screen media widget (shows the standalone panel when screen is locked) static let enableLockScreenMediaWidget = Key("enableLockScreenMediaWidget", default: true) static let enableLockScreenWeatherWidget = Key("enableLockScreenWeatherWidget", default: true) diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index ae057bf..22775d0 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -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