working some stuff

This commit is contained in:
Hariharan Mudaliar 2025-10-26 15:26:41 +05:30
commit 9677be3f68
8 changed files with 582 additions and 12 deletions

View file

@ -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

View file

@ -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." : {

View file

@ -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 {

View file

@ -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

View file

@ -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")
}

View 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()
}
}
}

View file

@ -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)

View file

@ -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