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 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/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/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/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 820b477..2a40810 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,79 +276,50 @@ struct VolumeControlView: View { } // ----------------------------------------------------------------- - // MARK: Header & Master Volume + // MARK: Header & Master Volume (Wrapping Flow Capsules) // ----------------------------------------------------------------- VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .center) { - // Output device selection dropdown - Menu { - Section(header: Text("Switch Output Device")) { + // 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 - Button(action: { - tapManager.setDefaultOutputDevice(device) - }) { - HStack { - Label { - Text(device.name) - } icon: { - Image(systemName: device.iconName) - } - if tapManager.currentOutputDevice?.id == device.id { - Image(systemName: "checkmark") + OutputDeviceChip( + device: device, + isSelected: tapManager.currentOutputDevice?.id == device.id, + onSelect: { + withAnimation(.easeInOut(duration: 0.15)) { + tapManager.setDefaultOutputDevice(device) } } - } + ) } } - } label: { - HStack(spacing: 5) { - Image(systemName: tapManager.currentOutputDevice?.iconName ?? "laptopcomputer") - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.blue) - - 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.up.chevron.down") - .font(.system(size: 9, weight: .bold)) - .foregroundColor(.secondary) - } - } - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(Color.primary.opacity(0.04)) - .cornerRadius(6) } - .menuStyle(.borderlessButton) - .accessibilityLabel("Select Output Device, currently \(tapManager.currentOutputDevice?.name ?? "Output Device")") + .frame(maxWidth: .infinity, alignment: .leading) - Spacer() - - // Batch Mute / Unmute Button if !appManager.apps.isEmpty { Button(action: toggleMuteAll) { Text(isAllMuted ? "Unmute All" : "Mute All") - .font(.caption) - .fontWeight(.medium) + .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") } } + .padding(.horizontal, 4) - // Master Volume Slider Row: [Device Icon] [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 Volume Slider Row: [Speaker Mute Button] [Slider] [Percentage] + HStack(spacing: 8) { // Master Speaker Mute Button Button(action: { if masterVolume > 0.001 { @@ -358,14 +330,19 @@ struct VolumeControlView: View { tapManager.setSystemVolume(Float(masterVolume)) }) { Image(systemName: masterVolume <= 0.001 ? "speaker.slash.fill" : "speaker.wave.3.fill") - .foregroundColor(masterVolume <= 0.001 ? .red : .secondary) - .font(.system(size: 11)) + .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 BoxySlider(value: $masterVolume, range: 0...1, tint: .blue) .accessibilityLabel("System Master Volume") @@ -392,17 +369,20 @@ 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(.horizontal, 6) + .frame(maxWidth: .infinity) } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background(Color(NSColor.controlBackgroundColor).opacity(0.5)) + .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 { @@ -521,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,6 +740,109 @@ struct VolumeControlView: View { } } +// ============================================================================= +// 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) + 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 += itemWidth + 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) + let itemWidth = min(size.width, bounds.width) + if currentX + itemWidth > bounds.maxX && currentX > bounds.minX { + currentX = bounds.minX + currentY += lineHeight + verticalSpacing + lineHeight = 0 + } + subview.place(at: CGPoint(x: currentX, y: currentY), proposal: ProposedViewSize(width: itemWidth, height: size.height)) + lineHeight = max(lineHeight, size.height) + currentX += itemWidth + horizontalSpacing + } + } +} + +// ============================================================================= +// MARK: - Output Device Chip +// ============================================================================= + +/// `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 + let onSelect: () -> Void + @State private var isHovered: Bool = false + + var body: some View { + 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) + } + .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")") + } +} + // ============================================================================= // MARK: - App Volume Row // ============================================================================= 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 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." }