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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
-9
@@ -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()) ]")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+178
-3
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+111
-6
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+17
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
+130
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user