Skip to content

Commit 87f0626

Browse files
committed
fix(output-selection) finalize cmd multi-output picker behavior
1 parent da7bbfc commit 87f0626

1 file changed

Lines changed: 156 additions & 73 deletions

File tree

FineTune/Views/Components/DevicePicker.swift

Lines changed: 156 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
// FineTune/Views/Components/DevicePicker.swift
2+
import AppKit
23
import SwiftUI
34

4-
/// Selection state for device picker - either following system default or explicit device
5-
enum DeviceSelection: Equatable {
6-
case systemAudio
7-
case device(String) // deviceUID
8-
}
9-
10-
/// A styled device picker dropdown with "System Audio" option and single/multi mode support
5+
/// A styled device picker dropdown with "System" option and single/multi mode support
116
struct DevicePicker: View {
127
let devices: [AudioDevice]
138
let selectedDeviceUID: String // For single mode
@@ -23,18 +18,28 @@ struct DevicePicker: View {
2318

2419
@State private var isExpanded = false
2520
@State private var isButtonHovered = false
21+
@State private var commandKeyPressed = false
22+
@State private var modifierMonitor: Any?
2623

2724
// Local state mirrors props for popover reactivity
2825
@State private var currentMode: DeviceSelectionMode = .single
2926
@State private var currentSelectedUIDs: Set<String> = []
3027

3128
// Configuration
32-
private let triggerWidth: CGFloat = 128
33-
private let popoverWidth: CGFloat = 210
29+
private let triggerWidth: CGFloat = 94
30+
private let popoverWidth: CGFloat = 192
3431
private let itemHeight: CGFloat = 26
3532
private let itemSpacing: CGFloat = 2
3633
private let cornerRadius: CGFloat = 8
3734

35+
private var isCommandSelectionActive: Bool {
36+
showModeToggle && isExpanded && commandKeyPressed
37+
}
38+
39+
private var hasCommittedMultiSelection: Bool {
40+
selectedDeviceUIDs.count > 1
41+
}
42+
3843
/// Menu item representation for unified dropdown
3944
enum MenuItem: Identifiable, Equatable {
4045
case systemAudio
@@ -49,7 +54,7 @@ struct DevicePicker: View {
4954

5055
var name: String {
5156
switch self {
52-
case .systemAudio: return "System Audio"
57+
case .systemAudio: return "System"
5358
case .device(let device): return device.name
5459
}
5560
}
@@ -68,6 +73,11 @@ struct DevicePicker: View {
6873

6974
/// Display text for trigger button
7075
private var triggerText: String {
76+
if selectedDeviceUIDs.count > 1 {
77+
let count = selectedDeviceUIDs.count
78+
return "\(count) device\(count == 1 ? "" : "s")"
79+
}
80+
7181
switch mode {
7282
case .single:
7383
return singleModeText
@@ -84,7 +94,7 @@ struct DevicePicker: View {
8494
/// Text for single-mode display (also used as fallback for empty multi-mode)
8595
private var singleModeText: String {
8696
if isFollowingDefault {
87-
return "System Audio"
97+
return "System"
8898
} else if let device = devices.first(where: { $0.uid == selectedDeviceUID }) {
8999
return device.name
90100
}
@@ -94,16 +104,21 @@ struct DevicePicker: View {
94104
/// Icon for trigger button
95105
@ViewBuilder
96106
private var triggerIcon: some View {
97-
switch mode {
98-
case .single:
99-
singleModeIcon
100-
case .multi:
101-
if selectedDeviceUIDs.isEmpty {
102-
// No multi selections - show single-mode icon
107+
if selectedDeviceUIDs.count > 1 {
108+
Image(systemName: "speaker.wave.2.fill")
109+
.font(.system(size: 13))
110+
} else {
111+
switch mode {
112+
case .single:
103113
singleModeIcon
104-
} else {
105-
Image(systemName: "speaker.wave.2.fill")
106-
.font(.system(size: 13))
114+
case .multi:
115+
if selectedDeviceUIDs.isEmpty {
116+
// No multi selections - show single-mode icon
117+
singleModeIcon
118+
} else {
119+
Image(systemName: "speaker.wave.2.fill")
120+
.font(.system(size: 13))
121+
}
107122
}
108123
}
109124
}
@@ -146,6 +161,20 @@ struct DevicePicker: View {
146161
currentMode = mode
147162
currentSelectedUIDs = selectedDeviceUIDs
148163
}
164+
.onChange(of: isExpanded) { _, expanded in
165+
if expanded {
166+
startModifierMonitoring()
167+
seedSelectionFromCurrentRouteIfNeeded()
168+
} else {
169+
stopModifierMonitoring()
170+
}
171+
}
172+
.onChange(of: commandKeyPressed) { _, _ in
173+
seedSelectionFromCurrentRouteIfNeeded()
174+
}
175+
.onDisappear {
176+
stopModifierMonitoring()
177+
}
149178
}
150179

151180
// MARK: - Trigger Button
@@ -172,7 +201,7 @@ struct DevicePicker: View {
172201
.rotationEffect(.degrees(isExpanded ? -180 : 0))
173202
.animation(.easeInOut(duration: 0.25), value: isExpanded)
174203
}
175-
.padding(.horizontal, DesignTokens.Spacing.sm)
204+
.padding(.horizontal, DesignTokens.Spacing.xs)
176205
.padding(.vertical, 4)
177206
.frame(width: triggerWidth)
178207
.contentShape(Rectangle())
@@ -197,17 +226,25 @@ struct DevicePicker: View {
197226

198227
private var dropdownContent: some View {
199228
VStack(spacing: 0) {
200-
// Mode toggle header (hidden for single-mode-only contexts like Settings)
229+
// Multi-select hint (shown where multi-select is supported)
201230
if showModeToggle {
202-
ModeToggle(mode: Binding(
203-
get: { currentMode },
204-
set: { newMode in
205-
currentMode = newMode // Update local state immediately
206-
onModeChange(newMode) // Notify parent
207-
// States are independent - no copying between modes
208-
}
209-
))
210-
.padding(.horizontal, DesignTokens.Spacing.xs + 2)
231+
HStack(spacing: 6) {
232+
Image(systemName: "questionmark.circle")
233+
.font(.system(size: 10, weight: .semibold))
234+
.foregroundStyle(DesignTokens.Colors.textTertiary)
235+
Text("Hold")
236+
.font(DesignTokens.Typography.caption)
237+
.foregroundStyle(DesignTokens.Colors.textTertiary)
238+
Image(systemName: "command")
239+
.font(.system(size: 10, weight: .semibold))
240+
.foregroundStyle(DesignTokens.Colors.textTertiary)
241+
Text("for multi-output")
242+
.font(DesignTokens.Typography.caption)
243+
.foregroundStyle(DesignTokens.Colors.textTertiary)
244+
Spacer(minLength: 0)
245+
}
246+
.padding(.leading, DesignTokens.Spacing.md + 2)
247+
.padding(.trailing, DesignTokens.Spacing.sm)
211248
.padding(.top, DesignTokens.Spacing.xs + 2)
212249
.padding(.bottom, DesignTokens.Spacing.xs)
213250

@@ -243,69 +280,123 @@ struct DevicePicker: View {
243280
@ViewBuilder
244281
private func deviceRow(for item: MenuItem) -> some View {
245282
let isSystemAudio = item.id == "__system_audio__"
246-
let isDisabled = currentMode == .multi && isSystemAudio
283+
let isDisabled = isCommandSelectionActive && isSystemAudio
247284
let isSelected = isItemSelected(item)
248285

249286
DevicePickerRow(
250287
item: item,
251288
isSelected: isSelected,
252289
isDisabled: isDisabled,
253-
isMultiMode: currentMode == .multi,
254-
isDefaultDevice: {
255-
if case .device(let device) = item {
256-
return device.uid == defaultDeviceUID
257-
}
258-
return false
259-
}(),
290+
isMultiMode: isCommandSelectionActive,
260291
onTap: {
261292
handleItemTap(item)
262293
}
263294
)
264295
}
265296

266297
private func isItemSelected(_ item: MenuItem) -> Bool {
267-
switch currentMode {
268-
case .single:
269-
if case .systemAudio = item {
270-
return isFollowingDefault
271-
} else if case .device(let device) = item {
272-
return !isFollowingDefault && device.uid == selectedDeviceUID
273-
}
274-
return false
275-
case .multi:
298+
if isCommandSelectionActive {
276299
if case .device(let device) = item {
277300
return currentSelectedUIDs.contains(device.uid)
278301
}
279-
return false // System Audio not selectable in multi mode
302+
return false // System audio is disabled in command multi-select mode.
280303
}
281-
}
282304

283-
private func handleItemTap(_ item: MenuItem) {
284-
switch currentMode {
285-
case .single:
286-
switch item {
287-
case .systemAudio:
288-
onSelectFollowDefault()
289-
case .device(let device):
290-
onDeviceSelected(device.uid)
305+
if hasCommittedMultiSelection {
306+
if case .device(let device) = item {
307+
return selectedDeviceUIDs.contains(device.uid)
291308
}
292-
withAnimation(.easeOut(duration: 0.15)) {
293-
isExpanded = false
309+
return false
310+
}
311+
312+
if case .systemAudio = item {
313+
return isFollowingDefault
314+
} else if case .device(let device) = item {
315+
if !isFollowingDefault && !selectedDeviceUID.isEmpty {
316+
return device.uid == selectedDeviceUID
294317
}
318+
}
319+
return false
320+
}
295321

296-
case .multi:
322+
private func handleItemTap(_ item: MenuItem) {
323+
if isCommandSelectionActive {
297324
guard case .device(let device) = item else { return }
298325
var newSelection = currentSelectedUIDs
299326
if newSelection.contains(device.uid) {
327+
guard newSelection.count > 1 else { return }
300328
newSelection.remove(device.uid)
301329
} else {
302330
newSelection.insert(device.uid)
303331
}
304332
currentSelectedUIDs = newSelection // Update local state immediately
305-
onDevicesSelected(newSelection) // Notify parent
306-
// Stay open in multi mode
333+
if newSelection.count > 1 {
334+
currentMode = .multi
335+
onModeChange(.multi)
336+
onDevicesSelected(newSelection)
337+
} else if let remaining = newSelection.first {
338+
currentMode = .single
339+
onModeChange(.single)
340+
onDeviceSelected(remaining)
341+
}
342+
return
343+
}
344+
345+
switch item {
346+
case .systemAudio:
347+
currentMode = .single
348+
currentSelectedUIDs = []
349+
onModeChange(.single)
350+
onSelectFollowDefault()
351+
case .device(let device):
352+
currentMode = .single
353+
currentSelectedUIDs = [device.uid]
354+
onModeChange(.single)
355+
onDeviceSelected(device.uid)
356+
}
357+
358+
withAnimation(.easeOut(duration: 0.15)) {
359+
isExpanded = false
360+
}
361+
}
362+
363+
private func seedSelectionFromCurrentRouteIfNeeded() {
364+
guard isCommandSelectionActive else { return }
365+
guard currentSelectedUIDs.isEmpty else { return }
366+
367+
if !selectedDeviceUID.isEmpty,
368+
devices.contains(where: { $0.uid == selectedDeviceUID }) {
369+
currentSelectedUIDs = [selectedDeviceUID]
370+
return
371+
}
372+
373+
if let defaultDeviceUID,
374+
devices.contains(where: { $0.uid == defaultDeviceUID }) {
375+
currentSelectedUIDs = [defaultDeviceUID]
376+
return
377+
}
378+
379+
if let fallback = devices.first?.uid {
380+
currentSelectedUIDs = [fallback]
307381
}
308382
}
383+
384+
private func startModifierMonitoring() {
385+
stopModifierMonitoring()
386+
commandKeyPressed = NSEvent.modifierFlags.contains(.command)
387+
modifierMonitor = NSEvent.addLocalMonitorForEvents(matching: [.flagsChanged]) { event in
388+
commandKeyPressed = event.modifierFlags.contains(.command)
389+
return event
390+
}
391+
}
392+
393+
private func stopModifierMonitoring() {
394+
if let modifierMonitor {
395+
NSEvent.removeMonitor(modifierMonitor)
396+
self.modifierMonitor = nil
397+
}
398+
commandKeyPressed = false
399+
}
309400
}
310401

311402
// MARK: - Device Picker Row
@@ -315,7 +406,6 @@ private struct DevicePickerRow: View {
315406
let isSelected: Bool
316407
let isDisabled: Bool
317408
let isMultiMode: Bool
318-
let isDefaultDevice: Bool
319409
let onTap: () -> Void
320410

321411
@State private var isHovered = false
@@ -333,13 +423,6 @@ private struct DevicePickerRow: View {
333423
itemText
334424

335425
Spacer()
336-
337-
// Default device star
338-
if isDefaultDevice {
339-
Image(systemName: "star.fill")
340-
.font(.system(size: 9))
341-
.foregroundStyle(DesignTokens.Colors.textTertiary)
342-
}
343426
}
344427
.font(.system(size: 11))
345428
.foregroundColor(isDisabled ? DesignTokens.Colors.textQuaternary : .primary)
@@ -407,9 +490,9 @@ private struct DevicePickerRow: View {
407490
switch item {
408491
case .systemAudio:
409492
VStack(alignment: .leading, spacing: 1) {
410-
Text("System Audio")
493+
Text("System")
411494
if isDisabled {
412-
Text("Not available in multi mode")
495+
Text("Not available")
413496
.font(DesignTokens.Typography.caption)
414497
.foregroundStyle(DesignTokens.Colors.textQuaternary)
415498
} else {

0 commit comments

Comments
 (0)