diff --git a/SessionFlow.xcodeproj/project.pbxproj b/SessionFlow.xcodeproj/project.pbxproj index 2b2144a..b870f33 100644 --- a/SessionFlow.xcodeproj/project.pbxproj +++ b/SessionFlow.xcodeproj/project.pbxproj @@ -571,7 +571,7 @@ CODE_SIGN_ENTITLEMENTS = SessionFlow/SessionFlow.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 754; + CURRENT_PROJECT_VERSION = 783; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_APP_SANDBOX = NO; @@ -608,7 +608,7 @@ CODE_SIGN_ENTITLEMENTS = SessionFlow/SessionFlow.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 754; + CURRENT_PROJECT_VERSION = 783; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_APP_SANDBOX = NO; diff --git a/SessionFlow/Services/MicrophoneMonitor.swift b/SessionFlow/Services/MicrophoneMonitor.swift index 85c7a42..c9cbb7a 100644 --- a/SessionFlow/Services/MicrophoneMonitor.swift +++ b/SessionFlow/Services/MicrophoneMonitor.swift @@ -4,12 +4,17 @@ import Combine class MicrophoneMonitor: ObservableObject { @Published private(set) var isMicActive = false + /// True when the default input and output devices are the same hardware + /// (e.g. Thunderbolt Display), which causes false mic activation from audio playback. + @Published private(set) var inputOutputSharedDevice = false + @Published private(set) var sharedDeviceName: String? private var listenerBlock: AudioObjectPropertyListenerBlock? private var observedDevice: AudioDeviceID = 0 init() { observeDefaultInputDevice() + checkSharedDevice() } deinit { @@ -62,6 +67,7 @@ class MicrophoneMonitor: ObservableObject { DispatchQueue.main.async { self?.removeListener() self?.observeDefaultInputDevice() + self?.checkSharedDevice() } } } @@ -85,6 +91,48 @@ class MicrophoneMonitor: ObservableObject { } } + /// Checks whether the default input and output devices are the same hardware. + private func checkSharedDevice() { + let inputID = defaultDeviceID(selector: kAudioHardwarePropertyDefaultInputDevice) + let outputID = defaultDeviceID(selector: kAudioHardwarePropertyDefaultOutputDevice) + let shared = inputID != 0 && inputID == outputID + if inputOutputSharedDevice != shared { + inputOutputSharedDevice = shared + sharedDeviceName = shared ? deviceName(for: inputID) : nil + } + } + + private func defaultDeviceID(selector: AudioObjectPropertySelector) -> AudioDeviceID { + var address = AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var deviceID: AudioDeviceID = 0 + var size = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &address, 0, nil, &size, &deviceID + ) + return status == noErr ? deviceID : 0 + } + + private func deviceName(for deviceID: AudioDeviceID) -> String? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioObjectPropertyName, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &size) == noErr else { return nil } + var name: Unmanaged? + let status = withUnsafeMutablePointer(to: &name) { ptr in + AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, ptr) + } + guard status == noErr, let cf = name?.takeUnretainedValue() else { return nil } + return cf as String + } + private func removeListener() { guard let block = listenerBlock, observedDevice != 0 else { return } var address = AudioObjectPropertyAddress( diff --git a/SessionFlow/Services/SessionAudioService.swift b/SessionFlow/Services/SessionAudioService.swift index 6b0c42a..f07f54b 100644 --- a/SessionFlow/Services/SessionAudioService.swift +++ b/SessionFlow/Services/SessionAudioService.swift @@ -21,6 +21,7 @@ class SessionAudioService: ObservableObject { let micMonitor = MicrophoneMonitor() private var micCancellable: AnyCancellable? + private var sharedDeviceCancellable: AnyCancellable? // MARK: - Audio engine @@ -85,12 +86,16 @@ class SessionAudioService: ObservableObject { micCancellable = micMonitor.$isMicActive .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.applyMuteState() } + sharedDeviceCancellable = micMonitor.$inputOutputSharedDevice + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.applyMuteState() } } // MARK: - Mute Logic private func applyMuteState() { - let shouldMute = muteEnabled || (micAwareEnabled && micMonitor.isMicActive) + let micAwareActive = micAwareEnabled && micMonitor.isMicActive && !micMonitor.inputOutputSharedDevice + let shouldMute = muteEnabled || micAwareActive guard shouldMute != isMuted else { return } isMuted = shouldMute if isMuted { muteAmbient() } else if shouldBePlayingAmbient { resumeAmbient() } diff --git a/SessionFlow/Views/AppSettingsView.swift b/SessionFlow/Views/AppSettingsView.swift index f494042..6683898 100644 --- a/SessionFlow/Views/AppSettingsView.swift +++ b/SessionFlow/Views/AppSettingsView.swift @@ -589,9 +589,19 @@ struct AppSettingsView: View { } )) .font(.system(size: 14, weight: .medium)) + .disabled(sessionAudioService.micMonitor.inputOutputSharedDevice) } - if sessionAwarenessService.config.micAwareEnabled { + if sessionAudioService.micMonitor.inputOutputSharedDevice { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 11)) + .foregroundColor(.orange) + Text("Mic Aware is unavailable — your input and output both use \"\(sessionAudioService.micMonitor.sharedDeviceName ?? "the same device")\", which causes false mic activation during audio playback. Use a separate mic or change your output device.") + .font(.caption) + .foregroundColor(.secondary) + } + } else if sessionAwarenessService.config.micAwareEnabled { Text("Auto-mutes while your microphone is in use.") .font(.caption) .foregroundColor(.secondary) diff --git a/SessionFlow/Views/ProductivityCard.swift b/SessionFlow/Views/ProductivityCard.swift index b523323..4c97118 100644 --- a/SessionFlow/Views/ProductivityCard.swift +++ b/SessionFlow/Views/ProductivityCard.swift @@ -260,6 +260,7 @@ struct ProductivityCard: View { .foregroundColor(.white.opacity(count > 0 ? 0.9 : 0.3)) } .frame(maxWidth: .infinity) + .help(rating.label) } // Unrated count @@ -272,6 +273,7 @@ struct ProductivityCard: View { .foregroundColor(.white.opacity(unratedCount > 0 ? 0.6 : 0.3)) } .frame(maxWidth: .infinity) + .help("Unrated") } } @@ -604,6 +606,7 @@ struct MonthlyStatsView: View { .font(.system(size: 14, weight: .medium, design: .monospaced)) .foregroundColor(count > 0 ? .primary : .secondary.opacity(0.4)) } + .help(rating.label) } Spacer() @@ -617,6 +620,7 @@ struct MonthlyStatsView: View { .font(.system(size: 14, weight: .medium, design: .monospaced)) .foregroundColor(unrated > 0 ? .primary.opacity(0.6) : .secondary.opacity(0.4)) } + .help("Unrated") Divider().frame(height: 18) diff --git a/SessionFlow/Views/SharedAwarenessComponents.swift b/SessionFlow/Views/SharedAwarenessComponents.swift index 222cbcd..663635e 100644 --- a/SessionFlow/Views/SharedAwarenessComponents.swift +++ b/SessionFlow/Views/SharedAwarenessComponents.swift @@ -94,10 +94,13 @@ struct AwarenessSkipSessionButton: View { struct AwarenessMuteButton: View { @ObservedObject var audioService: SessionAudioService + @EnvironmentObject var awarenessService: SessionAwarenessService var body: some View { Button { - audioService.toggleMute() + let newValue = !audioService.muteEnabled + audioService.muteEnabled = newValue + awarenessService.config.muteEnabled = newValue } label: { iconView .frame(width: 32, height: 32) diff --git a/SessionFlow/Views/TimelineView.swift b/SessionFlow/Views/TimelineView.swift index 35cd5ac..10b1cdc 100644 --- a/SessionFlow/Views/TimelineView.swift +++ b/SessionFlow/Views/TimelineView.swift @@ -2072,6 +2072,7 @@ extension TimelineView { .font(.system(size: 7, weight: .bold)) .foregroundColor(color) ) + .help(rating.label) } private func feedbackPopoverContent(for slot: BusyTimeSlot, existingRating: SessionRating?) -> some View { @@ -2083,7 +2084,11 @@ extension TimelineView { HStack(spacing: 8) { ForEach(SessionRating.allCases, id: \.rawValue) { rating in Button { - calendarService.setFeedbackTag(eventId: slot.id, rating: rating) + if existingRating == rating { + calendarService.clearFeedbackTag(eventId: slot.id) + } else { + calendarService.setFeedbackTag(eventId: slot.id, rating: rating) + } Task { await calendarService.fetchEvents(for: selectedDate) } feedbackPopoverEventId = nil } label: {