11// FineTune/Views/Components/DevicePicker.swift
2+ import AppKit
23import 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
116struct 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