Files
virtual-controller/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-WebSocket.swift
T
Paul Lipscomb 0c7b6b4442 JL Cooper MIDI Controller I/O, Focus widget hardware binding, iOS Channel Focus polish
Desktop:
- New dedicated MIDI Controller In port (separate from MIDI In), same CC learn/bind pipeline but never passes notes through; positioned first in the IO column order
- Fixed MIDI In "Not Connected" not persisting across restart (missing save_io_config call)
- Fixed MIDI Out/Transport Out mutual-exclusion incorrectly flagging "Not Connected" as an in-use port
- Renamed "Remote" checkbox to "App" on Fader/Toggle/Transport widgets
- FocusFaderWidget/FocusToggleWidget: new Hardware section (Learn + CC/CH bind) so a physical controller can drive a Channel Focus widget alongside the app/OSC, plus an independent Feedback checkbox that sends translated CC out (for motorized fader / LED sync)
- JL Cooper touch-sense profile ("No Profile" / "MIDI JL Cooper CC Mode") on FaderWidget/FocusFaderWidget: touch channel (value channel - 1, same CC) is filtered out of the value and gates incoming OSC/DAW feedback while touching
- FocusFeedbackWidget: "Custom" checkbox to show a manually-typed static string instead of live OSC feedback, disabling Learn/OSC input and suppressing the iPad broadcast while active
- New daw-config-reaper/ folder with reaper-osc-config-paul-custom.ReaperOSC

iOS:
- New FocusToggleWidget (dedicated Channel Focus toggle, split out of shared ToggleWidget) with colorName-aware darken/border and isMomentary handling — keeps Channel-Focus-only behavior off the regular ToggleWidget
- Arrange mode long-press menu: text alignment (left/center/right) and a 10-color named-palette Text Color picker for TitleWidget/FocusFeedbackWidget, persisted in the saved layout
- FocusFeedbackWidget: Reset Size action, monospaced-digit font fix for timestamp/counter jitter
- New "Cancel Changes" button in Arrange mode — reverts layout/hides/aligns to the state at Arrange-mode entry and restores desktop-side Remote/dest assignments
- Grid line brightness increased for visibility
- Bonjour discovery for desktop auto-connect

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 22:17:37 -04:00

146 lines
6.6 KiB
Swift

import UIKit
extension ArrangeObjects {
// MARK: - WebSocket
func connectWebSocket(url: URL) {
self.urlSession = URLSession(configuration: .default, delegate: self, delegateQueue: .main)
self.wsClient = self.urlSession?.webSocketTask(with: url)
self.wsClient?.resume()
self.updateUIFromConnectionState(.connected)
self.wsClientReceiveMessage()
}
func wsClientReceiveMessage() {
self.wsClient?.receive { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let message):
switch message {
case .string(let text): self.wsClientHandleIncomingJSON(text)
default: break
}
self.wsClientReceiveMessage()
case .failure:
DispatchQueue.main.async {
self.updateUIFromConnectionState(.disconnected)
}
}
}
}
func wsClientHandleIncomingJSON(_ text: String) {
self.loggingView.log(text, category: .incoming)
guard let data = text.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let event = json["event"] as? String else { return }
switch event {
case "preset":
guard self.autoSwitchEnabled else {
self.loggingView.log("preset event ignored — auto switch is off", category: .local)
return
}
guard let presetData = json["data"] as? [String: Any],
let presetBytes = try? JSONSerialization.data(withJSONObject: presetData) else {
let msg = "preset: missing or malformed 'data' field in envelope"
self.loggingView.log(msg, category: .local)
DispatchQueue.main.async {
let alert = UIAlertController(title: "Preset Load Error", message: msg, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(alert, animated: true)
}
return
}
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let preset = try decoder.decode(ModelPresetLoadToRemoteDevice.self, from: presetBytes)
self.loggingView.log(
"✓ preset loaded: '\(preset.presetName)' uuid=\(preset.presetUuid) faders=\(preset.faders.count) toggles=\(preset.toggles.count) transports=\(preset.transports.count)",
category: .local
)
if let encoded = try? JSONEncoder().encode(preset),
let jsonStr = String(data: encoded, encoding: .utf8) {
self.loggingView.log(jsonStr, category: .local)
}
DispatchQueue.main.async {
self.currentPreset = preset
guard self.isChannelFocusMode == false else {
self.loggingView.log("preset event cached but not rendered — channel focus is active", category: .local)
return
}
let isNewPreset = preset.presetUuid != self.presetUuid
if self.isLocked == false && isNewPreset {
let alert = UIAlertController(
title: "Preset Switched",
message: "Switching to:\n\"\(preset.presetName)\"\(preset.displayTitle)\n\nSave your current layout before switching?",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Save & Switch", style: .default) { _ in
self.saveLayout()
self.buildUIFromPreset(preset)
})
alert.addAction(UIAlertAction(title: "Discard & Switch", style: .destructive) { _ in
self.buildUIFromPreset(preset)
})
self.present(alert, animated: true)
} else {
self.buildUIFromPreset(preset)
}
}
} catch {
let msg = "preset decode failed: \(error.localizedDescription)"
self.loggingView.log(msg, category: .local)
DispatchQueue.main.async {
let alert = UIAlertController(title: "Preset Load Error", message: msg, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(alert, animated: true)
}
}
case "widget_update":
if let uid = json["uid"] as? String, let value = json["value"] as? Int {
DispatchQueue.main.async { self.updateWidget(uid: uid, value: value) }
}
case "widget_label":
if let uid = json["uid"] as? String, let label = json["label"] as? String {
DispatchQueue.main.async { self.updateWidgetLabel(uid: uid, label: label) }
}
case "widget_update_f":
if let uid = json["uid"] as? String, let value = json["value"] as? Double {
DispatchQueue.main.async { self.widgets[uid]?.applyFloatValue(value) }
}
case "widget_feedback":
if let uid = json["uid"] as? String, let text = json["text"] as? String {
DispatchQueue.main.async { self.widgets[uid]?.applyFeedbackText(text) }
}
case "daw_state":
DispatchQueue.main.async { self.updateUIFromDawState(json) }
default:
break
}
}
func wsClientSendJSON(_ dict: [String: Any]) {
guard let data = try? JSONSerialization.data(withJSONObject: dict),
let text = String(data: data, encoding: .utf8) else { return }
self.loggingView.log(text, category: .outgoing)
self.wsClient?.send(.string(text)) { _ in }
}
}
// MARK: - WebSocket Delegate
extension ArrangeObjects: URLSessionWebSocketDelegate {
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
self.updateUIFromConnectionState(.connected)
}
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
self.updateUIFromConnectionState(.disconnected)
}
}