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>
This commit is contained in:
Paul Lipscomb
2026-07-06 22:17:37 -04:00
parent d6201c59b4
commit 0c7b6b4442
34 changed files with 4815 additions and 208 deletions
@@ -0,0 +1,231 @@
import UIKit
class FaderTestVC: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(white: 0.08, alpha: 1)
let colors: [UIColor] = [
UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1),
UIColor(red: 0, green: 0.6, blue: 1, alpha: 1),
UIColor(red: 1, green: 0.55, blue: 0, alpha: 1),
]
let padW: CGFloat = 98
let padH: CGFloat = 540
let spacing: CGFloat = 20
let totalW = CGFloat(colors.count) * padW + CGFloat(colors.count - 1) * spacing
let startX = (UIScreen.main.bounds.width - totalW) / 2
let startY = (UIScreen.main.bounds.height - padH) / 2
for (i, color) in colors.enumerated() {
let x = startX + CGFloat(i) * (padW + spacing)
let pad = XYPadView(label: "PAD \(i)", color: color,
frame: CGRect(x: x, y: startY, width: padW, height: padH))
view.addSubview(pad)
}
}
}
class XYPadView: UIView {
private let label: String
private let color: UIColor
private let padHeight: CGFloat = 90
private var isRelative = true
private var currentValue: Float = 0
private var touchStartY: CGFloat = 0
private var valueAtTouchStart: Float = 0
private var didInitialLayout = false
private var lastHapticValue: Int = -1
private let haptic = UIImpactFeedbackGenerator(style: .medium)
private let topZone = UIView()
private let botZone = UIView()
private let fillView = UIView()
private let gradLayer = CAGradientLayer()
private let line = UIView()
private let dot = UIView()
private let nameLabel: UILabel = {
let lbl = UILabel()
lbl.textAlignment = .center
lbl.font = .systemFont(ofSize: 13, weight: .medium)
return lbl
}()
private lazy var modeButton: UIButton = {
let btn = UIButton(type: .system)
btn.setTitle("REL", for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 11, weight: .semibold)
btn.setTitleColor(.white, for: .normal)
btn.backgroundColor = color.withAlphaComponent(0.35)
btn.layer.cornerRadius = 4
btn.addTarget(self, action: #selector(toggleMode), for: .touchUpInside)
return btn
}()
init(label: String, color: UIColor, frame: CGRect) {
self.label = label
self.color = color
super.init(frame: frame)
backgroundColor = UIColor(white: 0.1, alpha: 1)
layer.borderColor = color.withAlphaComponent(0.3).cgColor
layer.borderWidth = 1
layer.cornerRadius = 6
clipsToBounds = true
topZone.backgroundColor = UIColor(white: 0.07, alpha: 1)
botZone.backgroundColor = UIColor(white: 0.07, alpha: 1)
addSubview(topZone)
addSubview(botZone)
gradLayer.colors = [UIColor.black.withAlphaComponent(0).cgColor, color.withAlphaComponent(0.45).cgColor]
gradLayer.startPoint = CGPoint(x: 0.5, y: 1)
gradLayer.endPoint = CGPoint(x: 0.5, y: 0)
fillView.layer.addSublayer(gradLayer)
addSubview(fillView)
line.backgroundColor = color
line.layer.shadowColor = color.cgColor
line.layer.shadowRadius = 4
line.layer.shadowOpacity = 0.8
line.layer.shadowOffset = .zero
addSubview(line)
dot.backgroundColor = color
dot.layer.cornerRadius = 5
dot.layer.shadowColor = color.cgColor
dot.layer.shadowRadius = 6
dot.layer.shadowOpacity = 1
dot.layer.shadowOffset = .zero
addSubview(dot)
nameLabel.textColor = UIColor(white: 0.4, alpha: 1)
nameLabel.text = label
addSubview(nameLabel)
addSubview(modeButton)
haptic.prepare()
}
required init?(coder: NSCoder) { fatalError() }
// MARK: - Layout
override func layoutSubviews() {
super.layoutSubviews()
let w = bounds.width
let h = bounds.height
topZone.frame = CGRect(x: 0, y: 0, width: w, height: padHeight)
botZone.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight)
line.frame.size = CGSize(width: w, height: 2)
dot.frame.size = CGSize(width: 10, height: 10)
dot.layer.cornerRadius = 5
nameLabel.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight)
modeButton.frame = CGRect(x: w / 2 - 22, y: padHeight / 2 - 12, width: 44, height: 24)
topZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }
botZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }
addDash(to: topZone, atBottom: true)
addDash(to: botZone, atBottom: false)
if !didInitialLayout {
didInitialLayout = true
showAtCurrentValue()
}
}
private func addDash(to zone: UIView, atBottom: Bool) {
let dash = CAShapeLayer()
let y: CGFloat = atBottom ? zone.bounds.height - 1 : 0
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: zone.bounds.width, y: y))
dash.path = path.cgPath
dash.strokeColor = UIColor(white: 0.25, alpha: 1).cgColor
dash.lineWidth = 1
dash.lineDashPattern = [6, 4]
zone.layer.addSublayer(dash)
}
// MARK: - Mode Toggle
@objc private func toggleMode() {
isRelative.toggle()
modeButton.setTitle(isRelative ? "REL" : "ABS", for: .normal)
modeButton.backgroundColor = isRelative
? color.withAlphaComponent(0.35)
: color.withAlphaComponent(0.15)
}
// MARK: - Touch
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let pt = touches.first?.location(in: self) else { return }
touchStartY = pt.y
valueAtTouchStart = currentValue
moveDot(to: pt)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let pt = touches.first?.location(in: self) else { return }
moveDot(to: pt)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {}
private func moveDot(to pt: CGPoint) {
let activeTop = padHeight
let activeBottom = bounds.height - padHeight
let activeHeight = activeBottom - activeTop
let value: Float
if isRelative {
let deltaY = pt.y - touchStartY
let deltaPct = Float(-deltaY / activeHeight)
value = max(0, min(127, valueAtTouchStart + deltaPct * 127))
} else {
let clampedY = max(activeTop, min(activeBottom, pt.y))
value = max(0, min(127, Float((1 - (clampedY - activeTop) / activeHeight) * 127)))
}
currentValue = value
updateDisplay()
let intValue = Int(value)
if (intValue == 0 || intValue == 127) && intValue != lastHapticValue {
haptic.impactOccurred()
lastHapticValue = intValue
} else if intValue != 0 && intValue != 127 {
lastHapticValue = -1
}
print("[\(label)] val:\(intValue)")
}
private func showAtCurrentValue() { updateDisplay() }
private func updateDisplay() {
let activeTop = padHeight
let activeBottom = bounds.height - padHeight
let activeHeight = activeBottom - activeTop
let w = bounds.width
let displayY = activeBottom - CGFloat(currentValue / 127) * activeHeight
let fillFrame = CGRect(x: 0, y: displayY, width: w, height: activeBottom - displayY)
CATransaction.begin()
CATransaction.setDisableActions(true)
fillView.frame = fillFrame
gradLayer.frame = fillView.bounds
CATransaction.commit()
line.frame.origin = CGPoint(x: 0, y: displayY - 1)
dot.center = CGPoint(x: w / 2, y: displayY)
}
}
@@ -7,6 +7,9 @@ struct ModelPresetLoadToRemoteDevice: Codable {
let faders: [ModelFaderPayload]
let toggles: [ModelTogglePayload]
let transports: [ModelTransportPayload]
let focusFaders: [ModelFocusFaderPayload]
let focusToggles: [ModelFocusTogglePayload]
let focusFeedbacks: [ModelFocusFeedbackPayload]
var displayTitle: String {
bigTitle.isEmpty ? "No Title" : bigTitle
@@ -27,10 +30,42 @@ struct ModelTogglePayload: Codable {
let state: Bool
let destId: Int
let color: String
// The desktop's actual palette name (e.g. "Gray", "White", "Red") used
// instead of inferring from the resolved color's saturation, which can't
// reliably tell a true neutral gray apart from white (both ~0 saturation).
let colorName: String
}
struct ModelTransportPayload: Codable {
let uid: String
let label: String
let destId: Int
let color: String
}
// Channel Focus items are global (not per-preset) and shown via the dedicated
// Channel Focus mode toggle rather than destId-based routing.
struct ModelFocusFaderPayload: Codable {
let uid: String
let label: String
let value: Int
let color: String
}
struct ModelFocusTogglePayload: Codable {
let uid: String
let label: String
let state: Bool
let color: String
let colorName: String
let triggerMode: String
var isMomentary: Bool { triggerMode == "momentary" }
}
struct ModelFocusFeedbackPayload: Codable {
let uid: String
let label: String
let text: String
let color: String
}
@@ -0,0 +1,86 @@
import Foundation
// Discovers the desktop app's WebSocket server on the LAN via Bonjour/mDNS
// (matches the "_vcremote._tcp" service the desktop advertises while its
// tablet server is running) and auto-fills ipField/portField the user
// still taps Connect, this just saves typing the IP by hand.
extension ArrangeObjects: NetServiceBrowserDelegate, NetServiceDelegate {
func startBonjourDiscovery() {
print("[Bonjour] starting search for \(bonjourServiceType).local.")
bonjourBrowser.delegate = self
bonjourBrowser.searchForServices(ofType: "\(bonjourServiceType).", inDomain: "local.")
loggingView.log("Bonjour: searching for \(bonjourServiceType)", category: .local)
}
func stopBonjourDiscovery() {
print("[Bonjour] stopping search")
bonjourBrowser.stop()
discoveredServices.removeAll()
}
// MARK: - NetServiceBrowserDelegate
func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
print("[Bonjour] found service: \(service.name) domain=\(service.domain) moreComing=\(moreComing)")
discoveredServices.append(service)
service.delegate = self
service.resolve(withTimeout: 5)
}
func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) {
print("[Bonjour] service removed: \(service.name)")
discoveredServices.removeAll { $0 === service }
}
func netServiceBrowserDidStopSearch(_ browser: NetServiceBrowser) {
print("[Bonjour] search stopped")
}
func netServiceBrowser(_ browser: NetServiceBrowser, didNotSearch errorDict: [String: NSNumber]) {
print("[Bonjour] search failed to start: \(errorDict)")
}
// MARK: - NetServiceDelegate
func netServiceDidResolveAddress(_ sender: NetService) {
print("[Bonjour] resolving \(sender.name) — addresses: \(sender.addresses?.count ?? 0), rawPort=\(sender.port)")
guard let ip = Self.ipv4Address(from: sender) else {
print("[Bonjour] could not extract an IPv4 address from \(sender.name)")
return
}
print("[Bonjour] resolved \(sender.name) -> \(ip):\(sender.port)")
DispatchQueue.main.async {
self.ipField.text = ip
self.portField.text = "\(sender.port)"
self.loggingView.log("Bonjour found \(sender.name) at \(ip):\(sender.port)", category: .local)
self.showStatus("[ Bonjour found \(sender.name) \(self.statusTimestamp()) ]")
}
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
print("[Bonjour] failed to resolve \(sender.name): \(errorDict)")
loggingView.log("Bonjour: failed to resolve \(sender.name)\(errorDict)", category: .local)
}
// MARK: - Address parsing
private static func ipv4Address(from service: NetService) -> String? {
guard let addresses = service.addresses else { return nil }
for data in addresses {
if let ip = ipv4String(from: data) {
return ip
}
}
return nil
}
private static func ipv4String(from data: Data) -> String? {
data.withUnsafeBytes { rawPtr -> String? in
guard let sockaddrPtr = rawPtr.baseAddress?.assumingMemoryBound(to: sockaddr.self) else { return nil }
guard sockaddrPtr.pointee.sa_family == sa_family_t(AF_INET) else { return nil }
let sinPtr = rawPtr.baseAddress!.assumingMemoryBound(to: sockaddr_in.self)
var addr = sinPtr.pointee.sin_addr
var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
guard inet_ntop(AF_INET, &addr, &buffer, socklen_t(INET_ADDRSTRLEN)) != nil else { return nil }
return String(cString: buffer)
}
}
}
@@ -21,10 +21,34 @@ extension ArrangeObjects {
guard checkerIPAddressInput(ip: ip) else { return }
guard checkerPortInput(port: port) else { return }
guard let url = buildWebSocketURL(ip: ip, port: port) else { return }
UserDefaults.standard.set(ip, forKey: "last_ip")
UserDefaults.standard.set(port, forKey: "last_port")
self.connectWebSocket(url: url)
}
}
@objc func buttonTappedGridButton() {
let green = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
gridEnabled.toggle()
gridOverlay.isHidden = !gridEnabled
gridButton.backgroundColor = gridEnabled ? green : UIColor(white: 0.15, alpha: 1)
gridButton.setTitleColor(gridEnabled ? .black : .lightGray, for: .normal)
}
@objc func tabletIdFieldChanged() {
guard let text = tabletIdField.text, let idVal = Int(text), idVal > 0 else { return }
myTabletId = idVal
if let preset = currentPreset {
buildUIFromPreset(preset)
}
}
@objc func buttonTappedClearLayouts() {
UserDefaults.standard.removeObject(forKey: "layout_\(self.presetUuid)")
loggingView.log("cleared layout for \(self.presetUuid)", category: .local)
if let preset = currentPreset { buildUIFromPreset(preset) }
}
@objc func buttonTappedShowLogging() {
let visible = (self.logWidget.isHidden == false)
self.logWidget.isHidden = visible
@@ -39,17 +63,69 @@ extension ArrangeObjects {
loggingView.log("auto switch \(autoSwitchEnabled ? "enabled" : "disabled")", category: .local)
}
@objc func buttonTappedArrangeButton() {
self.isLocked = false
self.updateUIArrangeLockButtons()
self.widgets.values.forEach { $0.setArrangeMode(true) }
@objc func buttonTappedChannelFocus() {
guard currentPreset != nil else {
loggingView.log("channel focus: no preset received yet", category: .local)
return
}
isChannelFocusMode.toggle()
let green = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
channelFocusButton.backgroundColor = isChannelFocusMode ? green : UIColor(white: 0.15, alpha: 1)
channelFocusButton.setTitleColor(isChannelFocusMode ? .black : .lightGray, for: .normal)
if isChannelFocusMode {
buildChannelFocusUI()
} else if let preset = currentPreset {
buildUIFromPreset(preset)
}
loggingView.log("channel focus \(isChannelFocusMode ? "enabled" : "disabled")", category: .local)
}
@objc func buttonTappedLockButton() {
self.isLocked = true
self.updateUIArrangeLockButtons()
self.saveLayout()
self.widgets.values.forEach { $0.setArrangeMode(false) }
@objc func buttonTappedArrangeButton() {
isLocked.toggle()
updateUIArrangeLockButtons()
if isLocked {
saveLayout()
arrangeSessionLayoutKey = nil
arrangeSessionLayoutSnapshot = nil
widgets.values.forEach { $0.setArrangeMode(false) }
} else {
if let key = currentLayoutKey {
arrangeSessionLayoutKey = key
arrangeSessionLayoutSnapshot = UserDefaults.standard.dictionary(forKey: key) ?? [:]
}
widgets.values.forEach { $0.setArrangeMode(true) }
}
}
// Reverts to how things were the moment Arrange mode was entered
// undoes drags/resizes/alignment changes/hides made this session, even
// though most of those already auto-saved along the way.
@objc func buttonTappedCancelChanges() {
guard let key = arrangeSessionLayoutKey else { return }
UserDefaults.standard.set(arrangeSessionLayoutSnapshot ?? [:], forKey: key)
// Hiding a regular (non-Channel-Focus) widget tells the desktop to
// uncheck its Remote assignment and saves that to the preset file
// resend every regular widget's original destId so the desktop's
// state matches again. Channel Focus uids aren't tracked server-side
// for this message, so this is a no-op for those either way.
if let preset = currentPreset {
for f in preset.faders { wsClientSendJSON(["event": "widget_visibility", "uid": f.uid, "dest_id": f.destId]) }
for t in preset.toggles { wsClientSendJSON(["event": "widget_visibility", "uid": t.uid, "dest_id": t.destId]) }
for t in preset.transports { wsClientSendJSON(["event": "widget_visibility", "uid": t.uid, "dest_id": t.destId]) }
}
arrangeSessionLayoutKey = nil
arrangeSessionLayoutSnapshot = nil
isLocked = true
updateUIArrangeLockButtons()
if isChannelFocusMode {
buildChannelFocusUI()
} else if let preset = currentPreset {
buildUIFromPreset(preset)
}
showStatus("[ Cancelled \(self.statusTimestamp()) ]")
}
}
@@ -1,19 +1,109 @@
import UIKit
extension ArrangeObjects: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
extension ArrangeObjects {
// MARK: - Gestures
func addDragGesture(to view: UIView) {
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleGesturePan(_:)))
pan.cancelsTouchesInView = false
view.addGestureRecognizer(pan)
}
@objc func handleGesturePan(_ gesture: UIPanGestureRecognizer) {
guard self.isLocked == false, let view = gesture.view else { return }
let translation = gesture.translation(in: self.containerView)
view.center = CGPoint(x: view.center.x + translation.x, y: view.center.y + translation.y)
gesture.setTranslation(.zero, in: self.containerView)
if gesture.state == .began {
dragStartOrigin = view.frame.origin
lastSnappedOrigin = view.frame.origin
if gridEnabled { snapHaptic.prepare() }
}
let total = gesture.translation(in: self.containerView)
let rawOrigin = CGPoint(x: dragStartOrigin.x + total.x, y: dragStartOrigin.y + total.y)
if gridEnabled {
let snapped = CGPoint(x: snapValue(rawOrigin.x), y: snapValue(rawOrigin.y))
view.frame.origin = snapped
if snapped != lastSnappedOrigin {
snapHaptic.impactOccurred()
lastSnappedOrigin = snapped
}
} else {
view.frame.origin = rawOrigin
}
}
func snapFrame(_ frame: CGRect) -> CGRect {
guard gridEnabled else { return frame }
let nearestLeft = snapValue(frame.minX)
let nearestRight = snapValue(frame.maxX) - frame.width
let newX = abs(nearestLeft - frame.minX) <= abs(nearestRight - frame.minX) ? nearestLeft : nearestRight
let nearestTop = snapValue(frame.minY)
let nearestBottom = snapValue(frame.maxY) - frame.height
let newY = abs(nearestTop - frame.minY) <= abs(nearestBottom - frame.minY) ? nearestTop : nearestBottom
return CGRect(x: newX, y: newY, width: frame.width, height: frame.height)
}
func snapValue(_ value: CGFloat) -> CGFloat {
(value / gridSize).rounded() * gridSize
}
func addPinchGesture(to view: UIView) {
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handleGesturePinch(_:)))
pinch.delegate = self
view.addGestureRecognizer(pinch)
}
@objc func handleGesturePinch(_ gesture: UIPinchGestureRecognizer) {
guard self.isLocked == false, let widget = gesture.view else { return }
switch gesture.state {
case .began:
pinchStartSize = widget.bounds.size
case .changed:
let scale = max(gesture.scale, 75 / min(pinchStartSize.width, pinchStartSize.height))
let center = widget.center
widget.bounds.size = CGSize(width: pinchStartSize.width * scale, height: pinchStartSize.height * scale)
widget.center = center
case .ended, .cancelled:
widget.frame.origin = CGPoint(x: snapValue(widget.frame.origin.x), y: snapValue(widget.frame.origin.y))
saveLayout()
default:
break
}
}
// FocusFeedbackWidget pinch: adjusts font size directly rather than an
// independent bounds size, so the frame is always a tight fit around the
// text (no baked-in minimum box) and aspect ratio can't drift from it.
func addFeedbackPinchGesture(to widget: FocusFeedbackWidget) {
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handleFeedbackPinch(_:)))
pinch.delegate = self
widget.addGestureRecognizer(pinch)
}
@objc func handleFeedbackPinch(_ gesture: UIPinchGestureRecognizer) {
guard self.isLocked == false, let widget = gesture.view as? FocusFeedbackWidget else { return }
switch gesture.state {
case .began:
pinchStartFontSize = widget.fontSize
case .changed:
widget.setFontSize(pinchStartFontSize * gesture.scale)
case .ended, .cancelled:
widget.frame.origin = CGPoint(x: snapValue(widget.frame.origin.x), y: snapValue(widget.frame.origin.y))
saveLayout()
default:
break
}
}
func addContextMenuGesture(to view: UIView) {
@@ -31,6 +121,56 @@ extension ArrangeObjects {
let alert = UIAlertController(title: widget.displayName, message: nil, preferredStyle: .actionSheet)
alert.overrideUserInterfaceStyle = .dark
// Channel Focus fader only: longer throw = finer touch resolution,
// since the active drag zone grows with the widget's height while the
// top/bottom padding stays fixed (see FocusFaderWidget.padHeight).
if widget is FocusFaderWidget {
alert.addAction(UIAlertAction(title: "50% Longer", style: .default) { [weak self] _ in
self?.scaleFocusFaderLength(widget, multiplier: 1.5)
})
alert.addAction(UIAlertAction(title: "70% Longer", style: .default) { [weak self] _ in
self?.scaleFocusFaderLength(widget, multiplier: 1.7)
})
alert.addAction(UIAlertAction(title: "Reset Length", style: .default) { [weak self] _ in
self?.scaleFocusFaderLength(widget, multiplier: 1.0)
})
}
if let alignable = widget as? TextAlignableWidget {
let current = alignable.textAlignmentName
func alignTitle(_ label: String, _ value: String) -> String {
value == current ? "\(label)" : label
}
alert.addAction(UIAlertAction(title: alignTitle("Align Left", "left"), style: .default) { [weak self] _ in
alignable.textAlignmentName = "left"
self?.saveLayout()
})
alert.addAction(UIAlertAction(title: alignTitle("Align Center", "center"), style: .default) { [weak self] _ in
alignable.textAlignmentName = "center"
self?.saveLayout()
})
alert.addAction(UIAlertAction(title: alignTitle("Align Right", "right"), style: .default) { [weak self] _ in
alignable.textAlignmentName = "right"
self?.saveLayout()
})
}
// FocusFeedbackWidget's box size is a direct function of its font
// size (see resizeToFitText) pinch changes fontSize, so resetting
// fontSize back to defaultFontSize is what "original size" means here.
if let feedbackWidget = widget as? FocusFeedbackWidget {
alert.addAction(UIAlertAction(title: "Reset Size", style: .default) { [weak self] _ in
feedbackWidget.setFontSize(FocusFeedbackWidget.defaultFontSize)
self?.saveLayout()
})
}
if let colorable = widget as? TextColorableWidget {
alert.addAction(UIAlertAction(title: "Text Color", style: .default) { [weak self] _ in
self?.presentTextColorPicker(for: colorable, widget: widget)
})
}
alert.addAction(UIAlertAction(title: "Hide", style: .destructive) { [weak self] _ in
self?.hideWidget(widget)
})
@@ -44,6 +184,28 @@ extension ArrangeObjects {
self.present(alert, animated: true)
}
func presentTextColorPicker(for colorable: TextColorableWidget, widget: BaseWidget) {
let alert = UIAlertController(title: "Text Color", message: nil, preferredStyle: .actionSheet)
alert.overrideUserInterfaceStyle = .dark
let current = colorable.textColorName
for entry in NamedColorPalette.entries {
let title = entry.name == current ? "\(entry.name)" : entry.name
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
colorable.textColorName = entry.name
self?.saveLayout()
})
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
if let popover = alert.popoverPresentationController {
popover.sourceView = widget
popover.sourceRect = widget.bounds
}
self.present(alert, animated: true)
}
func hideWidget(_ widget: BaseWidget) {
widget.removeFromSuperview()
self.widgets.removeValue(forKey: widget.uid)
@@ -51,4 +213,17 @@ extension ArrangeObjects {
self.wsClientSendJSON(["event": "widget_visibility", "uid": widget.uid, "dest_id": 0])
}
// Scales only the height, anchored to the fader's bottom edge (its "zero"
// reference point) width and position stay put. multiplier is relative
// to the widget's default size, not its current size, so picking the same
// option twice is idempotent rather than compounding.
func scaleFocusFaderLength(_ widget: BaseWidget, multiplier: CGFloat) {
let bottomY = widget.frame.maxY
let newHeight = FocusFaderWidget.size.height * multiplier
widget.frame.size.width = FocusFaderWidget.size.width
widget.frame.size.height = newHeight
widget.frame.origin.y = bottomY - newHeight
self.saveLayout()
}
}
@@ -19,13 +19,18 @@ extension ArrangeObjects {
self.presetUuid = preset.presetUuid
self.widgets = [:]
self.feedbackWidgets = []
self.containerView.subviews.forEach { if $0 !== self.logWidget { $0.removeFromSuperview() } }
self.containerView.subviews.forEach { if $0 !== self.logWidget && $0 !== self.gridOverlay { $0.removeFromSuperview() } }
self.containerView.sendSubviewToBack(self.gridOverlay)
self.presetLabel.text = preset.presetName
let titleUid = "title-big"
let titleFrame = savedFrame(uid: titleUid, in: savedLayout) ?? CGRect(x: 16, y: 16, width: 300, height: 44)
let titleFrame = savedFrame(uid: titleUid, in: savedLayout) ?? CGRect(x: 16, y: 90, width: 300, height: 44)
let titleWidget = TitleWidget(uid: titleUid, text: preset.displayTitle, frame: titleFrame)
if let align = savedAlignment(uid: titleUid, in: savedLayout) {
titleWidget.textAlignmentName = align
}
self.addDragGesture(to: titleWidget)
self.addContextMenuGesture(to: titleWidget)
self.containerView.addSubview(titleWidget)
self.widgets[titleUid] = titleWidget
@@ -41,6 +46,7 @@ extension ArrangeObjects {
frame: frame)
w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
self.addDragGesture(to: w)
// self.addPinchGesture(to: w)
self.addContextMenuGesture(to: w)
self.containerView.addSubview(w)
self.widgets[f.uid] = w
@@ -56,6 +62,7 @@ extension ArrangeObjects {
frame: frame)
w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
self.addDragGesture(to: w)
self.addPinchGesture(to: w)
self.addContextMenuGesture(to: w)
self.containerView.addSubview(w)
self.widgets[t.uid] = w
@@ -66,9 +73,11 @@ extension ArrangeObjects {
let frame = savedFrame(uid: t.uid, in: savedLayout) ?? TransportWidget.defaultFrame(idx: squareIdx)
let w = TransportWidget(uid: t.uid,
label: t.label,
color: UIColor.fromHex(t.color),
frame: frame)
w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
self.addDragGesture(to: w)
self.addPinchGesture(to: w)
self.addContextMenuGesture(to: w)
self.containerView.addSubview(w)
self.widgets[t.uid] = w
@@ -80,24 +89,110 @@ extension ArrangeObjects {
self.showStatus("[ \(preset.presetName) \(self.statusTimestamp()) ]")
}
// Channel Focus: global, static controls shown instead of the current preset's
// widgets, independent of destId routing and unaffected by preset auto-switch.
func buildChannelFocusUI() {
guard let preset = currentPreset else { return }
let layoutKey = "layout_channel_focus"
let savedLayout = UserDefaults.standard.dictionary(forKey: layoutKey) ?? [:]
self.widgets = [:]
self.feedbackWidgets = []
self.containerView.subviews.forEach { if $0 !== self.logWidget && $0 !== self.gridOverlay { $0.removeFromSuperview() } }
self.containerView.sendSubviewToBack(self.gridOverlay)
self.presetLabel.text = "Channel Focus"
var faderIdx = 0
var squareIdx = 0
for f in preset.focusFaders {
let faderFrame = savedFrame(uid: f.uid, in: savedLayout) ?? FocusFaderWidget.defaultFrame(idx: faderIdx)
let faderWidget = FocusFaderWidget(uid: f.uid,
label: f.label,
value: f.value,
color: UIColor.fromHex(f.color),
frame: faderFrame)
faderWidget.onSendFloat = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control_f", "uid": uid, "value": val]) }
self.addDragGesture(to: faderWidget)
self.addContextMenuGesture(to: faderWidget)
self.containerView.addSubview(faderWidget)
self.widgets[f.uid] = faderWidget
faderIdx += 1
}
for t in preset.focusToggles {
let frame = savedFrame(uid: t.uid, in: savedLayout) ?? FocusToggleWidget.defaultFrame(idx: squareIdx)
let w = FocusToggleWidget(uid: t.uid,
label: t.label,
isOn: t.state,
color: UIColor.fromHex(t.color),
colorName: t.colorName,
isMomentary: t.isMomentary,
frame: frame)
w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
self.addDragGesture(to: w)
self.addPinchGesture(to: w)
self.addContextMenuGesture(to: w)
self.containerView.addSubview(w)
self.widgets[t.uid] = w
squareIdx += 1
}
var feedbackIdx = 0
for fb in preset.focusFeedbacks {
let frame = savedFrame(uid: fb.uid, in: savedLayout) ?? FocusFeedbackWidget.defaultFrame(idx: feedbackIdx, text: fb.text)
let w = FocusFeedbackWidget(uid: fb.uid,
label: fb.label,
text: fb.text,
frame: frame)
if let align = savedAlignment(uid: fb.uid, in: savedLayout) {
w.textAlignmentName = align
}
if let colorName = savedTextColorName(uid: fb.uid, in: savedLayout) {
w.textColorName = colorName
}
self.addDragGesture(to: w)
self.addFeedbackPinchGesture(to: w)
self.addContextMenuGesture(to: w)
self.containerView.addSubview(w)
self.widgets[fb.uid] = w
feedbackIdx += 1
}
self.isLocked = savedLayout.count > 0
self.updateUIArrangeLockButtons()
self.showStatus("[ Channel Focus \(self.statusTimestamp()) ]")
}
func saveLayout() {
guard self.presetUuid.isEmpty == false else {
guard let key = currentLayoutKey else {
self.loggingView.log("saveLayout: aborted — presetUuid is empty", category: .local)
return
}
saveLayoutWidgets(key: key)
}
private func saveLayoutWidgets(key: String) {
var layoutDict: [String: Any] = [:]
for (uid, w) in self.widgets {
let f = w.frame
layoutDict[uid] = [
var entry: [String: Any] = [
"x": Double(f.origin.x),
"y": Double(f.origin.y),
"w": Double(f.width),
"h": Double(f.height)
]
if let alignable = w as? TextAlignableWidget {
entry["align"] = alignable.textAlignmentName
}
if let colorable = w as? TextColorableWidget {
entry["textColor"] = colorable.textColorName
}
layoutDict[uid] = entry
}
UserDefaults.standard.set(layoutDict, forKey: "layout_\(self.presetUuid)")
UserDefaults.standard.set(layoutDict, forKey: key)
self.showStatus("[ Saved \(self.statusTimestamp()) ]")
self.loggingView.log("✓ saveLayout: \(layoutDict.count) widgets saved key=layout_\(self.presetUuid)", category: .local)
self.loggingView.log("✓ saveLayout: \(layoutDict.count) widgets saved key=\(key)", category: .local)
if let data = try? JSONSerialization.data(withJSONObject: layoutDict),
let jsonStr = String(data: data, encoding: .utf8) {
self.loggingView.log(jsonStr, category: .local)
@@ -112,4 +207,14 @@ extension ArrangeObjects {
return CGRect(x: x, y: y, width: w, height: h)
}
func savedAlignment(uid: String, in savedLayout: [String: Any]) -> String? {
guard let saved = savedLayout[uid] as? [String: Any] else { return nil }
return saved["align"] as? String
}
func savedTextColorName(uid: String, in savedLayout: [String: Any]) -> String? {
guard let saved = savedLayout[uid] as? [String: Any] else { return nil }
return saved["textColor"] as? String
}
}
@@ -29,21 +29,30 @@ extension ArrangeObjects {
self.connectButton.backgroundColor = red
self.connectButton.setTitleColor(.white, for: .normal)
self.connectButton.isEnabled = true
stopBonjourDiscovery()
case .disconnected:
self.statusLabel.text = "disconnected"
self.connectButton.setTitle("Reconnect", for: .normal)
self.connectButton.backgroundColor = green
self.connectButton.setTitleColor(.black, for: .normal)
self.connectButton.isEnabled = true
startBonjourDiscovery()
}
}
func updateUIArrangeLockButtons() {
let green = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
self.lockButton.backgroundColor = self.isLocked ? green : UIColor(white: 0.15, alpha: 1)
self.lockButton.setTitleColor(self.isLocked ? .black : .lightGray, for: .normal)
self.arrangeButton.backgroundColor = self.isLocked ? UIColor(white: 0.15, alpha: 1) : green
self.arrangeButton.setTitleColor(self.isLocked ? .lightGray : .black, for: .normal)
arrangeButton.setTitle(isLocked ? "Arrange" : "Lock / Save", for: .normal)
arrangeButton.backgroundColor = isLocked ? UIColor(white: 0.15, alpha: 1) : green
arrangeButton.setTitleColor(isLocked ? .lightGray : .black, for: .normal)
gridButton.isHidden = isLocked
cancelChangesButton.isHidden = isLocked
if isLocked {
gridEnabled = false
gridOverlay.isHidden = true
gridButton.backgroundColor = UIColor(white: 0.15, alpha: 1)
gridButton.setTitleColor(.lightGray, for: .normal)
}
}
func updateUIFromDawState(_ json: [String: Any]) {
@@ -64,6 +73,10 @@ extension ArrangeObjects {
self.widgets[uid]?.update(value: value)
}
func updateWidgetLabel(uid: String, label: String) {
self.widgets[uid]?.applyLabel(label)
}
func showStatus(_ message: String) {
DispatchQueue.main.async {
self.statusToken += 1
@@ -66,6 +66,11 @@ extension ArrangeObjects {
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(
@@ -98,6 +103,18 @@ extension ArrangeObjects {
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:
@@ -4,9 +4,16 @@ class ArrangeObjects: UIViewController {
// MARK: - WebSocket
var wsClient: URLSessionWebSocketTask?
var urlSession: URLSession?
// MARK: - Bonjour Discovery
// Auto-fills ipField/portField when the desktop app's service is found on
// the LAN user still taps Connect, this just saves typing the IP.
let bonjourServiceType = "_vcremote._tcp"
lazy var bonjourBrowser: NetServiceBrowser = NetServiceBrowser()
var discoveredServices: [NetService] = []
// MARK: - Connection State
enum ConnectionState {
case notStarted, connecting, connected, disconnected
@@ -14,10 +21,25 @@ class ArrangeObjects: UIViewController {
var connectionState: ConnectionState = .notStarted
// MARK: - State
var currentPreset: ModelPresetLoadToRemoteDevice? = nil
var presetName: String = ""
var presetUuid: String = ""
var isLocked: Bool = true
var autoSwitchEnabled: Bool = true
var isChannelFocusMode: Bool = false
// MARK: - Arrange Session (Cancel Changes)
// Captured the moment Arrange mode is entered, so "Cancel Changes" can
// restore the layout exactly as it was, undoing any drags/resizes/hides
// made during the session (most of which auto-save immediately).
var arrangeSessionLayoutKey: String?
var arrangeSessionLayoutSnapshot: [String: Any]?
var currentLayoutKey: String? {
if isChannelFocusMode { return "layout_channel_focus" }
guard presetUuid.isEmpty == false else { return nil }
return "layout_\(presetUuid)"
}
var myTabletId: Int {
get {
let stored = UserDefaults.standard.integer(forKey: "tablet_id")
@@ -32,11 +54,25 @@ class ArrangeObjects: UIViewController {
// MARK: - Layout
var layout: [String: CGRect] = [:]
var pinchStartSize: CGSize = .zero
var pinchStartFontSize: CGFloat = FocusFeedbackWidget.defaultFontSize
var dragStartOrigin: CGPoint = .zero
var lastSnappedOrigin: CGPoint = .zero
lazy var snapHaptic = UIImpactFeedbackGenerator(style: .light)
var gridEnabled: Bool = false
let gridSize: CGFloat = 25
lazy var gridOverlay: GridView = {
let v = GridView(frame: .zero)
v.gridSize = self.gridSize
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
let containerView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = UIColor(white: 0.1, alpha: 1)
view.backgroundColor = .black
return view
}()
@@ -53,6 +89,21 @@ class ArrangeObjects: UIViewController {
return btn
}()
lazy var gridButton: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
btn.setTitle("Grid", for: .normal)
btn.setTitleColor(.lightGray, for: .normal)
btn.backgroundColor = UIColor(white: 0.15, alpha: 1)
btn.layer.borderWidth = 1
btn.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
btn.layer.cornerRadius = 4
btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
btn.addTarget(self, action: #selector(self.buttonTappedGridButton), for: .touchUpInside)
btn.isHidden = true
return btn
}()
lazy var arrangeButton: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
@@ -67,6 +118,21 @@ class ArrangeObjects: UIViewController {
return btn
}()
lazy var cancelChangesButton: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
btn.setTitle("Cancel Changes", for: .normal)
btn.setTitleColor(.lightGray, for: .normal)
btn.backgroundColor = UIColor(white: 0.15, alpha: 1)
btn.layer.borderWidth = 1
btn.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
btn.layer.cornerRadius = 4
btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
btn.addTarget(self, action: #selector(self.buttonTappedCancelChanges), for: .touchUpInside)
btn.isHidden = true
return btn
}()
lazy var autoSwitchButton: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
@@ -79,18 +145,21 @@ class ArrangeObjects: UIViewController {
return btn
}()
lazy var lockButton: UIButton = {
lazy var channelFocusButton: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
btn.setTitle("Lock / Save", for: .normal)
btn.setTitleColor(.black, for: .normal)
btn.backgroundColor = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
btn.setTitle("Channel Focus", for: .normal)
btn.setTitleColor(.lightGray, for: .normal)
btn.backgroundColor = UIColor(white: 0.15, alpha: 1)
btn.layer.borderWidth = 1
btn.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
btn.layer.cornerRadius = 4
btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
btn.addTarget(self, action: #selector(self.buttonTappedLockButton), for: .touchUpInside)
btn.addTarget(self, action: #selector(self.buttonTappedChannelFocus), for: .touchUpInside)
return btn
}()
// MARK: - Connection Fields
let ipField: UITextField = {
let tf = UITextField()
@@ -157,6 +226,20 @@ class ArrangeObjects: UIViewController {
return w
}()
lazy var clearLayoutsButton: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
btn.setTitle("Clear Layouts", for: .normal)
btn.setTitleColor(.lightGray, for: .normal)
btn.backgroundColor = UIColor(white: 0.15, alpha: 1)
btn.layer.borderWidth = 1
btn.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
btn.layer.cornerRadius = 4
btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
btn.addTarget(self, action: #selector(self.buttonTappedClearLayouts), for: .touchUpInside)
return btn
}()
lazy var showLoggingButton: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
@@ -5,7 +5,7 @@ extension ArrangeObjects {
func setupUI() {
DispatchQueue.main.async {
self.view.backgroundColor = UIColor(white: 0.1, alpha: 1)
self.view.backgroundColor = .black
// containerView
self.view.addSubview(self.containerView)
@@ -14,27 +14,38 @@ extension ArrangeObjects {
self.containerView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: 0).isActive = true
self.containerView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: 0).isActive = true
// statusLabel
self.view.addSubview(self.statusLabel)
self.statusLabel.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.statusLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 8).isActive = true
// presetLabel
self.view.addSubview(self.presetLabel)
self.presetLabel.topAnchor.constraint(equalTo: self.statusLabel.bottomAnchor, constant: 2).isActive = true
self.presetLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 8).isActive = true
// grid overlay pinned to containerView, behind all widgets
self.containerView.addSubview(self.gridOverlay)
self.gridOverlay.topAnchor.constraint(equalTo: self.containerView.topAnchor).isActive = true
self.gridOverlay.leadingAnchor.constraint(equalTo: self.containerView.leadingAnchor).isActive = true
self.gridOverlay.trailingAnchor.constraint(equalTo: self.containerView.trailingAnchor).isActive = true
self.gridOverlay.bottomAnchor.constraint(equalTo: self.containerView.bottomAnchor).isActive = true
self.gridOverlay.isHidden = true
// toolbar buttons (top-right)
self.view.addSubview(self.clearLayoutsButton)
self.clearLayoutsButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.clearLayoutsButton.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -8).isActive = true
// status and preset label stacked below Clear Layouts
self.view.addSubview(self.statusLabel)
self.statusLabel.topAnchor.constraint(equalTo: self.clearLayoutsButton.bottomAnchor, constant: 6).isActive = true
self.statusLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -8).isActive = true
self.view.addSubview(self.presetLabel)
self.presetLabel.topAnchor.constraint(equalTo: self.statusLabel.bottomAnchor, constant: 2).isActive = true
self.presetLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -8).isActive = true
self.view.addSubview(self.showLoggingButton)
self.showLoggingButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.showLoggingButton.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -8).isActive = true
self.showLoggingButton.trailingAnchor.constraint(equalTo: self.clearLayoutsButton.leadingAnchor, constant: -6).isActive = true
self.view.addSubview(self.toolbarStatusLabel)
self.toolbarStatusLabel.topAnchor.constraint(equalTo: self.showLoggingButton.bottomAnchor, constant: 4).isActive = true
self.toolbarStatusLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -8).isActive = true
self.view.addSubview(self.connectButton)
self.connectButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.connectButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.connectButton.trailingAnchor.constraint(equalTo: self.showLoggingButton.leadingAnchor, constant: -6).isActive = true
self.view.addSubview(self.tabletIdField)
@@ -43,6 +54,9 @@ extension ArrangeObjects {
self.tabletIdField.widthAnchor.constraint(equalToConstant: 40).isActive = true
self.tabletIdField.heightAnchor.constraint(equalTo: self.connectButton.heightAnchor).isActive = true
self.tabletIdField.text = "\(self.myTabletId)"
if let lastIp = UserDefaults.standard.string(forKey: "last_ip") { self.ipField.text = lastIp }
if let lastPort = UserDefaults.standard.string(forKey: "last_port") { self.portField.text = lastPort }
self.tabletIdField.addTarget(self, action: #selector(self.tabletIdFieldChanged), for: .editingChanged)
self.view.addSubview(self.portField)
self.portField.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
@@ -56,17 +70,25 @@ extension ArrangeObjects {
self.ipField.widthAnchor.constraint(equalToConstant: 120).isActive = true
self.ipField.heightAnchor.constraint(equalTo: self.connectButton.heightAnchor).isActive = true
self.view.addSubview(self.lockButton)
self.lockButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.lockButton.trailingAnchor.constraint(equalTo: self.ipField.leadingAnchor, constant: -6).isActive = true
self.view.addSubview(self.autoSwitchButton)
self.autoSwitchButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.autoSwitchButton.trailingAnchor.constraint(equalTo: self.lockButton.leadingAnchor, constant: -6).isActive = true
self.autoSwitchButton.trailingAnchor.constraint(equalTo: self.ipField.leadingAnchor, constant: -6).isActive = true
self.view.addSubview(self.gridButton)
self.gridButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.view.addSubview(self.cancelChangesButton)
self.cancelChangesButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.view.addSubview(self.arrangeButton)
self.arrangeButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.arrangeButton.trailingAnchor.constraint(equalTo: self.autoSwitchButton.leadingAnchor, constant: -6).isActive = true
self.cancelChangesButton.trailingAnchor.constraint(equalTo: self.arrangeButton.leadingAnchor, constant: -6).isActive = true
self.gridButton.trailingAnchor.constraint(equalTo: self.cancelChangesButton.leadingAnchor, constant: -6).isActive = true
self.view.addSubview(self.channelFocusButton)
self.channelFocusButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.channelFocusButton.trailingAnchor.constraint(equalTo: self.gridButton.leadingAnchor, constant: -6).isActive = true
self.containerView.addSubview(self.logWidget)
self.addDragGesture(to: self.logWidget)
@@ -12,6 +12,7 @@ class ArrangeView: ArrangeObjects {
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
startBonjourDiscovery()
}
}
@@ -28,8 +28,14 @@ class BaseWidget: UIView {
}
func applyValue(_ value: Int) {}
func applyFloatValue(_ value: Double) {}
func applyFeedbackText(_ text: String) {}
func setArrangeMode(_ enabled: Bool) {}
func applyLabel(_ label: String) {
self.displayName = label
}
// MARK: - Activity Ring
private func setupActivityRingGesture() {
@@ -68,6 +74,40 @@ class BaseWidget: UIView {
}
// Conformed to by label-only widgets (TitleWidget, FocusFeedbackWidget) whose
// text can be left/center/right aligned within their own box via the Arrange
// mode long-press menu.
protocol TextAlignableWidget: AnyObject {
var textAlignmentName: String { get set }
}
// Conformed to by widgets whose text color can be picked from the app's
// named palette via the Arrange mode long-press menu.
protocol TextColorableWidget: AnyObject {
var textColorName: String { get set }
}
// Mirrors the desktop's palette.py PALETTE list exactly, so a color picked
// here means the same thing it would on the desktop.
enum NamedColorPalette {
static let entries: [(name: String, hex: String)] = [
("Red", "#e53935"),
("Orange", "#fb8c00"),
("Yellow", "#fdd835"),
("Green", "#43a047"),
("Teal", "#00897b"),
("Blue", "#1e88e5"),
("Purple", "#8e24aa"),
("Pink", "#e91e63"),
("White", "#f5f5f5"),
("Gray", "#4a5568"),
]
static func hex(for name: String) -> String {
entries.first(where: { $0.name == name })?.hex ?? "#f5f5f5"
}
}
extension BaseWidget: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
@@ -87,4 +127,14 @@ extension UIColor {
alpha: CGFloat((int >> 24) & 0xFF) / 255
)
}
/// Scales each RGB channel toward black by `percentage` (0-1). Used for
/// toggle off-states a dim, tinted version of the widget's own assigned
/// color instead of a flat generic gray.
func darkened(by percentage: CGFloat) -> UIColor {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
guard self.getRed(&r, green: &g, blue: &b, alpha: &a) else { return self }
let factor = 1 - percentage
return UIColor(red: r * factor, green: g * factor, blue: b * factor, alpha: a)
}
}
@@ -2,35 +2,62 @@ import UIKit
class FaderWidget: BaseWidget {
static let size = CGSize(width: 140, height: 360)
static let size = CGSize(width: 98, height: 540)
static func defaultFrame(idx: Int) -> CGRect {
let col = idx % 3
let row = idx / 3
return CGRect(x: 16 + CGFloat(col) * 170, y: 48 + CGFloat(row) * 380, width: size.width, height: size.height)
let col = idx % 4
let row = idx / 4
return CGRect(x: 16 + CGFloat(col) * 118, y: 90 + CGFloat(row) * 560, width: size.width, height: size.height)
}
// MARK: - Properties
let color: UIColor
private var sliderWidthConstraint: NSLayoutConstraint?
private let padHeight: CGFloat = 90
private var arrangeMode = false
private var isRelative = true
private var currentValue: Float = 0
private var touchStartY: CGFloat = 0
private var valueAtTouchStart: Float = 0
private var didInitialLayout = false
private var lastHapticValue: Int = -1
private var lastSentValue: Int = -1
private let haptic = UIImpactFeedbackGenerator(style: .medium)
// MARK: - Subviews
lazy var nameLabel: UILabel = {
private let topZone = UIView()
private let botZone = UIView()
private let fillView = UIView()
private let gradLayer = CAGradientLayer()
private let line = UIView()
private let dot = UIView()
private let arrangeCover = UIView() // clear overlay that blocks touches in arrange mode
private let nameLabel: UILabel = {
let lbl = UILabel()
lbl.translatesAutoresizingMaskIntoConstraints = false
lbl.textColor = UIColor(white: 0.53, alpha: 1)
lbl.font = .systemFont(ofSize: 14)
lbl.textAlignment = .center
lbl.font = .systemFont(ofSize: 13, weight: .medium)
return lbl
}()
lazy var slider: UISlider = {
let s = UISlider()
s.translatesAutoresizingMaskIntoConstraints = false
s.minimumValue = 0
s.maximumValue = 127
s.transform = CGAffineTransform(rotationAngle: -.pi / 2)
return s
private lazy var modeButton: UIButton = {
let btn = UIButton(type: .system)
btn.setTitle("REL", for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 11, weight: .semibold)
btn.setTitleColor(.white, for: .normal)
btn.backgroundColor = color.withAlphaComponent(0.35)
btn.layer.cornerRadius = 4
btn.addTarget(self, action: #selector(toggleMode), for: .touchUpInside)
return btn
}()
private let valueLabel: UILabel = {
let lbl = UILabel()
lbl.textAlignment = .center
lbl.font = .monospacedSystemFont(ofSize: 13, weight: .medium)
lbl.textColor = .white
return lbl
}()
// MARK: - Init
@@ -38,51 +65,190 @@ class FaderWidget: BaseWidget {
self.color = color
super.init(uid: uid, frame: frame)
self.displayName = label
self.restingBorderColor = color.cgColor
self.layer.borderColor = color.cgColor
self.restingBorderColor = color.withAlphaComponent(0.3).cgColor
self.layer.borderColor = color.withAlphaComponent(0.3).cgColor
self.currentValue = Float(value)
self.nameLabel.text = label
self.slider.value = Float(value)
self.slider.minimumTrackTintColor = color
setupUI()
}
required init?(coder: NSCoder) { fatalError() }
// MARK: - Layout
func setupUI() {
self.addSubview(self.nameLabel)
self.nameLabel.topAnchor.constraint(equalTo: self.topAnchor, constant: 8).isActive = true
self.nameLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 4).isActive = true
self.nameLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -4).isActive = true
// MARK: - Setup
private func setupUI() {
backgroundColor = UIColor(white: 0.1, alpha: 1)
layer.cornerRadius = 6
clipsToBounds = true
self.addSubview(self.slider)
self.slider.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
self.slider.centerYAnchor.constraint(equalTo: self.centerYAnchor, constant: 10).isActive = true
let wc = self.slider.widthAnchor.constraint(equalToConstant: 216)
wc.isActive = true
self.sliderWidthConstraint = wc
topZone.backgroundColor = UIColor(white: 0.07, alpha: 1)
botZone.backgroundColor = UIColor(white: 0.07, alpha: 1)
addSubview(topZone)
addSubview(botZone)
self.slider.addTarget(self, action: #selector(self.sliderChanged), for: .valueChanged)
gradLayer.colors = [UIColor.black.withAlphaComponent(0).cgColor, color.withAlphaComponent(0.45).cgColor]
gradLayer.startPoint = CGPoint(x: 0.5, y: 1)
gradLayer.endPoint = CGPoint(x: 0.5, y: 0)
fillView.layer.addSublayer(gradLayer)
addSubview(fillView)
line.backgroundColor = color
line.layer.shadowColor = color.cgColor
line.layer.shadowRadius = 4
line.layer.shadowOpacity = 0.8
line.layer.shadowOffset = .zero
addSubview(line)
dot.backgroundColor = color
dot.layer.cornerRadius = 5
dot.layer.shadowColor = color.cgColor
dot.layer.shadowRadius = 6
dot.layer.shadowOpacity = 1
dot.layer.shadowOffset = .zero
addSubview(dot)
nameLabel.textColor = UIColor(white: 0.4, alpha: 1)
addSubview(nameLabel)
addSubview(modeButton)
addSubview(valueLabel)
arrangeCover.backgroundColor = .clear
arrangeCover.isHidden = true
addSubview(arrangeCover)
haptic.prepare()
}
// MARK: - Layout
override func layoutSubviews() {
super.layoutSubviews()
self.sliderWidthConstraint?.constant = bounds.height * 0.6
let w = bounds.width
let h = bounds.height
topZone.frame = CGRect(x: 0, y: 0, width: w, height: padHeight)
botZone.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight)
line.frame.size = CGSize(width: w, height: 2)
dot.frame.size = CGSize(width: 10, height: 10)
dot.layer.cornerRadius = 5
nameLabel.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight)
modeButton.frame = CGRect(x: w / 2 - 22, y: padHeight / 2 - 16, width: 44, height: 24)
valueLabel.frame = CGRect(x: 0, y: padHeight / 2 + 12, width: w, height: 18)
arrangeCover.frame = bounds
topZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }
botZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }
addDash(to: topZone, atBottom: true)
addDash(to: botZone, atBottom: false)
if !didInitialLayout {
didInitialLayout = true
updateDisplay()
}
}
// MARK: - Update
override func applyValue(_ value: Int) {
self.slider.value = Float(value)
private func addDash(to zone: UIView, atBottom: Bool) {
let dash = CAShapeLayer()
let y: CGFloat = atBottom ? zone.bounds.height - 1 : 0
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: zone.bounds.width, y: y))
dash.path = path.cgPath
dash.strokeColor = UIColor(white: 0.25, alpha: 1).cgColor
dash.lineWidth = 1
dash.lineDashPattern = [6, 4]
zone.layer.addSublayer(dash)
}
// MARK: - Arrange Mode
override func setArrangeMode(_ enabled: Bool) {
self.slider.isUserInteractionEnabled = (enabled == false)
arrangeMode = enabled
arrangeCover.isHidden = !enabled
}
// MARK: - Action
@objc private func sliderChanged() {
self.onSend?(self.uid, Int(self.slider.value))
// MARK: - Mode Toggle
@objc private func toggleMode() {
isRelative.toggle()
modeButton.setTitle(isRelative ? "REL" : "ABS", for: .normal)
modeButton.backgroundColor = isRelative
? color.withAlphaComponent(0.35)
: color.withAlphaComponent(0.15)
}
// MARK: - Touch
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard !arrangeMode, let pt = touches.first?.location(in: self) else { return }
touchStartY = pt.y
valueAtTouchStart = currentValue
move(to: pt)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard !arrangeMode, let pt = touches.first?.location(in: self) else { return }
move(to: pt)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {}
private func move(to pt: CGPoint) {
let activeTop = padHeight
let activeBottom = bounds.height - padHeight
let activeHeight = activeBottom - activeTop
let value: Float
if isRelative {
let deltaY = pt.y - touchStartY
let deltaPct = Float(-deltaY / activeHeight)
value = max(0, min(127, valueAtTouchStart + deltaPct * 127))
} else {
let clampedY = max(activeTop, min(activeBottom, pt.y))
value = max(0, min(127, Float((1 - (clampedY - activeTop) / activeHeight) * 127)))
}
currentValue = value
updateDisplay()
let intValue = Int(value)
if (intValue == 0 || intValue == 127) && intValue != lastHapticValue {
haptic.impactOccurred()
lastHapticValue = intValue
} else if intValue != 0 && intValue != 127 {
lastHapticValue = -1
}
if intValue != lastSentValue {
lastSentValue = intValue
onSend?(uid, intValue)
}
}
// MARK: - External value
override func applyValue(_ value: Int) {
currentValue = Float(value)
updateDisplay()
}
override func applyLabel(_ label: String) {
super.applyLabel(label)
self.nameLabel.text = label
}
// MARK: - Display
private func updateDisplay() {
let activeTop = padHeight
let activeBottom = bounds.height - padHeight
let activeHeight = activeBottom - activeTop
let w = bounds.width
let displayY = activeBottom - CGFloat(currentValue / 127) * activeHeight
let fillFrame = CGRect(x: 0, y: displayY, width: w, height: activeBottom - displayY)
CATransaction.begin()
CATransaction.setDisableActions(true)
fillView.frame = fillFrame
gradLayer.frame = fillView.bounds
CATransaction.commit()
line.frame.origin = CGPoint(x: 0, y: displayY - 1)
dot.center = CGPoint(x: w / 2, y: displayY)
valueLabel.text = "\(Int(currentValue))"
}
}
@@ -0,0 +1,308 @@
import UIKit
// Dedicated fader for the Channel Focus overlay OSC-native, so drags send a
// continuous 0.0-1.0 Double (onSendFloat) instead of the 0-127 Int the shared
// FaderWidget sends for MIDI-CC-bound preset faders. Kept as its own class,
// not a flag on FaderWidget, so the two never interfere with each other.
class FocusFaderWidget: BaseWidget {
static let size = CGSize(width: 98, height: 540)
static func defaultFrame(idx: Int) -> CGRect {
let col = idx % 4
let row = idx / 4
return CGRect(x: 16 + CGFloat(col) * 118, y: 90 + CGFloat(row) * 560, width: size.width, height: size.height)
}
// MARK: - Properties
let color: UIColor
private let padHeight: CGFloat = 90
var onSendFloat: ((String, Double) -> Void)?
private var arrangeMode = false
private var isRelative = true
private var isFine = false
private var currentValue: Float = 0
private var touchStartY: CGFloat = 0
private var valueAtTouchStart: Float = 0
private var didInitialLayout = false
private var lastHapticValue: Int = -1
private var lastSentFloatValue: Double = -1
private let haptic = UIImpactFeedbackGenerator(style: .medium)
// MARK: - Subviews
private let topZone = UIView()
private let botZone = UIView()
private let fillView = UIView()
private let gradLayer = CAGradientLayer()
private let line = UIView()
private let dot = UIView()
private let arrangeCover = UIView() // clear overlay that blocks touches in arrange mode
private let nameLabel: UILabel = {
let lbl = UILabel()
lbl.textAlignment = .center
lbl.font = .systemFont(ofSize: 13, weight: .medium)
return lbl
}()
private lazy var modeButton: UIButton = {
let btn = UIButton(type: .system)
btn.setTitle("REL", for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold)
btn.layer.cornerRadius = 5
btn.addTarget(self, action: #selector(toggleMode), for: .touchUpInside)
return btn
}()
// 10x reduced drag sensitivity for precise automation nudges overrides
// REL/ABS while engaged (fine adjustment always behaves as a relative nudge).
private lazy var fineButton: UIButton = {
let btn = UIButton(type: .system)
btn.setTitle("FINE", for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold)
btn.layer.cornerRadius = 5
btn.addTarget(self, action: #selector(toggleFine), for: .touchUpInside)
return btn
}()
// Output value readout commented out of the visible layout per request;
// still updated internally in case it's wanted back later.
private let valueLabel: UILabel = {
let lbl = UILabel()
lbl.textAlignment = .center
lbl.font = .monospacedSystemFont(ofSize: 13, weight: .medium)
lbl.textColor = .white
lbl.isHidden = true
return lbl
}()
// MARK: - Init
init(uid: String, label: String, value: Int, color: UIColor, frame: CGRect) {
self.color = color
super.init(uid: uid, frame: frame)
self.displayName = label
self.restingBorderColor = color.withAlphaComponent(0.3).cgColor
self.layer.borderColor = color.withAlphaComponent(0.3).cgColor
self.currentValue = Float(value)
self.nameLabel.text = label
setupUI()
}
required init?(coder: NSCoder) { fatalError() }
// MARK: - Setup
private func setupUI() {
backgroundColor = UIColor(white: 0.1, alpha: 1)
layer.cornerRadius = 6
clipsToBounds = true
topZone.backgroundColor = UIColor(white: 0.07, alpha: 1)
botZone.backgroundColor = UIColor(white: 0.07, alpha: 1)
addSubview(topZone)
addSubview(botZone)
gradLayer.colors = [UIColor.black.withAlphaComponent(0).cgColor, color.withAlphaComponent(0.45).cgColor]
gradLayer.startPoint = CGPoint(x: 0.5, y: 1)
gradLayer.endPoint = CGPoint(x: 0.5, y: 0)
fillView.layer.addSublayer(gradLayer)
addSubview(fillView)
line.backgroundColor = color
line.layer.shadowColor = color.cgColor
line.layer.shadowRadius = 4
line.layer.shadowOpacity = 0.8
line.layer.shadowOffset = .zero
addSubview(line)
dot.backgroundColor = color
dot.layer.cornerRadius = 5
dot.layer.shadowColor = color.cgColor
dot.layer.shadowRadius = 6
dot.layer.shadowOpacity = 1
dot.layer.shadowOffset = .zero
addSubview(dot)
nameLabel.textColor = UIColor(white: 0.4, alpha: 1)
addSubview(nameLabel)
addSubview(fineButton)
addSubview(modeButton)
addSubview(valueLabel)
arrangeCover.backgroundColor = .clear
arrangeCover.isHidden = true
addSubview(arrangeCover)
applyModeButtonStyle()
applyFineButtonStyle()
haptic.prepare()
}
// MARK: - Layout
override func layoutSubviews() {
super.layoutSubviews()
let w = bounds.width
let h = bounds.height
topZone.frame = CGRect(x: 0, y: 0, width: w, height: padHeight)
botZone.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight)
line.frame.size = CGSize(width: w, height: 2)
dot.frame.size = CGSize(width: 10, height: 10)
dot.layer.cornerRadius = 5
nameLabel.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight)
// Buttons fill the whole top dead zone now that valueLabel is hidden
// bigger targets, easier to tap.
let btnMargin: CGFloat = 6
let btnGap: CGFloat = 4
let btnWidth = w - btnMargin * 2
let btnHeight = (padHeight - btnMargin * 2 - btnGap) / 2
fineButton.frame = CGRect(x: btnMargin, y: btnMargin, width: btnWidth, height: btnHeight)
modeButton.frame = CGRect(x: btnMargin, y: fineButton.frame.maxY + btnGap, width: btnWidth, height: btnHeight)
valueLabel.frame = CGRect(x: 0, y: padHeight / 2 + 12, width: w, height: 18)
arrangeCover.frame = bounds
topZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }
botZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }
addDash(to: topZone, atBottom: true)
addDash(to: botZone, atBottom: false)
if !didInitialLayout {
didInitialLayout = true
updateDisplay()
}
}
private func addDash(to zone: UIView, atBottom: Bool) {
let dash = CAShapeLayer()
let y: CGFloat = atBottom ? zone.bounds.height - 1 : 0
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: zone.bounds.width, y: y))
dash.path = path.cgPath
dash.strokeColor = UIColor(white: 0.25, alpha: 1).cgColor
dash.lineWidth = 1
dash.lineDashPattern = [6, 4]
zone.layer.addSublayer(dash)
}
// MARK: - Arrange Mode
override func setArrangeMode(_ enabled: Bool) {
arrangeMode = enabled
arrangeCover.isHidden = !enabled
}
// MARK: - Mode Toggle
@objc private func toggleMode() {
isRelative.toggle()
modeButton.setTitle(isRelative ? "REL" : "ABS", for: .normal)
applyModeButtonStyle()
}
private func applyModeButtonStyle() {
// Unmistakable on/off: solid color fill vs. dim gray, same convention
// as the rest of the app's toggle buttons not just an alpha shift.
modeButton.backgroundColor = isRelative ? color : UIColor(white: 0.15, alpha: 1)
modeButton.setTitleColor(isRelative ? .black : .lightGray, for: .normal)
}
@objc private func toggleFine() {
isFine.toggle()
applyFineButtonStyle()
}
private func applyFineButtonStyle() {
fineButton.backgroundColor = isFine ? color : UIColor(white: 0.15, alpha: 1)
fineButton.setTitleColor(isFine ? .black : .lightGray, for: .normal)
}
// MARK: - Touch
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard !arrangeMode, let pt = touches.first?.location(in: self) else { return }
touchStartY = pt.y
valueAtTouchStart = currentValue
move(to: pt)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard !arrangeMode, let pt = touches.first?.location(in: self) else { return }
move(to: pt)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {}
private func move(to pt: CGPoint) {
let activeTop = padHeight
let activeBottom = bounds.height - padHeight
let activeHeight = activeBottom - activeTop
let value: Float
if isFine {
// Fine always behaves as a relative nudge, at 1/10th sensitivity,
// regardless of the REL/ABS setting for precise automation moves.
let deltaY = pt.y - touchStartY
let deltaPct = Float(-deltaY / activeHeight)
value = max(0, min(127, valueAtTouchStart + deltaPct * 127 * 0.1))
} else if isRelative {
let deltaY = pt.y - touchStartY
let deltaPct = Float(-deltaY / activeHeight)
value = max(0, min(127, valueAtTouchStart + deltaPct * 127))
} else {
let clampedY = max(activeTop, min(activeBottom, pt.y))
value = max(0, min(127, Float((1 - (clampedY - activeTop) / activeHeight) * 127)))
}
currentValue = value
updateDisplay()
let intValue = Int(value)
if (intValue == 0 || intValue == 127) && intValue != lastHapticValue {
haptic.impactOccurred()
lastHapticValue = intValue
} else if intValue != 0 && intValue != 127 {
lastHapticValue = -1
}
let normalized = Double(value) / 127.0
if lastSentFloatValue < 0 || abs(normalized - lastSentFloatValue) > 0.0005 {
lastSentFloatValue = normalized
onSendFloat?(uid, normalized)
}
}
// MARK: - External value
override func applyFloatValue(_ value: Double) {
currentValue = Float(value * 127)
updateDisplay()
}
override func applyLabel(_ label: String) {
super.applyLabel(label)
self.nameLabel.text = label
}
// MARK: - Display
private func updateDisplay() {
let activeTop = padHeight
let activeBottom = bounds.height - padHeight
let activeHeight = activeBottom - activeTop
let w = bounds.width
let displayY = activeBottom - CGFloat(currentValue / 127) * activeHeight
let fillFrame = CGRect(x: 0, y: displayY, width: w, height: activeBottom - displayY)
CATransaction.begin()
CATransaction.setDisableActions(true)
fillView.frame = fillFrame
gradLayer.frame = fillView.bounds
CATransaction.commit()
line.frame.origin = CGPoint(x: 0, y: displayY - 1)
dot.center = CGPoint(x: w / 2, y: displayY)
valueLabel.text = String(format: "%.3f", currentValue / 127)
}
}
@@ -0,0 +1,130 @@
import UIKit
// Read-only OSC feedback display for Channel Focus shows whatever text the
// desktop last learned/received on this widget's bound address (e.g. track
// name, bar, time). A plain large label, same spirit as TitleWidget: no
// background, no border, just text and always sized to exactly fit that
// text, no baked-in minimum box. Pinch adjusts font size directly (not an
// independent bounds size), so the frame is always a tight fit and aspect
// ratio can't drift out of sync with the text.
class FocusFeedbackWidget: BaseWidget, TextAlignableWidget, TextColorableWidget {
private static let padding: CGFloat = 8
static let defaultFontSize: CGFloat = 32
static let minFontSize: CGFloat = 12
static func defaultFrame(idx: Int, text: String = "") -> CGRect {
let size = fittedSize(for: text, fontSize: defaultFontSize)
let col = idx % 3
let row = idx / 3
return CGRect(x: 16 + CGFloat(col) * (size.width + 20), y: 640 + CGFloat(row) * (size.height + 12),
width: size.width, height: size.height)
}
private static func fittedSize(for text: String, fontSize: CGFloat) -> CGSize {
let font = UIFont.monospacedDigitSystemFont(ofSize: fontSize, weight: .semibold)
let textSize = (text.isEmpty ? "" : text as NSString).size(withAttributes: [.font: font])
return CGSize(width: ceil(textSize.width) + padding * 2, height: ceil(textSize.height) + padding * 2)
}
// MARK: - Properties
private(set) var fontSize: CGFloat = defaultFontSize
// MARK: - Subviews
private let textLabel: UILabel = {
let lbl = UILabel()
lbl.translatesAutoresizingMaskIntoConstraints = false
lbl.textColor = UIColor(white: 0.92, alpha: 1)
lbl.textAlignment = .center
lbl.numberOfLines = 1
return lbl
}()
// MARK: - Init
init(uid: String, label: String, text: String, frame: CGRect) {
super.init(uid: uid, frame: frame)
self.displayName = label
self.backgroundColor = .clear
self.restingBorderColor = UIColor.clear.cgColor
self.layer.borderColor = UIColor.clear.cgColor
self.textLabel.text = text
// frame may be a fresh default (already sized for defaultFontSize) or
// a restored, possibly pinch-resized, saved layout frame derive the
// font size that actually matches whichever one we were given, so
// resizeToFitText() converges instead of snapping back to default.
let defaultHeight = Self.fittedSize(for: text, fontSize: Self.defaultFontSize).height
if defaultHeight > 0 {
let ratio = frame.height / defaultHeight
self.fontSize = max(Self.minFontSize, Self.defaultFontSize * ratio)
}
self.textLabel.font = .monospacedDigitSystemFont(ofSize: fontSize, weight: .semibold)
setupUI()
}
required init?(coder: NSCoder) { fatalError() }
// MARK: - Layout
private func setupUI() {
addSubview(textLabel)
textLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.padding).isActive = true
textLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.padding).isActive = true
textLabel.topAnchor.constraint(equalTo: topAnchor, constant: Self.padding).isActive = true
textLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.padding).isActive = true
}
// MARK: - Arrange Mode
override func setArrangeMode(_ enabled: Bool) {}
// MARK: - Sizing
/// Sets the font size and re-fits the frame around the current text,
/// keeping the widget's center fixed.
func setFontSize(_ size: CGFloat) {
fontSize = max(Self.minFontSize, size)
textLabel.font = .monospacedDigitSystemFont(ofSize: fontSize, weight: .semibold)
resizeToFitText()
}
private func resizeToFitText() {
let text = textLabel.text ?? ""
let newSize = Self.fittedSize(for: text, fontSize: fontSize)
let center = self.center
self.bounds.size = newSize
self.center = center
}
// MARK: - External updates
override func applyFeedbackText(_ text: String) {
textLabel.text = text
resizeToFitText()
}
// MARK: - Alignment
var textAlignmentName: String {
get {
switch textLabel.textAlignment {
case .left: return "left"
case .right: return "right"
default: return "center"
}
}
set {
switch newValue {
case "left": textLabel.textAlignment = .left
case "right": textLabel.textAlignment = .right
default: textLabel.textAlignment = .center
}
}
}
// MARK: - Text Color
private var _textColorName: String = "White"
var textColorName: String {
get { _textColorName }
set {
_textColorName = newValue
textLabel.textColor = UIColor.fromHex(NamedColorPalette.hex(for: newValue))
}
}
}
@@ -0,0 +1,127 @@
import UIKit
// Dedicated toggle for the Channel Focus overlay kept as its own class,
// not shared with the regular ToggleWidget, so Channel-Focus-only behavior
// (momentary triggers, color/darken/ring tuning) never affects the plain
// preset toggles that ToggleWidget still serves.
class FocusToggleWidget: BaseWidget {
static let size = CGSize(width: 75, height: 75)
static func defaultFrame(idx: Int) -> CGRect {
let gap: CGFloat = 12
let col = idx % 6
let row = idx / 6
return CGRect(x: 16 + CGFloat(col) * (size.width + gap),
y: 420 + CGFloat(row) * (size.height + gap),
width: size.width, height: size.height)
}
// MARK: - Properties
let color: UIColor
// The desktop's actual palette name for `color` (e.g. "Gray") tells us
// definitively when the heavy off-state darken/ring treatment should be
// skipped, rather than guessing from the resolved color's saturation
// (which can't tell a true neutral gray apart from white; both read as
// ~0 saturation).
let colorName: String
var isOn: Bool
// When true, the button's own UI feedback on tap shouldn't persist
// there's no real "on" state for a momentary action to reflect (e.g. Next
// Channel), so only actual feedback from the desktop (applyValue, via a
// widget_update broadcast) should ever light the background.
let isMomentary: Bool
// MARK: - Subviews
lazy var button: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
btn.layer.cornerRadius = 4
btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .semibold)
return btn
}()
// MARK: - Init
init(uid: String, label: String, isOn: Bool, color: UIColor, colorName: String, isMomentary: Bool = false, frame: CGRect) {
self.color = color
self.colorName = colorName
self.isOn = isOn
self.isMomentary = isMomentary
super.init(uid: uid, frame: frame)
self.displayName = label
// Blends the margin between the button and the border into the
// app's black background, instead of BaseWidget's shared gray default.
self.backgroundColor = .black
self.button.setTitle(label, for: .normal)
setupUI()
applyState()
}
required init?(coder: NSCoder) { fatalError() }
// MARK: - Layout
func setupUI() {
self.addSubview(self.button)
self.button.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
self.button.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
self.button.widthAnchor.constraint(equalToConstant: 63).isActive = true
self.button.heightAnchor.constraint(equalToConstant: 63).isActive = true
self.button.addTarget(self, action: #selector(self.buttonTappedToggleButton), for: .touchUpInside)
}
// MARK: - State
func applyState() {
if self.colorName == "Gray" {
// Gray borrows ToggleWidget's plain scheme exactly: flat off-fill,
// and a border that stays the same static neutral gray regardless
// of on/off Gray has no real "color" of its own to dim or ring.
self.button.backgroundColor = self.isOn ? self.color : UIColor(white: 0.15, alpha: 1)
self.restingBorderColor = UIColor(white: 0.27, alpha: 1).cgColor
} else if self.colorName == "White" {
// White has no hue either, so the 0.8 darken used for saturated
// colors crushes it down to the same dark gray as everything
// else. Keep its off-state noticeably brighter so it still
// reads as "dim white" instead of "generic dark gray."
self.button.backgroundColor = self.isOn ? self.color : UIColor(white: 0.45, alpha: 1)
self.restingBorderColor = UIColor(white: 0.45, alpha: 1).cgColor
} else {
self.button.backgroundColor = self.isOn ? self.color : self.color.darkened(by: 0.8)
let offBorderColor = self.color.withAlphaComponent(0.675)
self.restingBorderColor = self.isOn ? self.color.cgColor : offBorderColor.cgColor
}
self.button.setTitleColor(self.isOn ? .black : .lightGray, for: .normal)
self.layer.borderColor = self.restingBorderColor
}
override func applyValue(_ value: Int) {
self.isOn = value > 63
applyState()
}
override func applyLabel(_ label: String) {
super.applyLabel(label)
self.button.setTitle(label, for: .normal)
}
// MARK: - Arrange Mode
override func setArrangeMode(_ enabled: Bool) {
self.button.isUserInteractionEnabled = (enabled == false)
}
// MARK: - Action
@objc private func buttonTappedToggleButton() {
if isMomentary {
// Just send the trigger don't touch isOn/applyState locally.
// UIButton's own built-in touch-down highlight is the only visual
// feedback a momentary tap gets; the persistent background is
// reserved for real feedback-confirmed state.
self.onSend?(self.uid, 127)
return
}
self.isOn = !self.isOn
applyState()
self.onSend?(self.uid, self.isOn ? 127 : 0)
}
}
@@ -0,0 +1,35 @@
import UIKit
class GridView: UIView {
var gridSize: CGFloat = 25 {
didSet { setNeedsDisplay() }
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isUserInteractionEnabled = false
}
required init?(coder: NSCoder) { fatalError() }
override func draw(_ rect: CGRect) {
guard let ctx = UIGraphicsGetCurrentContext() else { return }
ctx.setStrokeColor(UIColor(white: 1, alpha: 0.315).cgColor)
ctx.setLineWidth(0.5)
var x: CGFloat = 0
while x <= rect.width {
ctx.move(to: CGPoint(x: x, y: 0))
ctx.addLine(to: CGPoint(x: x, y: rect.height))
x += gridSize
}
var y: CGFloat = 0
while y <= rect.height {
ctx.move(to: CGPoint(x: 0, y: y))
ctx.addLine(to: CGPoint(x: rect.width, y: y))
y += gridSize
}
ctx.strokePath()
}
}
@@ -1,6 +1,6 @@
import UIKit
class TitleWidget: BaseWidget {
class TitleWidget: BaseWidget, TextAlignableWidget {
static func defaultFrame(idx: Int) -> CGRect {
let col = idx % 3
@@ -20,8 +20,14 @@ class TitleWidget: BaseWidget {
// MARK: - Init
init(uid: String, text: String, frame: CGRect) {
super.init(uid: uid, frame: frame)
let textWidth = ceil((text as NSString).size(withAttributes: [
.font: UIFont.systemFont(ofSize: 22, weight: .semibold)
]).width) + 8
var tightFrame = frame
tightFrame.size.width = textWidth
super.init(uid: uid, frame: tightFrame)
self.backgroundColor = .clear
self.restingBorderColor = UIColor.clear.cgColor
self.layer.borderColor = UIColor.clear.cgColor
self.titleLabel.text = text
setupUI()
@@ -37,4 +43,22 @@ class TitleWidget: BaseWidget {
self.titleLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
}
// MARK: - Alignment
var textAlignmentName: String {
get {
switch titleLabel.textAlignment {
case .center: return "center"
case .right: return "right"
default: return "left"
}
}
set {
switch newValue {
case "center": titleLabel.textAlignment = .center
case "right": titleLabel.textAlignment = .right
default: titleLabel.textAlignment = .left
}
}
}
}
@@ -62,6 +62,11 @@ class ToggleWidget: BaseWidget {
applyState()
}
override func applyLabel(_ label: String) {
super.applyLabel(label)
self.button.setTitle(label, for: .normal)
}
// MARK: - Arrange Mode
override func setArrangeMode(_ enabled: Bool) {
self.button.isUserInteractionEnabled = (enabled == false)
@@ -13,6 +13,9 @@ class TransportWidget: BaseWidget {
width: size.width, height: size.height)
}
// MARK: - Properties
let color: UIColor
// MARK: - Subviews
lazy var button: UIButton = {
let btn = UIButton(type: .system)
@@ -25,7 +28,8 @@ class TransportWidget: BaseWidget {
}()
// MARK: - Init
init(uid: String, label: String, frame: CGRect) {
init(uid: String, label: String, color: UIColor, frame: CGRect) {
self.color = color
super.init(uid: uid, frame: frame)
self.displayName = label
self.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
@@ -54,6 +58,18 @@ class TransportWidget: BaseWidget {
// MARK: - Action
@objc private func buttonTappedTransportButton() {
self.onSend?(self.uid, 127)
flashBackground()
}
private func flashBackground() {
let restColor = UIColor(white: 0.15, alpha: 1)
UIView.animate(withDuration: 0.015, animations: {
self.button.backgroundColor = self.color
}, completion: { _ in
UIView.animate(withDuration: 0.015) {
self.button.backgroundColor = restColor
}
})
}
}
@@ -2,6 +2,12 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Used to discover the Virtual Controller desktop app on your local network.</string>
<key>NSBonjourServices</key>
<array>
<string>_vcremote._tcp.</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>