diff --git a/KJamsClone/DJAudioRouting.swift b/KJamsClone/DJAudioRouting.swift index 622773f..062bb65 100644 --- a/KJamsClone/DJAudioRouting.swift +++ b/KJamsClone/DJAudioRouting.swift @@ -20,6 +20,16 @@ struct DJAudioRouting: Codable, Equatable { if mode == "Internal",device.outputChannels >= 4 { routing.cue = .init(uid:device.uid,firstChannel:2) } return routing } + func changingMode(_ mode: String,preferred: DJAudioDevice?) -> Self { + var next = self;next.mode = mode + if mode == "External" { + if next.deckA == DJAudioRoute(),next.deckB == DJAudioRoute(),let preferred { + next.deckA = .init(uid:preferred.uid,firstChannel:0);next.deckB = .init(uid:preferred.uid,firstChannel:2) + } + if !next.cue.uid.isEmpty,[next.deckA,next.deckB].contains(next.cue) { next.cue = .init() } + } + return next + } static func channelMap(channels: Int,first: Int) throws -> [Int32] { guard channels >= 2,first >= 0,first % 2 == 0,first+1 < channels else { throw LibraryError.message("Choose an available stereo channel pair.") } var map = [Int32](repeating:-1,count:channels);map[first] = 0;map[first+1] = 1;return map @@ -67,7 +77,7 @@ struct DJAudioDevicesPane: View { VStack(alignment:.leading,spacing:22) { PreferencesSection("Audio Routing") { VStack(spacing:22) { - HStack { Text("Mixer Mode:").frame(width:130,alignment:.trailing);Picker("Mixer Mode",selection:$draft.mode) { Text("Internal").tag("Internal");Text("External").tag("External") }.pickerStyle(.radioGroup).horizontalRadioGroupLayout().labelsHidden();Spacer() } + HStack { Text("Mixer Mode:").frame(width:130,alignment:.trailing);Picker("Mixer Mode",selection:Binding(get:{draft.mode},set:{draft = draft.changingMode($0,preferred:recommended)})) { Text("Internal").tag("Internal");Text("External").tag("External") }.pickerStyle(.radioGroup).horizontalRadioGroupLayout().labelsHidden();Spacer() } if draft.mode == "Internal" { row("Main Output",route:$draft.main,icon:"hifispeaker.fill") } else { row("Deck A",route:$draft.deckA,icon:"a.circle");row("Deck B",route:$draft.deckB,icon:"b.circle") } row("Pre-Cueing",route:$draft.cue,icon:"headphones",optional:true) @@ -76,7 +86,7 @@ struct DJAudioDevicesPane: View { } if let recommended { Button("Use \(recommended.name)") { draft = .controllerPreset(recommended,mode:draft.mode) } - .help("Set up the controller’s stereo pairs, then Apply") + .help("Use the controller’s stereo pairs immediately") } Text(draft.mode == "Internal" ? "Internal mixes both decks through the main output. Pre-cueing can use channels 3–4 on the same four-channel sound card." : "External sends each deck to its own stereo pair. The software crossfader is bypassed; mix the outputs on your external mixer.").font(.callout).foregroundStyle(.secondary) if system.devices.isEmpty { Text("No audio outputs reported by macOS. Reconnect the interface and refresh.").foregroundStyle(.orange) } @@ -89,7 +99,7 @@ struct DJAudioDevicesPane: View { Text("\(device.outputChannels) out · \(device.inputChannels) in") if device.outputChannels >= 2 { Text(device.stereoPairs.map { "ch \($0+1)–\($0+2)" }.joined(separator:", ")).foregroundStyle(.secondary) - } else { Text("Input only").foregroundStyle(.secondary) } + } else { Text(device.outputChannels == 0 ? "Input only" : "Mono output").foregroundStyle(.secondary) } } }.font(.caption).padding(.vertical,3) } @@ -101,18 +111,14 @@ struct DJAudioDevicesPane: View { HStack { Button("Refresh Devices") { system.refreshDevices() } Spacer() - Button("Revert") { draft = system.routing }.disabled(draft == system.routing) - Button("Apply") { system.applyRouting(draft) }.buttonStyle(.borderedProminent).disabled(draft == system.routing || validation != nil) + if validation != nil { Button("Revert invalid changes") { draft = system.routing } } + Text("Changes apply immediately").font(.caption).foregroundStyle(.secondary) } }.onAppear { system.refreshDevices();draft = system.routing - if draft == DJAudioRouting(),let recommended { draft = .controllerPreset(recommended,mode:"Internal") } } - .onChange(of:draft.mode) { mode in - if mode == "External",draft.deckA == DJAudioRoute(),draft.deckB == DJAudioRoute(),let recommended { - draft.deckA = .init(uid:recommended.uid,firstChannel:0);draft.deckB = .init(uid:recommended.uid,firstChannel:2) - if draft.cue.uid == recommended.uid { draft.cue = .init() } - } + .onChange(of:draft) { next in + if next != system.routing,validation == nil { system.applyRouting(next) } } .task { while !Task.isCancelled { @@ -147,7 +153,7 @@ struct DJAudioChannelPicker: NSViewRepresentable { func makeNSView(context: Context) -> NSPopUpButton { let button = NSPopUpButton(frame:.zero,pullsDown:false) button.target = context.coordinator;button.action = #selector(Coordinator.changed(_:)) - button.setAccessibilityLabel(label);return button + button.setAccessibilityLabel(label);button.identifier = NSUserInterfaceItemIdentifier(label);return button } func updateNSView(_ button: NSPopUpButton,context: Context) { context.coordinator.value = $value diff --git a/KJamsClone/DJAutomix.swift b/KJamsClone/DJAutomix.swift index 731afde..9c85c9d 100644 --- a/KJamsClone/DJAutomix.swift +++ b/KJamsClone/DJAutomix.swift @@ -52,6 +52,7 @@ import UniformTypeIdentifiers func manualTakeover() { if enabled { stop("Automix off · manual control") } } func start(runTimer: Bool = true) { guard let system,!enabled else { return } + guard !system.importing else { message = "Wait for the current DJ import to finish";return } guard system.routing.mode == "Internal" else { message = "Automix requires Internal mixer mode";return } let playing = [system.a,system.b].filter{$0.player.playing} guard playing.count <= 1 else { message = "Pause one deck before starting Automix";return } diff --git a/KJamsClone/DJWorkspace.swift b/KJamsClone/DJWorkspace.swift index 220b90e..e98793a 100644 --- a/KJamsClone/DJWorkspace.swift +++ b/KJamsClone/DJWorkspace.swift @@ -663,7 +663,7 @@ struct StudioSettingsDialog: View { else if category == "Devices" { KaraokeDevicesPreferences(model:model) } else { StudioSettingsPane(showHeading:false,section:category,model:model).id(category) } Divider() - HStack { Text(scope == "DJ" && category == "Devices" ? "DJ routing · Use Apply to save device changes" : scope+" settings · Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16) + HStack { Text(scope == "DJ" && category == "Devices" ? "DJ routing · Changes apply immediately" : scope+" settings · Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16) }.frame(width:850,height:700).background(Color(nsColor:.windowBackgroundColor)) .onChange(of:scope) { _ in model.dj.midi.learning = nil;if !categories.contains(category) { category = "General" } } .onChange(of:category) { _ in model.dj.midi.learning = nil } diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index 4d072a3..51cf741 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -773,24 +773,11 @@ struct LibraryView: View { } } private var workspaceLayout: some View { - VStack(spacing:0) { - HStack { - Spacer() - Picker("Workspace",selection:$workspaceTab) { - Label("Karaoke",systemImage:"music.mic").tag("Karaoke") - Label("DJ Studio",systemImage:"opticaldisc").tag("DJ Studio") - }.pickerStyle(.segmented).frame(width:340).accessibilityIdentifier("workspace.tabs") - Spacer() - }.frame(height:43).background(.bar) - Divider() - // Keep both workspaces mounted: playback, selection and MIDI stay alive between tabs. - ZStack { - libraryWorkspace.opacity(workspaceTab == "Karaoke" ? 1 : 0) - .allowsHitTesting(workspaceTab == "Karaoke").accessibilityHidden(workspaceTab != "Karaoke") - DJConsole(model:model,system:model.dj,expanded:true).opacity(workspaceTab == "DJ Studio" ? 1 : 0) - .allowsHitTesting(workspaceTab == "DJ Studio").accessibilityHidden(workspaceTab != "DJ Studio") - }.frame(maxWidth:.infinity,maxHeight:.infinity) - } + TabView(selection:$workspaceTab) { + libraryWorkspace.tabItem { Label("Karaoke",systemImage:"music.mic") }.tag("Karaoke") + DJConsole(model:model,system:model.dj,expanded:true) + .tabItem { Label("DJ Studio",systemImage:"opticaldisc") }.tag("DJ Studio") + }.accessibilityIdentifier("workspace.tabs") } private var workspace: some View { workspaceLayout diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 842e7f2..e487c5b 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -1352,7 +1352,10 @@ final class KJamsCloneTests: XCTestCase { try await Task.sleep(nanoseconds:200_000_000);host.layoutSubtreeIfNeeded();host.displayIfNeeded() if let image = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:image) } func click(_ x: Double,_ top: Double) async throws { - let point = NSPoint(x:x,y:900-top-44) + func descendants(_ view: NSView) -> [NSView] { [view]+view.subviews.flatMap{descendants($0)} } + let tabs = try XCTUnwrap(descendants(host).compactMap{$0 as? NSTabView}.first) + let rect = tabs.contentRect + let point = tabs.convert(NSPoint(x:rect.minX+x,y:tabs.isFlipped ? rect.minY+top : rect.maxY-top),to:nil) for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] { let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:point,modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0)) NSApp.sendEvent(event) @@ -1497,8 +1500,12 @@ final class KJamsCloneTests: XCTestCase { host.layoutSubtreeIfNeeded();if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try rep.representation(using:.png,properties:[:])!.write(to:URL(fileURLWithPath:"/tmp/kjmix-rotation-test-"+name+".png")) } } func click(_ x: Double,_ top: Double) async throws { + func descendants(_ view: NSView) -> [NSView] { [view]+view.subviews.flatMap{descendants($0)} } + let tabs = try XCTUnwrap(descendants(host).compactMap{$0 as? NSTabView}.first) + let rect = tabs.contentRect + let point = tabs.convert(NSPoint(x:rect.minX+x,y:tabs.isFlipped ? rect.minY+top : rect.maxY-top),to:nil) for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] { - let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:NSPoint(x:x,y:760-top-44),modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0));NSApp.sendEvent(event) + let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:point,modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0));NSApp.sendEvent(event) } try await Task.sleep(nanoseconds:120_000_000) } @@ -2104,6 +2111,9 @@ extension KJamsCloneTests { XCTAssertEqual(device.stereoPairs,[0,2]) let preset = DJAudioRouting.controllerPreset(device,mode:"Internal") XCTAssertEqual(preset.main.firstChannel,0);XCTAssertEqual(preset.cue.firstChannel,2) + let switched = preset.changingMode("External",preferred:device) + XCTAssertTrue(switched.cue.uid.isEmpty,"Switching to external clears cue if both pairs are used by public decks") + XCTAssertNoThrow(try switched.validate(devices:[device],defaultID:1)) let external = DJAudioRouting.controllerPreset(device,mode:"External") XCTAssertEqual(external.deckA.firstChannel,0);XCTAssertEqual(external.deckB.firstChannel,2) XCTAssertNoThrow(try external.validate(devices:[device],defaultID:1)) @@ -2114,6 +2124,22 @@ extension KJamsCloneTests { XCTAssertNoThrow(try routing.validate(devices:[device],defaultID:1)) routing.deckB.firstChannel = 0;XCTAssertThrowsError(try routing.validate(devices:[device],defaultID:1)) } + @MainActor func testNativeChannelPickerListsBothPairsAndChangesBindingImmediately() async throws { + var selected = 0 + let host = NSHostingView(rootView:DJAudioChannelPicker(value:Binding(get:{selected},set:{selected = $0}),pairs:[0,2],label:"Test channels").frame(width:140,height:30)) + host.frame = NSRect(x:0,y:0,width:140,height:30) + let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false) + window.contentView = host;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil) + defer { window.orderOut(nil);window.contentView = nil } + try await Task.sleep(nanoseconds:100_000_000);host.layoutSubtreeIfNeeded() + func descendants(_ view: NSView) -> [NSView] { [view]+view.subviews.flatMap{descendants($0)} } + let control = try XCTUnwrap(descendants(host).compactMap{$0 as? NSPopUpButton}.first) + XCTAssertEqual(control.itemTitles,["ch 1–2","ch 3–4"]) + control.selectItem(withTag:2);_ = control.sendAction(try XCTUnwrap(control.action),to:control.target) + XCTAssertEqual(selected,2) + control.selectItem(withTag:0);_ = control.sendAction(try XCTUnwrap(control.action),to:control.target) + XCTAssertEqual(selected,0) + } @MainActor func testAudioOutputsAndActualFourChannelRoutingWhenInterfaceAttached() async throws { let outputs = DJAudioDevice.outputs() print("DJ audio discovery:",outputs.map { "\($0.name) [\($0.outputChannels) out / \($0.inputChannels) in]" }.joined(separator:", ")) @@ -2140,6 +2166,19 @@ extension KJamsCloneTests { let menus = descendants(host).compactMap{$0 as? NSPopUpButton} for menu in menus { menu.menu?.update() } XCTAssertTrue(menus.contains{$0.itemTitles.contains("ch 1–2") && $0.itemTitles.contains("ch 3–4")},"Native channel menus expose both pairs from the attached Numark") + let main = try XCTUnwrap(menus.first{$0.identifier?.rawValue == "Main Output channels"}) + let cue = try XCTUnwrap(menus.first{$0.identifier?.rawValue == "Pre-Cueing channels"}) + func choose(_ button: NSPopUpButton,_ pair: Int) async throws { + button.selectItem(withTag:pair);_ = button.sendAction(try XCTUnwrap(button.action),to:button.target) + try await Task.sleep(nanoseconds:100_000_000) + } + try await choose(main,2) + XCTAssertEqual(model.dj.routing.main.firstChannel,0,"Conflicting draft keeps the last valid live route") + try await choose(cue,0) + XCTAssertEqual(model.dj.routing.main.firstChannel,2);XCTAssertEqual(model.dj.routing.cue.firstChannel,0) + XCTAssertEqual(model.dj.a.player.outputChannelMap(),[-1,-1,0,1],"Native selection applies immediately to the actual output unit") + try await choose(main,0);try await choose(cue,2) + XCTAssertEqual(model.dj.routing.main.firstChannel,0);XCTAssertEqual(model.dj.routing.cue.firstChannel,2) if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-xml-mappings/audio-routing.png")) } } func testXMLStandardLEDOutputHonoursOverridesAndInversion() throws { @@ -2270,14 +2309,14 @@ extension KJamsCloneTests { defer { window.orderOut(nil);window.contentView = nil } try await Task.sleep(nanoseconds:200_000_000);host.layoutSubtreeIfNeeded() func descendants(_ view: NSView) -> [NSView] { [view]+view.subviews.flatMap{descendants($0)} } - let tabs = try XCTUnwrap(descendants(host).compactMap{$0 as? NSSegmentedControl}.first{$0.segmentCount == 2}) + let tabs = try XCTUnwrap(descendants(host).compactMap{$0 as? NSTabView}.first{$0.numberOfTabViewItems == 2}) func selectTab(_ index: Int) async throws { - let point = tabs.convert(NSPoint(x:tabs.bounds.width*(index == 0 ? 0.25 : 0.75),y:tabs.bounds.midY),to:nil) - for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] { - let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:point,modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0));NSApp.sendEvent(event) - } + tabs.selectTabViewItem(at:index) try await Task.sleep(nanoseconds:100_000_000);host.layoutSubtreeIfNeeded() - XCTAssertEqual(tabs.selectedSegment,index) + XCTAssertEqual(tabs.selectedTabViewItem,tabs.tabViewItem(at:index)) + if let inactive = tabs.tabViewItem(at:1-index).view { + XCTAssertTrue(inactive.superview == nil || inactive.isHiddenOrHasHiddenAncestor,"Inactive pages cannot intercept deck drops") + } XCTAssertTrue(model.player.playing);XCTAssertTrue(model.dj.a.player.playing) XCTAssertEqual(model.player.currentSongID,id);XCTAssertEqual(model.dj.a.player.currentSongID,id) }