Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions SessionFlow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
48 changes: 48 additions & 0 deletions SessionFlow/Services/MicrophoneMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -62,6 +67,7 @@ class MicrophoneMonitor: ObservableObject {
DispatchQueue.main.async {
self?.removeListener()
self?.observeDefaultInputDevice()
self?.checkSharedDevice()
}
}
}
Expand All @@ -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<AudioDeviceID>.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<CFString>?
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(
Expand Down
7 changes: 6 additions & 1 deletion SessionFlow/Services/SessionAudioService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class SessionAudioService: ObservableObject {

let micMonitor = MicrophoneMonitor()
private var micCancellable: AnyCancellable?
private var sharedDeviceCancellable: AnyCancellable?

// MARK: - Audio engine

Expand Down Expand Up @@ -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() }
Expand Down
12 changes: 11 additions & 1 deletion SessionFlow/Views/AppSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions SessionFlow/Views/ProductivityCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ struct ProductivityCard: View {
.foregroundColor(.white.opacity(count > 0 ? 0.9 : 0.3))
}
.frame(maxWidth: .infinity)
.help(rating.label)
}

// Unrated count
Expand All @@ -272,6 +273,7 @@ struct ProductivityCard: View {
.foregroundColor(.white.opacity(unratedCount > 0 ? 0.6 : 0.3))
}
.frame(maxWidth: .infinity)
.help("Unrated")
}
}

Expand Down Expand Up @@ -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()
Expand All @@ -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)

Expand Down
5 changes: 4 additions & 1 deletion SessionFlow/Views/SharedAwarenessComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion SessionFlow/Views/TimelineView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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: {
Expand Down
Loading