From a2f0c801fedc8f97ebfa0503ab719f1842e94802 Mon Sep 17 00:00:00 2001 From: Xuan Nguyen Date: Fri, 4 Sep 2026 23:28:57 -0700 Subject: [PATCH 1/8] refactor: update output device menu UI with hover state and remove redundant device icon from volume row --- Sources/VolumeControlView.swift | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/Sources/VolumeControlView.swift b/Sources/VolumeControlView.swift index 820b477..e6997cf 100644 --- a/Sources/VolumeControlView.swift +++ b/Sources/VolumeControlView.swift @@ -188,6 +188,7 @@ struct VolumeControlView: View { @State private var isGearHovered: Bool = false @State private var hasPermission: Bool = true @State private var permissionCheckTimer: Timer? + @State private var isDeviceHovered: Bool = false // CoreAudio property listener blocks for real-time master volume sync @State private var volumeListenerBlock: AudioObjectPropertyListenerBlock? @@ -303,7 +304,8 @@ struct VolumeControlView: View { HStack(spacing: 5) { Image(systemName: tapManager.currentOutputDevice?.iconName ?? "laptopcomputer") .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.blue) + .foregroundColor(.primary) + .frame(width: 18, height: 18) Text(tapManager.currentOutputDevice?.name ?? "Output Device") .font(.system(size: 13, weight: .semibold)) @@ -312,17 +314,24 @@ struct VolumeControlView: View { .truncationMode(.tail) if tapManager.availableOutputDevices.count > 1 { - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 9, weight: .bold)) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) .foregroundColor(.secondary) } } - .padding(.horizontal, 6) + .padding(.horizontal, 4) .padding(.vertical, 3) - .background(Color.primary.opacity(0.04)) - .cornerRadius(6) + .background(isDeviceHovered ? Color.primary.opacity(0.08) : Color.clear) + .cornerRadius(4) + .contentShape(Rectangle()) } .menuStyle(.borderlessButton) + .padding(.leading, -4) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.12)) { + isDeviceHovered = hovering + } + } .accessibilityLabel("Select Output Device, currently \(tapManager.currentOutputDevice?.name ?? "Output Device")") Spacer() @@ -340,14 +349,8 @@ struct VolumeControlView: View { } } - // Master Volume Slider Row: [Device Icon] [Speaker Mute Button] [Slider] [Percentage] + // Master Volume Slider Row: [Speaker Mute Button] [Slider] [Percentage] HStack(spacing: 6) { - // Device Icon - Image(systemName: tapManager.currentOutputDevice?.iconName ?? "laptopcomputer") - .font(.system(size: 13)) - .foregroundColor(.secondary) - .frame(width: 18, height: 18) - // Master Speaker Mute Button Button(action: { if masterVolume > 0.001 { From 05475673f562bc0537cbcfbbb9d485c8d86f6968 Mon Sep 17 00:00:00 2001 From: Xuan Nguyen Date: Fri, 4 Sep 2026 23:43:13 -0700 Subject: [PATCH 2/8] refactor: redesign VolumeControlView header with a modern frosted card layout and update project ignores --- .gitignore | 3 + Sources/VolumeControlView.swift | 156 +++++++++++++++++++------------- 2 files changed, 96 insertions(+), 63 deletions(-) diff --git a/.gitignore b/.gitignore index ae1cb11..a59297c 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,6 @@ __pycache__/ .venv/ venv/ ENV/ + +# Brainstorming Visual Companion +.superpowers/ diff --git a/Sources/VolumeControlView.swift b/Sources/VolumeControlView.swift index e6997cf..111b81f 100644 --- a/Sources/VolumeControlView.swift +++ b/Sources/VolumeControlView.swift @@ -276,71 +276,29 @@ struct VolumeControlView: View { } // ----------------------------------------------------------------- - // MARK: Header & Master Volume + // MARK: Header & Master Volume (Modern Frosted Card Island) // ----------------------------------------------------------------- - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 9) { + // Top Row: [OUTPUT Badge] and [Mute All Button] HStack(alignment: .center) { - // Output device selection dropdown - Menu { - Section(header: Text("Switch Output Device")) { - ForEach(tapManager.availableOutputDevices) { device in - Button(action: { - tapManager.setDefaultOutputDevice(device) - }) { - HStack { - Label { - Text(device.name) - } icon: { - Image(systemName: device.iconName) - } - if tapManager.currentOutputDevice?.id == device.id { - Image(systemName: "checkmark") - } - } - } - } - } - } label: { - HStack(spacing: 5) { - Image(systemName: tapManager.currentOutputDevice?.iconName ?? "laptopcomputer") - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.primary) - .frame(width: 18, height: 18) - - Text(tapManager.currentOutputDevice?.name ?? "Output Device") - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.primary) - .lineLimit(1) - .truncationMode(.tail) - - if tapManager.availableOutputDevices.count > 1 { - Image(systemName: "chevron.down") - .font(.system(size: 8, weight: .bold)) - .foregroundColor(.secondary) - } - } - .padding(.horizontal, 4) - .padding(.vertical, 3) - .background(isDeviceHovered ? Color.primary.opacity(0.08) : Color.clear) - .cornerRadius(4) - .contentShape(Rectangle()) - } - .menuStyle(.borderlessButton) - .padding(.leading, -4) - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.12)) { - isDeviceHovered = hovering - } - } - .accessibilityLabel("Select Output Device, currently \(tapManager.currentOutputDevice?.name ?? "Output Device")") + Text("OUTPUT") + .font(.system(size: 9, weight: .bold, design: .rounded)) + .tracking(0.6) + .foregroundColor(.blue) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(Color.blue.opacity(0.14)) + ) Spacer() - + // Batch Mute / Unmute Button if !appManager.apps.isEmpty { Button(action: toggleMuteAll) { Text(isAllMuted ? "Unmute All" : "Mute All") - .font(.caption) + .font(.caption2) .fontWeight(.medium) .foregroundColor(.blue) } @@ -348,9 +306,72 @@ struct VolumeControlView: View { .accessibilityLabel(isAllMuted ? "Unmute all running applications" : "Mute all running applications") } } + .frame(maxWidth: .infinity) + + // Middle Row: Device Info & Switcher Menu + Menu { + Section(header: Text("Switch Output Device")) { + ForEach(tapManager.availableOutputDevices) { device in + Button(action: { + tapManager.setDefaultOutputDevice(device) + }) { + HStack { + Label { + Text(device.name) + } icon: { + Image(systemName: device.iconName) + } + if tapManager.currentOutputDevice?.id == device.id { + Image(systemName: "checkmark") + } + } + } + } + } + } label: { + HStack(spacing: 7) { + Image(systemName: tapManager.currentOutputDevice?.iconName ?? "laptopcomputer") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.primary) + .frame(width: 18, height: 18) + + Text(tapManager.currentOutputDevice?.name ?? "Output Device") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.primary) + .lineLimit(1) + .truncationMode(.tail) + + Spacer() + + if tapManager.availableOutputDevices.count > 1 { + HStack(spacing: 3) { + Text("Switch") + .font(.system(size: 11, weight: .medium)) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .foregroundColor(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(isDeviceHovered ? Color.primary.opacity(0.12) : Color.primary.opacity(0.06)) + .cornerRadius(5) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .menuIndicator(.hidden) + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: .leading) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.12)) { + isDeviceHovered = hovering + } + } + .accessibilityLabel("Select Output Device, currently \(tapManager.currentOutputDevice?.name ?? "Output Device")") // Master Volume Slider Row: [Speaker Mute Button] [Slider] [Percentage] - HStack(spacing: 6) { + HStack(spacing: 8) { // Master Speaker Mute Button Button(action: { if masterVolume > 0.001 { @@ -368,7 +389,7 @@ struct VolumeControlView: View { } .buttonStyle(.plain) .accessibilityLabel(masterVolume <= 0.001 ? "Unmute system master volume" : "Mute system master volume") - + // Custom Master Volume Slider BoxySlider(value: $masterVolume, range: 0...1, tint: .blue) .accessibilityLabel("System Master Volume") @@ -395,17 +416,26 @@ struct VolumeControlView: View { masterVolume = 1.0 tapManager.setSystemVolume(1.0) } - + // Percentage readout Text("\(Int(masterVolume * 100))%") .font(.caption.monospacedDigit()) .foregroundColor(masterVolume <= 0.001 ? .secondary.opacity(0.5) : .secondary) - .frame(width: 36, alignment: .trailing) + .frame(width: 34, alignment: .trailing) } } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor).opacity(0.65)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) .padding(.horizontal, 10) - .padding(.vertical, 8) - .background(Color(NSColor.controlBackgroundColor).opacity(0.5)) + .padding(.top, 10) + .padding(.bottom, 6) .onAppear { masterVolume = Double(tapManager.getSystemVolume()) if masterVolume > 0.001 { From 921acea5aa7ea14cebbfb567d43a21a9acf0f370 Mon Sep 17 00:00:00 2001 From: Xuan Nguyen Date: Fri, 4 Sep 2026 23:50:44 -0700 Subject: [PATCH 3/8] refactor: replace output device switcher menu with direct 1-click selectable rows --- Sources/VolumeControlView.swift | 129 ++++++++++++++++++-------------- 1 file changed, 73 insertions(+), 56 deletions(-) diff --git a/Sources/VolumeControlView.swift b/Sources/VolumeControlView.swift index 111b81f..34e4532 100644 --- a/Sources/VolumeControlView.swift +++ b/Sources/VolumeControlView.swift @@ -188,7 +188,6 @@ struct VolumeControlView: View { @State private var isGearHovered: Bool = false @State private var hasPermission: Bool = true @State private var permissionCheckTimer: Timer? - @State private var isDeviceHovered: Bool = false // CoreAudio property listener blocks for real-time master volume sync @State private var volumeListenerBlock: AudioObjectPropertyListenerBlock? @@ -306,69 +305,32 @@ struct VolumeControlView: View { .accessibilityLabel(isAllMuted ? "Unmute all running applications" : "Mute all running applications") } } + .padding(.horizontal, 6) .frame(maxWidth: .infinity) - // Middle Row: Device Info & Switcher Menu - Menu { - Section(header: Text("Switch Output Device")) { + // Middle Section: Dynamic Direct 1-Click Selectable Device Rows + VStack(spacing: 3) { + if tapManager.availableOutputDevices.isEmpty { + OutputDeviceSelectableRow( + device: tapManager.currentOutputDevice ?? AudioOutputDevice(id: 0, name: "Output Device", uid: "default"), + isSelected: true, + onSelect: {} + ) + } else { ForEach(tapManager.availableOutputDevices) { device in - Button(action: { - tapManager.setDefaultOutputDevice(device) - }) { - HStack { - Label { - Text(device.name) - } icon: { - Image(systemName: device.iconName) - } - if tapManager.currentOutputDevice?.id == device.id { - Image(systemName: "checkmark") + OutputDeviceSelectableRow( + device: device, + isSelected: tapManager.currentOutputDevice?.id == device.id, + onSelect: { + withAnimation(.easeInOut(duration: 0.15)) { + tapManager.setDefaultOutputDevice(device) } } - } + ) } } - } label: { - HStack(spacing: 7) { - Image(systemName: tapManager.currentOutputDevice?.iconName ?? "laptopcomputer") - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.primary) - .frame(width: 18, height: 18) - - Text(tapManager.currentOutputDevice?.name ?? "Output Device") - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.primary) - .lineLimit(1) - .truncationMode(.tail) - - Spacer() - - if tapManager.availableOutputDevices.count > 1 { - HStack(spacing: 3) { - Text("Switch") - .font(.system(size: 11, weight: .medium)) - Image(systemName: "chevron.down") - .font(.system(size: 8, weight: .bold)) - } - .foregroundColor(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(isDeviceHovered ? Color.primary.opacity(0.12) : Color.primary.opacity(0.06)) - .cornerRadius(5) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) } - .menuIndicator(.hidden) - .buttonStyle(.plain) .frame(maxWidth: .infinity, alignment: .leading) - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.12)) { - isDeviceHovered = hovering - } - } - .accessibilityLabel("Select Output Device, currently \(tapManager.currentOutputDevice?.name ?? "Output Device")") // Master Volume Slider Row: [Speaker Mute Button] [Slider] [Percentage] HStack(spacing: 8) { @@ -423,8 +385,11 @@ struct VolumeControlView: View { .foregroundColor(masterVolume <= 0.001 ? .secondary.opacity(0.5) : .secondary) .frame(width: 34, alignment: .trailing) } + .padding(.horizontal, 6) + .frame(maxWidth: .infinity) } - .padding(10) + .padding(.vertical, 8) + .padding(.horizontal, 4) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color(NSColor.controlBackgroundColor).opacity(0.65)) @@ -787,6 +752,58 @@ struct VolumeControlView: View { } } +// ============================================================================= +// MARK: - Output Device Selectable Row +// ============================================================================= + +/// `OutputDeviceSelectableRow` renders a direct 1-click selectable output device item +/// with its hardware icon, device name, active checkmark, and subtle hover styling. +struct OutputDeviceSelectableRow: View { + let device: AudioOutputDevice + let isSelected: Bool + let onSelect: () -> Void + @State private var isHovered: Bool = false + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 7) { + Image(systemName: device.iconName) + .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) + .foregroundColor(isSelected ? .primary : (isHovered ? .primary : .secondary)) + .frame(width: 18, height: 18) + + Text(device.name) + .font(.system(size: 12, weight: isSelected ? .semibold : .regular)) + .foregroundColor(isSelected ? .primary : (isHovered ? .primary : .secondary)) + .lineLimit(1) + .truncationMode(.tail) + + Spacer() + + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.blue) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(isSelected ? Color.primary.opacity(0.08) : (isHovered ? Color.primary.opacity(0.04) : Color.clear)) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.12)) { + isHovered = hovering + } + } + .accessibilityLabel("\(device.name)\(isSelected ? ", currently active" : ", click to select")") + } +} + // ============================================================================= // MARK: - App Volume Row // ============================================================================= From d1d4bb0b341d1e8a126f88c14b187b46641781b6 Mon Sep 17 00:00:00 2001 From: Xuan Nguyen Date: Fri, 4 Sep 2026 23:59:44 -0700 Subject: [PATCH 4/8] feat: implement minimalist seamless audio device switcher with dynamic icons and refined UI components --- README.md | 4 +- Sources/VolumeControlView.swift | 141 ++++++++++++++------------------ version.json | 4 +- 3 files changed, 64 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index ba9f311..712ed4c 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ MySound gives you total control over your Mac's audio. Adjust the volume of individual applications like Chrome, Spotify, or Zoom independently of your system volume—all from a beautiful, minimalist menu bar interface.

- MySound UI Preview (v1.6.0) + MySound UI Preview (v1.7.0)

- Version 1.6.0 + Version 1.7.0 macOS 14.2+ Universal Binary License: MIT diff --git a/Sources/VolumeControlView.swift b/Sources/VolumeControlView.swift index 34e4532..7124b2c 100644 --- a/Sources/VolumeControlView.swift +++ b/Sources/VolumeControlView.swift @@ -275,52 +275,28 @@ struct VolumeControlView: View { } // ----------------------------------------------------------------- - // MARK: Header & Master Volume (Modern Frosted Card Island) + // MARK: Header & Master Volume (Minimalist Seamless) // ----------------------------------------------------------------- - VStack(alignment: .leading, spacing: 9) { - // Top Row: [OUTPUT Badge] and [Mute All Button] - HStack(alignment: .center) { - Text("OUTPUT") - .font(.system(size: 9, weight: .bold, design: .rounded)) - .tracking(0.6) - .foregroundColor(.blue) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - Capsule() - .fill(Color.blue.opacity(0.14)) - ) - - Spacer() - - // Batch Mute / Unmute Button - if !appManager.apps.isEmpty { - Button(action: toggleMuteAll) { - Text(isAllMuted ? "Unmute All" : "Mute All") - .font(.caption2) - .fontWeight(.medium) - .foregroundColor(.blue) - } - .buttonStyle(.plain) - .accessibilityLabel(isAllMuted ? "Unmute all running applications" : "Mute all running applications") - } - } - .padding(.horizontal, 6) - .frame(maxWidth: .infinity) - - // Middle Section: Dynamic Direct 1-Click Selectable Device Rows - VStack(spacing: 3) { + VStack(alignment: .leading, spacing: 6) { + // Dynamic Direct 1-Click Selectable Device Rows + VStack(spacing: 2) { if tapManager.availableOutputDevices.isEmpty { OutputDeviceSelectableRow( device: tapManager.currentOutputDevice ?? AudioOutputDevice(id: 0, name: "Output Device", uid: "default"), isSelected: true, + showMuteAll: !appManager.apps.isEmpty, + isAllMuted: isAllMuted, + onToggleMuteAll: toggleMuteAll, onSelect: {} ) } else { - ForEach(tapManager.availableOutputDevices) { device in + ForEach(Array(tapManager.availableOutputDevices.enumerated()), id: \.element.id) { index, device in OutputDeviceSelectableRow( device: device, isSelected: tapManager.currentOutputDevice?.id == device.id, + showMuteAll: index == 0 && !appManager.apps.isEmpty, + isAllMuted: isAllMuted, + onToggleMuteAll: toggleMuteAll, onSelect: { withAnimation(.easeInOut(duration: 0.15)) { tapManager.setDefaultOutputDevice(device) @@ -332,9 +308,9 @@ struct VolumeControlView: View { } .frame(maxWidth: .infinity, alignment: .leading) - // Master Volume Slider Row: [Speaker Mute Button] [Slider] [Percentage] + // Master Volume Slider Row: [Device Mute Button] [Slider] [Percentage] HStack(spacing: 8) { - // Master Speaker Mute Button + // Master Speaker/Device Mute Button Button(action: { if masterVolume > 0.001 { masterVolume = 0 @@ -343,9 +319,9 @@ struct VolumeControlView: View { } tapManager.setSystemVolume(Float(masterVolume)) }) { - Image(systemName: masterVolume <= 0.001 ? "speaker.slash.fill" : "speaker.wave.3.fill") + Image(systemName: masterVolume <= 0.001 ? "speaker.slash.fill" : (tapManager.currentOutputDevice?.iconName ?? "laptopcomputer")) .foregroundColor(masterVolume <= 0.001 ? .red : .secondary) - .font(.system(size: 11)) + .font(.system(size: 12)) .frame(width: 18, height: 18) .contentShape(Rectangle()) } @@ -388,19 +364,10 @@ struct VolumeControlView: View { .padding(.horizontal, 6) .frame(maxWidth: .infinity) } - .padding(.vertical, 8) - .padding(.horizontal, 4) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(NSColor.controlBackgroundColor).opacity(0.65)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color.primary.opacity(0.08), lineWidth: 1) - ) - .padding(.horizontal, 10) - .padding(.top, 10) + .padding(.horizontal, 6) + .padding(.top, 8) .padding(.bottom, 6) + .background(Color(NSColor.controlBackgroundColor).opacity(0.35)) .onAppear { masterVolume = Double(tapManager.getSystemVolume()) if masterVolume > 0.001 { @@ -761,46 +728,58 @@ struct VolumeControlView: View { struct OutputDeviceSelectableRow: View { let device: AudioOutputDevice let isSelected: Bool + var showMuteAll: Bool = false + var isAllMuted: Bool = false + var onToggleMuteAll: (() -> Void)? = nil let onSelect: () -> Void @State private var isHovered: Bool = false var body: some View { - Button(action: onSelect) { - HStack(spacing: 7) { - Image(systemName: device.iconName) - .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) - .foregroundColor(isSelected ? .primary : (isHovered ? .primary : .secondary)) - .frame(width: 18, height: 18) + HStack(spacing: 4) { + Button(action: onSelect) { + HStack(spacing: 7) { + Image(systemName: device.iconName) + .font(.system(size: 13, weight: isSelected ? .semibold : .medium)) + .foregroundColor(isSelected ? .primary : (isHovered ? .primary : .secondary.opacity(0.7))) + .frame(width: 18, height: 18) + + Text(device.name) + .font(.system(size: 12, weight: isSelected ? .semibold : .regular)) + .foregroundColor(isSelected ? .primary : (isHovered ? .primary : .secondary.opacity(0.7))) + .lineLimit(1) + .truncationMode(.tail) - Text(device.name) - .font(.system(size: 12, weight: isSelected ? .semibold : .regular)) - .foregroundColor(isSelected ? .primary : (isHovered ? .primary : .secondary)) - .lineLimit(1) - .truncationMode(.tail) - - Spacer() + Spacer() + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(isSelected ? Color.primary.opacity(0.07) : (isHovered ? Color.primary.opacity(0.03) : Color.clear)) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.12)) { + isHovered = hovering + } + } + .accessibilityLabel("\(device.name)\(isSelected ? ", currently active" : ", click to select")") - if isSelected { - Image(systemName: "checkmark") - .font(.system(size: 10, weight: .bold)) + if showMuteAll, let toggleMute = onToggleMuteAll { + Button(action: toggleMute) { + Text(isAllMuted ? "Unmute All" : "Mute All") + .font(.caption2) + .fontWeight(.medium) .foregroundColor(.blue) + .padding(.horizontal, 6) + .padding(.vertical, 4) } - } - .padding(.horizontal, 6) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(isSelected ? Color.primary.opacity(0.08) : (isHovered ? Color.primary.opacity(0.04) : Color.clear)) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.12)) { - isHovered = hovering + .buttonStyle(.plain) + .accessibilityLabel(isAllMuted ? "Unmute all running applications" : "Mute all running applications") } } - .accessibilityLabel("\(device.name)\(isSelected ? ", currently active" : ", click to select")") } } diff --git a/version.json b/version.json index 36d8498..89a6d0f 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "version": "1.6.0", + "version": "1.7.0", "downloadUrl": "https://github.com/xuanmn/MySound/releases/latest/download/MySound.zip", - "releaseNotes": "v1.6.0 - Global audio output device selector with hardware transport detection (Bluetooth, USB, HDMI, DisplayPort, Built-in), smart SF Symbol device icons, persistent preferred device auto-reconnect, and seamless real-time audio re-routing." + "releaseNotes": "v1.7.0 - Modern seamless output device switcher with real-time hardware detection, direct 1-click output switching, subtle typographic selection indicators, and dynamic hardware icons matching the active audio device on the master volume slider." } From 06c1036a2542969a4e8cbe061059665b569ce89f Mon Sep 17 00:00:00 2001 From: Xuan Nguyen Date: Sat, 5 Sep 2026 00:10:45 -0700 Subject: [PATCH 5/8] feat: replace device rows with a dynamic wrapping chip layout and improve master mute UI controls --- Sources/VolumeControlView.swift | 213 ++++++++++++++++++++------------ 1 file changed, 133 insertions(+), 80 deletions(-) diff --git a/Sources/VolumeControlView.swift b/Sources/VolumeControlView.swift index 7124b2c..87fbbc1 100644 --- a/Sources/VolumeControlView.swift +++ b/Sources/VolumeControlView.swift @@ -186,6 +186,7 @@ struct VolumeControlView: View { @State private var savedAppVolumes: [Int32: Double] = [:] @State private var isQuitHovered: Bool = false @State private var isGearHovered: Bool = false + @State private var isMasterMuteHovered: Bool = false @State private var hasPermission: Bool = true @State private var permissionCheckTimer: Timer? @@ -275,42 +276,51 @@ struct VolumeControlView: View { } // ----------------------------------------------------------------- - // MARK: Header & Master Volume (Minimalist Seamless) + // MARK: Header & Master Volume (Wrapping Flow Capsules) // ----------------------------------------------------------------- - VStack(alignment: .leading, spacing: 6) { - // Dynamic Direct 1-Click Selectable Device Rows - VStack(spacing: 2) { - if tapManager.availableOutputDevices.isEmpty { - OutputDeviceSelectableRow( - device: tapManager.currentOutputDevice ?? AudioOutputDevice(id: 0, name: "Output Device", uid: "default"), - isSelected: true, - showMuteAll: !appManager.apps.isEmpty, - isAllMuted: isAllMuted, - onToggleMuteAll: toggleMuteAll, - onSelect: {} - ) - } else { - ForEach(Array(tapManager.availableOutputDevices.enumerated()), id: \.element.id) { index, device in - OutputDeviceSelectableRow( - device: device, - isSelected: tapManager.currentOutputDevice?.id == device.id, - showMuteAll: index == 0 && !appManager.apps.isEmpty, - isAllMuted: isAllMuted, - onToggleMuteAll: toggleMuteAll, - onSelect: { - withAnimation(.easeInOut(duration: 0.15)) { - tapManager.setDefaultOutputDevice(device) - } - } + VStack(alignment: .leading, spacing: 8) { + // Top Row: Dynamic 1-Click Selectable Wrapping Device Capsules & Mute All + HStack(alignment: .top, spacing: 6) { + WrappingHStack(horizontalSpacing: 6, verticalSpacing: 6) { + if tapManager.availableOutputDevices.isEmpty { + OutputDeviceChip( + device: tapManager.currentOutputDevice ?? AudioOutputDevice(id: 0, name: "Output Device", uid: "default"), + isSelected: true, + onSelect: {} ) + } else { + ForEach(tapManager.availableOutputDevices) { device in + OutputDeviceChip( + device: device, + isSelected: tapManager.currentOutputDevice?.id == device.id, + onSelect: { + withAnimation(.easeInOut(duration: 0.15)) { + tapManager.setDefaultOutputDevice(device) + } + } + ) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + if !appManager.apps.isEmpty { + Button(action: toggleMuteAll) { + Text(isAllMuted ? "Unmute All" : "Mute All") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.blue) + .padding(.horizontal, 4) + .padding(.vertical, 4) } + .buttonStyle(.plain) + .accessibilityLabel(isAllMuted ? "Unmute all running applications" : "Mute all running applications") } } - .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) - // Master Volume Slider Row: [Device Mute Button] [Slider] [Percentage] + // Master Volume Slider Row: [Speaker Mute Button] [Slider] [Percentage] HStack(spacing: 8) { - // Master Speaker/Device Mute Button + // Master Speaker Mute Button Button(action: { if masterVolume > 0.001 { masterVolume = 0 @@ -319,13 +329,18 @@ struct VolumeControlView: View { } tapManager.setSystemVolume(Float(masterVolume)) }) { - Image(systemName: masterVolume <= 0.001 ? "speaker.slash.fill" : (tapManager.currentOutputDevice?.iconName ?? "laptopcomputer")) - .foregroundColor(masterVolume <= 0.001 ? .red : .secondary) + Image(systemName: masterVolume <= 0.001 ? "speaker.slash.fill" : "speaker.wave.3.fill") + .foregroundColor(masterVolume <= 0.001 ? .red : (isMasterMuteHovered ? .primary : .secondary)) .font(.system(size: 12)) .frame(width: 18, height: 18) .contentShape(Rectangle()) } .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.12)) { + isMasterMuteHovered = hovering + } + } .accessibilityLabel(masterVolume <= 0.001 ? "Unmute system master volume" : "Mute system master volume") // Custom Master Volume Slider @@ -720,66 +735,104 @@ struct VolumeControlView: View { } // ============================================================================= -// MARK: - Output Device Selectable Row +// MARK: - Wrapping HStack Layout +// ============================================================================= + +/// A custom SwiftUI `Layout` that arranges subviews horizontally and wraps to subsequent lines +/// when available horizontal width is exceeded. +struct WrappingHStack: Layout { + var horizontalSpacing: CGFloat = 6 + var verticalSpacing: CGFloat = 6 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + guard !subviews.isEmpty else { return .zero } + let width = proposal.width ?? 300 + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var lineHeight: CGFloat = 0 + var maxRowWidth: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if currentX + size.width > width && currentX > 0 { + maxRowWidth = max(maxRowWidth, currentX - horizontalSpacing) + currentX = 0 + currentY += lineHeight + verticalSpacing + lineHeight = 0 + } + lineHeight = max(lineHeight, size.height) + currentX += size.width + horizontalSpacing + } + maxRowWidth = max(maxRowWidth, currentX > 0 ? currentX - horizontalSpacing : 0) + + return CGSize(width: min(width, maxRowWidth), height: currentY + lineHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + guard !subviews.isEmpty else { return } + var currentX: CGFloat = bounds.minX + var currentY: CGFloat = bounds.minY + var lineHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if currentX + size.width > bounds.maxX && currentX > bounds.minX { + currentX = bounds.minX + currentY += lineHeight + verticalSpacing + lineHeight = 0 + } + let itemWidth = min(size.width, bounds.width) + subview.place(at: CGPoint(x: currentX, y: currentY), proposal: ProposedViewSize(width: itemWidth, height: size.height)) + lineHeight = max(lineHeight, size.height) + currentX += size.width + horizontalSpacing + } + } +} + +// ============================================================================= +// MARK: - Output Device Chip // ============================================================================= -/// `OutputDeviceSelectableRow` renders a direct 1-click selectable output device item -/// with its hardware icon, device name, active checkmark, and subtle hover styling. -struct OutputDeviceSelectableRow: View { +/// `OutputDeviceChip` renders an individual interactive output device capsule button +/// with device-specific SF Symbol, localized device name, active state indicator, +/// and smooth hover brightening. +struct OutputDeviceChip: View { let device: AudioOutputDevice let isSelected: Bool - var showMuteAll: Bool = false - var isAllMuted: Bool = false - var onToggleMuteAll: (() -> Void)? = nil let onSelect: () -> Void @State private var isHovered: Bool = false var body: some View { - HStack(spacing: 4) { - Button(action: onSelect) { - HStack(spacing: 7) { - Image(systemName: device.iconName) - .font(.system(size: 13, weight: isSelected ? .semibold : .medium)) - .foregroundColor(isSelected ? .primary : (isHovered ? .primary : .secondary.opacity(0.7))) - .frame(width: 18, height: 18) - - Text(device.name) - .font(.system(size: 12, weight: isSelected ? .semibold : .regular)) - .foregroundColor(isSelected ? .primary : (isHovered ? .primary : .secondary.opacity(0.7))) - .lineLimit(1) - .truncationMode(.tail) - - Spacer() - } - .padding(.horizontal, 6) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(isSelected ? Color.primary.opacity(0.07) : (isHovered ? Color.primary.opacity(0.03) : Color.clear)) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.12)) { - isHovered = hovering - } + Button(action: onSelect) { + HStack(spacing: 5) { + Image(systemName: device.iconName) + .font(.system(size: 11, weight: isSelected ? .semibold : .medium)) + .foregroundColor(isSelected ? .primary : (isHovered ? .primary : .secondary)) + + Text(device.name) + .font(.system(size: 11.5, weight: isSelected ? .semibold : .regular)) + .foregroundColor(isSelected ? .primary : (isHovered ? .primary : .secondary)) + .lineLimit(1) } - .accessibilityLabel("\(device.name)\(isSelected ? ", currently active" : ", click to select")") - - if showMuteAll, let toggleMute = onToggleMuteAll { - Button(action: toggleMute) { - Text(isAllMuted ? "Unmute All" : "Mute All") - .font(.caption2) - .fontWeight(.medium) - .foregroundColor(.blue) - .padding(.horizontal, 6) - .padding(.vertical, 4) - } - .buttonStyle(.plain) - .accessibilityLabel(isAllMuted ? "Unmute all running applications" : "Mute all running applications") + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background( + Capsule(style: .continuous) + .fill(isSelected ? Color.primary.opacity(0.14) : (isHovered ? Color.primary.opacity(0.08) : Color.primary.opacity(0.04))) + ) + .overlay( + Capsule(style: .continuous) + .stroke(isSelected ? Color.primary.opacity(0.12) : Color.clear, lineWidth: 0.5) + ) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.12)) { + isHovered = hovering } } + .accessibilityLabel("\(device.name)\(isSelected ? ", currently active" : ", click to select")") } } From 6ae40c7dd97449e730630aaa52a8ae9d0d762148 Mon Sep 17 00:00:00 2001 From: Xuan Nguyen Date: Sat, 5 Sep 2026 00:13:25 -0700 Subject: [PATCH 6/8] fix(permissions): add bluetooth hardware entitlement and usage descriptions --- Entitlements.plist | 2 ++ build.sh | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/Entitlements.plist b/Entitlements.plist index ff26b27..acbfffe 100644 --- a/Entitlements.plist +++ b/Entitlements.plist @@ -6,6 +6,8 @@ com.apple.security.system-audio-capture + com.apple.security.device.bluetooth + com.apple.security.app-sandbox com.apple.security.get-task-allow diff --git a/build.sh b/build.sh index 0d76592..48fbb91 100755 --- a/build.sh +++ b/build.sh @@ -84,6 +84,10 @@ cat < "${APP_BUNDLE}/Contents/Info.plist" MySound needs access to audio to mix your application volumes. NSAudioCaptureUsageDescription MySound needs access to system audio to capture per-app sounds. + NSBluetoothAlwaysUsageDescription + MySound accesses audio output devices including Bluetooth headphones and speakers. + NSBluetoothPeripheralUsageDescription + MySound accesses audio output devices including Bluetooth headphones and speakers. CFBundleInfoDictionaryVersion 6.0 From 5888e46e299ecbc5d463484c0b3076e22809995e Mon Sep 17 00:00:00 2001 From: Xuan Nguyen Date: Sat, 5 Sep 2026 00:17:17 -0700 Subject: [PATCH 7/8] perf(audit): optimize teardown, eliminate idle permission polling, and align layout measurement --- Sources/App.swift | 5 ++--- Sources/VolumeControlView.swift | 25 ++++++++++++++++--------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/Sources/App.swift b/Sources/App.swift index 18a4539..c47fadd 100644 --- a/Sources/App.swift +++ b/Sources/App.swift @@ -8,13 +8,12 @@ import AppKit /// `MySoundAppDelegate` handles macOS application lifecycle events that SwiftUI does not expose, /// ensuring clean teardown of CoreAudio resources (aggregate devices, process taps) when the /// app is terminated by the system (e.g. logout, shutdown, force quit). +@MainActor class MySoundAppDelegate: NSObject, NSApplicationDelegate { /// Called by macOS when the application is about to terminate (user quit, system shutdown, etc.). /// Ensures all CoreAudio process taps and aggregate devices are destroyed cleanly. func applicationWillTerminate(_ notification: Notification) { - Task { @MainActor in - AudioTapManager.shared.removeAllTaps() - } + AudioTapManager.shared.removeAllTaps() } } diff --git a/Sources/VolumeControlView.swift b/Sources/VolumeControlView.swift index 87fbbc1..2a40810 100644 --- a/Sources/VolumeControlView.swift +++ b/Sources/VolumeControlView.swift @@ -501,11 +501,17 @@ struct VolumeControlView: View { let newApps = AppManager.getRunningApps(existingApps: appManager.apps) appManager.apps = newApps - // Re-check permissions every 3 seconds so banner vanishes when user approves in Settings + // Re-check permissions every 3 seconds only if permission is missing permissionCheckTimer?.invalidate() - permissionCheckTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in - Task { @MainActor in - hasPermission = AudioTapManager.hasAudioCapturePermission() + if !hasPermission { + permissionCheckTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { timer in + Task { @MainActor in + let granted = AudioTapManager.hasAudioCapturePermission() + hasPermission = granted + if granted { + timer.invalidate() + } + } } } } @@ -754,14 +760,15 @@ struct WrappingHStack: Layout { for subview in subviews { let size = subview.sizeThatFits(.unspecified) - if currentX + size.width > width && currentX > 0 { + let itemWidth = min(size.width, width) + if currentX + itemWidth > width && currentX > 0 { maxRowWidth = max(maxRowWidth, currentX - horizontalSpacing) currentX = 0 currentY += lineHeight + verticalSpacing lineHeight = 0 } lineHeight = max(lineHeight, size.height) - currentX += size.width + horizontalSpacing + currentX += itemWidth + horizontalSpacing } maxRowWidth = max(maxRowWidth, currentX > 0 ? currentX - horizontalSpacing : 0) @@ -776,15 +783,15 @@ struct WrappingHStack: Layout { for subview in subviews { let size = subview.sizeThatFits(.unspecified) - if currentX + size.width > bounds.maxX && currentX > bounds.minX { + let itemWidth = min(size.width, bounds.width) + if currentX + itemWidth > bounds.maxX && currentX > bounds.minX { currentX = bounds.minX currentY += lineHeight + verticalSpacing lineHeight = 0 } - let itemWidth = min(size.width, bounds.width) subview.place(at: CGPoint(x: currentX, y: currentY), proposal: ProposedViewSize(width: itemWidth, height: size.height)) lineHeight = max(lineHeight, size.height) - currentX += size.width + horizontalSpacing + currentX += itemWidth + horizontalSpacing } } } From ed7800a6912647b6dfe3afbaed553eab1d399b11 Mon Sep 17 00:00:00 2001 From: Xuan Nguyen Date: Sat, 5 Sep 2026 00:27:22 -0700 Subject: [PATCH 8/8] ci: modernize GitHub Actions runner to macos-15 and secure release workflow --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 17 +++++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 814d47d..3c5cf8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ permissions: jobs: build: name: Build & Verify Universal Binary - runs-on: macos-14 + runs-on: macos-15 steps: - name: Checkout repository @@ -25,7 +25,7 @@ jobs: - name: Select Xcode run: | - XCODE_PATH=$(ls -d /Applications/Xcode_16*.app 2>/dev/null | sort -V | tail -1) + XCODE_PATH=$(ls -d /Applications/Xcode_16*.app /Applications/Xcode_15*.app 2>/dev/null | sort -V | tail -1) if [ -n "$XCODE_PATH" ]; then sudo xcode-select -s "$XCODE_PATH" echo "Selected: $XCODE_PATH" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ddcedc..cc563a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ permissions: jobs: release: name: Build & Publish Release - runs-on: macos-14 + runs-on: macos-15 steps: - name: Checkout repository @@ -27,7 +27,7 @@ jobs: - name: Select Xcode run: | - XCODE_PATH=$(ls -d /Applications/Xcode_16*.app 2>/dev/null | sort -V | tail -1) + XCODE_PATH=$(ls -d /Applications/Xcode_16*.app /Applications/Xcode_15*.app 2>/dev/null | sort -V | tail -1) if [ -n "$XCODE_PATH" ]; then sudo xcode-select -s "$XCODE_PATH" echo "Selected: $XCODE_PATH" @@ -42,11 +42,15 @@ jobs: - name: Resolve Release Tag & Notes id: release_info + env: + INPUT_TAG: ${{ inputs.version_tag || github.event.inputs.version_tag }} + REF_NAME: ${{ github.ref_name }} + REF: ${{ github.ref }} run: | - if [ -n "${{ github.event.inputs.version_tag }}" ]; then - TAG="${{ github.event.inputs.version_tag }}" - elif [ -n "${{ github.ref_name }}" ] && [[ "${{ github.ref }}" == refs/tags/* ]]; then - TAG="${{ github.ref_name }}" + if [ -n "$INPUT_TAG" ]; then + TAG="$INPUT_TAG" + elif [ -n "$REF_NAME" ] && [[ "$REF" == refs/tags/* ]]; then + TAG="$REF_NAME" else VER=$(grep '"version"' version.json | head -1 | sed 's/.*: *"\(.*\)".*/\1/') TAG="v${VER}" @@ -59,6 +63,7 @@ jobs: tag_name: ${{ steps.release_info.outputs.tag }} name: "MySound ${{ steps.release_info.outputs.tag }}" generate_release_notes: true + fail_on_unmatched_files: true files: | build/MySound.dmg build/MySound.zip