Multi-iPad dest_id routing, activity ring, context menu hide, live label/visibility sync
Desktop: - Replace Remote checkbox with T: dest_spin (0=Off, 1-9=tablet) on all widgets - dest_id persisted in get_state/set_state, broadcast in tablet snapshot - dest_spin.valueChanged triggers save + broadcast to iPad - _on_widget_visibility_from_tablet updates dest_spin from iPad hide event - _broadcast_preset_update shared helper; ws_server.has_clients guard - label editingFinished triggers live label sync to iPad iOS: - VC-Arrange/VC-Logging/Widgets-Reusable-UI file reorganisation - ArrangeObjects/ArrangeView rename, 7-bucket Arrange-Functions split - myTabletId (UserDefaults) + tabletIdField next to port in toolbar - Preset filter: destId == myTabletId instead of visible bool - BaseWidget activity ring: long press gesture (touch) + final update() flash - Context menu long press (arrange mode): hide widget, save layout, send dest_id:0 - Same-UUID preset check skips Preset Switched alert on visibility/label updates - autoSwitchEnabled, toolbarStatusLabel, presetLabel, statusToken Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,283 +0,0 @@
|
||||
import UIKit
|
||||
|
||||
extension ObjectsViewController {
|
||||
|
||||
// MARK: - Connection State UI
|
||||
func applyConnectionState(_ state: ConnectionState) {
|
||||
self.connectionState = state
|
||||
let green = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
|
||||
let red = UIColor(red: 0.8, green: 0.2, blue: 0.2, alpha: 1)
|
||||
let gray = UIColor(white: 0.3, alpha: 1)
|
||||
|
||||
switch state {
|
||||
case .notStarted:
|
||||
self.statusLabel.text = "not started"
|
||||
self.connectButton.setTitle("Connect", for: .normal)
|
||||
self.connectButton.backgroundColor = green
|
||||
self.connectButton.setTitleColor(.black, for: .normal)
|
||||
self.connectButton.isEnabled = true
|
||||
case .connecting:
|
||||
self.statusLabel.text = "connecting..."
|
||||
self.connectButton.setTitle("Connecting...", for: .normal)
|
||||
self.connectButton.backgroundColor = gray
|
||||
self.connectButton.setTitleColor(.lightGray, for: .normal)
|
||||
self.connectButton.isEnabled = false
|
||||
case .connected:
|
||||
self.statusLabel.text = "connected"
|
||||
self.connectButton.setTitle("Disconnect", for: .normal)
|
||||
self.connectButton.backgroundColor = red
|
||||
self.connectButton.setTitleColor(.white, for: .normal)
|
||||
self.connectButton.isEnabled = true
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WebSocket Connect / Disconnect
|
||||
@objc func connectTapped() {
|
||||
if self.connectionState == .connected {
|
||||
self.wsTask?.cancel(with: .goingAway, reason: nil)
|
||||
self.wsTask = nil
|
||||
self.applyConnectionState(.disconnected)
|
||||
} else {
|
||||
self.connectWebSocket()
|
||||
}
|
||||
}
|
||||
|
||||
func connectWebSocket() {
|
||||
let ip = self.ipField.text?.trimmingCharacters(in: .whitespaces) ?? "127.0.0.1"
|
||||
let port = self.portField.text?.trimmingCharacters(in: .whitespaces) ?? "8765"
|
||||
guard let url = URL(string: "ws://\(ip):\(port)") else { return }
|
||||
self.urlSession = URLSession(configuration: .default, delegate: self, delegateQueue: .main)
|
||||
self.wsTask = self.urlSession?.webSocketTask(with: url)
|
||||
self.wsTask?.resume()
|
||||
self.applyConnectionState(.connecting)
|
||||
self.listen()
|
||||
}
|
||||
|
||||
// MARK: - Listen
|
||||
func listen() {
|
||||
self.wsTask?.receive { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success(let message):
|
||||
switch message {
|
||||
case .string(let text): self.handleMessage(text)
|
||||
default: break
|
||||
}
|
||||
self.listen()
|
||||
case .failure:
|
||||
DispatchQueue.main.async { self.applyConnectionState(.disconnected) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Show Logging Toggle
|
||||
@objc func showLoggingTapped() {
|
||||
let nowVisible = self.logWidget.isHidden
|
||||
self.logWidget.isHidden = !nowVisible
|
||||
self.showLoggingButton.setTitle(nowVisible ? "Hide Logging" : "Show Logging", for: .normal)
|
||||
}
|
||||
|
||||
// MARK: - Handle Incoming Message
|
||||
func handleMessage(_ text: String) {
|
||||
self.loggingView.log(text)
|
||||
guard let data = text.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let event = json["event"] as? String else { return }
|
||||
|
||||
switch event {
|
||||
case "preset":
|
||||
if let presetData = json["data"] as? [String: Any],
|
||||
let presetBytes = try? JSONSerialization.data(withJSONObject: presetData) {
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
let preset = try decoder.decode(ModelPresetLoadToRemoteDevice.self, from: presetBytes)
|
||||
DispatchQueue.main.async { self.renderPreset(preset) }
|
||||
} catch {
|
||||
self.loggingView.log("decode error: \(error)")
|
||||
}
|
||||
}
|
||||
case "widget_update":
|
||||
if let uid = json["uid"] as? String, let value = json["value"] as? Int {
|
||||
DispatchQueue.main.async { self.updateWidget(uid: uid, value: value) }
|
||||
}
|
||||
case "daw_state":
|
||||
DispatchQueue.main.async { self.updateDawState(json) }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Send
|
||||
func send(_ dict: [String: Any]) {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: dict),
|
||||
let text = String(data: data, encoding: .utf8) else { return }
|
||||
self.wsTask?.send(.string(text)) { _ in }
|
||||
}
|
||||
|
||||
// MARK: - Render Preset
|
||||
func renderPreset(_ preset: ModelPresetLoadToRemoteDevice) {
|
||||
let savedLayout = UserDefaults.standard.dictionary(forKey: "layout_\(preset.presetUuid)") ?? [:]
|
||||
self.presetName = preset.presetName
|
||||
self.presetUuid = preset.presetUuid
|
||||
self.widgets = [:]
|
||||
self.feedbackWidgets = []
|
||||
self.containerView.subviews.forEach { if $0 !== self.logWidget { $0.removeFromSuperview() } }
|
||||
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 titleWidget = TitleWidget(uid: titleUid, text: preset.displayTitle, frame: titleFrame)
|
||||
self.setupDrag(on: titleWidget)
|
||||
self.containerView.addSubview(titleWidget)
|
||||
self.widgets[titleUid] = titleWidget
|
||||
|
||||
var faderIdx = 0
|
||||
var squareIdx = 0
|
||||
|
||||
for f in preset.faders where f.visible {
|
||||
let frame = savedFrame(uid: f.uid, in: savedLayout) ?? FaderWidget.defaultFrame(idx: faderIdx)
|
||||
let w = FaderWidget(uid: f.uid,
|
||||
label: f.label,
|
||||
value: f.value,
|
||||
color: UIColor.fromHex(f.color),
|
||||
frame: frame)
|
||||
w.onSend = { [weak self] uid, val in self?.send(["event": "control", "uid": uid, "value": val]) }
|
||||
self.setupDrag(on: w)
|
||||
self.containerView.addSubview(w)
|
||||
self.widgets[f.uid] = w
|
||||
faderIdx += 1
|
||||
}
|
||||
|
||||
for t in preset.toggles where t.visible {
|
||||
let frame = savedFrame(uid: t.uid, in: savedLayout) ?? ToggleWidget.defaultFrame(idx: squareIdx)
|
||||
let w = ToggleWidget(uid: t.uid,
|
||||
label: t.label,
|
||||
isOn: t.state,
|
||||
color: UIColor.fromHex(t.color),
|
||||
frame: frame)
|
||||
w.onSend = { [weak self] uid, val in self?.send(["event": "control", "uid": uid, "value": val]) }
|
||||
self.setupDrag(on: w)
|
||||
self.containerView.addSubview(w)
|
||||
self.widgets[t.uid] = w
|
||||
squareIdx += 1
|
||||
}
|
||||
|
||||
for t in preset.transports where t.visible {
|
||||
let frame = savedFrame(uid: t.uid, in: savedLayout) ?? TransportWidget.defaultFrame(idx: squareIdx)
|
||||
let w = TransportWidget(uid: t.uid,
|
||||
label: t.label,
|
||||
frame: frame)
|
||||
w.onSend = { [weak self] uid, val in self?.send(["event": "control", "uid": uid, "value": val]) }
|
||||
self.setupDrag(on: w)
|
||||
self.containerView.addSubview(w)
|
||||
self.widgets[t.uid] = w
|
||||
squareIdx += 1
|
||||
}
|
||||
|
||||
self.isLocked = !savedLayout.isEmpty
|
||||
self.refreshToolbar()
|
||||
}
|
||||
|
||||
// MARK: - Update Widget
|
||||
func updateWidget(uid: String, value: Int) {
|
||||
self.widgets[uid]?.update(value: value)
|
||||
}
|
||||
|
||||
// MARK: - DAW State
|
||||
func updateDawState(_ json: [String: Any]) {
|
||||
let track = json["track"] as? String
|
||||
let bar = json["bar"] as? Int
|
||||
let playing = json["playing"] as? Bool
|
||||
|
||||
self.feedbackWidgets.forEach { $0.updateDaw(track: track, bar: bar, playing: playing) }
|
||||
|
||||
var parts: [String] = []
|
||||
if let track = track { parts.append(track) }
|
||||
if let bar = bar { parts.append("Bar \(bar)") }
|
||||
if let playing = playing { parts.append(playing ? "▶" : "■") }
|
||||
if !parts.isEmpty { self.statusLabel.text = parts.joined(separator: " ") }
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
@objc func arrangeTapped() {
|
||||
self.isLocked = false
|
||||
self.refreshToolbar()
|
||||
}
|
||||
|
||||
@objc func lockTapped() {
|
||||
self.isLocked = true
|
||||
self.refreshToolbar()
|
||||
self.saveLayout()
|
||||
}
|
||||
|
||||
func refreshToolbar() {
|
||||
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)
|
||||
}
|
||||
|
||||
// MARK: - Save Layout
|
||||
func saveLayout() {
|
||||
guard !self.presetUuid.isEmpty else {
|
||||
self.loggingView.log("saveLayout: aborted — presetUuid is empty")
|
||||
return
|
||||
}
|
||||
var layoutDict: [String: Any] = [:]
|
||||
for (uid, w) in self.widgets {
|
||||
let f = w.frame
|
||||
layoutDict[uid] = [
|
||||
"x": Double(f.origin.x),
|
||||
"y": Double(f.origin.y),
|
||||
"w": Double(f.width),
|
||||
"h": Double(f.height)
|
||||
]
|
||||
}
|
||||
UserDefaults.standard.set(layoutDict, forKey: "layout_\(self.presetUuid)")
|
||||
self.loggingView.log("saveLayout: \(layoutDict.count) widgets saved locally for \(self.presetUuid)")
|
||||
}
|
||||
|
||||
// MARK: - Drag
|
||||
func setupDrag(on view: UIView) {
|
||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
||||
view.addGestureRecognizer(pan)
|
||||
}
|
||||
|
||||
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||
guard !self.isLocked, 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)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
func savedFrame(uid: String, in savedLayout: [String: Any]) -> CGRect? {
|
||||
guard let saved = savedLayout[uid] as? [String: Any],
|
||||
let x = saved["x"] as? CGFloat, let y = saved["y"] as? CGFloat,
|
||||
let w = saved["w"] as? CGFloat, let h = saved["h"] as? CGFloat
|
||||
else { return nil }
|
||||
return CGRect(x: x, y: y, width: w, height: h)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - WebSocket Delegate
|
||||
extension ObjectsViewController: URLSessionWebSocketDelegate {
|
||||
|
||||
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
|
||||
self.applyConnectionState(.connected)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
self.applyConnectionState(.disconnected)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import UIKit
|
||||
|
||||
extension LoggingObjects {
|
||||
|
||||
func log(_ message: String) {
|
||||
DispatchQueue.main.async {
|
||||
var display = message
|
||||
if let data = message.data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: data),
|
||||
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted),
|
||||
let str = String(data: pretty, encoding: .utf8) {
|
||||
display = str
|
||||
}
|
||||
let current = self.textView.text ?? ""
|
||||
let separator = current.isEmpty ? "" : "\n--- ✦ ---\n"
|
||||
self.textView.text = display + separator + current
|
||||
}
|
||||
}
|
||||
|
||||
@objc func clearTapped() {
|
||||
self.textView.text = ""
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,7 +17,7 @@ struct ModelFaderPayload: Codable {
|
||||
let uid: String
|
||||
let label: String
|
||||
let value: Int
|
||||
let visible: Bool
|
||||
let destId: Int
|
||||
let color: String
|
||||
}
|
||||
|
||||
@@ -25,12 +25,12 @@ struct ModelTogglePayload: Codable {
|
||||
let uid: String
|
||||
let label: String
|
||||
let state: Bool
|
||||
let visible: Bool
|
||||
let destId: Int
|
||||
let color: String
|
||||
}
|
||||
|
||||
struct ModelTransportPayload: Codable {
|
||||
let uid: String
|
||||
let label: String
|
||||
let visible: Bool
|
||||
let destId: Int
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import UIKit
|
||||
|
||||
extension ArrangeObjects {
|
||||
|
||||
// MARK: - Alerts
|
||||
|
||||
func presentAlert(title: String, message: String) {
|
||||
DispatchQueue.main.async {
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
self.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import UIKit
|
||||
|
||||
extension ArrangeObjects {
|
||||
|
||||
// MARK: - Button Actions
|
||||
|
||||
@objc func buttonTappedConnectButton() {
|
||||
if self.connectionState == .connected {
|
||||
//disconnect path
|
||||
self.wsClient?.cancel(with: .goingAway, reason: nil)
|
||||
self.wsClient = nil
|
||||
self.updateUIFromConnectionState(.disconnected)
|
||||
} else {
|
||||
//connect path
|
||||
let ip = self.ipField.text?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
let port = self.portField.text?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
if let idText = self.tabletIdField.text?.trimmingCharacters(in: .whitespaces),
|
||||
let idVal = Int(idText), idVal > 0 {
|
||||
self.myTabletId = idVal
|
||||
}
|
||||
guard checkerIPAddressInput(ip: ip) else { return }
|
||||
guard checkerPortInput(port: port) else { return }
|
||||
guard let url = buildWebSocketURL(ip: ip, port: port) else { return }
|
||||
self.connectWebSocket(url: url)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func buttonTappedShowLogging() {
|
||||
let visible = (self.logWidget.isHidden == false)
|
||||
self.logWidget.isHidden = visible
|
||||
self.showLoggingButton.setTitle(visible ? "Hide Logging" : "Show Logging", for: .normal)
|
||||
}
|
||||
|
||||
@objc func buttonTappedAutoSwitchButton() {
|
||||
autoSwitchEnabled.toggle()
|
||||
let green = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
|
||||
autoSwitchButton.backgroundColor = autoSwitchEnabled ? green : UIColor(white: 0.15, alpha: 1)
|
||||
autoSwitchButton.setTitleColor(autoSwitchEnabled ? .black : .lightGray, for: .normal)
|
||||
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 buttonTappedLockButton() {
|
||||
self.isLocked = true
|
||||
self.updateUIArrangeLockButtons()
|
||||
self.saveLayout()
|
||||
self.widgets.values.forEach { $0.setArrangeMode(false) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import UIKit
|
||||
|
||||
extension ArrangeObjects {
|
||||
|
||||
// MARK: - Gestures
|
||||
|
||||
func addDragGesture(to view: UIView) {
|
||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleGesturePan(_:)))
|
||||
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)
|
||||
}
|
||||
|
||||
func addContextMenuGesture(to view: UIView) {
|
||||
let press = UILongPressGestureRecognizer(target: self, action: #selector(handleGestureLongPress(_:)))
|
||||
press.minimumPressDuration = 0.5
|
||||
press.cancelsTouchesInView = false
|
||||
view.addGestureRecognizer(press)
|
||||
}
|
||||
|
||||
@objc func handleGestureLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||
guard self.isLocked == false else { return }
|
||||
guard gesture.state == .began else { return }
|
||||
guard let widget = gesture.view as? BaseWidget else { return }
|
||||
|
||||
let alert = UIAlertController(title: widget.displayName, message: nil, preferredStyle: .actionSheet)
|
||||
alert.overrideUserInterfaceStyle = .dark
|
||||
|
||||
alert.addAction(UIAlertAction(title: "Hide", style: .destructive) { [weak self] _ in
|
||||
self?.hideWidget(widget)
|
||||
})
|
||||
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)
|
||||
self.saveLayout()
|
||||
self.wsClientSendJSON(["event": "widget_visibility", "uid": widget.uid, "dest_id": 0])
|
||||
}
|
||||
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import UIKit
|
||||
|
||||
extension ArrangeObjects {
|
||||
|
||||
// MARK: - Input Validation
|
||||
|
||||
func checkerIPAddressInput(ip: String) -> Bool {
|
||||
|
||||
// Step 1: make sure the field is not empty
|
||||
if ip.isEmpty {
|
||||
presentAlert(title: "Missing IP Address", message: "Please enter an IP address.")
|
||||
return false
|
||||
}
|
||||
|
||||
// Step 2: an IPv4 address is four numbers separated by dots e.g. 192.168.1.1
|
||||
// Split the string on "." and check we got exactly 4 parts
|
||||
let parts = ip.split(separator: ".")
|
||||
|
||||
if parts.count != 4 {
|
||||
presentAlert(title: "Invalid IP Address", message: "\"\(ip)\" doesn't look like a valid IP address.")
|
||||
return false
|
||||
}
|
||||
|
||||
// Step 3: each part must be a number between 0 and 255
|
||||
for part in parts {
|
||||
if UInt8(part) == nil {
|
||||
presentAlert(title: "Invalid IP Address", message: "\"\(ip)\" doesn't look like a valid IP address.")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func checkerPortInput(port: String) -> Bool {
|
||||
|
||||
// Step 1: make sure the field is not empty
|
||||
if port.isEmpty {
|
||||
presentAlert(title: "Missing Port", message: "Please enter a port number.")
|
||||
return false
|
||||
}
|
||||
|
||||
// Step 2: the port must be a whole number
|
||||
guard let number = Int(port) else {
|
||||
presentAlert(title: "Invalid Port", message: "\"\(port)\" is not a valid port number.")
|
||||
return false
|
||||
}
|
||||
|
||||
// Step 3: port numbers must be between 1 and 65535
|
||||
if number < 1 || number > 65535 {
|
||||
presentAlert(title: "Invalid Port", message: "\"\(port)\" is not a valid port number (1–65535).")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func buildWebSocketURL(ip: String, port: String) -> URL? {
|
||||
let urlString = "ws://\(ip):\(port)"
|
||||
guard let url = URL(string: urlString) else {
|
||||
presentAlert(title: "Invalid URL", message: "Could not build a valid WebSocket URL from \"\(urlString)\".")
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import UIKit
|
||||
|
||||
extension ArrangeObjects {
|
||||
|
||||
// MARK: - Preset & Layout
|
||||
|
||||
func buildUIFromPreset(_ preset: ModelPresetLoadToRemoteDevice) {
|
||||
let savedLayout = UserDefaults.standard.dictionary(forKey: "layout_\(preset.presetUuid)") ?? [:]
|
||||
if savedLayout.isEmpty {
|
||||
self.loggingView.log("buildUIFromPreset: no saved layout for key=layout_\(preset.presetUuid) — using defaults", category: .local)
|
||||
} else {
|
||||
self.loggingView.log("buildUIFromPreset: restored \(savedLayout.count) positions from key=layout_\(preset.presetUuid)", category: .local)
|
||||
if let data = try? JSONSerialization.data(withJSONObject: savedLayout),
|
||||
let jsonStr = String(data: data, encoding: .utf8) {
|
||||
self.loggingView.log(jsonStr, category: .local)
|
||||
}
|
||||
}
|
||||
self.presetName = preset.presetName
|
||||
self.presetUuid = preset.presetUuid
|
||||
self.widgets = [:]
|
||||
self.feedbackWidgets = []
|
||||
self.containerView.subviews.forEach { if $0 !== self.logWidget { $0.removeFromSuperview() } }
|
||||
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 titleWidget = TitleWidget(uid: titleUid, text: preset.displayTitle, frame: titleFrame)
|
||||
self.addDragGesture(to: titleWidget)
|
||||
self.containerView.addSubview(titleWidget)
|
||||
self.widgets[titleUid] = titleWidget
|
||||
|
||||
var faderIdx = 0
|
||||
var squareIdx = 0
|
||||
|
||||
for f in preset.faders where f.destId == self.myTabletId {
|
||||
let frame = savedFrame(uid: f.uid, in: savedLayout) ?? FaderWidget.defaultFrame(idx: faderIdx)
|
||||
let w = FaderWidget(uid: f.uid,
|
||||
label: f.label,
|
||||
value: f.value,
|
||||
color: UIColor.fromHex(f.color),
|
||||
frame: frame)
|
||||
w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
|
||||
self.addDragGesture(to: w)
|
||||
self.addContextMenuGesture(to: w)
|
||||
self.containerView.addSubview(w)
|
||||
self.widgets[f.uid] = w
|
||||
faderIdx += 1
|
||||
}
|
||||
|
||||
for t in preset.toggles where t.destId == self.myTabletId {
|
||||
let frame = savedFrame(uid: t.uid, in: savedLayout) ?? ToggleWidget.defaultFrame(idx: squareIdx)
|
||||
let w = ToggleWidget(uid: t.uid,
|
||||
label: t.label,
|
||||
isOn: t.state,
|
||||
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.addContextMenuGesture(to: w)
|
||||
self.containerView.addSubview(w)
|
||||
self.widgets[t.uid] = w
|
||||
squareIdx += 1
|
||||
}
|
||||
|
||||
for t in preset.transports where t.destId == self.myTabletId {
|
||||
let frame = savedFrame(uid: t.uid, in: savedLayout) ?? TransportWidget.defaultFrame(idx: squareIdx)
|
||||
let w = TransportWidget(uid: t.uid,
|
||||
label: t.label,
|
||||
frame: frame)
|
||||
w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
|
||||
self.addDragGesture(to: w)
|
||||
self.addContextMenuGesture(to: w)
|
||||
self.containerView.addSubview(w)
|
||||
self.widgets[t.uid] = w
|
||||
squareIdx += 1
|
||||
}
|
||||
|
||||
self.isLocked = savedLayout.count > 0
|
||||
self.updateUIArrangeLockButtons()
|
||||
self.showStatus("[ \(preset.presetName) \(self.statusTimestamp()) ]")
|
||||
}
|
||||
|
||||
func saveLayout() {
|
||||
guard self.presetUuid.isEmpty == false else {
|
||||
self.loggingView.log("saveLayout: aborted — presetUuid is empty", category: .local)
|
||||
return
|
||||
}
|
||||
var layoutDict: [String: Any] = [:]
|
||||
for (uid, w) in self.widgets {
|
||||
let f = w.frame
|
||||
layoutDict[uid] = [
|
||||
"x": Double(f.origin.x),
|
||||
"y": Double(f.origin.y),
|
||||
"w": Double(f.width),
|
||||
"h": Double(f.height)
|
||||
]
|
||||
}
|
||||
UserDefaults.standard.set(layoutDict, forKey: "layout_\(self.presetUuid)")
|
||||
self.showStatus("[ Saved \(self.statusTimestamp()) ]")
|
||||
self.loggingView.log("✓ saveLayout: \(layoutDict.count) widgets saved key=layout_\(self.presetUuid)", category: .local)
|
||||
if let data = try? JSONSerialization.data(withJSONObject: layoutDict),
|
||||
let jsonStr = String(data: data, encoding: .utf8) {
|
||||
self.loggingView.log(jsonStr, category: .local)
|
||||
}
|
||||
}
|
||||
|
||||
func savedFrame(uid: String, in savedLayout: [String: Any]) -> CGRect? {
|
||||
guard let saved = savedLayout[uid] as? [String: Any],
|
||||
let x = saved["x"] as? CGFloat, let y = saved["y"] as? CGFloat,
|
||||
let w = saved["w"] as? CGFloat, let h = saved["h"] as? CGFloat
|
||||
else { return nil }
|
||||
return CGRect(x: x, y: y, width: w, height: h)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import UIKit
|
||||
|
||||
extension ArrangeObjects {
|
||||
|
||||
// MARK: - UI Updates
|
||||
|
||||
func updateUIFromConnectionState(_ state: ConnectionState) {
|
||||
self.connectionState = state
|
||||
let green = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
|
||||
let red = UIColor(red: 0.8, green: 0.2, blue: 0.2, alpha: 1)
|
||||
let gray = UIColor(white: 0.3, alpha: 1)
|
||||
|
||||
switch state {
|
||||
case .notStarted:
|
||||
self.statusLabel.text = "not started"
|
||||
self.connectButton.setTitle("Connect", for: .normal)
|
||||
self.connectButton.backgroundColor = green
|
||||
self.connectButton.setTitleColor(.black, for: .normal)
|
||||
self.connectButton.isEnabled = true
|
||||
case .connecting:
|
||||
self.statusLabel.text = "connecting..."
|
||||
self.connectButton.setTitle("Connecting...", for: .normal)
|
||||
self.connectButton.backgroundColor = gray
|
||||
self.connectButton.setTitleColor(.lightGray, for: .normal)
|
||||
self.connectButton.isEnabled = false
|
||||
case .connected:
|
||||
self.statusLabel.text = "connected"
|
||||
self.connectButton.setTitle("Disconnect", for: .normal)
|
||||
self.connectButton.backgroundColor = red
|
||||
self.connectButton.setTitleColor(.white, for: .normal)
|
||||
self.connectButton.isEnabled = true
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func updateUIFromDawState(_ json: [String: Any]) {
|
||||
let track = json["track"] as? String
|
||||
let bar = json["bar"] as? Int
|
||||
let playing = json["playing"] as? Bool
|
||||
|
||||
self.feedbackWidgets.forEach { $0.updateDaw(track: track, bar: bar, playing: playing) }
|
||||
|
||||
var parts: [String] = []
|
||||
if let track = track { parts.append(track) }
|
||||
if let bar = bar { parts.append("Bar \(bar)") }
|
||||
if let playing = playing { parts.append(playing ? "▶" : "■") }
|
||||
if parts.count > 0 { self.statusLabel.text = parts.joined(separator: " ") }
|
||||
}
|
||||
|
||||
func updateWidget(uid: String, value: Int) {
|
||||
self.widgets[uid]?.update(value: value)
|
||||
}
|
||||
|
||||
func showStatus(_ message: String) {
|
||||
DispatchQueue.main.async {
|
||||
self.statusToken += 1
|
||||
let token = self.statusToken
|
||||
self.toolbarStatusLabel.text = message
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4.0) {
|
||||
if self.statusToken == token {
|
||||
self.toolbarStatusLabel.text = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func statusTimestamp() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "h:mm:ss a"
|
||||
return "Today \(formatter.string(from: Date()))"
|
||||
}
|
||||
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import UIKit
|
||||
|
||||
extension ArrangeObjects {
|
||||
|
||||
// MARK: - WebSocket
|
||||
|
||||
func connectWebSocket(url: URL) {
|
||||
self.urlSession = URLSession(configuration: .default, delegate: self, delegateQueue: .main)
|
||||
self.wsClient = self.urlSession?.webSocketTask(with: url)
|
||||
self.wsClient?.resume()
|
||||
self.updateUIFromConnectionState(.connected)
|
||||
self.wsClientReceiveMessage()
|
||||
}
|
||||
|
||||
func wsClientReceiveMessage() {
|
||||
self.wsClient?.receive { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success(let message):
|
||||
switch message {
|
||||
case .string(let text): self.wsClientHandleIncomingJSON(text)
|
||||
default: break
|
||||
}
|
||||
self.wsClientReceiveMessage()
|
||||
case .failure:
|
||||
DispatchQueue.main.async {
|
||||
self.updateUIFromConnectionState(.disconnected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func wsClientHandleIncomingJSON(_ text: String) {
|
||||
self.loggingView.log(text, category: .incoming)
|
||||
guard let data = text.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let event = json["event"] as? String else { return }
|
||||
|
||||
switch event {
|
||||
case "preset":
|
||||
guard self.autoSwitchEnabled else {
|
||||
self.loggingView.log("preset event ignored — auto switch is off", category: .local)
|
||||
return
|
||||
}
|
||||
guard let presetData = json["data"] as? [String: Any],
|
||||
let presetBytes = try? JSONSerialization.data(withJSONObject: presetData) else {
|
||||
let msg = "preset: missing or malformed 'data' field in envelope"
|
||||
self.loggingView.log(msg, category: .local)
|
||||
DispatchQueue.main.async {
|
||||
let alert = UIAlertController(title: "Preset Load Error", message: msg, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
self.present(alert, animated: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
let preset = try decoder.decode(ModelPresetLoadToRemoteDevice.self, from: presetBytes)
|
||||
self.loggingView.log(
|
||||
"✓ preset loaded: '\(preset.presetName)' uuid=\(preset.presetUuid) faders=\(preset.faders.count) toggles=\(preset.toggles.count) transports=\(preset.transports.count)",
|
||||
category: .local
|
||||
)
|
||||
if let encoded = try? JSONEncoder().encode(preset),
|
||||
let jsonStr = String(data: encoded, encoding: .utf8) {
|
||||
self.loggingView.log(jsonStr, category: .local)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
let isNewPreset = preset.presetUuid != self.presetUuid
|
||||
if self.isLocked == false && isNewPreset {
|
||||
let alert = UIAlertController(
|
||||
title: "Preset Switched",
|
||||
message: "Switching to:\n\"\(preset.presetName)\" — \(preset.displayTitle)\n\nSave your current layout before switching?",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "Save & Switch", style: .default) { _ in
|
||||
self.saveLayout()
|
||||
self.buildUIFromPreset(preset)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Discard & Switch", style: .destructive) { _ in
|
||||
self.buildUIFromPreset(preset)
|
||||
})
|
||||
self.present(alert, animated: true)
|
||||
} else {
|
||||
self.buildUIFromPreset(preset)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
let msg = "preset decode failed: \(error.localizedDescription)"
|
||||
self.loggingView.log(msg, category: .local)
|
||||
DispatchQueue.main.async {
|
||||
let alert = UIAlertController(title: "Preset Load Error", message: msg, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
self.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
case "widget_update":
|
||||
if let uid = json["uid"] as? String, let value = json["value"] as? Int {
|
||||
DispatchQueue.main.async { self.updateWidget(uid: uid, value: value) }
|
||||
}
|
||||
case "daw_state":
|
||||
DispatchQueue.main.async { self.updateUIFromDawState(json) }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func wsClientSendJSON(_ dict: [String: Any]) {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: dict),
|
||||
let text = String(data: data, encoding: .utf8) else { return }
|
||||
self.loggingView.log(text, category: .outgoing)
|
||||
self.wsClient?.send(.string(text)) { _ in }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - WebSocket Delegate
|
||||
extension ArrangeObjects: URLSessionWebSocketDelegate {
|
||||
|
||||
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
|
||||
self.updateUIFromConnectionState(.connected)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
self.updateUIFromConnectionState(.disconnected)
|
||||
}
|
||||
|
||||
}
|
||||
+59
-6
@@ -1,9 +1,10 @@
|
||||
import UIKit
|
||||
|
||||
class ObjectsViewController: UIViewController {
|
||||
class ArrangeObjects: UIViewController {
|
||||
|
||||
// MARK: - WebSocket
|
||||
var wsTask: URLSessionWebSocketTask?
|
||||
var wsClient: URLSessionWebSocketTask?
|
||||
|
||||
var urlSession: URLSession?
|
||||
|
||||
// MARK: - Connection State
|
||||
@@ -16,6 +17,14 @@ class ObjectsViewController: UIViewController {
|
||||
var presetName: String = ""
|
||||
var presetUuid: String = ""
|
||||
var isLocked: Bool = true
|
||||
var autoSwitchEnabled: Bool = true
|
||||
var myTabletId: Int {
|
||||
get {
|
||||
let stored = UserDefaults.standard.integer(forKey: "tablet_id")
|
||||
return stored == 0 ? 1 : stored
|
||||
}
|
||||
set { UserDefaults.standard.set(newValue, forKey: "tablet_id") }
|
||||
}
|
||||
|
||||
// MARK: - Widgets
|
||||
var widgets: [String: BaseWidget] = [:]
|
||||
@@ -40,7 +49,7 @@ class ObjectsViewController: UIViewController {
|
||||
btn.backgroundColor = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
|
||||
btn.layer.cornerRadius = 4
|
||||
btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
|
||||
btn.addTarget(self, action: #selector(self.connectTapped), for: .touchUpInside)
|
||||
btn.addTarget(self, action: #selector(self.buttonTappedConnectButton), for: .touchUpInside)
|
||||
return btn
|
||||
}()
|
||||
|
||||
@@ -54,7 +63,19 @@ class ObjectsViewController: UIViewController {
|
||||
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.arrangeTapped), for: .touchUpInside)
|
||||
btn.addTarget(self, action: #selector(self.buttonTappedArrangeButton), for: .touchUpInside)
|
||||
return btn
|
||||
}()
|
||||
|
||||
lazy var autoSwitchButton: UIButton = {
|
||||
let btn = UIButton(type: .system)
|
||||
btn.translatesAutoresizingMaskIntoConstraints = false
|
||||
btn.setTitle("Auto Switch", for: .normal)
|
||||
btn.setTitleColor(.black, for: .normal)
|
||||
btn.backgroundColor = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
|
||||
btn.layer.cornerRadius = 4
|
||||
btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
|
||||
btn.addTarget(self, action: #selector(self.buttonTappedAutoSwitchButton), for: .touchUpInside)
|
||||
return btn
|
||||
}()
|
||||
|
||||
@@ -66,7 +87,7 @@ class ObjectsViewController: UIViewController {
|
||||
btn.backgroundColor = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
|
||||
btn.layer.cornerRadius = 4
|
||||
btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
|
||||
btn.addTarget(self, action: #selector(self.lockTapped), for: .touchUpInside)
|
||||
btn.addTarget(self, action: #selector(self.buttonTappedLockButton), for: .touchUpInside)
|
||||
return btn
|
||||
}()
|
||||
|
||||
@@ -90,6 +111,25 @@ class ObjectsViewController: UIViewController {
|
||||
return tf
|
||||
}()
|
||||
|
||||
let tabletIdField: UITextField = {
|
||||
let tf = UITextField()
|
||||
tf.translatesAutoresizingMaskIntoConstraints = false
|
||||
tf.textColor = .lightGray
|
||||
tf.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
|
||||
tf.backgroundColor = UIColor(white: 0.12, alpha: 1)
|
||||
tf.layer.borderWidth = 1
|
||||
tf.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
|
||||
tf.layer.cornerRadius = 4
|
||||
tf.keyboardType = .numberPad
|
||||
tf.textAlignment = .center
|
||||
tf.placeholder = "ID"
|
||||
tf.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 8, height: 0))
|
||||
tf.leftViewMode = .always
|
||||
tf.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 8, height: 0))
|
||||
tf.rightViewMode = .always
|
||||
return tf
|
||||
}()
|
||||
|
||||
let portField: UITextField = {
|
||||
let tf = UITextField()
|
||||
tf.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -127,10 +167,23 @@ class ObjectsViewController: UIViewController {
|
||||
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.showLoggingTapped), for: .touchUpInside)
|
||||
btn.addTarget(self, action: #selector(self.buttonTappedShowLogging), for: .touchUpInside)
|
||||
return btn
|
||||
}()
|
||||
|
||||
// MARK: - Toolbar Status
|
||||
var statusToken: Int = 0
|
||||
|
||||
let toolbarStatusLabel: UILabel = {
|
||||
let lbl = UILabel()
|
||||
lbl.translatesAutoresizingMaskIntoConstraints = false
|
||||
lbl.text = ""
|
||||
lbl.textColor = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
|
||||
lbl.font = .italicSystemFont(ofSize: 11)
|
||||
lbl.textAlignment = .right
|
||||
return lbl
|
||||
}()
|
||||
|
||||
// MARK: - Labels
|
||||
let statusLabel: UILabel = {
|
||||
let lbl = UILabel()
|
||||
+21
-5
@@ -1,9 +1,10 @@
|
||||
import UIKit
|
||||
|
||||
extension ObjectsViewController {
|
||||
extension ArrangeObjects {
|
||||
|
||||
func setupUI() {
|
||||
DispatchQueue.main.async {
|
||||
|
||||
self.view.backgroundColor = UIColor(white: 0.1, alpha: 1)
|
||||
|
||||
// containerView
|
||||
@@ -20,7 +21,7 @@ extension ObjectsViewController {
|
||||
|
||||
// presetLabel
|
||||
self.view.addSubview(self.presetLabel)
|
||||
self.presetLabel.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -8).isActive = true
|
||||
self.presetLabel.topAnchor.constraint(equalTo: self.statusLabel.bottomAnchor, constant: 2).isActive = true
|
||||
self.presetLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 8).isActive = true
|
||||
|
||||
// toolbar buttons (top-right)
|
||||
@@ -28,13 +29,24 @@ extension ObjectsViewController {
|
||||
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.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.trailingAnchor.constraint(equalTo: self.showLoggingButton.leadingAnchor, constant: -6).isActive = true
|
||||
|
||||
self.view.addSubview(self.tabletIdField)
|
||||
self.tabletIdField.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
|
||||
self.tabletIdField.trailingAnchor.constraint(equalTo: self.connectButton.leadingAnchor, constant: -6).isActive = true
|
||||
self.tabletIdField.widthAnchor.constraint(equalToConstant: 40).isActive = true
|
||||
self.tabletIdField.heightAnchor.constraint(equalTo: self.connectButton.heightAnchor).isActive = true
|
||||
self.tabletIdField.text = "\(self.myTabletId)"
|
||||
|
||||
self.view.addSubview(self.portField)
|
||||
self.portField.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
|
||||
self.portField.trailingAnchor.constraint(equalTo: self.connectButton.leadingAnchor, constant: -6).isActive = true
|
||||
self.portField.trailingAnchor.constraint(equalTo: self.tabletIdField.leadingAnchor, constant: -6).isActive = true
|
||||
self.portField.widthAnchor.constraint(equalToConstant: 60).isActive = true
|
||||
self.portField.heightAnchor.constraint(equalTo: self.connectButton.heightAnchor).isActive = true
|
||||
|
||||
@@ -48,12 +60,16 @@ extension ObjectsViewController {
|
||||
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.view.addSubview(self.arrangeButton)
|
||||
self.arrangeButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
|
||||
self.arrangeButton.trailingAnchor.constraint(equalTo: self.lockButton.leadingAnchor, constant: -6).isActive = true
|
||||
self.arrangeButton.trailingAnchor.constraint(equalTo: self.autoSwitchButton.leadingAnchor, constant: -6).isActive = true
|
||||
|
||||
self.containerView.addSubview(self.logWidget)
|
||||
self.setupDrag(on: self.logWidget)
|
||||
self.addDragGesture(to: self.logWidget)
|
||||
self.logWidget.attach(to: self, loggingView: self.loggingView)
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -7,13 +7,11 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
class ViewController: ObjectsViewController {
|
||||
class ArrangeView: ArrangeObjects {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import UIKit
|
||||
|
||||
extension LoggingObjects {
|
||||
|
||||
func log(_ message: String, category: LogCategory = .local) {
|
||||
var entry = message
|
||||
if let data = message.data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: data),
|
||||
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted),
|
||||
let str = String(data: pretty, encoding: .utf8) {
|
||||
entry = str
|
||||
}
|
||||
|
||||
switch category {
|
||||
case .incoming: logIncoming.insert(entry, at: 0)
|
||||
case .outgoing: logOutgoing.insert(entry, at: 0)
|
||||
case .local: logLocal.insert(entry, at: 0)
|
||||
}
|
||||
|
||||
if category == activeCategory {
|
||||
DispatchQueue.main.async { self.rebuildTextView() }
|
||||
}
|
||||
}
|
||||
|
||||
@objc func categoryChanged(_ sender: UISegmentedControl) {
|
||||
activeCategory = LogCategory(rawValue: sender.selectedSegmentIndex) ?? .local
|
||||
rebuildTextView()
|
||||
}
|
||||
|
||||
@objc func buttonTappedClearButton() {
|
||||
switch activeCategory {
|
||||
case .incoming: logIncoming.removeAll()
|
||||
case .outgoing: logOutgoing.removeAll()
|
||||
case .local: logLocal.removeAll()
|
||||
}
|
||||
textView.text = ""
|
||||
}
|
||||
|
||||
private func activeLog() -> [String] {
|
||||
switch activeCategory {
|
||||
case .incoming: return logIncoming
|
||||
case .outgoing: return logOutgoing
|
||||
case .local: return logLocal
|
||||
}
|
||||
}
|
||||
|
||||
private func rebuildTextView() {
|
||||
textView.text = activeLog().joined(separator: "\n--- ✦ ---\n")
|
||||
}
|
||||
|
||||
}
|
||||
+20
-1
@@ -1,7 +1,26 @@
|
||||
import UIKit
|
||||
|
||||
enum LogCategory: Int {
|
||||
case incoming = 0
|
||||
case outgoing = 1
|
||||
case local = 2
|
||||
}
|
||||
|
||||
class LoggingObjects: UIViewController {
|
||||
|
||||
var logIncoming: [String] = []
|
||||
var logOutgoing: [String] = []
|
||||
var logLocal: [String] = []
|
||||
var activeCategory: LogCategory = .local
|
||||
|
||||
lazy var segmentedControl: UISegmentedControl = {
|
||||
let sc = UISegmentedControl(items: ["In", "Out", "Local"])
|
||||
sc.translatesAutoresizingMaskIntoConstraints = false
|
||||
sc.selectedSegmentIndex = LogCategory.local.rawValue
|
||||
sc.addTarget(self, action: #selector(self.categoryChanged(_:)), for: .valueChanged)
|
||||
return sc
|
||||
}()
|
||||
|
||||
lazy var textView: UITextView = {
|
||||
let tv = UITextView()
|
||||
tv.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -19,7 +38,7 @@ class LoggingObjects: UIViewController {
|
||||
btn.setTitle("Clear", for: .normal)
|
||||
btn.setTitleColor(UIColor(white: 0.4, alpha: 1), for: .normal)
|
||||
btn.titleLabel?.font = .systemFont(ofSize: 10)
|
||||
btn.addTarget(self, action: #selector(self.clearTapped), for: .touchUpInside)
|
||||
btn.addTarget(self, action: #selector(self.buttonTappedClearButton), for: .touchUpInside)
|
||||
return btn
|
||||
}()
|
||||
|
||||
+6
-2
@@ -7,11 +7,15 @@ extension LoggingObjects {
|
||||
self.view.backgroundColor = UIColor(white: 0.08, alpha: 1)
|
||||
|
||||
self.view.addSubview(self.clearButton)
|
||||
self.clearButton.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 6).isActive = true
|
||||
self.view.addSubview(self.segmentedControl)
|
||||
self.segmentedControl.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 6).isActive = true
|
||||
self.segmentedControl.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 8).isActive = true
|
||||
self.segmentedControl.trailingAnchor.constraint(equalTo: self.clearButton.leadingAnchor, constant: -8).isActive = true
|
||||
self.clearButton.centerYAnchor.constraint(equalTo: self.segmentedControl.centerYAnchor).isActive = true
|
||||
self.clearButton.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -8).isActive = true
|
||||
|
||||
self.view.addSubview(self.textView)
|
||||
self.textView.topAnchor.constraint(equalTo: self.clearButton.bottomAnchor, constant: 2).isActive = true
|
||||
self.textView.topAnchor.constraint(equalTo: self.segmentedControl.bottomAnchor, constant: 6).isActive = true
|
||||
self.textView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 4).isActive = true
|
||||
self.textView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -4).isActive = true
|
||||
self.textView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: -4).isActive = true
|
||||
@@ -0,0 +1,90 @@
|
||||
import UIKit
|
||||
|
||||
class BaseWidget: UIView {
|
||||
|
||||
let uid: String
|
||||
var displayName: String = ""
|
||||
var onSend: ((String, Int) -> Void)?
|
||||
var restingBorderColor: CGColor = UIColor(white: 0.27, alpha: 1).cgColor
|
||||
private var isTouching = false
|
||||
private let activityGreen = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1).cgColor
|
||||
|
||||
init(uid: String, frame: CGRect) {
|
||||
self.uid = uid
|
||||
super.init(frame: frame)
|
||||
self.backgroundColor = UIColor(white: 0.13, alpha: 1)
|
||||
self.layer.borderWidth = 1
|
||||
self.layer.cornerRadius = 6
|
||||
self.setupActivityRingGesture()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
// Intercepts all incoming value updates so the ring can flash.
|
||||
// Subclasses override applyValue(_:), not this.
|
||||
final func update(value: Int) {
|
||||
applyValue(value)
|
||||
flashActivityRing()
|
||||
}
|
||||
|
||||
func applyValue(_ value: Int) {}
|
||||
func setArrangeMode(_ enabled: Bool) {}
|
||||
|
||||
// MARK: - Activity Ring
|
||||
|
||||
private func setupActivityRingGesture() {
|
||||
let press = UILongPressGestureRecognizer(target: self, action: #selector(self.handleTouchStateChanged(_:)))
|
||||
press.minimumPressDuration = 0
|
||||
press.allowableMovement = 10000
|
||||
press.cancelsTouchesInView = false
|
||||
press.delegate = self
|
||||
self.addGestureRecognizer(press)
|
||||
}
|
||||
|
||||
@objc private func handleTouchStateChanged(_ gesture: UILongPressGestureRecognizer) {
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
self.isTouching = true
|
||||
self.layer.borderColor = self.activityGreen
|
||||
self.layer.borderWidth = 3
|
||||
case .ended, .cancelled, .failed:
|
||||
self.isTouching = false
|
||||
self.layer.borderColor = self.restingBorderColor
|
||||
self.layer.borderWidth = 1
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func flashActivityRing() {
|
||||
self.layer.borderColor = self.activityGreen
|
||||
self.layer.borderWidth = 3
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
|
||||
guard let self = self, self.isTouching == false else { return }
|
||||
self.layer.borderColor = self.restingBorderColor
|
||||
self.layer.borderWidth = 1
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension BaseWidget: UIGestureRecognizerDelegate {
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
static func fromHex(_ hex: String) -> UIColor {
|
||||
var h = hex.trimmingCharacters(in: .alphanumerics.inverted)
|
||||
if h.count == 6 { h = "FF" + h }
|
||||
var int: UInt64 = 0
|
||||
Scanner(string: h).scanHexInt64(&int)
|
||||
return UIColor(
|
||||
red: CGFloat((int >> 16) & 0xFF) / 255,
|
||||
green: CGFloat((int >> 8) & 0xFF) / 255,
|
||||
blue: CGFloat(int & 0xFF) / 255,
|
||||
alpha: CGFloat((int >> 24) & 0xFF) / 255
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -37,6 +37,8 @@ class FaderWidget: BaseWidget {
|
||||
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.cgColor
|
||||
self.layer.borderColor = color.cgColor
|
||||
self.nameLabel.text = label
|
||||
self.slider.value = Float(value)
|
||||
@@ -69,10 +71,15 @@ class FaderWidget: BaseWidget {
|
||||
}
|
||||
|
||||
// MARK: - Update
|
||||
override func update(value: Int) {
|
||||
override func applyValue(_ value: Int) {
|
||||
self.slider.value = Float(value)
|
||||
}
|
||||
|
||||
// MARK: - Arrange Mode
|
||||
override func setArrangeMode(_ enabled: Bool) {
|
||||
self.slider.isUserInteractionEnabled = (enabled == false)
|
||||
}
|
||||
|
||||
// MARK: - Action
|
||||
@objc private func sliderChanged() {
|
||||
self.onSend?(self.uid, Int(self.slider.value))
|
||||
+9
-3
@@ -31,6 +31,7 @@ class ToggleWidget: BaseWidget {
|
||||
self.color = color
|
||||
self.isOn = isOn
|
||||
super.init(uid: uid, frame: frame)
|
||||
self.displayName = label
|
||||
self.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
|
||||
self.button.setTitle(label, for: .normal)
|
||||
setupUI()
|
||||
@@ -47,7 +48,7 @@ class ToggleWidget: BaseWidget {
|
||||
self.button.widthAnchor.constraint(equalToConstant: 63).isActive = true
|
||||
self.button.heightAnchor.constraint(equalToConstant: 63).isActive = true
|
||||
|
||||
self.button.addTarget(self, action: #selector(self.tapped), for: .touchUpInside)
|
||||
self.button.addTarget(self, action: #selector(self.buttonTappedToggleButton), for: .touchUpInside)
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
@@ -56,13 +57,18 @@ class ToggleWidget: BaseWidget {
|
||||
self.button.setTitleColor(self.isOn ? .black : .lightGray, for: .normal)
|
||||
}
|
||||
|
||||
override func update(value: Int) {
|
||||
override func applyValue(_ value: Int) {
|
||||
self.isOn = value > 63
|
||||
applyState()
|
||||
}
|
||||
|
||||
// MARK: - Arrange Mode
|
||||
override func setArrangeMode(_ enabled: Bool) {
|
||||
self.button.isUserInteractionEnabled = (enabled == false)
|
||||
}
|
||||
|
||||
// MARK: - Action
|
||||
@objc private func tapped() {
|
||||
@objc private func buttonTappedToggleButton() {
|
||||
self.isOn = !self.isOn
|
||||
applyState()
|
||||
self.onSend?(self.uid, self.isOn ? 127 : 0)
|
||||
+8
-2
@@ -27,6 +27,7 @@ class TransportWidget: BaseWidget {
|
||||
// MARK: - Init
|
||||
init(uid: String, label: String, frame: CGRect) {
|
||||
super.init(uid: uid, frame: frame)
|
||||
self.displayName = label
|
||||
self.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
|
||||
self.button.setTitle(label, for: .normal)
|
||||
setupUI()
|
||||
@@ -42,11 +43,16 @@ class TransportWidget: BaseWidget {
|
||||
self.button.widthAnchor.constraint(equalToConstant: 63).isActive = true
|
||||
self.button.heightAnchor.constraint(equalToConstant: 63).isActive = true
|
||||
|
||||
self.button.addTarget(self, action: #selector(self.tapped), for: .touchUpInside)
|
||||
self.button.addTarget(self, action: #selector(self.buttonTappedTransportButton), for: .touchUpInside)
|
||||
}
|
||||
|
||||
// MARK: - Arrange Mode
|
||||
override func setArrangeMode(_ enabled: Bool) {
|
||||
self.button.isUserInteractionEnabled = (enabled == false)
|
||||
}
|
||||
|
||||
// MARK: - Action
|
||||
@objc private func tapped() {
|
||||
@objc private func buttonTappedTransportButton() {
|
||||
self.onSend?(self.uid, 127)
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import UIKit
|
||||
|
||||
class BaseWidget: UIView {
|
||||
|
||||
let uid: String
|
||||
var onSend: ((String, Int) -> Void)?
|
||||
|
||||
init(uid: String, frame: CGRect) {
|
||||
self.uid = uid
|
||||
super.init(frame: frame)
|
||||
self.backgroundColor = UIColor(white: 0.13, alpha: 1)
|
||||
self.layer.borderWidth = 1
|
||||
self.layer.cornerRadius = 6
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func update(value: Int) {}
|
||||
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
static func fromHex(_ hex: String) -> UIColor {
|
||||
var h = hex.trimmingCharacters(in: .alphanumerics.inverted)
|
||||
if h.count == 6 { h = "FF" + h }
|
||||
var int: UInt64 = 0
|
||||
Scanner(string: h).scanHexInt64(&int)
|
||||
return UIColor(
|
||||
red: CGFloat((int >> 16) & 0xFF) / 255,
|
||||
green: CGFloat((int >> 8) & 0xFF) / 255,
|
||||
blue: CGFloat(int & 0xFF) / 255,
|
||||
alpha: CGFloat((int >> 24) & 0xFF) / 255
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
guard let windowScene = (scene as? UIWindowScene) else { return }
|
||||
window = UIWindow(windowScene: windowScene)
|
||||
window?.rootViewController = ViewController()
|
||||
window?.rootViewController = ArrangeView()
|
||||
window?.makeKeyAndVisible()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user